Uh oh!
There was an error while loading. Please reload this page.
fix(plugins,services): guarantee a warn channel on twelve optional-error sinks - #10691
Conversation
…`error` sinks Pays down twelve of the fifteen rows in the shrink-only optional-error sink ledger (#9754): each sink declaring an optional `error` now declares a NON-optional `warn`, so a durability report always has somewhere to land. `error` stays optional everywhere — requiring it was measured and rejected as #9754 option C. The twelve ledger rows are deleted in this same commit, which the checker requires (it fails on a stale entry). Three forwarding seams were tightened with them, each reported by `tsc` rather than guessed at: `MinimalLogger` in plugin-sharing's `rule-hooks.ts` and `record-share-cascade.ts` (both forward into bulk-recompute's now-guaranteed sink), and `AuthManagerConfig['logger']` in plugin-auth (forwards verbatim into `ReconcileMembershipDeps`). Two of the twelve turned out NOT to be one-line repairs: `report-service.ts` and `auto-enqueuer.ts` both defaulted their logger to `?? {}` — the same shape the ledger fences off as an open design call for plugin-security. Neither default was decided here. The field is now honestly optional instead of being given a minted sink that declares it can warn and discards everything; runtime behaviour is unchanged in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…ghtened sink
`partial-index-probe.test.ts` passed `{}` as an `IndexMigrationLogger` to pin
that `logProblem` degrades to silence rather than throwing. `{}` stopped being
a legal value of that type when `warn` became non-optional, so the case now
casts deliberately and says why: the type-level guarantee and the runtime
backstop are different promises, and this case asserts the second.
Caught by `check:type-check-debt --re-measure` (DEBT 63 -> 64), not by the
package build: `@objectstack/metadata-protocol` declares no `typecheck` script,
so its test files are hidden from tsc and a `--filter ... typecheck` run would
have reported a silent zero-match pass. Ledger number untouched, back at 63.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx…r doubles `ReconcileMembershipDeps['logger']` guarantees a `warn` channel (#9754), so the three `{ error: vi.fn() }` doubles in `reconcile-membership.test.ts` stopped being legal values of it. A fourth double in the same file already declared `warn` and was unaffected. Measured rather than assumed, because these files are HIDDEN from `pnpm typecheck` (plugin-auth's tsconfig excludes its own tests, TEST_DEBT): `tsc` over a tsconfig with the test exclusion dropped reports 98 with this branch's plugin-auth changes reverted and 101 with them, so the +3 was this branch's. With these three doubles repaired it is 98 again. ⛔ The TEST_DEBT ledger number is untouched at 109 and `--lower` was NOT run. The 11-error surplus #10615 reports is left exactly as that card describes it — neither consumed by this branch nor closed by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 10 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 31 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 8ce371e3e8ed09ebb3771e2d2e55b4884dbc125d && git checkout 8ce371e3e8ed09ebb3771e2d2e55b4884dbc125d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 47cd3ec1f8d7defc207b7a6669edafc5e9a565f4 44232c2c855f040c2e783d061c7d858108981b85 && git checkout -B drift-repro 47cd3ec1f8d7defc207b7a6669edafc5e9a565f4 && git merge --no-ff 44232c2c855f040c2e783d061c7d858108981b85
node scripts/docs-audit/affected-docs.mjs --json 47cd3ec1f8d7defc207b7a6669edafc5e9a565f4
|
…inor` Three defects in the changeset text, all found in PM review of #10691: 1. `AuthManagerConfig` does not exist. The type is `AuthManagerOptions` (auth-manager.ts:375, public via `export * from './auth-manager.js'`), and the tightened `logger` field is :647 inside it. Measured: `AuthManagerConfig` has ZERO hits in `packages/**` source and only two in a stale CHANGELOG.md — so an embedder who hit the compile error and grepped the name this changeset shipped would have found an already-retired spelling and been led away from the fix. That is what makes it worse than a typo: a changeset body IS the published CHANGELOG. 2. `patch` contradicted the changeset's own text. `check-changeset-no-major.mjs` records the convention in its own WHY THIS EXISTS block: "During the launch window we ship breaking changes as `minor` (pre-1.0 semantics ...)" — every publishable package is in the Changesets `fixed` group and versions in lockstep, so the bump level is the only channel that tells the stack a release can break a build. A changeset that says "an external embedder ... will now see a compile error" cannot also be `patch`. Now `minor`; `major` is banned by that same guard. 3. The break is now DECLARED (`**BREAKING**`) and carries its ADR-0087 disposition, which a declared-breaking changeset must state in writing (AGENTS.md, enforced by `check:adr-0087-registration`). The marker is `runtime-interface-only`, naming the seven exported symbols, and it was chosen by reading the gate rather than by pattern-matching the common case: `no-migration-prescription` is REFUSED when the body carries a prescription, and `runtime-interface-only` inherits that same refusal (#8299) — it is a narrowing, never an escape. It is claimable here because it is TRUE and mechanically re-checkable: every tightened type is a plain TS logger interface, no Zod projection, no metadata surface and unreferenced by one, so `objectstack migrate meta` provably has nothing to rewrite. Nothing is removed or renamed, so AGENTS.md's FROM → TO requirement (scoped to removals and renames) is not triggered; the consumer action is stated plainly instead. Changeset text only — no source file is touched by this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
Uh oh!
There was an error while loading. Please reload this page.
…sink Drops the `?` from `warn` on ApprovalServiceOptions['logger'] so a sink declaring an optional `error` always has a guaranteed durability-report channel (#9754, #10556). `error` stays optional. This is the thirteenth of the card's thirteen mechanical repairs -- held out of #10691 to serialize against PR #10547, which owned this file while open; that fence cleared when #10739 merged. Deletes the now-stale row from the shrink-only scripts/optional-error-sink-contract.baseline.json (the checker itself named the row as no-longer-red). The other two rows -- plugin-security's default-sink design call and service-settings' no-fallback design call -- are untouched; both remain escalated to the maintainer. No construction site needed a widening fix: the sole non-test call (ApprovalsServicePlugin.start) passes ctx.logger, whose warn is already required by the core Logger contract. Part of #10556. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0f14f70b-575c-5f2b-a235-4000a55db042
Part of #10556
Part of, not a closing keyword. The dispatch asked forFixes, and that would be wrong here: #10556 covers fifteen ledger rows andthis PR pays down twelve. The three that remain are the two DESIGN calls the
card escalates and one serialisation leftover. A closing keyword would merge this
and silently close the card that is the only tracking artifact for those design
decisions — and inbox filters only read
open. The card should be re-triaged, notauto-closed.
Scope, and the one exclusion
Twelve of the card's thirteen mechanical repairs: drop the
?fromwarnso asink declaring an optional
erroralways has somewhere to put a durabilityreport, then re-check the package's call sites.
errorstays optional on everyone of them — requiring it was measured and rejected as #9754 option C.
⛔
plugin-approvals/src/approval-service.tsis excluded — serialisationagainst PR #10546, which owned that file. 12/13, not an omission.
it. PR #10546 merged at 2026-08-21T05:08:46Z, so the file is no longer held.
It is a one-line repair with no design content, and its ledger row now says so.
Left for a follow-up dispatch rather than silently widened.
The
plugin-auditquestion, verified rather than inheritedThe card holds the two
plugin-auditentries back because that package was PR#10450's file surface. Re-checked directly: #10450 merged at
2026-08-21T04:53:22Z. The package is free, so
AuthEventAuditLoggerandReadAuditLoggerare repaired normally here.AuthEventAuditLoggeris the sink#9754's body calls the sharpest instance, and its docblock now records the whole
arc:
error?+debug?and nowarnat all → #9750 addedwarn?→ non-optional.What
tscfound that the card did not predictThe card sizes all thirteen as one-line repairs. Two of them are not, and three
call sites went red. Every one of these is a real signal, and none was papered
over by widening a consumer.
Three forwarding seams
A guarantee is lost at the seam where a looser sink is handed to a tighter one.
tscnamed each; none was guessed at.MinimalLogger— plugin-sharingrule-hooks.tsstashAffectedRowsOnCtxwarnrequiredMinimalLogger— plugin-sharingrecord-share-cascade.tsstashAffectedRowswarnrequiredAuthManagerOptions['logger']— plugin-authReconcileMembershipDeps['logger']warnrequiredThe third is the only externally visible one, so it was measured before being
tightened rather than after: the sole non-test construction site in this repo is
auth-plugin.ts, which passesctx.logger— the kernelLogger, whosewarnis already required — so the in-tree cost is zero. The field stays optional;
what changed is that a logger which is supplied must be able to warn.
Two of the twelve carry the
= {}default the card fences off as a design callreport-service.tsandauto-enqueuer.tsboth didopts.logger ?? {}— thesame shape the ledger records against
plugin-security, where it is explicitlynot a
?-deletion. So the card's "thirteen are a one-line repair" is notquite right, and this PR does not quietly decide the question it opens.
Neither default was invented here. The private field is now honestly optional
(
this.logger?.warn?.(…)) instead of being handed a minted{ warn: () => {} }— a sink that declares it can report and discards everything, which is the
failure #9754 exists to stop, not a repair for it. Runtime behaviour is
unchanged in both directions: an absent logger and
{}both printed nothingbefore and print nothing now. Whether an absent host sink should instead default
to a
console-backed one is left to the maintainer, with the rest of the designcall.
Two DESIGN calls: measured, not decided
Per the dispatch these are reported, not answered. Both measurements are new.
1.
plugin-security/src/security-plugin.ts— the= {}field is not merelya boot-window risk.
start()has two early returns above thethis.logger = ctx.loggerassignment (missingobjectql/metadataservice, andan engine without
registerMiddleware), so on either paththis.loggerstays{}for the lifetime of the instance — the field is assigned nowhere else. Costof requiring
warn: a default must be chosen, which is the decision.rather than quietly dropped. I first wrote that the
securityservice"registered back in
init()keeps serving, so its six fail-closed reports gonowhere". Both halves were wrong:
registerService('security', …)is at:1175inside
start()(which spans:812–:3085), not ininit(), so on anearly-return boot the service is never registered and those reports are
unreachable rather than silent; and the file has eleven
this.logger.*sites, not six — six was a partial grep window. Mapping each line to its
enclosing method (rather than eyeballing) leaves exactly one site reachable with
logger === {}::3576incheckAuthoredRowWrite, a public instance methodthat does not depend on registration. That path is structurally possible and
not demonstrated — no in-repo caller invokes it on the instance rather than
through the registered service. Filed with the full correction as #10706, so
the finding survives however the default question is ruled.
2.
service-settings/src/settings-service.types.ts— cost of a requiredwarn, counted: 10 one-member{ error }spies across 6 test files stopbeing assignable. Two corrections to the card's framing, both measured:
config-change-audit.tsalready re-adds the channel locally asSettingsDiagnosticsLogger & { warn?: … }and degradeswarn→error, so partof the tree has already paid for the missing member; and the three report sites in
settings-service.tsare not silent at runtime — each isif (this.logger?.error) … else console.error(message). Theno-fallbackverdict is a statement about the type, and the module routes around it with a
hard-coded
console. That makes this the same question as (1) — console escapehatch, or a declared channel — which is an argument for ruling on both together.
Ablation
Predicted signature written before mutating. Resolution path: the checker
readFileSyncs.tssource underpackages/**(SCAN_ROOTS = ['packages'])— no
dist/, no module resolution — so no rebuild is involved, and no ablationleg here can be a build artifact.
Leg A — un-repair
auth-event-audit.ts, ledger row already deleted.Predicted: exit 1, census
22 → 21guaranteed /3 → 4red, violation namingAuthEventAuditLogger. Observed, exactly:Leg B — sink repaired, its ledger row put back. Predicted: exit 1, census
unchanged at 3 red, a stale-entry failure. Observed, exactly:
Leg B is the one that matters for review: it proves deleting the twelve rows was
mandatory in this PR, not cosmetic tidying.
Both restores proved byte-identical with
git hash-object(
01f65820ed8da845e70129f7fd3b37776115b59aand568bfc54f3153d6b254a16d8424b956e3ee48ee3), and the restore leg re-measuredgreen (exit 0,
3 baselined).Verification
Gate union derived by
node scripts/pm/dispatch-gates.mjswith no patharguments, re-derived after the source-final commit at
5243df47f(21paths; the family set came back identical to the 19-path derivation). Exit codes
captured before any pipe —
cmd > file 2>&1; EXIT=$?— never throughtail.44232c2c8), statedrather than implied. That commit changes one file,
.changeset/optional-error-sink-paydown.md:git diff --name-only 5243df47f HEAD -- packages/ scripts/returns 0 files,so every source input the ratchets read is byte-identical and the full suite was
not re-run. What was re-run at the new head: the five changeset gates below,
plus
check:optional-error-sink(exit 0,3 baselined) andcheck:nul-bytes(exit 0) because those two are cheap and read the tree directly. The test suites,
the 44-package consumer sweep and
check:type-check-debt --re-measureare the5243df47fmeasurements and are labelled as such.13 path-matched families + 6 convention-triggered +
check:optional-error-sinkand
check:nul-bytes— all exit 0. Verdict lines as each gate printed them:Census movement, the whole point of the card: 15 red → 3, and
fallback-guaranteed10 → 22.check:route-envelopeandcheck:dispatcher-error-vocabularywere run explicitly (each also with--self-test) because the diff touchespackages/plugins/plugin-auth/**— class#10309. The derivation did name
route-envelopeon its own (viacloud-connection-plugin.ts) but notdispatcher-error-vocabulary; bothgreen.
Tests — all ten changed packages,
5410 passedacross323files:cloud-connection 210, metadata-protocol 1738 (+10 skipped), plugin-approvals 519,
plugin-audit 313, plugin-auth 1359, plugin-email 421, plugin-reports 70,
plugin-sharing 624, plugin-webhooks 119, service-knowledge 37.
typecheckclean on all seven packages that declare the script — each echoedtsc --noEmitthenDone, so none was a zero-match silent pass.Consumer sweep, direction stated. The downstream (prefix) direction is
where a contract tightening lands, so the filters were spelled
--filter '...@objectstack/plugin-auth'— dots before the name, consumers —and not the suffix form
'@objectstack/plugin-auth^...', which is the oppositedirection (dependencies). 44 packages built green in the consumer direction,
after the dependency closure was built first with the suffix form.
(Spelled out with a real package name on purpose: GitHub's body sanitizer eats
short angle-bracket fragments, so an angle-bracketed
pkgplaceholder heresilently became
--filter '...'— and the whole distinction this paragraphexists to make disappeared with it. Observed on this very PR body, and corrected.)
Two things the package builds could not have caught
@objectstack/cloud-connection,@objectstack/metadata-protocoland@objectstack/service-knowledgedeclare notypecheckscript, so a--filter … typecheckrun over them exits 0 having run nothing. Both findingsbelow came from measuring instead:
check:type-check-debt --re-measurecaughtmetadata-protocolDEBT drifting63 → 64. Cause:
partial-index-probe.test.tspassed{}to pin thatlogProblemtolerates neither channel.{}is exactly what the tightenedtype forbids, so the case now casts deliberately and says why — the type-level
guarantee and the runtime
?.backstop for a lying host are differentpromises, and that case asserts the second. Ledger number untouched, back at
63.
@objectstack/plugin-auth's hidden test layer. Measured directly with the testexclusion dropped: 98 with this branch's plugin-auth changes reverted, 101
with them — so the +3 was this branch's, and three
{ error: vi.fn() }doubles in
reconcile-membership.test.tsare repaired. Back to 98.⛔ TEST_DEBT ledger untouched at 109, and
--lowerwas NOT run. The 11-errorsurplus #10615 reports is left exactly as that card describes it — this branch
neither consumes nor closes it. (The gate reports the surplus as
ℹ, not anerror.)
Scope discipline
⛔ No
packages/specfile is modified —git diff --name-onlycovers.changeset/, tenpackages/**source trees, three test files andscripts/optional-error-sink-contract.baseline.jsononly. ⛔ Nothing undercontent/docs/releases/. The card's own fences hold: theexamples/app-showcase16th sink is untouched and the checker's
packages/**scan is not widened.Changeset: present,
minorfor all ten published packages, declared**BREAKING**, and carrying its ADR-0087 disposition.Three corrections after PM review, all measured rather than taken on advice:
AuthManagerConfigdoes not exist —git grep AuthManagerConfig -- packages/returns zero source hits and onlytwo lines in a stale
plugin-auth/CHANGELOG.md. The real declaration isAuthManagerOptions(auth-manager.ts:375, public viaexport * from './auth-manager.js'), with the tightenedloggerat:647.This is worse than a typo because a changeset body ships verbatim as
CHANGELOG.md: an embedder who hit the compile error and grepped the namewould have found an already-retired spelling and been led away from the fix.
patchcontradicted the changeset's own text.check-changeset-no-major.mjsstates the convention in its own WHY THISEXISTS block — "During the launch window we ship breaking changes as
minor(pre-1.0 semantics …)" — and every publishable package is in the Changesets
fixedgroup, so the bump level is the only channel that tells the whole stacka release can break a build. I looked for a convention exempting a types-only
break and found none.
majoris banned by that same guard.rather than pattern-matching the common case.
no-migration-prescriptionisrefused when the body carries a prescription, and
runtime-interface-onlyinherits that same refusal (ADR-0087's changeset disposition has no category for published runtime TS interfaces with no metadata surface — the #8277 exemption argument is correct, unverifiable, and will be re-litigated #8299) — a narrowing, never an escape. The
honest and mechanically re-checkable claim here is
runtime-interface-only,and the gate verified all seven named symbols resolve as plain interfaces that
are neither metadata surfaces nor referenced by one:
no declared-breaking changeset (1 non-breaking changeset(s) seen)— becausethe gate reads changeset content from git, and the rewrite was still
uncommitted. The green was about the old
patchtext. Committing first andre-running is what produced the verdict above; the pair is the evidence that the
first green was uninformative.
Release condition, not an observation
This closes silence at authoring time, not at runtime. Call sites keep the
logger?.warn?.(…)spelling — deliberately, perSweepLogger's recordedmeasurement — so a host that lies about its shape (a plain-JS embedder, or a cast
at the boundary) still degrades to silence rather than throwing inside a
durability catch. The guarantee this PR buys is that such a host can no longer be
written in TypeScript against these contracts; it does not make a lying host
loud. Three sinks still permit silence by declaration and are listed in the
ledger with the reason each is still there.
Generated by Claude Code