diff --git a/docs/adr/0112-error-code-vocabulary-and-ledger.md b/docs/adr/0112-error-code-vocabulary-and-ledger.md index 20a9f9de97..43433a38a5 100644 --- a/docs/adr/0112-error-code-vocabulary-and-ledger.md +++ b/docs/adr/0112-error-code-vocabulary-and-ledger.md @@ -107,6 +107,20 @@ Nine rulings, D1–D9. **D4 — `ApiErrorSchema.code` stops being `z.string()`.** It becomes the generated union of the standard catalog and the registered ledger (`ErrorCode`, generated at build time — Zod-first per PD#1, enum generated from ledger + `StandardErrorCode`). Conformance suites thereby assert values for free. There is **no regex escape hatch** (see Alternatives — a casing regex passes hallucinated codes, which is precisely the AI failure mode this ADR exists to prevent). +> **Amendment (2026-08-17, [#9106](https://github.com/objectstack-ai/objectstack/issues/9106)) — `error.code` is closed at EVERY door, and `declaredCode` is the open, author-authored channel beside it.** D4 closed the *schema*; it did not say what a door does with a throw whose `.code` is not a member, and the two doors answered differently. The REST package door narrowed (an unregistered code fell to the code the status derives); the runtime dispatcher door put the producer's string on the wire verbatim. #8087 first ruled the verbatim spelling stays and delivered a gate over the platform producers (`pnpm check:dispatcher-error-vocabulary`) — and that gate's own first derivation measured the limb no registration can ever close: `SandboxError` carries a metadata app's **own** `.code` across the QuickJS boundary on purpose ([#7867](https://github.com/objectstack-ai/objectstack/issues/7867)), so `domains/actions.ts` served a code **authored by tenants, at runtime**, into `error.code`. `ERROR_CODE_LEDGER` cannot enumerate strings that do not exist when CI runs. +> +> **Ruled (maintainer, 2026-08-16).** `error.code` stays a closed vocabulary at every door. A thrown code that is not a member of `StandardErrorCode` union the serving side's ledger is **demoted** to a declared sibling field, `ApiError.declaredCode`, and `error.code` carries the member the HTTP status derives. One rule platform-wide, and the same one hotcrm#1075 states from the app side: **apps branch on enum codes; app-specific spellings ride `declaredCode`.** +> +> **The boundary, named.** `code` is closed — a consumer may switch on it exhaustively and that is now true at every door, which is exactly what it was not before this amendment. `declaredCode` is **open**: any string a producer spells, platform or tenant, and no ledger governs it. **Presence means demotion** — the field is absent when the producer's code IS a vocabulary member (it is already in `code`, and repeating it would put two spellings of one fact on every refusal) and absent when the producer declared none. So a consumer that reads `declaredCode` at all knows the serving side's ledger did not recognise the spelling. +> +> **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`). +> +> ⛔ **`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). +> +> **Scope.** This amendment rules the doors served by `resolveThrownHttpError` — the runtime dispatcher exits (the actions door among them) and the direct-mount REST package registrar. `packages/rest`'s flat `sendThrownError` dialect, which puts `code` at the body's **top level** rather than in `error.code`, is a different envelope position and is out of scope here; it is measured and filed as [#9232](https://github.com/objectstack-ai/objectstack/issues/9232), and `check:dispatcher-error-vocabulary` keeps its platform producers swept meanwhile. Read that card before quoting "every door" at the flat dialect: this amendment rules the field `ApiError.code`, and #9232 is where the remaining question about the flat top-level `code` is recorded. + **D5 — One location, eventually: `error.code` carries the semantic code.** Target end-state, recorded here so the follow-ups have a fixed destination: the HTTP status lives on the transport and (optionally) `error.httpStatus`; `error.code` is always the semantic string; `error.details.code` and `error.type` are retired as code carriers. The dispatcher-occupation fix (#3689 sibling) and `ROUTE_NOT_FOUND`-in-`type` retirement land as follow-ups (Rollout, batch 3). The client's three-location probe is deleted only after both. > **Amendment (2026-07-30, [#4007](https://github.com/objectstack-ai/objectstack/issues/4007)) — ruled and done.** With batch 3 landed, the client's parking-spot read (`error.details.code`) is deleted: SDK and server ship as a changesets fixed group, so the "newer SDK, older server" pairing it served is not a supported deployment — and batches 1–2 renamed the code *values* anyway, so a code dug out of an old server's parking spot would match no branch written against the current catalog; location-compat without value-compat protects nothing. The client's two remaining reads are the two *live* envelopes' declared spots (flat top-level `code`, wrapped `error.code`) — a present-tense fact, not a fallback chain; retiring the flat shape itself belongs to the envelope-convergence line (#3843 family), not this ADR. The D9b nesting fix (`category`/`retryable`, [#4006](https://github.com/objectstack-ai/objectstack/issues/4006)) landed in the same change. @@ -148,6 +162,7 @@ The line that decides all three of D6/D6b/D6c: **the catalog governs the code a - **New-code friction is introduced deliberately:** an unregistered code fails CI. This is the entropy gate; the ≈240-literal status quo is what its absence produces. - **Unblocked follow-ups:** the `error.code`-occupied-by-status fix; `error.type` retirement; deletion of the client's three-location probe and its explanatory comment block; the field-level vocabulary decision (D6's issue). - **Docs debt retired:** `error-catalog.mdx` regenerated; the two error-code items in the 2026-06 docs-accuracy audit close. +- **Breaking on the wire, for author-thrown codes only (added by the 2026-08-17 amendment):** a metadata app throwing its own `.code` from an action body used to see it echoed at `error.code`; it now arrives at `error.declaredCode`, with `error.code` carrying the closed member the status derives. The binding precondition the ruling attached — measure whether any existing consumer of the actions door branches on author-authored strings in `error.code` — was measured before landing and came back empty, twice, by two independent search shapes (the second widened to every SCREAMING_SNAKE literal precisely because a comparison-shape scan cannot see helper-mediated branches such as objectui's `errorCodeIs`). Registered platform codes are unaffected: they were, and remain, in `error.code` verbatim. ## Rollout