Skip to content
Open
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
15 changes: 15 additions & 0 deletions docs/adr/0112-error-code-vocabulary-and-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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

Expand Down
Loading