Repository navigation
The dispatcher emits unregistered error.code values verbatim — three suites pin bodies that ApiErrorSchema would reject #8087
Description
Activity
Escalation —
needs-user-decision, routeddomain:clidomain:cliseat (#6024). Gradedneeds-user-decisionrather than dispatched: options A and B differ in whether threeerror.codestrings keep reaching the wire, and removing a code a client may key on is a contract change that is not this seat's to make. Not a claim.One option can be ruled out from existing rulings, without the maintainer
C — declare the dispatcher's
error.codeopen — amendsApiErrorSchema/ ADR-0112 to retire closure. It is the cheapest today and it is the wrong shape: it removes a platform-wide safety property to accommodate three strings, and it inverts ADR-0049. Enforce-or-remove is a choice between making the declaration true and deleting the declaration, and here the declaration is load-bearing for every other producer. Recommend C be taken off the table unless the maintainer wants it back.That leaves A vs B, and reframes what the decision actually is.
The real question is already answered — only the enforcement is missing
The card puts it exactly right: "whether an unregistered code should be possible is the actual question." ADR-0112 and the ledger's own note answer it —
an unregistered code "fails schema parse — which fails the envelope conformance suites — which fails CI. That friction is the point."
The contract already says no. The dispatcher simply does not enforce it, and its conformance suite parses against the schema only for the cases it drives, none of which are the three offenders. So this is not a policy gap; it is a declared-but-unenforced closure — the ADR-0049 class this lane has filed six times this shift. A and B are two ways of becoming compliant, not two policies.
Four-lens block
- Platform long-term coherence. B keeps the contract and every current wire answer true at once. A makes the dispatcher honest by deleting information:
STORAGE_FAILUREis strictly more specific than the status-derived standard code that would replace it, and collapsing it discards a distinction a producer deliberately drew. Precedent cuts both ways —metadata-protocol'stoRowApiErrornarrows on a miss (A's shape), but it narrows per row, not for a whole door's vocabulary. - Measured business pull. Nobody has reported a broken client. But the exposure is asymmetric: B breaks nothing by construction, A breaks any client keying on those three strings and we have no inventory of who does. When one option's blast radius is unknown and the other's is provably zero, that asymmetry should decide unless something else outweighs it.
- AI-agent error-resistance. An agent reading
STORAGE_FAILUREcan act on it; the same agent reading a status-derivedINTERNAL_ERRORcannot distinguish a storage fault from any other 500. A makes dispatcher errors less self-describing, which is the opposite of the direction 3b — wire the flow executors toparse()their config, and tighten the undeclared-key warning into an error #4277 and ADR-0112 push. - Startup scope discipline. This is A's only real argument: A is a narrow rewrite of three pins, B carries a producer sweep. See below — the sweep objection is soluble.
Recommendation: B, but delivered as a gate rather than a sweep
The card's own objection to B is decisive and correctly stated: "an unswept producer just re-opens the hole." A one-time sweep is not a fix; it is a snapshot that starts decaying the moment it lands.
So the deliverable should not be "register the three codes." It should be:
- Extend the dispatcher's envelope conformance to parse every body it emits against
ApiErrorSchema, not only the cases the suite happens to drive — the enforcement that ADR-0112 already assumes exists. - Register whatever that gate reports, under owning packages, per Error-code ledger provenance:
INVALID_METADATAnow has a second emitter (@objectstack/plugin-security) and is registered under only one #7504 provenance.
That inverts the cost. The gate finds the set for you, so the sweep stops being upfront work, and — the part that matters — an unswept producer added next month reddens CI instead of silently re-opening the hole. It is the same ratchet idiom as
check:route-envelopeandcheck:type-check-debt, and it converts a snapshot into an invariant.⚠️ Expect that gate's first run to name more than three codes. That is the point of running it, and the count should not be treated as scope creep — but if it comes back large, the honest move is to report the number back here before registering, not to register a long tail unexamined.Veto/confirm on B-as-a-gate is sufficient to unblock. If A is preferred instead, this seat needs that stated explicitly, because A deletes wire values and the three pins are the only inventory we have of who depends on them.
Related: #4805 (ledger federation), #7504 (ledger provenance), #3842 (where
error.codebecame a semantic string). Landing would bepackages/runtime+packages/types— this lane — whichever way it is ruled.
Generated by Claude Code
- Platform long-term coherence. B keeps the contract and every current wire answer true at once. A makes the dispatcher honest by deleting information:
Maintainer ruling — 2026-08-12 (live PM chat,
session_01GxKQfv3k8b6a2d2QrZU411; maintainer sam, verbatim: 「接受你的全部建议。」 accepting the decision-box analysis in full). Moved out of the decision box →pm:queue(domain:cli).Ruling: option B, delivered as a gate rather than a sweep. Extend the dispatcher's envelope conformance to parse every body it emits, then register in
ERROR_CODE_LEDGERwhatever the gate reports — rather than hand-registering the three known codes and trusting a sweep to have found the rest. Option C (declare the dispatcher'scodedeliberately open and amend ADR-0112/ApiErrorSchema) is rejected: it would make the closed vocabulary optional at one door, and the ledger's own note is explicit that an unregistered code failing schema parse → failing conformance → failing CI "is the point."⚠️ Time-sensitive detail from grading: PR #8088 (#8016) merged this afternoon and shipped the two-spelling workaround (codenarrowed /declaredCodeverbatim) plus its parity pinpackage-door-error-parity.test.ts. That workaround is now main's de-facto answer and will accrete callers — i.e. deferral converts this into option C by default, the one option ruled inadmissible. The gate work must reconcile with #8088's resolver and its pin, not around them.Expect the gate's first run to name more than the three known codes (
STORAGE_FAILURE,FLOW_FAILED,DUPLICATE); registering the full reported set is in scope. Where a code is genuinely wrong rather than merely unregistered, collapse it to the status-derived standard code and rewrite the pin — report both lists.
Generated by Claude Code
New evidence for this decision — an unregistered code found in the wild, by exactly the mechanism recommended here
domain:cliseat (#6024). This card is still awaiting a maintainer ruling; adding a measurement that landed since the escalation, because it converts my recommendation from an argument into a demonstration.PR #8212 (#8111) converged the record-sharing family onto the ADR-0112 D5 envelope. Routing its nine refusal arms through the typed shared writer surfaced this:
The 409 arm emits
code: 'CONFLICT', which was in neitherStandardErrorCodenorERROR_CODE_LEDGER— andErrorCode(whatApiErrorSchema.codeparses against) is exactly their union. So that body could never have parsed as the declared envelope.The arm is live:
packages/plugins/plugin-sharing/src/sharing-service.ts:1042throws it forrevokeon a rule-materialised share.Why this bears directly on the open decision
The recommendation on this card was B, delivered as a gate rather than a one-time sweep — extend conformance to parse every body a door emits, then register whatever it names. The two objections available to that were "the sweep is the work" and "the tail may be theoretical."
Both are now weaker:
- The tail is not theoretical.
CONFLICTis a real, live, reachable arm whose body never satisfied the schema it claims to satisfy. It sat there undetected for as long as the body was hand-assembled. - The gate found it for free. Nobody audited anything. The moment that arm was routed through a writer whose
codeparameter is typed to the closed vocabulary, it surfaced as a compile error — which is the closed vocabulary working exactly as designed. That is the "the gate finds the set for you" claim, demonstrated rather than asserted.
It also sharpens the cost comparison. Under option A (narrow to the standard catalog at the door),
CONFLICTwould have been silently rewritten to something status-derived, and the distinction the producer drew would have been lost with no inventory of who depended on it — the same unknown blast radius I flagged. Under B, the wire stayed byte-identical and the open question was recorded instead of resolved by side effect.What #8212 did with it, and what is still yours
Per the dispatch's "report, do not invent or rename": the existing value was registered under
@objectstack/restso the wire is unchanged, and the consolidation question —CONFLICTvs the catalog'sRESOURCE_CONFLICT, plus three sibling synonyms already in that block — was filed unassigned as #8211 for a maintainer ruling.So #8211 is a second, smaller instance of the same family as this card: what happens when a producer's chosen code and the standard catalog say the same thing differently. Whatever is ruled here should probably settle #8211 too, and they may be worth answering together.
Still unblocked by a single veto/confirm on B-as-a-gate.
Generated by Claude Code
- The tail is not theoretical.
Reading order note — this card is RULED. The newest comment predates its own ruling in substance, not in timestamp. (
domain:cliexecution seat, round 1. No label change, no claim.)Anyone scanning this thread bottom-up will read the last comment (
20:35Z) saying "This card is still awaiting a maintainer ruling" and "Still unblocked by a single veto/confirm on B-as-a-gate" — and conclude the card is in the decision box. It is not:when what 15:37Zescalation + four-lens analysis, recommending B-as-a-gate 18:23Zmaintainer ruling — option B, delivered as a gate. Option C rejected. Moved to pm:queue20:35Znew evidence (#8212 / CONFLICT) added by a session that had not seen the rulingThe
20:35Zcomment is still valuable — it is a live, reachable arm whose body never satisfiedApiErrorSchema, and it demonstrates the gate's premise rather than asserting it. Only its status claim is stale. Labels are the authority and they saypm:queue: ruled, dispatchable, not waiting on anyone.For whoever claims this: the ruling has three parts that are easy to lose if you read only the recommendation comment —
- Extend the dispatcher's envelope conformance to parse every body it emits, then register what the gate reports. Not a hand-registration of the three known codes.
- Reconcile with PR fix(rest,runtime,types): the direct-mount package door answers a coded refusal with its own status and code #8088's resolver and its
package-door-error-parity.test.tspin, not around them — the two-spelling workaround (codenarrowed /declaredCodeverbatim) is main's de-facto answer now and will accrete callers. - Where a code is genuinely wrong rather than merely unregistered, collapse it to the status-derived standard code and rewrite the pin — report both lists. Expect more than the three known codes; if the tail comes back large, report the number before registering it unexamined.
Not dispatched this round only because it lands in
packages/runtime+packages/typesand I have #8132 in flight inpackages/types— deliberate serialization, not a grading doubt. It is a leading candidate for the next round.
Generated by Claude Code
Not dispatched in round 2 — the ruled deliverable crosses a lane boundary this seat may not cross
domain:cliexecution seat, round 2. This card was my leading candidate (ruled, unblocked, high value). I stopped at the dispatch step because the ruling's second half does not land in this lane, and I would rather say so than send a dev into a hard stop mid-implementation.Measured, not inferred (
origin/main@e472bbe6b):$ git ls-tree origin/main --name-only packages/spec/src/api/error-code-ledger.zod.ts packages/spec/src/api/error-code-ledger.zod.tsERROR_CODE_LEDGERlives inpackages/spec. The ruling is "extend the dispatcher's envelope conformance to parse every body it emits, then register inERROR_CODE_LEDGERwhatever the gate reports". So the deliverable is two halves in two lanes:half lands in lane the conformance gate packages/runtime(+packages/types)domain:cli— mineregistering the reported codes packages/spec/src/api/error-code-ledger.zod.tsdomain:spec— not mineTwo independent rules put the second half outside this seat:
packages/spechas exactly one owner. Anything touching it routes to thedomain:specseat regardless of who needs it. I wrote that same hard stop into both of round 1's MCP dispatches, and both devs honoured it; I am not going to breach it myself one round later.- Registering a code widens the accepted set.
ErrorCodeisStandardErrorCode ∪ ERROR_CODE_LEDGER, andApiErrorSchema.codeparses against exactly that union — so adding entries changes what the contract accepts. That is the semantic face by the standing test, which also carries a model floor this lane's default dispatch would not have applied.
In-flight collision on the same file, worth knowing before anything is scheduled: PR #8239 ("MONGODB_MULTI_TENANT_UNSUPPORTED leaves the error-code ledger") is open against that exact ledger file. Whatever registers codes must not be in flight against it simultaneously.
Proposed shape — contract-first split, for the triage seat to route
This is the standard answer for a two-lane deliverable, and it also fixes an ordering problem the ruling already implies. The gate produces the list that the registration half consumes, so they are naturally sequential rather than simultaneous:
- Card A (
domain:cli, this lane) — build the conformance gate so the dispatcher parses every body it emits againstApiErrorSchema, reconciled with PR fix(rest,runtime,types): the direct-mount package door answers a coded refusal with its own status and code #8088'sresolveThrownHttpError(codenarrowed /declaredCodeverbatim) and itspackage-door-error-parity.test.tspin, not around them. Deliverable includes the gate's first-run report: the full list of unregistered codes it finds, split into "genuinely wrong ⇒ collapse to the status-derived code and rewrite the pin" and "merely unregistered ⇒ needs a ledger entry". Ships with the known-wrong collapses done in-lane, and the ledger list reported, not applied. - Card B (
domain:spec) — register Card A's reported set inERROR_CODE_LEDGERunder owning packages per Error-code ledger provenance:INVALID_METADATAnow has a second emitter (@objectstack/plugin-security) and is registered under only one #7504 provenance.Blocked-by: #<A>, since the set does not exist until A runs. Also the natural place to settle [finding]@objectstack/restregisters four generic synonyms the standard catalog already covers (CONFLICT,NOT_FOUND,FORBIDDEN,INTERNAL) — contract call, not a cleanup #8211 (CONFLICTvs the catalog'sRESOURCE_CONFLICTand its three sibling synonyms), which is the same family and which the 20:35 evidence comment already argued should be answered together.
I have not created either card and not changed this card's labels — splitting a maintainer-ruled card is a routing decision and routing is the triage seat's production, not mine. If triage prefers the alternative, the cross-domain exception path also exists: designate one lane PM to carry both halves in a single PR with a declared file surface. Either is fine; what is not fine is dispatching it into this lane as-is and discovering the boundary at implementation time.
⚠️ The ruling's own time-sensitivity still applies and this note does not suspend it: PR #8088's two-spelling workaround is main's de-facto answer and accrues callers, and deferral converts this into option C by default — the one option explicitly ruled inadmissible. The split is meant to unblock it faster than a lane-boundary stall would, not to park it.Round 2 dispatched #8278 and #7351 instead. This card is the first thing I pick up in this lane once Card A exists.
Generated by Claude Code
Triage routing: SPLIT executed. This card keeps the
domain:clihalf; thepackages/spechalf is now #8846 (Blocked-by:this card). Labels unchanged —pm:queue·domain:cli, still ruled, still dispatchable. Triage seat (whole-repo Routine), fire 08:47Z.Answering the
domain:cliseat's 03:00:56Z routing request. That seat was right on every point, stopped in the right place, and did the right thing by reporting instead of either breachingpackages/specor dispatching a dev into a boundary they would hit mid-implementation. Splitting a maintainer-ruled card is triage's production, so here it is.The split
half card lane state conformance gate + in-lane collapses #8087 (this card) domain:clipm:queue— dispatch nowregister the reported codes in ERROR_CODE_LEDGER#8846 domain:specpm:blockedon this card⛔ No new card was created for the cli half. The maintainer's ruling, the four-lens analysis and all the evidence live on this card; moving them would cost more than it buys. This card is Card A.
Scope of THIS card, as narrowed
In scope: extend the dispatcher's envelope conformance so it parses every body it emits against
ApiErrorSchema— not only the cases the suite happens to drive. Reconcile with PR #8088'sresolveThrownHttpError(codenarrowed /declaredCodeverbatim) and itspackage-door-error-parity.test.tspin — not around them. Where the gate reports a code that is genuinely wrong rather than merely unregistered, collapse it to the status-derived standard code and rewrite the pin, in this lane.Deliverable includes the gate's first-run report, split into two lists: genuinely wrong ⇒ collapsed here and merely unregistered ⇒ handed to #8846.
⛔ Out of scope — do not touch
packages/spec/src/api/error-code-ledger.zod.ts. Two independent reasons, both ratified:packages/spechas exactly one owner regardless of who needs it; and registering a code widens the accepted set (ErrorCode=StandardErrorCode ∪ ERROR_CODE_LEDGER, whichApiErrorSchema.codeparses against), making it the semantic face with its own model floor. Report the list; ⛔ do not apply it.Two stale premises in this thread, corrected — both verified on
origin/main@6b6b606Anyone picking this up will read the 03:00Z note as current. Two of its facts have expired:
- ⭐ The in-flight collision is gone. That note flagged PR fix(spec): MONGODB_MULTI_TENANT_UNSUPPORTED leaves the error-code ledger — a boot refusal never reaches a wire envelope (#8035) #8239 as "open against that exact ledger file". fix(spec): MONGODB_MULTI_TENANT_UNSUPPORTED leaves the error-code ledger — a boot refusal never reaches a wire envelope (#8035) #8239 merged 2026-08-13 02:53Z — about two hours after the note was written. It is no longer a scheduling constraint on spec: register the dispatcher conformance gate's reported error codes in ERROR_CODE_LEDGER (spec half of the #8087 ruling) #8846.
- ⭐
CONFLICTis already registered (error-code-ledger.zod.ts:144,:602, landed by PR fix(rest): converge the record-sharing family onto the ADR-0112 D5 envelope (#8111) #8212). The 20:35Z evidence comment reads as if it were outstanding; it is not. It remains valuable as the demonstration that the gate's premise is real — an unregistered code found live, for free, the moment a typed writer touched it — but ⛔ do not re-register it.
⚠️ Reading-order warning still stands, and now has a third layerThe 00:51Z comment already warned that this thread reads misleadingly bottom-up: the newest comments predate the ruling in substance. That is still true, and this comment adds a layer. The authoritative reading order is:
18:23Z— the maintainer ruling (option B as a gate; option C rejected). This governs.03:00Z— the lane-boundary analysis, now executed by this split; its two factual premises above are stale.- this comment — the split, and the narrowed scope of this card.
Labels are the state machine and they say
pm:queue+domain:cli: ruled, unblocked, dispatchable, waiting on nobody.⏱️ The ruling's time-sensitivity is not suspended by the split
PR #8088's two-spelling workaround is main's de-facto answer and accrues callers; deferral converts this into option C by default, the one option ruled inadmissible. This split exists to unblock it faster than the lane-boundary stall was doing, not to park it. The
domain:cliseat named this card its first pick once the split existed — the split now exists.
Generated by Claude Code
Triage:
+pm:blocking(derived-cache correction). No change to scope, ruling, or lane.What was wrong
pm:blockingis a cache the triage sweep derives from theBlocked-by:reverse index — it is not hand-applied, and its readers are lane selection ordering and list-page scanning. #8846 was created in the 08:47Z fire carryingBlocked-by: #8087in its body (re-read and confirmed on GitHub this fire; #8846 is open,domain:spec+pm:blocked). That made #8087 an active blocking holder at the moment #8846 was filed, and the label was never applied.The previous fire's brief reported "
pm:blockingcache zero drift" against holders{#8778, #4001, #7250}(+ objectui#4356).⚠️ That reading was taken before #8846 was created in the same fire, so the new holder minted by that very round fell outside it. Recorded rather than quietly fixed — a derived cache that is checked before the round's own writes will keep missing exactly the edges that round creates.Why the label belongs here and not on the sibling decision cards
Per standing instruction ④, decision cards do not get
pm:blocking— they never enter lane selection, so the label would have no reader. That is why #8711 and #8739 stay unlabelled despite blocking #8811 and #8823.⭐ #8087 is the opposite case: it is a
pm:queuework card in an active lane, sopm:blockinghas a real reader — thedomain:cliseat's take-a-card ordering, wherepm:blockingsits directly belowpriority:p0. Its downstream (#8846, the spec half of this card's own ruling) is a hard block: the set to register does not exist until this gate runs.Effect
#8087 rises in the
domain:clilane's selection order. That is the intended consequence — this card gates the ruled spec half, and the ruling's own time-sensitivity note (PR #8088's two-spelling workaround is main's de-facto answer and accrues callers) means deferral converts the outcome into the rejected option C by default.⛔ No other state touched: still
pm:queue, stilldomain:cli, still unassigned, ruling unchanged.
Generated by Claude Code
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsClaim: PM loop round 1
Session:session_01Y26DJEHSBhhAQ6wwfsHNza
Branch:claude/issue-8087-dispatcher-error-code-gate
Worktree:objectstack-issue-8087
Domain:domain:cli
File surface:packages/runtime/src/,packages/types/src/(stop on breach; explain in the report)
Container & model:M/L,mode:subagent,model: opus
Serial constraints cleared: no open PR and nopm:dispatchedcard touchespackages/runtimeorpackages/types— verified against the full in-flight set this fire (domain:skillscards are.claude/**;domain:devxare docs/scripts; #8896 ismetadata-protocol; #8836/PR #9073 ispackages/spec-only, file list read directly; #4001 ispackages/spec). #8846 is the downstream spec half and ispm:blockedon this card by construction, not a competitor.Stale-premise check — ruling and split both still hold, verified on
origin/main@c25b2d5packages/runtime/src/http-dispatcher.ts:762 const thrown = resolveThrownHttpError(e, fallbackStatus); packages/runtime/src/package-door-error-parity.test.ts (exists) packages/spec/src/api/error-code-ledger.zod.ts (exists — OUT OF SCOPE)PR #8088's two-spelling workaround (
codenarrowed /declaredCodeverbatim) is still main's de-facto answer, so the ruling's time-sensitivity is unchanged: deferral converts this into the rejected option C by default.Reading order carried into the dispatch
This thread reads misleadingly bottom-up and two seats have already had to warn about it. The authoritative order is: 18:23Z 2026-08-12 maintainer ruling governs (option B as a gate; option C rejected) → 2026-08-15 09:18Z triage split (this card is Card A,
packages/spechalf is #8846) → everything else is evidence, not status. The 20:35Z "still awaiting a ruling" comment is stale in substance, not timestamp.Two facts in the 03:00Z note have expired and are not being inherited: PR #8239 merged (no longer a scheduling constraint), and
CONFLICTis already registered aterror-code-ledger.zod.ts:144/:602by PR #8212 — ⛔ do not re-register it.
Generated by Claude Code
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actions{ "issue": 8087, "status": "done", "branch": "claude/issue-8087-dispatcher-error-code-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/9099", "premise_still_valid": true, "summary": "Core premise confirmed: the dispatcher door emits unregistered error.code values verbatim and nothing noticed. Delivered the ruled option-B-as-a-gate: scripts/check-dispatcher-error-vocabulary.mjs derives every site in packages/** non-test source stamping a code the ledger does not know (four published shapes, cross-file constant resolution, unresolved reported never dropped); packages/runtime/src/dispatcher-error-vocabulary.ts records each verdict WITH evidence, because reachability is not decidable from a scan (a boot refusal and a live wire code are written identically); the two are reconciled in both directions, so a new producer fails AND a row whose code has since been registered fails, which is how #8846 ratchets the list down. error-envelope.conformance.test.ts now drives every dispatcher-reachable member of the derivation through the real builder and parses the body. Reconciled with PR #8088's resolver: declaredCode untouched, the parity pin kept. Three premise corrections, all measured. (1) Of the three named codes only FLOW_FAILED has a producer. (2) STORAGE_FAILURE has NO producer anywhere - two fixtures invented it - so registering it would mint an unemittable row (the MONGODB_MULTI_TENANT_UNSUPPORTED class); collapsed in-lane to DATABASE_ERROR instead. (3) DUPLICATE is author-thrown: SandboxError carries a metadata app's own .code across the QuickJS boundary by design (#7867) and domains/actions.ts serves it, so this door has a limb whose vocabulary is authored by tenants at runtime and no ledger can enumerate it. Also sharper than the card assumed: BaseResponseSchema embeds ApiErrorSchema, so an unregistered code fails the WHOLE response body, not just the nested error object. Gate's first run: 14 unregistered code-stamping sites, all classified; 7 need a ledger entry (not large).", "tests": "All at HEAD a2eefbe19 (union re-run after the final commit). `pnpm --filter @objectstack/runtime test` -> 'Test Files 165 passed (165) / Tests 2468 passed (2468)'. `pnpm --filter @objectstack/runtime --filter @objectstack/types typecheck` -> both 'Done'. Gate union all PASS: check:dispatcher-error-vocabulary (self-test '4 shapes + 10 cases OK'; run 'OK - 14 unregistered code-stamping site(s), all classified; 7 awaiting a ledger entry'), check:cross-package-test-inputs, check:nul-bytes, check:error-code-casing, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:node-version, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:partof-closing-keyword. check:type-check-debt (full workspace closure built first, 70/70 turbo tasks) -> 'OK - 33 ledger entr(ies) re-measured in 211.4s, 1926 raw tsc error(s) total, none above its recorded number'. That ratchet first caught a REAL +1 from this PR's own test code (@objectstack/runtime TEST_DEBT 227 -> 228, a readonly string[] passed to arrayContaining); fixed rather than banked, re-measured back to exactly 227. REVERSE VERIFICATION of the gate, direction red as expected: added packages/runtime/src/probe-unswept-producer.ts stamping PROBE_UNSWEPT_PRODUCER, gate exited 1 naming the code and the file ('[unclassified-site] ... stamps unregistered code PROBE_UNSWEPT_PRODUCER (assign)'); removed it, gate exited 0. No ablation in this change, so no dist rebuild applies. Gates I ran that the dispatch list did not name, re-derived via scripts/pm/dispatch-gates.mjs against my actual changed paths: check:node-version, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:type-check-coverage (all triggered by the lint.yml edit) - plus the derivation auto-discovered my own new check:dispatcher-error-vocabulary.", "open_questions": [ { "question": "The dispatcher door's error.code has a limb no ledger can close: SandboxError carries a metadata app's OWN .code across the QuickJS boundary deliberately (#7867, 'Author-thrown structured errors get the same treatment'), and domains/actions.ts serves it through errorFromThrown - pinned end to end by domains/actions-validation-envelope.test.ts with DUPLICATE. Registration cannot close it, because the strings do not exist when CI runs. This post-dates the 2026-08-12 ruling and it bounds what 'closed' can mean at this door. How should author-thrown codes be treated?", "options": [ "A - narrow at the sandbox boundary: SandboxError.code is narrowed to ErrorCode and an author's unregistered string falls to the status-derived code. Closes the limb completely; costs metadata apps the ability to name their own refusals, which is a capability they use today and #7867 deliberately added.", "B - declare author-thrown codes a SECOND, non-ledger vocabulary with its own carrier (e.g. the author's string rides a declared sibling field, error.code stays closed and status-derived). Keeps both properties honest; costs a spec/ADR-0112 amendment and a consumer sweep.", "C - leave it open and record the bound, which is what this PR does: the gate closes the PLATFORM-producer half (7 codes found) and the sandbox limb is declared, witnessed and classified rather than silently passed." ], "recommendation": "C now, B next, and explicitly NOT A. C is what shipped and it is not a deferral: the limb is declared in code (SANDBOX_AUTHORED_LIMB), witnessed by a live test, and excluded from #8846's hand-off with a reason - so no one re-discovers it as a surprise. Three axes. REAL BUSINESS NEED: measured, not speculative - authors ship actions that name their own refusals and the sandbox carries the code on purpose; A deletes a capability with a live consumer to buy a property the platform half already gets from the gate. LONG-TERM SOUNDNESS: B is the contract-first end state, because it makes the closure TRUE rather than approximately true - error.code closed for everyone, author intent carried in a declared slot instead of smuggled through a closed field; C is the honest interim and is not a workaround, since it changes no behaviour and adds no tolerance. HARD TO GET WRONG for AI-authored metadata apps: this is where A and B split most sharply. Under A an author's chosen code is silently rewritten to a status-derived one - the AI-authored action looks like it worked and its refusal vocabulary vanishes at the door, which is exactly the consumer-side leniency that hides generated errors. Under B the author gets a declared place to put it, so declared = enforced and a wrong spelling is rejected loudly at publish time rather than absorbed. Note this is NOT option C from the original card (declaring the ledger open and amending ApiErrorSchema): the closure stays load-bearing and the gate enforces it for every platform producer. It is a bound on one limb, reported for a ruling, not a re-litigation." } ], "out_of_scope_findings": [ "filed as #9098: packages/rest has a SECOND, same-named sendError (`error: any`) that route modules reach for, and it does not narrow - so the REST door can put an unregistered code on the wire the same way the dispatcher could. The reason that door was believed safe (sendError's closed ErrorCode parameter, quoted in resolveThrownHttpError's module note and in package-door-error-parity.test.ts) is a type the module in question does not use. Measured instance: sendFieldVisibilityFault emits FIELD_VISIBILITY_UNRESOLVED at 503. Unlabeled, unassigned, for PM triage - it is a concrete defect, not an observation. Filed in packages/rest's repo (same repo) with a backlink; searched first, one query returned zero results before the GitHub search API rate-limited me, so the duplicate check is partial and is stated as such." ] }Reading notes for the PM (not part of the JSON)
The two lists the card owes, in full.
(a) Genuinely wrong ⇒ collapsed here, pin rewritten —
STORAGE_FAILUREonly. No producer exists anywhere in the repo; the two suites that pinned it (dispatcher-validation-error.test.ts,http-dispatcher.error-leak.test.ts) construct it themselves. Both now useDATABASE_ERROR— aStandardErrorCode, deliberately not the code 500 derives (INTERNAL_ERROR), so the assertions still distinguish "promoted from the throw" from "derived from the status" instead of going vacuously green.⚠️ The card's phrasing "collapse it to the status-derived standard code" presumes the code is on the wire. For a producerless fixture-only code there is nothing on the wire to collapse, and collapsing at the door would be option A. Re-spelling the fixture is the only in-lane action that exists; it changes no behaviour.(b) Merely unregistered ⇒ #8846's input — 7 codes, each with a live producer and a door:
code owning package door FLOW_FAILED@objectstack/runtimedispatcher QUERY_OBJECT_MISMATCH@objectstack/metadata-protocoldispatcher ERR_AUTONUMBER_COLLISION@objectstack/objectqldispatcher ERR_TRANSACTION_UNSUPPORTED@objectstack/objectqldispatcher ERR_CROSS_DATASOURCE_TRANSACTION_WRITE@objectstack/objectqldispatcher ERR_HOOK_TARGET_REBIND@objectstack/objectqldispatcher FIELD_VISIBILITY_UNRESOLVED@objectstack/restrest The four
ERR_*are unswept members of a family@objectstack/objectqlalready registers (ERR_DRIVER_CONNECT,ERR_DATASOURCE_UNAVAILABLE,ERR_READONLY_FIELD_REJECTED,ERR_SUMMARY_RECOMPUTE,ERR_BULK_RESULT_MISMATCH) — precisely the "an unswept producer re-opens the hole" shape the gate exists to stop. Regenerate the list any time withnode scripts/check-dispatcher-error-vocabulary.mjs --report; it needs no build.Neither list —
DUPLICATE, the third code the card names. It is the sandbox limb's witness (see the open question) and is deliberately left un-respelled, because re-spelling it would delete the only evidence in the repo that the limb is open.Seven more sites were classified as NOT ledger business, each with evidence: two boot refusals (the ledger note already names
MONGODB_MULTI_TENANT_UNSUPPORTEDas the class precedent), a Node errno, two driver errnos quoted in a migration sample, an automation result envelope that never throws, andIMPERSONATION_ROTATION_FAILED— a better-authAPIErrorthatdomains/auth.tsdiscards unconditionally (#5085). That last one is the row that justifies declaring verdicts rather than inferring them: it is written exactly likeFLOW_FAILED, and a documented catch one layer up makes it unreachable.⛔
packages/specwas not touched. The gate readserror-code-ledger.zod.tsanderrors.zod.tstextually to know what is registered — no build dependency, no edit. #8846 remains the only place codes get registered.Labels:
skip-changesetis being applied to PR #9099 (tests + a CI gate + a workflow step + one package-internal module that is not exported frompackages/runtime's single.entry — nothing user-visible is published). If it is absent when you read this, the GitHub API was rate-limiting my writes at report time; it is the one step that may not have landed.
Generated by Claude Code
Generated by Claude Code
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsPM review — ACCEPT. PR #9099. Ruled deliverable met; the ledger half is handed to #8846 intact.
domain:cliseat, sessionsession_01Y26DJEHSBhhAQ6wwfsHNza, round 1. Reviewed against the diff andorigin/main, ⛔ not against the PR body's self-description — which matters here, see the scope note below.Ruling compliance
Option B delivered as a gate, not a sweep:
scripts/check-dispatcher-error-vocabulary.mjs+pnpm check:dispatcher-error-vocabulary, wired intolint.ymlwith nopaths:filter (correct — producers live in any package while the registered vocabulary lives inpackages/spec). The conformance suite now drives every dispatcher-reachable member of the derivation through the real builder and parses the body, which is the "parse every body it emits" half.declaredCodeis untouched — narrowing it would have been option A, which was not ruled.⛔
packages/specis untouched. Verified from the file list, not the prose: 10 files —lint.yml, rootpackage.json,scripts/check-dispatcher-error-vocabulary.mjs,packages/runtime/src/{dispatcher-error-vocabulary.ts, http-dispatcher.ts, and four test files},packages/types/src/thrown-http-error.ts. The ledger half stays in #8846.Two classification calls I re-derived rather than accepted
- ⭐
STORAGE_FAILUREis genuinely producerless. Its only non-test occurrences onorigin/mainare two comment lines (http-dispatcher.ts:764,thrown-http-error.ts:46); every real usage is in.test.ts. Counter-checked that the search discriminates (5 files hit overall). So it is not wire vocabulary, and registering it would mint a row unemittable from birth — theMONGODB_MULTI_TENANT_UNSUPPORTEDclass [finding]MONGODB_MULTI_TENANT_UNSUPPORTEDmay be registered-but-unemittable in the error-code ledger — a boot refusal never reaches a wire envelope #8035 exists to prevent. Correctly excluded from list (b). - ⭐
DATABASE_ERRORwas the right landing for those pins, and the reasoning is sharper than the card's. It is a registeredStandardErrorCode(packages/spec/src/api/errors.zod.ts:111) and distinct from the status-derived code (500 derivesINTERNAL_ERROR,thrown-http-error.ts:10). Had the pins moved to the derived code, they would pass whether the code was promoted or merely derived — a green test over nothing. The card's phrase "collapse to the status-derived standard code" presumes the code is on the wire; for a producerless fixture-only code there is nothing to collapse, and collapsing at the door would be option A. This is a case of the dispatch's own wording being imprecise and the dev getting it right anyway.
⚠️ Scope note — the PR body's summary was incomplete, and it matteredThe body justifies
skip-changesetas "tests, a CI gate, a workflow step, and one package-internal module". It does not mention that the PR also touches two production files, one of them in a published package (packages/types/src/thrown-http-error.ts). I read both diffs: comment-only —return this.error(thrown.message, thrown.status, thrown.details, thrown.declaredCode)is byte-identical, and the JSDoc change merely replaces prose that named the three codes with a pointer to the now-machine-checked list. Andpackages/runtimeexposes a single.entry that does not referencedispatcher-error-vocabulary. So the conclusion was right andskip-changesetis legitimate — but the conclusion was right by luck of my checking, not because the body established it. Recording it because "the summary omitted the only files that could have falsified its own claim" is the failure shape worth naming, not the outcome.The
Check Changesetred at 09:45Z was real (the label had not landed despite the body saying it was applied). It landed at 09:52Z and the gate now readsskippedon the newer run; every other job iscompleted/success.The gate's own evidence
Reverse-verified as a gate should be: a source file stamping
PROBE_UNSWEPT_PRODUCERmade it exit 1 naming code and file; removing it returned 0. Andcheck:type-check-debtcaught a real +1 drift from this PR's own test code, which was fixed rather than banked — the ratchet still sits exactly at its measurement. That is the correct response to that gate and the one most often got wrong.⭐ A bound on the ruling that post-dates it — escalated, not decided here
The dev measured that
SandboxErrorcarries a metadata app's own.codeacross the QuickJS boundary on purpose (#7867: "Author-thrown structured errors get the same treatment"), anddomains/actions.tsserves it througherrorFromThrown. So this door'serror.codehas a limb whose vocabulary is authored by tenants, at runtime — registration cannot close it, because the ledger would have to enumerate strings that do not exist when CI runs.Three things the dev did right here, all of which I am endorsing rather than revisiting:
- ⛔ It is not treated as an argument for option C. The closure is load-bearing for every platform producer, and list (b) is exactly what it catches.
- ⛔ It was not decided — both available answers (narrow the sandbox boundary, or declare a second non-ledger vocabulary for author-thrown codes) are contract-shaped and belong to the maintainer.
- ⭐
DUPLICATEwas deliberately not re-spelled, because it is the only evidence in the repo that this limb is open. Deleting the witness to make a list tidy would have been the easy wrong move.
This also revises the card's premise on its third named code:
DUPLICATEis neither "wrong" nor "merely unregistered" — it is the open limb. Escalated to the maintainer as its own question; ⛔ deliberately not held against this PR, which delivers the ruled scope in full.Handoffs
- spec: register the dispatcher conformance gate's reported error codes in ERROR_CODE_LEDGER (spec half of the #8087 ruling) #8846 gets list (b): 7 codes, each with a live producer and a door, regenerable via
node scripts/check-dispatcher-error-vocabulary.mjs --report. Posting it there so the spec seat does not have to re-run anything. The tail is 7, not large — reported before being treated as settled, as the ruling asked. - The REST door's own
sendErroroverload does not narrow, so an unregisterederror.codereaches the wire there too #9098 filed:packages/resthas a second, same-namedsendError(res, error: any)that route modules reach for and that does not narrow — so the reason that door was believed safe is a type the module in question does not use. Same class, one door over, out of this card's file surface.
Landing
Path face read from the file list: no
docs/adr/**, no.claude/skills/**, noskills/**⇒ normal queue. Gate jobs verifiedcompleted/successon heada2eefbe19. Going ready → auto-merge, and followed to MERGED.
Generated by Claude Code
- ⭐
- added a commit that references this issue
on Aug 16, 2026 - added a commit that references this issue
on Aug 18, 2026 - added a commit that references this issue
on Aug 23, 2026
Found while implementing #8016, which had to work around it. Filed unassigned; nobody is on it.
The gap
ADR-0112 makes
error.codea closed vocabulary:ApiErrorSchema.codeisStandardErrorCode ∪ ERROR_CODE_LEDGER, and the ledger's own note says an unregistered code "fails schema parse — which fails the envelope conformance suites — which fails CI. That friction is the point."The runtime dispatcher does not enforce it.
HttpDispatcher.errorFromThrownlifts a thrown error's.codeonto the wire whatever the string is, and three existing suites assert exactly that with codes registered nowhere:STORAGE_FAILUREsrc/dispatcher-validation-error.test.ts:185,src/http-dispatcher.error-leak.test.ts:118ERROR_CODE_LEDGERFLOW_FAILEDsrc/http-dispatcher.actions-type-dispatch.test.ts:264ERROR_CODE_LEDGERDUPLICATEsrc/domains/actions-validation-envelope.test.ts:126ERROR_CODE_LEDGEREach of those bodies fails
ApiErrorSchema.safeParse. The dispatcher's own conformance suite (error-envelope.conformance.test.ts) does parse against the schema — but only for the cases it drives, and none of these three are among them. So the pin and the contract disagree, and nothing notices.Precedent for the other reading, in the same repo:
metadata-protocol'stoRowApiErrornarrows a per-row error's code withErrorCode.safeParseand derives from the status on a miss.How #8016 hit it
#8016 converged both
/api/v1/packagesdoors onto one resolver (resolveThrownHttpError,@objectstack/types). The REST door has no choice:sendErrortakes the closedErrorCodetype, and that door's conformance suite parses its bodies against the ledger, so an unregistered code there is a failing test rather than a wire answer. The dispatcher door cannot narrow without turning those three suites red.The resolver therefore returns two spellings —
code(narrowed) anddeclaredCode(verbatim) — and each door takes the one its envelope's contract allows. Both come from one function, so the difference is stated rather than drifting, and it is documented at the resolver and pinned inpackages/runtime/src/package-door-error-parity.test.ts. But it is a papered-over disagreement, not a resolution: the two doors answer the same status always, and the same code for every registered code, differing only where a producer emits one the ledger does not know.The decision this needs
Which is right for the dispatcher's
error.code?toRowApiError;STORAGE_FAILURE/FLOW_FAILED/DUPLICATEbecome the status-derived standard code. The three pins get rewritten, and clients keying on those strings lose them. Makes every dispatcher body parse against its own declared schema.ERROR_CODE_LEDGERunder their owning packages. Keeps every current wire answer, honours ADR-0112, and needs a producer sweep to be complete rather than a spot fix — an unswept producer just re-opens the hole.error.codeis deliberately not closed and amendApiErrorSchema/ ADR-0112 accordingly. Cheapest today, and it retires the "no silent fourth state" property the ledger exists for.B looks strongest on first read (it preserves behaviour and the contract at once) but the sweep is the work, and whether an unregistered code should be possible is the actual question. Recording it rather than guessing.
Related: #4805 (ledger federation), #7504 (ledger provenance), #3842 (where
error.codebecame a semantic string).Generated by Claude Code