Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/adr/0112-error-code-vocabulary-and-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,7 +119,13 @@ Nine rulings, D1–D9.
>
> **The #7867 capability is preserved, not retired.** Narrowing the sandbox boundary — rejecting or namespacing an author's code before it reaches the wire — was the rejected alternative: it withdraws a capability this platform deliberately granted, to protect a property nothing had yet needed at that door. The author's code still crosses the sandbox and still reaches the wire; it lands in the open channel rather than the closed one.
>
> **Where it is pinned.** `resolveThrownHttpError` and `demotedDeclaredCode` (`packages/types/src/thrown-http-error.ts`, anchored in `scripts/adr-anchors/`) are the ONE definition of both spellings; all three dispatcher exits (`HttpDispatcher.errorFromThrown`, `dispatcher-plugin`'s `errorResponseBase`, `endpoint-executor`'s `endpointErrorAnswer`) read them rather than restating the rule. The demote is pinned end to end in `packages/runtime/src/domains/actions-validation-envelope.test.ts` (the actions door, with the tenant witness `DUPLICATE`), `packages/runtime/src/package-door-error-parity.test.ts` and `packages/runtime/src/error-envelope.conformance.test.ts` (which asserts, for every body the door emits, that a present `declaredCode` is never a vocabulary member and never a copy of `code`).
> **Where it is pinned.** `resolveThrownHttpError` and `demotedDeclaredCode` (`packages/types/src/thrown-http-error.ts`, anchored in `scripts/adr-anchors/`) are the ONE definition of both spellings for every **door** emission of the pair; all three dispatcher exits (`HttpDispatcher.errorFromThrown`, `dispatcher-plugin`'s `errorResponseBase`, `endpoint-executor`'s `endpointErrorAnswer`) read them rather than restating the rule. The demote is pinned end to end in `packages/runtime/src/domains/actions-validation-envelope.test.ts` (the actions door, with the tenant witness `DUPLICATE`), `packages/runtime/src/package-door-error-parity.test.ts` and `packages/runtime/src/error-envelope.conformance.test.ts` (which asserts, for every body the door emits, that a present `declaredCode` is never a vocabulary member and never a copy of `code`).
>
> ⚠️ **Scope correction (2026-08-29, [#12948](https://github.com/objectstack-ai/objectstack/issues/12948)) — “one definition” is a claim about DOORS, and there is exactly ONE declared non-door emission.** The word *door* above is load-bearing and was added by this correction. Read as universal, the sentence was measurably false: `packages/cloud-connection/src/cloud-connection-plugin.ts:382` writes `declaredCode` **by hand**, inside a hand-built body on a hard-coded **400**, calling neither function. That site is a **relay**, not a door — it forwards an upstream RFC 8628 device-authorization spelling (`expired_token`, `access_denied`, …) verbatim, beside the registered `DEVICE_CODE_FAILED` its own route chose. It is declared here so that a reader auditing “everything that emits this field” audits a **complete** population. ⛔ It is not a mistake to tidy away: the choice is deliberate and documented at that file’s `:370`.
>
> **Why the relay is EXEMPT rather than converted — measured, not asserted.** Routing it through the shared pair was measured on 2026-08-29 and **changes the wire on 5 of 5 realistic inputs**. `resolveThrownHttpError` **derives** the closed `code` from the throw or the status; no RFC 8628 spelling is a ledger member (probed `expired_token`, `access_denied`, `authorization_pending`, `slow_down`, `invalid_grant` — all **0** hits in `error-code-ledger.zod.ts`, against positive control `DEVICE_CODE_FAILED` — **1**), so the derived answer is `standardErrorCodeForHttpStatus(400)` = `VALIDATION_ERROR`. Converting the site would therefore replace a registered, domain-meaningful code with a generic 400 bucket that is also semantically wrong — an upstream device-authorization refusal is not a validation failure — on a route the Console polls. The shared rule cannot express this shape, because it **derives** the code a boundary emits while a relay legitimately **chooses** one. ⇒ Conversion is a wire change needing its own ruling, not a cleanup.
>
> ⚠️ **The trap this exemption carries — worth more than the exemption itself.** The relay is safe today for exactly ONE structural reason: **it emits a 4xx**. The server-fault withholding this ADR’s neighbourhood relies on keys on **5xx** — `declaresServerFault` is `status >= 500 && typeof code === 'string' && code.length > 0` (`packages/types/src/error-leak.ts`, read 2026-08-29) — so the relay falls outside that scope **by construction, not by luck**. ⇒ ⛔ **If this route ever grows a 5xx limb, that limb will silently bypass the shared rule**, and nothing will say so: measured 2026-08-29, `check:dispatcher-error-vocabulary`, `check:route-envelope` and `check:nul-bytes` are **all green with the hand-built emission already in the tree**, and `check:route-envelope` carries that very file in its own registry — it sees the FILE and still says nothing about this field. Anyone adding a 5xx exit to `bind/poll` must route it through the shared pair or re-open this exemption. (#12509 / PR #12946 refine precisely that 5xx limb; when they land the trap sharpens, it does not go away.)
>
> ⛔ **`DUPLICATE` is deliberately NOT registered.** It is the pinned witness to the tenant-authored limb, re-homed under this rule rather than added to the ledger: registering one tenant spelling closes nothing (the next app picks a different string) and would promote a single app's vocabulary into the platform catalog every consumer branches on. It stays fenced off from the ledger-registration hand-off (#8846).
>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,5 +3,5 @@
"adrs": [
"ADR-0112"
],
"invariant": "This is the ONE definition of how a thrown error becomes an HTTP answer, and the two spellings it returns are not a redundancy to tidy away. ADR-0112 makes `error.code` a CLOSED vocabulary (`StandardErrorCode` union the registered ledger), and the 2026-08-16 ruling on #9106 extended that from the REST package door to every door this function serves: `code` is always a union member — a throw whose `.code` is unregistered falls to the member the status derives — while `declaredCode` keeps the producer's verbatim string. `demotedDeclaredCode()` is the single rule for which spelling a boundary surfaces beside the closed one, and it answers `undefined` for a registered code on purpose: emitting both would put two spellings of one fact on every refusal, and `ApiErrorSchema.declaredCode`'s documented semantics are that PRESENCE MEANS DEMOTION. Do not 'simplify' a boundary by writing `thrown.declaredCode` into `error.code` — that is the pre-#9106 dispatcher behaviour, and it re-opens the tenant-authored limb #9106 closed: a metadata app's action code crosses the QuickJS sandbox carrying the app's own `.code` (#7867, a capability deliberately granted and preserved), so `error.code` would again carry strings authored by tenants at runtime, which no ledger can enumerate and no gate can sweep. The author's spelling is not dropped — it rides the wire's open `declaredCode` channel instead. [#12509, ruled 2026-08-27] The channel also has a 5xx SCOPE, and it is the SECOND thing `demotedDeclaredCode()` answers: on a 5xx the producer did NOT declare, the demoted spelling came off an undeclared producer -- a driver errno, measured on the wire -- and is withheld along with the prose; an AUTHOR-DECLARED code survives at every status. `serverFaultProvenance()` is the ONE definition of that distinction, and the discriminator is the STATUS channel because it is the only structural one: a driver errno and an app's own spelling both arrive on `.code` as a plain string, so anything that told them apart by LOOKING at the string would be a heuristic over an open channel. Do not re-derive the condition at a door and do not gate it on whether `looksLikeInternalErrorLeak` fired -- that predicate reads a DIFFERENT channel, and gating on it leaks the errno for exactly the dialects whose prose the heuristic misses. The 'declared' limb of the same function is what #12281's prose rule will read; it is deliberately not applied yet."
"invariant": "This is the ONE definition of how a thrown error becomes an HTTP answer, and the two spellings it returns are not a redundancy to tidy away. ADR-0112 makes `error.code` a CLOSED vocabulary (`StandardErrorCode` union the registered ledger), and the 2026-08-16 ruling on #9106 extended that from the REST package door to every door this function serves: `code` is always a union member — a throw whose `.code` is unregistered falls to the member the status derives — while `declaredCode` keeps the producer's verbatim string. `demotedDeclaredCode()` is the single rule for which spelling a DOOR surfaces beside the closed one (scoped from 'a boundary' to 'a door' on 2026-08-29, #12948 — see the declared non-door relay at the end of this invariant), and it answers `undefined` for a registered code on purpose: emitting both would put two spellings of one fact on every refusal, and `ApiErrorSchema.declaredCode`'s documented semantics are that PRESENCE MEANS DEMOTION. Do not 'simplify' a boundary by writing `thrown.declaredCode` into `error.code` — that is the pre-#9106 dispatcher behaviour, and it re-opens the tenant-authored limb #9106 closed: a metadata app's action code crosses the QuickJS sandbox carrying the app's own `.code` (#7867, a capability deliberately granted and preserved), so `error.code` would again carry strings authored by tenants at runtime, which no ledger can enumerate and no gate can sweep. The author's spelling is not dropped — it rides the wire's open `declaredCode` channel instead. [#12509, ruled 2026-08-27] The channel also has a 5xx SCOPE, and it is the SECOND thing `demotedDeclaredCode()` answers: on a 5xx the producer did NOT declare, the demoted spelling came off an undeclared producer -- a driver errno, measured on the wire -- and is withheld along with the prose; an AUTHOR-DECLARED code survives at every status. `serverFaultProvenance()` is the ONE definition of that distinction, and the discriminator is the STATUS channel because it is the only structural one: a driver errno and an app's own spelling both arrive on `.code` as a plain string, so anything that told them apart by LOOKING at the string would be a heuristic over an open channel. Do not re-derive the condition at a door and do not gate it on whether `looksLikeInternalErrorLeak` fired -- that predicate reads a DIFFERENT channel, and gating on it leaks the errno for exactly the dialects whose prose the heuristic misses. The 'declared' limb of the same function is what #12281's prose rule will read; it is deliberately not applied yet. DECLARED NON-DOOR EMISSION (2026-08-29, #12948): exactly one site in the repo emits `declaredCode` without calling either function, and it is exempt on purpose rather than un-migrated — `packages/cloud-connection/src/cloud-connection-plugin.ts:382` hand-writes the field on a hard-coded 400 to RELAY an upstream RFC 8628 device-authorization spelling (`expired_token`, `access_denied`, …) verbatim, beside the registered `DEVICE_CODE_FAILED` its own route chose. Do NOT 'finish the migration' by routing it through this pair: that was measured on 2026-08-29 and CHANGES THE WIRE on 5 of 5 realistic inputs, because `resolveThrownHttpError` DERIVES the closed code from the throw or the status, no RFC 8628 spelling is a ledger member (all 0 hits; positive control `DEVICE_CODE_FAILED` 1), and the derived answer is `standardErrorCodeForHttpStatus(400)` = `VALIDATION_ERROR` — a generic 400 bucket replacing a registered, domain-meaningful code, on a route the Console polls. This pair DERIVES the code a boundary emits; a relay legitimately CHOOSES one, so the shapes do not match. ⚠️ THE TRAP, worth more than the exemption: that relay is safe today for ONE structural reason — it emits a 4xx — while the server-fault withholding keys on 5xx (`declaresServerFault` is `status >= 500 && typeof code === 'string' && code.length > 0`, `error-leak.ts`). It is outside that scope BY CONSTRUCTION, not by luck, so if that route ever grows a 5xx limb the limb will SILENTLY bypass the shared rule, and no gate will say so: `check:dispatcher-error-vocabulary`, `check:route-envelope` and `check:nul-bytes` were all green on 2026-08-29 with the hand-built emission already in the tree, and route-envelope carries that file in its own registry — it sees the FILE and still says nothing about this field. Add a 5xx exit to `bind/poll` only by routing it through this pair or re-opening the exemption."
}
Loading