diff --git a/docs/adr/0112-error-code-vocabulary-and-ledger.md b/docs/adr/0112-error-code-vocabulary-and-ledger.md index d98471da0c..b186e1c8ff 100644 --- a/docs/adr/0112-error-code-vocabulary-and-ledger.md +++ b/docs/adr/0112-error-code-vocabulary-and-ledger.md @@ -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). > diff --git a/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json b/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json index b4aedd022f..e76a726f29 100644 --- a/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json +++ b/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json @@ -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." }