Skip to content

Require a guaranteed warn channel on any sink type that declares an optional error (#9754) - #10555

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-9754-optional-error-sink-contract
Aug 21, 2026
Merged

Require a guaranteed warn channel on any sink type that declares an optional error (#9754)#10555
os-zhuang merged 3 commits into
mainfrom
claude/issue-9754-optional-error-sink-contract

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes: #9754

Ruled B: require a fallback channel on any sink type that declares an optional error. This PR answers the card's four pricing questions with measurements, repairs the two sinks the card names, and lands the checker plus a shrink-only ledger for the rest.

The rule, and why warn specifically

A sink type that declares error as optional must declare warn as non-optional.

The card left the exact strength to whoever took it ("whether the alternative must be warn specifically, or any of {warn, info, log}, is a judgement — say which and why"). The answer here is warn, non-optional, for two reasons:

error stays optional on both types. Option C is not what this asks for and is not done here.

How the population is drawn (card question 1)

Structurally, not by name. A name convention (*Logger) would have missed most of it: the tree holds LoggerLike, MinimalLogger, OptionalLogger, and eight anonymous inline literals. A sink is an interface, a type alias to a type literal, or an inline type literal, all of whose members are function-typed and named from {error, warn, info, debug, log, fatal, trace, verbose, silly}, and which declares error.

Structural sweeps in unrelated shapes, so there are two narrowings — and each one's cost is printed as a positive number on every run rather than argued in prose, because a narrowing that only ever produced silence is indistinguishable from a matcher that stopped matching:

  • as-cast narrowings are skipped (2 today, both with an optional error).(globalThis as { console?: { error?: ... } }).console?.error?.(m) describes a foreign object the module does not own; nobody can add warn to the host's console by decree. Without this narrowing the gate would have reddened packages/types/src/degraded-boot.ts's deliberate stderr-then-console-then-silence chain.
  • Impure shapes are skipped (2 today, cost measured at ZERO). Both shapes with an error member that carry non-vocabulary members — DomainHandlerDeps and the kernel Logger — declare errorrequired, so neither could have been red under this rule.

One more measured fact, stated rather than left to silence: examples/** holds one further red sink (app-showcase/.../recalc-endpoint.ts). The scan is packages/** — those are the contracts plugins and services publish — so widening it is a decision someone can now make with the number in hand.

A trap worth recording: the first draft of this population read a clean tree while missing the two sinks the card calls the sharpest instances. AuthEventAuditLogger and ReadAuditLogger use the method-signature spelling (error?(msg): void), and both the file prefilter and the member matcher only knew the property form. The fix is pinned as a self-test case, and the prefilter regex now accepts both spellings.

The population, measured (card question 2)

Over packages/**, immediately before this PR's repairs:

populationcount
sink types declaring error (non-test source)36
...with error REQUIRED — nothing to guarantee11
...with error optional and warn REQUIRED — already clean8
...with error optional and warn OPTIONAL — red16
...with error optional and NO warn at all — red1
skipped: as-cast narrowing of a foreign object2
skipped: not a pure sink2
pure sinks declaring no error — out of the population56

So the first run is 17 red, not the near-zero the card hoped for. But it is not an invented rule either: the eight already-clean ones are the reduced sinks this repo wrote with caresql-driver.ts, service-datasource/logger.ts, db-job-adapter.ts, email-service.ts, lifecycle-service.ts, service-queue/common.ts and both triggers — every one of which already declares warn required beside an optional error. The 17 are drift from a convention the repo half-holds.

What is repaired, and what is ledgered

Repaired (2):SweepLogger (plugin-email) and ProjectionLogger (plugin-security) — the two the card names, and the two whose consumer surface the lane PM had already measured. Re-verified that surface: ProjectionLogger is reached by bootstrap-declared-capabilities.ts and bootstrap-declared-permissions.ts, and escapes the package through the exported ProjectionDeps; SweepLogger is package-internal. No call site outside those packages passes either sink (reconcilePermissionSetProjection( / sweepStrandedOutbox( have no callers in packages/**, apps/** or examples/** beyond their own packages).

Ledgered (15), in scripts/optional-error-sink-contract.baseline.json — shrink-only, stale-entry-fails, no --fix flag, and its header says in as many words that it is not a place to add new work. Every entry names why it is still there. Three are not one-line repairs, and those reasons are the interesting ones:

  • plugin-security/security-plugin.ts — the field is initialised = {}, so today the plugin's own default sink prints nothing at all. Making warn required forces a decision about what that default should be, which is a design call.
  • service-settings/SettingsDiagnosticsLogger — the onlyno-fallback sink left ({ error? } and nothing else, so a call site there cannot be written correctly at all). Its doc explains the surface is kept to one member so a one-line spy stays assignable; a required warn breaks that. Highest-priority entry.
  • plugin-audit's two sinks — deliberately untouched: packages/plugins/plugin-audit is open PR docs(plugin-audit): document the os serve opt-in, and rule out a config-derived audit options helper #10450's file surface.

Anti-vacuity: the harm first, then both ablations

The harm, reproduced as a test (outbox-sweep.test.ts, permission-set-projection.test.ts): a { info } sink, cast in — which is exactly what the old contract handed out without a cast — hears nothing. Not the per-row failure, not the count, and in the reconcile case not even the reassuring "reconciled" line, because that else branch is skipped too. Both tests then show the type refusing that sink.

Ablation 1 — the checker. Reverting warn to warn? on each repaired sink, one at a time, with the mutation confirmed on disk by counting the removed and the injected text (not by an editor's exit code):

GATE_ABLATED_EXIT=1
packages/plugins/plugin-email/src/outbox-sweep.ts:93
sink : interface SweepLogger { info? warn? error? }
found : `error` is optional and `warn` is optional too — every value of this type may print nothing
GATE_ABLATED_EXIT=1
packages/plugins/plugin-security/src/permission-set-projection.ts:93
sink : interface ProjectionLogger { info? warn? error? }

Restored both times with git checkout HEAD -- path, git status --porcelain empty afterwards (no staged/unstaged split), and the checker back to exit 0.

Ablation 2 — the type. Same mutation, pnpm --filter @objectstack/plugin-email typecheck:

TSC_ABLATED_EXIT=2
src/outbox-sweep.test.ts(368,7): error TS2578: Unused '@ts-expect-error' directive.

That is the compile-time pin proving it is live rather than decorative.

A phantom pin this PR's own gate run caught. The first draft put the same @ts-expect-error in permission-set-projection.test.ts — and pnpm check:type-check-coverage reds on it: plugin-security's tsconfig excludes **/*.test.ts (the package carries a TEST_DEBT ledger entry), so no tsc program compiles that file and the directive would evaluate never. It was removed, with the reason written where the next author will look; the runtime half of that test stays, the compile-time half lives in plugin-email where it is actually evaluated.

Wiring — deliberately NOT wired into CI

pnpm check:optional-error-sink runs the self-test and then the scan; nothing in .github/workflows/** calls it. Two reasons, and the first is the card's: "⛔ Not a new required context; this argues for a producer-side constraint, not more merge-blocking", consistent with #9747's family ruling (visibility-only) that the lane PM carried forward. The second is mechanical: every check:* gate in this repo lives in the required Lint and Repo Gates job, and .github/workflows/lint.yml is open PR #10506's file surface. Whether to wire it is a lane-PM call, not one to take inside this PR.

Verification, at 4d7374f8a9

  • turbo run typecheck test --filter=@objectstack/plugin-email --filter=@objectstack/plugin-security — 23/23 tasks, plugin-email 421 tests / 26 files, plugin-security 1321 tests / 67 files, all passing.
  • Gate union re-derived from the real diff with node scripts/pm/dispatch-gates.mjs (no path arguments) and run at this head: check:optional-error-sink, check:nul-bytes, check:type-check-coverage, check:cross-package-test-inputs, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:i18n, check:changeset-gate-self-tests, check:objectui-changeset, check:slot-lookup, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-affected-docs — all exit 0.
  • ⚠️One declared narrowing:check:type-check-debt --re-measure was not run locally — it needs the whole workspace closure built, and neither edited package is a ledger entry (both declare a typecheck script), so this diff cannot move that ratchet. CI runs it.
  • check:i18n needed @objectstack/cli built before it would check anything; built, then green (9 packages in sync).

Changeset

.changeset/optional-error-sink-contract-requires-warn.md, minor on @objectstack/plugin-email and @objectstack/plugin-security. Both are published (.changeset/config.json lists them in the fixed group, ignore is empty, neither is private), and this tightens an exported type contract, so it is a minor rather than a patch and the body carries the one-line fix for anyone passing a warn-less sink. No skip-changeset.

For review

  • The call sites keep spelling the fallback logger?.warn?.(...). That ?. is now redundant for TS callers and is kept on purpose as a backstop for hosts the type cannot reach: dropping it was measured, and a sink that lies about its shape then throws logger?.warn is not a functioninside the per-row durability catch, aborting the batch the sweep promises never to stop. The interface says so where a reader would otherwise wonder.
  • The ledger's 15 entries are real debt with a named owner-file each; the follow-up they imply is one paydown task, not fifteen.

Generated by Claude Code

…nteed `warn` (#9754)
An optional `error` with no declared alternative is a contract that permits
silence. #9657 and #9748 repaired the call-site spellings; this is the type
contract behind them.
- `SweepLogger` (plugin-email) and `ProjectionLogger` (plugin-security) now
declare `warn` non-optionally, so `{ info }` is no longer a legal sink and
the durability reports cannot evaporate against one. `error` stays optional
— hosts do inject reduced sinks (#9754 option C, falsified).
- New producer-side checker `scripts/check-optional-error-sink-contract.mjs`
(`pnpm check:optional-error-sink`): structural population, both narrowings
reported as positive counts on every run, 13-case `--self-test`, and a
shrink-only ledger holding the 15 sinks in `packages/**` that still permit
silence. Not wired into CI — #9754 forbids more merge-blocking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
… cannot evaluate
`pnpm check:type-check-coverage` caught it in this PR's own diff: plugin-security's
tsconfig excludes `**/*.test.ts` (the package carries a TEST_DEBT ledger entry),
so a `@ts-expect-error` in that file evaluates NEVER — a pin that reads like proof
and holds nothing (AGENTS.md → "Build & Test").
The runtime half stays: a `{ info }` sink is shown hearing NOTHING — not the
failure, not the count, not the reassuring "reconciled" line. The compile-time
half lives in plugin-email's `outbox-sweep.test.ts`, whose package does compile
its tests (observed red as `error TS2578: Unused '@ts-expect-error' directive`
when the contract is reverted), and the type contract of both sinks is held by
`pnpm check:optional-error-sink`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
A heredoc concatenation joined the option-C paragraph and the call-site
backstop note onto one line in both interfaces. Comment text only; no code,
type or test changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ceb33a9f1274fec5f90548b5f5550727b37543bdpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e6b28dc495804a2976182abf1991d9213980a143 — the merge of head 4d7374f8a9bf549053e2a2e5b39f6d6d5cb63171 into base ceb33a9f1274fec5f90548b5f5550727b37543bd, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e6b28dc495804a2976182abf1991d9213980a143 && git checkout e6b28dc495804a2976182abf1991d9213980a143
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ceb33a9f1274fec5f90548b5f5550727b37543bd 4d7374f8a9bf549053e2a2e5b39f6d6d5cb63171 && git checkout -B drift-repro ceb33a9f1274fec5f90548b5f5550727b37543bd && git merge --no-ff 4d7374f8a9bf549053e2a2e5b39f6d6d5cb63171
node scripts/docs-audit/affected-docs.mjs --json ceb33a9f1274fec5f90548b5f5550727b37543bd

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 21, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — ⭐ accepted, both open questions ruled below.

⭐ The justification is the part that makes this land-able

36 sinks declare error, 11 require it, 8 already pair an optional error with a REQUIRED warn, 17 are red.

⇒ The rule is the repo's own half-held convention, not an invention. That single measurement is what turns "here is a rule I think is good" into "here is a rule this codebase already follows in 8 places and forgot in 17" — and it is the difference between a gate people accept and a gate people route around.

And you reproduced the harm as a test rather than as prose, in both packages: a { info } sink — legal under the old contract — hears nothing. plugin-security asserting heard === [] is the sharpest form of it: not the failure, not the count, and not the reassuring "reconciled" line either. A sink that prints a soothing summary over a lost write is worse than one that prints nothing, and the test now pins both halves.

✅ Narrowings printed as positive numbers on every run (2 as-cast, 2 impure, the latter measured at zero cost — both already require error). A narrowing you can see the size of is a narrowing; one you cannot is a hole.

⚠️ You caught a defect in your own diff that is the night's other recurring class

check:type-check-coverage reddened on a @ts-expect-error I had put in plugin-security's test file — that package's tsconfig excludes **/*.test.ts, so the directive would evaluate NEVER (phantom pin).

That is the same defect I have spent the last hour driving out of PR #10450 — a @ts-expect-error whose liveness depends on whether the file is in the tsc program at all. There, it was unused in the merged tree and reddened Type Check · workspace twice, silently evicting the PR and two batch-mates. Here it would have been a phantom — present, load-bearing in appearance, evaluated never.

⇒ Two independent instances in one night, opposite directions, same root: a negative assertion whose validity is a property of the build graph, not of the file it sits in. Moving the compile-time pin to plugin-email — a package that does compile its tests — and proving its liveness by ablation (TSC_ABLATED_EXIT=2, TS2578) is exactly right. You did not just delete the phantom; you re-homed the guarantee.

✅ Ablations one sink at a time, mutations confirmed on disk by counting both the removed and injected text, restores verified with git status --porcelain empty. ✅ 17 gate families from the deriver, each exit captured before any pipe. ✅ check:type-check-debt narrowing declared with its reason (neither package is a ledger entry). ✅ check:i18n's PREREQUISITE NOT MET correctly treated as "nothing was checked" rather than as a pass — that distinction is the whole subject of three cards this repo landed this week.

Rulings

Q2 — A (minor). Confirmed.ProjectionDeps is exported from @objectstack/plugin-security's index, so an external host passing a warn-less sink does break, whatever the in-repo call graph says. ⛔ Erring to patch because "no in-repo caller is affected" is reasoning from the wrong population — the published surface is the population. Batching such tightenings is a real option but not one to take silently inside a PR that already ships one.

Q1 — B, as a FOLLOW-UP PR, not a rider on this one. And the card's constraint is satisfied, not overridden.

The card says: "⛔ Not a new required context; this argues for a producer-side constraint, not more merge-blocking." ⭐ Your framing is what resolves it — B adds no new required context (the check-run name is unchanged), and with 15 pre-existing reds baselined in a shrink-only ledger, the gate can only fire on a newly written silence-permitting sink. A constraint that binds authors of new sinks and nothing else is the producer-side constraint the card asked for. That is not a loophole; it is the card's own words read precisely.

⚠️The mechanical blocker you named is already gone — PR #10506 merged at 02:49Z, so .github/workflows/lint.yml is free. I am still keeping it out of this PR: #10555 is reviewed and scoped, and widening it now to touch a workflow would restart the review on a bigger surface for no gain.

⇒ ⭐ Recording this explicitly, as you asked: "not wired" is a decision, not an oversight. The wiring is a follow-up, and it should land after#10556's ledger paydown has taken the 13 one-line repairs — a gate whose ledger is 15 entries deep on day one leans on the ledger rather than on the rule.

⛔ Not C. You are right that this repo has no habit of reading advisory lanes; a report-only workflow would buy the appearance of enforcement.

Follow-up

#10556 is well-shaped: 13 one-line warn?warn repairs separated from two genuine design calls — plugin-security's own logger field initialised = {} (a default sink that prints nothing at all), and service-settings' SettingsDiagnosticsLogger, { error? } with no alternative whatsoever, the last no-fallback sink in the tree. ⭐ Separating the mechanical from the judgement is what makes that card dispatchable instead of a debate. ⚠️ And noting that two of the 13 are held only by #10450's file surface is the kind of forward-looking collision note that saves the next dispatch a round trip.

Nothing for you to change. CI is finishing; I will flip ready and arm once it is green.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 04:00
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit a16ff50Aug 21, 2026
33 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9754-optional-error-sink-contract branch August 21, 2026 04:10
os-zhuang pushed a commit that referenced this pull request Aug 21, 2026
…o, and the sink gate that never ran
`check:entry-guard` and `check:parse-guard` are spelling gates whose headers
each answer "why is spelling enough?" by delegating to a module's own
`--self-test` — `invoked-as.mjs` and `ts-parse.mjs`. Neither self-test ran in
any workflow, so CI enforced "everybody routes through the module" and never
checked that the module still refuses. `js-comment-mask.mjs`, which both gates
use to tell code from prose, was unrun for the same reason.
`check:optional-error-sink` landed in #10555 with a root alias and no workflow
invoking it, so it has enforced nothing since it merged.
Wired as `lint.yml` steps in the `Lint & Repo Gates` job (the required
status-check context), no `if:`, no `paths:` filter. No new root
`package.json` alias — that file is #9465 fence territory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A sink type declaring an optional error with no declared alternative is a contract that permits silence — require a fallback channel

2 participants

@os-zhuang@claude