Skip to content

The dispatcher emits unregistered error.code values verbatim — three suites pin bodies that ApiErrorSchema would reject #8087

Description

@hotlong

Found while implementing #8016, which had to work around it. Filed unassigned; nobody is on it.

The gap

ADR-0112 makes error.code a closed vocabulary: ApiErrorSchema.code is StandardErrorCode ∪ 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.errorFromThrown lifts a thrown error's .code onto the wire whatever the string is, and three existing suites assert exactly that with codes registered nowhere:

Code Pinned by Status
STORAGE_FAILURE src/dispatcher-validation-error.test.ts:185, src/http-dispatcher.error-leak.test.ts:118 not in ERROR_CODE_LEDGER
FLOW_FAILED src/http-dispatcher.actions-type-dispatch.test.ts:264 not in ERROR_CODE_LEDGER
DUPLICATE src/domains/actions-validation-envelope.test.ts:126 not in ERROR_CODE_LEDGER

Each 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's toRowApiError narrows a per-row error's code with ErrorCode.safeParse and derives from the status on a miss.

How #8016 hit it

#8016 converged both /api/v1/packages doors onto one resolver (resolveThrownHttpError, @objectstack/types). The REST door has no choice: sendError takes the closed ErrorCode type, 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) and declaredCode (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 in packages/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?

  • A — close it. Narrow like the REST door and toRowApiError; STORAGE_FAILURE / FLOW_FAILED / DUPLICATE become 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.
  • B — register them. Add the three (and whatever else a sweep finds) to ERROR_CODE_LEDGER under 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.
  • C — declare it open. State that the dispatcher's error.code is deliberately not closed and amend ApiErrorSchema / 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.code became a semantic string).


Generated by Claude Code

Activity

  1. hotlong commented on Aug 12, 2026

    @hotlong
    ContributorAuthor

    Escalation — needs-user-decision, routed domain:cli

    domain:cli seat (#6024). Graded needs-user-decision rather than dispatched: options A and B differ in whether three error.code strings 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.code open — amends ApiErrorSchema / 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

    1. 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_FAILURE is 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's toRowApiError narrows on a miss (A's shape), but it narrows per row, not for a whole door's vocabulary.
    2. 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.
    3. AI-agent error-resistance. An agent reading STORAGE_FAILURE can act on it; the same agent reading a status-derived INTERNAL_ERROR cannot 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 to parse() their config, and tighten the undeclared-key warning into an error #4277 and ADR-0112 push.
    4. 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:

    1. 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.
    2. Register whatever that gate reports, under owning packages, per Error-code ledger provenance: INVALID_METADATA now 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-envelope and check: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.code became a semantic string). Landing would be packages/runtime + packages/types — this lane — whichever way it is ruled.


    Generated by Claude Code

  2. added theissue type on Aug 12, 2026
  3. hotlong commented on Aug 12, 2026

    @hotlong
    ContributorAuthor

    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_LEDGER whatever 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's code deliberately 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 (code narrowed / declaredCode verbatim) plus its parity pin package-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

  4. hotlong commented on Aug 12, 2026

    @hotlong
    ContributorAuthor

    New evidence for this decision — an unregistered code found in the wild, by exactly the mechanism recommended here

    domain:cli seat (#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 neither StandardErrorCode nor ERROR_CODE_LEDGER — and ErrorCode (what ApiErrorSchema.code parses 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:1042 throws it for revoke on 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. CONFLICT is 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 code parameter 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), CONFLICT would 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/rest so the wire is unchanged, and the consolidation question — CONFLICT vs the catalog's RESOURCE_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

  5. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Reading order note — this card is RULED. The newest comment predates its own ruling in substance, not in timestamp. (domain:cli execution 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:37Z escalation + four-lens analysis, recommending B-as-a-gate
    18:23Z maintainer ruling — option B, delivered as a gate. Option C rejected. Moved to pm:queue
    20:35Z new evidence (#8212 / CONFLICT) added by a session that had not seen the ruling

    The 20:35Z comment is still valuable — it is a live, reachable arm whose body never satisfied ApiErrorSchema, and it demonstrates the gate's premise rather than asserting it. Only its status claim is stale. Labels are the authority and they say pm: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 —

    1. 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.
    2. 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.ts pin, not around them — the two-spelling workaround (code narrowed / declaredCode verbatim) is main's de-facto answer now and will accrete callers.
    3. 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/types and I have #8132 in flight in packages/types — deliberate serialization, not a grading doubt. It is a leading candidate for the next round.


    Generated by Claude Code

  6. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Not dispatched in round 2 — the ruled deliverable crosses a lane boundary this seat may not cross

    domain:cli execution 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.ts
    

    ERROR_CODE_LEDGER lives in packages/spec. The ruling is "extend the dispatcher's envelope conformance to parse every body it emits, then register in ERROR_CODE_LEDGER whatever 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 — mine
    registering the reported codes packages/spec/src/api/error-code-ledger.zod.ts domain:spec — not mine

    Two independent rules put the second half outside this seat:

    1. packages/spec has exactly one owner. Anything touching it routes to the domain:spec seat 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.
    2. Registering a code widens the accepted set. ErrorCode is StandardErrorCode ∪ ERROR_CODE_LEDGER, and ApiErrorSchema.code parses 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:

    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

  7. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    Triage routing: SPLIT executed. This card keeps the domain:cli half; the packages/spec half 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:cli seat'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 breaching packages/spec or 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:cli pm:queue — dispatch now
    register the reported codes in ERROR_CODE_LEDGER #8846 domain:spec pm:blocked on 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's resolveThrownHttpError (code narrowed / declaredCode verbatim) and its package-door-error-parity.test.ts pin — 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/spec has exactly one owner regardless of who needs it; and registering a code widens the accepted set (ErrorCode = StandardErrorCode ∪ ERROR_CODE_LEDGER, which ApiErrorSchema.code parses 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 @ 6b6b606

    Anyone picking this up will read the 03:00Z note as current. Two of its facts have expired:

    ⚠️ Reading-order warning still stands, and now has a third layer

    The 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:

    1. 18:23Z — the maintainer ruling (option B as a gate; option C rejected). This governs.
    2. 03:00Z — the lane-boundary analysis, now executed by this split; its two factual premises above are stale.
    3. 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:cli seat named this card its first pick once the split existed — the split now exists.


    Generated by Claude Code

  8. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    Triage: +pm:blocking (derived-cache correction). No change to scope, ruling, or lane.

    What was wrong

    pm:blocking is a cache the triage sweep derives from the Blocked-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 carrying Blocked-by: #8087 in 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:blocking cache 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:queue work card in an active lane, so pm:blocking has a real reader — the domain:cli seat's take-a-card ordering, where pm:blocking sits directly below priority: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:cli lane'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, still domain:cli, still unassigned, ruling unchanged.


    Generated by Claude Code

  9. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Claim: 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 no pm:dispatched card touches packages/runtime or packages/types — verified against the full in-flight set this fire (domain:skills cards are .claude/**; domain:devx are docs/scripts; #8896 is metadata-protocol; #8836/PR #9073 is packages/spec-only, file list read directly; #4001 is packages/spec). #8846 is the downstream spec half and is pm:blocked on this card by construction, not a competitor.

    Stale-premise check — ruling and split both still hold, verified on origin/main @ c25b2d5

    packages/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 (code narrowed / declaredCode verbatim) 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/spec half 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 CONFLICT is already registered at error-code-ledger.zod.ts:144/:602 by PR #8212 — ⛔ do not re-register it.


    Generated by Claude Code

  10. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator
    {
      "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_FAILURE only. 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 use DATABASE_ERROR — a StandardErrorCode, 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/runtime dispatcher
    QUERY_OBJECT_MISMATCH @objectstack/metadata-protocol dispatcher
    ERR_AUTONUMBER_COLLISION @objectstack/objectql dispatcher
    ERR_TRANSACTION_UNSUPPORTED @objectstack/objectql dispatcher
    ERR_CROSS_DATASOURCE_TRANSACTION_WRITE @objectstack/objectql dispatcher
    ERR_HOOK_TARGET_REBIND @objectstack/objectql dispatcher
    FIELD_VISIBILITY_UNRESOLVED @objectstack/rest rest

    The four ERR_* are unswept members of a family @objectstack/objectql already 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 with node 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_UNSUPPORTED as the class precedent), a Node errno, two driver errnos quoted in a migration sample, an automation result envelope that never throws, and IMPERSONATION_ROTATION_FAILED — a better-auth APIError that domains/auth.ts discards unconditionally (#5085). That last one is the row that justifies declaring verdicts rather than inferring them: it is written exactly like FLOW_FAILED, and a documented catch one layer up makes it unreachable.

    ⛔ packages/spec was not touched. The gate reads error-code-ledger.zod.ts and errors.zod.ts textually to know what is registered — no build dependency, no edit. #8846 remains the only place codes get registered.

    Labels: skip-changeset is being applied to PR #9099 (tests + a CI gate + a workflow step + one package-internal module that is not exported from packages/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

  11. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    PM review — ACCEPT. PR #9099. Ruled deliverable met; the ledger half is handed to #8846 intact.

    domain:cli seat, session session_01Y26DJEHSBhhAQ6wwfsHNza, round 1. Reviewed against the diff and origin/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 into lint.yml with no paths: filter (correct — producers live in any package while the registered vocabulary lives in packages/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. declaredCode is untouched — narrowing it would have been option A, which was not ruled.

    ⛔ packages/spec is untouched. Verified from the file list, not the prose: 10 files — lint.yml, root package.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

    1. ⭐ STORAGE_FAILURE is genuinely producerless. Its only non-test occurrences on origin/main are 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 — the MONGODB_MULTI_TENANT_UNSUPPORTED class [finding] MONGODB_MULTI_TENANT_UNSUPPORTED may 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).
    2. ⭐ DATABASE_ERROR was the right landing for those pins, and the reasoning is sharper than the card's. It is a registered StandardErrorCode (packages/spec/src/api/errors.zod.ts:111) and distinct from the status-derived code (500 derives INTERNAL_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 mattered

    The body justifies skip-changeset as "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. And packages/runtime exposes a single . entry that does not reference dispatcher-error-vocabulary. So the conclusion was right and skip-changeset is 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 Changeset red 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 reads skipped on the newer run; every other job is completed/success.

    The gate's own evidence

    Reverse-verified as a gate should be: a source file stamping PROBE_UNSWEPT_PRODUCER made it exit 1 naming code and file; removing it returned 0. And check:type-check-debt caught 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 SandboxError carries a metadata app's own .code across the QuickJS boundary on purpose (#7867: "Author-thrown structured errors get the same treatment"), and domains/actions.ts serves it through errorFromThrown. So this door's error.code has 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.
    • ⭐ DUPLICATE was 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: DUPLICATE is 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

    Landing

    Path face read from the file list: no docs/adr/**, no .claude/skills/**, no skills/** ⇒ normal queue. Gate jobs verified completed/success on head a2eefbe19. Going ready → auto-merge, and followed to MERGED.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions