Skip to content

[Decision] The dispatcher's error.code has a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106

Closed

Description

@os-project-manager

Surfaced by #8087's implementation (PR #9099) and filed rather than decided there. ⛔ Deliberately not held against #9099, which delivers its ruled scope in full — this is a bound on the ruling that was measured after it, not a defect in it.

Filed unassigned and without a domain:* label: the two candidate answers land in different lanes (see below), so routing is triage's call.

The measurement

SandboxError carries a metadata app's own .code across the QuickJS boundary on purpose. From sandbox/error-passthrough.test.ts (#7867), verbatim:

"Author-thrown structured errors get the same treatment; nothing here is objectql-specific"

domains/actions.ts then serves that error through errorFromThrown, which puts declaredCode — the producer's own string, un-narrowed — onto the wire. domains/actions-validation-envelope.test.ts pins that end to end.

So this door's error.code has a limb whose vocabulary is written by tenants, at runtime.

Why this is a decision and not a bug

The 2026-08-12 ruling on #8087 chose option B: keep the verbatim spelling, register the producers, delivered as a gate. That is correct and now shipped — the gate found 7 unregistered platform producers (handed to #8846) and will fail on an unswept one added later.

But registration cannot close this limb, by construction: ERROR_CODE_LEDGER would have to enumerate strings that do not exist when CI runs. No amount of sweeping reaches it.

