Uh oh!
There was an error while loading. Please reload this page.
Require a guaranteed warn channel on any sink type that declares an optional error (#9754) - #10555
Conversation
…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_01DdCnBGcHeufjrq7drTD3wtA 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
📓 Docs Drift Check4 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
os-zhuang
commented
Aug 21, 2026
PM review — ⭐ accepted, both open questions ruled below.⭐ The justification is the part that makes this land-able
⇒ 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 ✅ Narrowings printed as positive numbers on every run (2
|
Uh oh!
There was an error while loading. Please reload this page.
…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
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
warnspecificallyThe card left the exact strength to whoever took it ("whether the alternative must be
warnspecifically, or any of{warn, info, log}, is a judgement — say which and why"). The answer here iswarn, non-optional, for two reasons:{ info }was a legalSweepLogger— and against it both repaired reports still print nothing: each reaches forerror, finds none, falls back towarn, and finds none of that either. Requiringwarnis what turns "discouraged" into "unrepresentable", which is the card's own deciding argument (authoring-time, not gate-run-time).warnand notinfo/log, because the channel exists to carry a durability degradation. AGENTS.md "Degradation log levels" ([convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632) rules such a report must beerror; check-durability-degradation-log-level:loggerLevelcannot see the(logger.error ?? logger.warn)(…)fallback, so a loud catch reads assilent-swallow— and the spelling it CAN see prints nothing #9657/Thelogger?.error?.(…)spelling also carries two SUMMARY durability reports that no catch guards, so no gate can see them #9748 degrade it towarnand no further, sincewarnis the lowest level a reader still reads as a failure. A lost write reported atinfois precisely the reassuring half-truth that rule exists to remove.errorstays 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 holdsLoggerLike,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 declareserror.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 optionalerror).(globalThis as { console?: { error?: ... } }).console?.error?.(m)describes a foreign object the module does not own; nobody can addwarnto the host'sconsoleby decree. Without this narrowing the gate would have reddenedpackages/types/src/degraded-boot.ts's deliberate stderr-then-console-then-silence chain.errormember that carry non-vocabulary members —DomainHandlerDepsand the kernelLogger— declareerrorrequired, 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 ispackages/**— 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.
AuthEventAuditLoggerandReadAuditLoggeruse 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:error(non-test source)errorREQUIRED — nothing to guaranteeerroroptional andwarnREQUIRED — already cleanerroroptional andwarnOPTIONAL — rederroroptional and NOwarnat all — redas-cast narrowing of a foreign objecterror— out of the populationSo 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 care —
sql-driver.ts,service-datasource/logger.ts,db-job-adapter.ts,email-service.ts,lifecycle-service.ts,service-queue/common.tsand both triggers — every one of which already declareswarnrequired beside an optionalerror. The 17 are drift from a convention the repo half-holds.What is repaired, and what is ledgered
Repaired (2):
SweepLogger(plugin-email) andProjectionLogger(plugin-security) — the two the card names, and the two whose consumer surface the lane PM had already measured. Re-verified that surface:ProjectionLoggeris reached bybootstrap-declared-capabilities.tsandbootstrap-declared-permissions.ts, and escapes the package through the exportedProjectionDeps;SweepLoggeris package-internal. No call site outside those packages passes either sink (reconcilePermissionSetProjection(/sweepStrandedOutbox(have no callers inpackages/**,apps/**orexamples/**beyond their own packages).Ledgered (15), in
scripts/optional-error-sink-contract.baseline.json— shrink-only, stale-entry-fails, no--fixflag, 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. Makingwarnrequired forces a decision about what that default should be, which is a design call.service-settings/SettingsDiagnosticsLogger— the onlyno-fallbacksink 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 requiredwarnbreaks that. Highest-priority entry.plugin-audit's two sinks — deliberately untouched:packages/plugins/plugin-auditis open PR docs(plugin-audit): document theos serveopt-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 thatelsebranch is skipped too. Both tests then show the type refusing that sink.Ablation 1 — the checker. Reverting
warntowarn?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):Restored both times with
git checkout HEAD -- path,git status --porcelainempty afterwards (no staged/unstaged split), and the checker back to exit 0.Ablation 2 — the type. Same mutation,
pnpm --filter @objectstack/plugin-email typecheck: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-errorinpermission-set-projection.test.ts— andpnpm check:type-check-coveragereds 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-sinkruns 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: everycheck:*gate in this repo lives in the requiredLint and Repo Gatesjob, and.github/workflows/lint.ymlis open PR #10506's file surface. Whether to wire it is a lane-PM call, not one to take inside this PR.Verification, at
4d7374f8a9turbo run typecheck test --filter=@objectstack/plugin-email --filter=@objectstack/plugin-security— 23/23 tasks,plugin-email421 tests / 26 files,plugin-security1321 tests / 67 files, all passing.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.check:type-check-debt --re-measurewas not run locally — it needs the whole workspace closure built, and neither edited package is a ledger entry (both declare atypecheckscript), so this diff cannot move that ratchet. CI runs it.check:i18nneeded@objectstack/clibuilt 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-emailand@objectstack/plugin-security. Both are published (.changeset/config.jsonlists them in thefixedgroup,ignoreis empty, neither isprivate), and this tightens an exported type contract, so it is aminorrather than apatchand the body carries the one-line fix for anyone passing awarn-less sink. Noskip-changeset.For review
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 throwslogger?.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.Generated by Claude Code