⚠️ This is NOT an argument for option C (declare the dispatcher's error.code open), and it should not be read as reopening that. Option C was rejected, correctly: the closure is load-bearing for every platform producer, and the 7 rows the gate just found are exactly what it catches. What is open is narrower — what the contract should say about the author-authored limb specifically, which nobody has ruled on because nobody had measured it.

⭐ DUPLICATE is the pinned witness to this limb and was deliberately not re-spelled in #9099 — re-spelling it would have deleted the only evidence in the repo that the limb is open. It should stay unregistered and unrewritten until this is ruled. That also revises #8087's premise on its third named code: DUPLICATE is neither "genuinely wrong" nor "merely unregistered".

The two available answers

Both are contract-shaped, which is why neither the dev nor the PM took them.

Four-prism block

  1. Platform long-term coherence. B makes an existing truth explicit; A makes the stated invariant actually hold everywhere. A is the cleaner invariant, B is the more honest description of a capability we already shipped. The real question is whether "closed" was ever meant to survive a tenant-extensible surface.
  2. Measured business pull. No reported consumer breakage. The limb is reachable today by any metadata app throwing a structured error — so it is live, not theoretical, but nothing measured says an operator or client has tripped on it. Neither option is urgent; the cost of deferring is that the ledger's guarantee is quietly weaker than its own note claims.
  3. AI-agent error-resistance. This is the axis that actually discriminates. Today an agent reading ADR-0112 concludes error.code is closed and may write a consumer that switches exhaustively on it — which is wrong at exactly one door, and wrong in a way no gate will tell it about. Both A and B fix that; silence does not. Whichever is chosen, the ADR text is the deliverable, not just the code.
  4. Startup scope discipline. A removes a capability that was deliberately granted (Action-body writes have no not-found gate: ctx.api.object().update() against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867) — that is a retirement decision, not a cleanup. B adds no code and no declared surface, only prose and a pin. B is the cheaper honest move; A is the more expensive correct-if-you-want-closure move.

Recommendation: B, on the grounds that the capability in #7867 was deliberate and product-shaped, and retiring it to preserve a property nobody has yet needed at that door would be paying a real cost for a theoretical one. But prism 1 genuinely cuts both ways and this is a contract shape — recording the recommendation, not taking it.

Routing note for triage

Landing differs by option: A lands in the sandbox/actions path (domain:cli, possibly domain:engine-core); B lands in ADR-0112 plus packages/spec prose (domain:spec, and ADRs are maintainer-merged either way). Suggest leaving domain:* off until it is ruled, since the ruling picks the lane.

Related: #8087 (the ruling and its gate) · PR #9099 (where this was measured) · #8846 (the ledger half) · #7867 (where the passthrough was granted) · #9098 (a different, unrelated hole at the REST door).

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 16, 2026
  2. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Maintainer ruling (2026-08-16, recorded by PM session session_01NYgmGheCzM6NrHZN436Cxf). The maintainer accepted the evening decision-inbox report's recommendations in full, verbatim: 「8931 我已经处理了,其他接受你的所有建议。」 For this card the accepted recommendation was a variant of Option A: align the actions door with the REST door's demote semantics — not the reject/namespace form of A, and not B's two-vocabulary declaration:

    • error.code stays a closed vocabulary at every door. An author-thrown code that is not an ErrorCode enum member is demoted to declaredCode at the actions door, exactly as the REST mapper (resolveThrownHttpError) already does — one consistent rule platform-wide, and consistent with the hotcrm#1075 ruling (apps branch on enum codes; app-specific spellings ride declaredCode).
    • The Action-body writes have no not-found gate: ctx.api.object().update() against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867 capability is preserved: the author's code still crosses the sandbox and still reaches the wire — it lands in declaredCode instead of error.code.
    • ADR-0112 text is a deliverable: state that error.code is closed everywhere and declaredCode is the open, author-authored channel, with the boundary named and pinned. AI seats draft; the ADR PR is maintainer-merged per standing rule.
    • DUPLICATE (the pinned witness) gets re-homed under this rule rather than registered.
    • Precondition, binding: measure whether any existing consumer of the actions door branches on author-authored strings in error.code today. If real consumers exist, this is a behaviour change with a blast radius — stop and bring the measurement back here before landing.

    Routing per the card's own note: implementation lands in the sandbox/actions path; domain:* left for triage now that the lane is picked. Label flipped needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 16, 2026
  4. added
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 16, 2026
  5. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    CollaboratorAuthor

    Triage routing per the 13:03Z ruling (the ruled shape — demote at the actions door, mirroring resolveThrownHttpError — lands in the runtime sandbox/actions path): domain:cli. Type Bug (ADR-0112 declares error.code closed; the limb violates the declaration on the wire today).

    Dispatch constraints for the executing seat, from the ruling verbatim:

    Triage: this comment comes from the triage seat Routine; not a claim.


    Generated by Claude Code

  6. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Claiming for implementation per the 2026-08-16 13:03Z maintainer ruling (demote non-enum author codes to declaredCode at the actions door; ADR-0112 prose deliverable; binding consumer-measurement precondition).

    • Session: session_01Y26DJEHSBhhAQ6wwfsHNza
    • Branch: claude/issue-9106-demote-author-codes

    Assignee untouched — PM holds the claim.


    Generated by Claude Code


    Generated by Claude Code

  7. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Binding-precondition measurement (session session_01Y26DJEHSBhhAQ6wwfsHNza): no existing consumer of the actions door branches on author-authored strings in error.code. Empty result, search proven — proceeding to land.

    Method (two passes, because the first pass was proven insufficient):

    1. Comparison-shape scan (x.code === 'LIT', reversed, .includes, switch/case) over @objectstack/client, client-react, examples/, packages/qa, and the whole objectui checkout, classifying every literal against the 288-member closed union (53 StandardErrorCode + 235 ledger rows, parsed from spec source). Proof-of-search on a known-present neighbour failed: objectui's RecordAttachmentsPanel branch is has('ATTACHMENT_DOWNLOAD_DENIED') — helper-mediated, invisible to comparison patterns. So pass 1 alone was discarded as evidence.
    2. Widened literal scan: every SCREAMING_SNAKE string literal outside the closed union, all consumer surfaces (catches helper-mediated branches by construction — a branch on an out-of-vocabulary code must spell the literal somewhere). Proof-of-search: found the known-present out-of-vocabulary literals (DEV_ENV_PLAN_LOCKED, ANALYTICS_NOT_INSTALLED). Every hit hand-classified:
      • cloud entitlement codes (DEV_ENV_LIMIT, DEV_ENV_PLAN_LOCKED, PRODUCTION_ENV_LIMIT) — cloud control-plane env routes; the pre-cloud#948 wire shape carried undeclared error siblings no framework builder emits, so these ride cloud's own sender, not this repo's dispatcher exits. Not the actions door, not tenant-authored.
      • client-minted error classes (objectui data-objectstack: ANALYTICS_NOT_INSTALLED, ANALYTICS_QUERY_REJECTED, APPROVAL_LOOKUP_FAILED, UNSUPPORTED_OPERATION, CONNECTION_ERROR, …) — constructed client-side, never read off the wire's error.code.
      • transport/Node errnos (ECONNREFUSED, ENOENT, NETWORK_ERROR, EADDRINUSE) and config-map constants — not error.code reads.
    3. Witness search: zero branches on DUPLICATE (the only in-repo witness value) across objectui, examples, client, dogfood, docs.
    4. Actions-door path read: objectui useConsoleActionRuntime + actionErrorDetail + ActionRunner extract only error.message for toasts — no error.code branch on the /actions route at all. Published docs teach branching on the closed catalog only.

    Named residual (could not be scanned): the objectstack-ai/cloud repo is not accessible to this session. Evidence it is unaffected: cloud's consumer-branched codes ride its own control-plane sender (above), and any coded throw transiting the framework's REST door has been narrowed there since #8016 with no cloud breakage on record.

    File surface for this claim (beyond domains/actions.ts, since the ruling's "closed at every door" requires all three dispatcher-door exits that lift a thrown .code): packages/types/src/thrown-http-error.ts (prose + helper), packages/runtime/src/http-dispatcher.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/endpoint-executor.ts, packages/runtime/src/dispatcher-error-vocabulary.ts, scripts/check-dispatcher-error-vocabulary.mjs (prose), packages/spec/src/api/contract.zod.ts (+error-code-ledger.zod.ts prose) + regenerated spec artifacts, the pinned tests, a changeset — plus a separate ADR-0112 amendment PR (maintainer-merged). ⛔ Not touching packages/rest/src/error-response.ts (#9098's claim).


    Generated by Claude Code


    Generated by Claude Code

  8. 2 remaining items

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:queueReady for the PM dispatch loop

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions