diff --git a/.changeset/flat-door-declared-code.md b/.changeset/flat-door-declared-code.md new file mode 100644 index 0000000000..0236b97a43 --- /dev/null +++ b/.changeset/flat-door-declared-code.md @@ -0,0 +1,41 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest): the flat error responder narrows a thrown `code` to the declared ADR-0112 vocabulary, demoting an unregistered spelling to `declaredCode` (#9232) + +**If you read `error.code` off a `packages/rest` flat error body today, read this.** + +`packages/rest` answers errors in the flat dialect — `{ error: 'message', code: 'X' }`, +with `code` at the body's top level. Until now, when that body came from a *caught* +error, whatever string the producer had put on `.code` was copied to the wire verbatim, +including spellings that are not members of the ADR-0112 error vocabulary +(`StandardErrorCode` plus the registered ledger). Every other HTTP door in the platform +had already stopped doing that. + +It stops here too. A thrown `code` is now resolved exactly as the dispatcher door +resolves it, by the same shared `resolveThrownHttpError` / `demotedDeclaredCode` pair: + +- **A registered code is unchanged.** It still arrives in `code`, verbatim, with nothing + added beside it. If your branches read registered codes — and every consumer branch + measured in this repo, the SDK and the console does — nothing about your code changes. +- **An unregistered code is demoted.** `code` now carries the vocabulary member the HTTP + status derives (a 403 gives `PERMISSION_DENIED`, a 409 `RESOURCE_CONFLICT`, and so on), + and the producer's own spelling moves, unchanged, to a new top-level `declaredCode` + field beside it. Nothing is lost — but a branch written against an *unregistered* + spelling in `code` will stop matching, and must read `declaredCode` instead. + Presence of `declaredCode` means demotion: it is absent whenever the producer's code + was recognised. +- **A throw that declared no code still carries none.** Narrowing the vocabulary does not + start inventing codes for bodies that had none. +- **A non-string `code` no longer reaches the body at all.** A numeric driver errno could + previously land in `code`; it was never a legal value there and is now treated as + context, as it already was at every other door. + +The observable case in this repo: the object-posture gate's `403 owd_widening_forbidden` +now answers `{ code: 'PERMISSION_DENIED', declaredCode: 'owd_widening_forbidden' }`. That +body could not previously satisfy the schema it claimed to satisfy. + +The error body's **position** is unchanged — this dialect still puts `code` at the top +level rather than in `error.code`. Converging the position is a separate, still-open line +held by the `check:route-envelope` ratchet, and was explicitly not a precondition here. diff --git a/docs/adr/0112-error-code-vocabulary-and-ledger.md b/docs/adr/0112-error-code-vocabulary-and-ledger.md index 43433a38a5..efba286774 100644 --- a/docs/adr/0112-error-code-vocabulary-and-ledger.md +++ b/docs/adr/0112-error-code-vocabulary-and-ledger.md @@ -119,7 +119,17 @@ Nine rulings, D1–D9. > > ⛔ **`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. +> **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`, was a different envelope position and was out of scope *here*; it was measured and filed separately as [#9232](https://github.com/objectstack-ai/objectstack/issues/9232), and is ruled by the amendment immediately below. + +> **Amendment (2026-08-17, [#9232](https://github.com/objectstack-ai/objectstack/issues/9232)) — the flat door narrows too, so "every door" is literal.** The paragraph above left exactly one thrown path outside the closure, and #9232 measured what that cost: `packages/rest`'s flat `{ error, code }` responder passed a caught error's `code` to the wire verbatim, so an invariant this ADR had been amended to state **absolutely** the day before was contradicted by an observable door. An agent reading both had no way to tell which was authoritative — the AI-error-resistance failure #9106 exists to remove, moved one door over rather than closed. +> +> **Ruled (maintainer, 2026-08-17).** Demote alignment: a thrown `code` that is not an `ErrorCode` member is demoted to a **top-level `declaredCode` sibling** in the flat body, and `code` carries the member the HTTP status derives — the same rule, the same two spellings, computed by the same `resolveThrownHttpError` / `demotedDeclaredCode` pair. **Body POSITION is not a carve-out.** The rejected alternative was to rule the flat top-level `code` a different field this ADR does not govern; that would have cut an exception into an invariant amended to be absolute one day earlier, which is the reader contradiction the card measured rather than a fix for it. +> +> **Envelope-position convergence is a separate line and was explicitly NOT a precondition.** The flat dialect still puts `code` beside `error` rather than inside it; moving it is owned by the `check:route-envelope` ratchet, whose end state is routing these bodies through the shared `sendOk` / `sendError` pair. Vocabulary and position are two decisions, and this one is only the first. ⚠️ Do not cite #7035 for the position half — it closed on 2026-08-10 (PR #7293) having converged three `/meta` 501 handlers, and was still being quoted as the open owner of that line across `packages/rest` prose until #9232 repointed it. +> +> **What it changes on the wire.** A registered code is untouched at this door, exactly as at the others. An unregistered one moves to `declaredCode`, so a consumer branching on an *unregistered* spelling in the flat top-level `code` must read `declaredCode` instead — the same "breaking for author-thrown codes only" class the Consequences section records for the actions door, and measured the same way: the consumer sweep found the SDK's read of the flat top-level `code` to be a generic pass-through normalisation, with no branch anywhere on a spelling outside the union. One live producer was found and is the observable case: `plugin-security`'s object-posture gate throws a **lowercase** `owd_widening_forbidden`, which violates D1 as well as the ledger and could never satisfy `ApiErrorSchema`; it now answers `PERMISSION_DENIED` with its own spelling preserved beside it. +> +> **Where it is pinned.** `packages/rest/src/rest-thrown-code-vocabulary.test.ts`, in both directions — an unregistered code demotes, and a registered code still arrives verbatim in `code`. The second half is the one a regression does not redden, which is why it is written down. **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. @@ -162,7 +172,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. +- **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. **#9232 extends the same consequence to `packages/rest`'s flat dialect**, where the demoted spelling lands in a top-level `declaredCode` beside a top-level `code`; its own sweep re-ran the measurement for that door's consumers rather than inheriting this one, and likewise came back empty of any branch on an unregistered spelling. ## Rollout diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 90be67c7b5..d0ee49a6d5 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -52,6 +52,8 @@ import { uniqueViolationColumn, matchMissingColumnOfRelation, declaresServerFault, + resolveThrownHttpError, + demotedDeclaredCode, INTERNAL_ERROR_MESSAGE, } from '@objectstack/types'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; @@ -309,6 +311,64 @@ function declaredHttpStatus(error: any): number | undefined { return declared; } +/** + * [#9232] The flat door's `code` fields for a THROWN error: the closed + * ADR-0112 `code`, plus the open `declaredCode` sibling when the producer's own + * spelling did not survive into it. + * + * ## Why this exists + * + * The 2026-08-16 ruling on #9106 made `error.code` a closed vocabulary at every + * door and demoted an unregistered thrown spelling to a `declaredCode` sibling. + * That ruling's scope named the doors served by `resolveThrownHttpError` — the + * dispatcher exits and the direct-mount package registrar — and left THIS one + * out, because the flat dialect puts `code` at the body's TOP level rather than + * in `error.code`. So the flat responder went on passing a caught error's + * `code` through verbatim, and an ADR sentence amended to read "closed at every + * door" was contradicted by an observable door: exactly the reader ambiguity + * #9106 was filed to remove, one door over. The 2026-08-17 ruling on #9232 + * closed it — body POSITION is not a carve-out from the vocabulary. + * + * ## Why it delegates rather than restating the rule + * + * `resolveThrownHttpError` / {@link demotedDeclaredCode} (`@objectstack/types`, + * anchored in `scripts/adr-anchors/`) are the ONE definition of both spellings, + * and the three dispatcher exits read them rather than re-deriving them. A + * fourth open-coded `ErrorCode.safeParse(...)` here would be a second + * definition of one rule — the shape that let two doors answer differently in + * the first place. The status this boundary already resolved is passed in as + * the fallback, so the demote is computed against the status the client will + * actually receive. + * + * ## The three answers, and why "no code" stays "no code" + * + * - the producer spelled a REGISTERED code → `{ code }`, verbatim, unchanged. + * - the producer spelled an UNREGISTERED one → `{ code, declaredCode }`: the + * member the status derives, and the producer's string beside it. Presence + * of `declaredCode` means demotion, exactly as `ApiErrorSchema.declaredCode` + * documents for the nested envelope. + * - the producer spelled NO string code → `{}`. Nothing is invented: ADR-0112 + * says the PRODUCER names the condition, so a half-declaration is honoured + * for the half that was declared. That is the answer this file already gave + * (see the 5xx arm of {@link mapDataError}) and it is deliberately preserved + * — narrowing the vocabulary must not start ADDING codes to bodies that + * carried none. + * + * ⚠️ "No string code" is what `resolveThrownHttpError` means by it, which is + * stricter than the truthiness check {@link resolveErrorResponse}'s two arms + * used to apply: a NON-string truthy `code` — a numeric driver errno — is + * context rather than a wire code (the drift #3842 removed), so it no longer + * reaches the flat body at all. It could not have been a legal ADR-0112 code in + * any case; a number in the field callers branch on is the loudest possible + * violation of a closed vocabulary. All four flat arms now ask ONE question. + */ +function thrownCodeFields(error: any, status: number): { code?: string; declaredCode?: string } { + const thrown = resolveThrownHttpError(error, status); + if (thrown.declaredCode === undefined) return {}; + const demoted = demotedDeclaredCode(thrown); + return { code: thrown.code, ...(demoted !== undefined ? { declaredCode: demoted } : {}) }; +} + /** * [#8264] Postgres' missing-relation template, anchored on the QUOTED * identifier the driver always emits — never on the bare "does not exist" @@ -653,13 +713,20 @@ export function mapDataError(error: any, object?: string): { status: number; bod // ADR-0112 code silently dropped. The predicate's OWN read stays // `status`-only for its own callers — this is one call site handing it // the status this boundary just resolved. + // + // [#9232] The `code` that rides along is now the NARROWED one: + // {@link thrownCodeFields} answers with the closed member and puts an + // unregistered spelling in `declaredCode` beside it. `declaresServerFault` + // still decides WHETHER a code rides — its non-empty-string half is the + // same question `thrownCodeFields` asks internally, so the two agree by + // construction and this arm's body-shape decision is unchanged. if (declaredStatus >= 500) { return { status: declaredStatus, body: { error: INTERNAL_ERROR_MESSAGE, ...(declaresServerFault({ status: declaredStatus, code: error?.code }) - ? { code: error.code as string } + ? thrownCodeFields(error, declaredStatus) : {}), }, }; @@ -673,11 +740,16 @@ export function mapDataError(error: any, object?: string): { status: number; bod const msg = typeof error?.message === 'string' && error.message.length > 0 ? truncateClientMessage(error.message) : 'Request failed'; + // [#9232] Same narrowing as the 5xx arm above. The gate this replaces + // (`typeof error?.code === 'string' && error.code`) is exactly the + // question {@link thrownCodeFields} asks, so which bodies carry a + // `code` at all is unchanged here; only the VALUE can move, and only + // for a spelling the ADR-0112 union does not contain. return { status: declaredStatus, body: { error: msg, - ...(typeof error?.code === 'string' && error.code ? { code: error.code } : {}), + ...thrownCodeFields(error, declaredStatus), ...(object ? { object } : {}), }, }; @@ -1053,12 +1125,23 @@ export function mapDataError(error: any, object?: string): { status: number; bod * reader off the same conflation. Prose had already failed; the names are * different now. * - * ⛔ `error` stays `any` DELIBERATELY. This parameter is a caught value — a - * driver error, a `TypeError`, anything a `catch` can bind — and narrowing - * what a thrown error may carry would change what the REST door is allowed to - * emit. That is a public-contract decision (ADR-0112), not an internal typing - * one; the dispatcher door's equivalent required a maintainer ruling. What + * ⛔ `error` stays `any` DELIBERATELY, and that is now a statement about the + * PARAMETER only. This is a caught value — a driver error, a `TypeError`, + * anything a `catch` can bind — so there is no type to demand of it; what a + * caught error may CARRY is not narrowed at the signature and cannot be. What * #9098 closed is the AUTHOR-side hole: see {@link sendDeclaredFault}. + * + * [#9232] What IS narrowed now is the wire answer. The public-contract decision + * this docblock used to defer ("narrowing what a thrown error may emit is an + * ADR-0112 call, not an internal typing one") was made by the maintainer on + * 2026-08-17: `code` is a closed vocabulary at every door, with no carve-out for + * body position, so an unregistered thrown spelling is demoted to a + * `declaredCode` sibling in the flat body exactly as the dispatcher door demotes + * it in the nested one. The demote is computed by {@link thrownCodeFields}, + * which reads the shared `resolveThrownHttpError` / `demotedDeclaredCode` pair + * rather than restating the rule. An `error: any` parameter and a closed wire + * vocabulary are no longer in tension: the door accepts anything and answers in + * the declared vocabulary, which is what a classification door is for. */ export function sendThrownError(res: any, error: any, object?: string): void { const resolved = resolveErrorResponse(error, object); @@ -1099,10 +1182,19 @@ export function sendThrownError(res: any, error: any, object?: string): void { * ⛔ In particular this is NOT a migration to the `@objectstack/types` * envelope writer. That one emits the NESTED `{ success: false, error: { code, * message } }` and applies no sanitization — routing these emissions through - * it would move the envelope POSITION (open finding #7035, deliberately out of - * #9098's scope) and would re-open the #5437 leak class by shipping a declared - * 5xx's own prose. Narrowing the vocabulary and moving the dialect are two - * separate decisions; this is only the first. + * it would move the envelope POSITION and would re-open the #5437 leak class by + * shipping a declared 5xx's own prose. Narrowing the vocabulary and moving the + * dialect are two separate decisions. + * + * Both halves of that sentence have since been settled, in opposite directions, + * so read it as a live boundary rather than as pending work: the VOCABULARY is + * closed at this door as of #9232 (see {@link thrownCodeFields}), while the + * POSITION is still the flat one and is held by the `check:route-envelope` + * ratchet's entry for this file — whose end state is converting these bodies + * onto the shared `sendOk` / `sendError` pair. ⚠️ #7035 is NOT that card and + * has not been since 2026-08-10: it closed with PR #7293 having converged three + * `/meta` 501 handlers only, and the citation that used to stand here called it + * an open finding long after it was neither. */ export function sendDeclaredFault( res: any, @@ -1255,12 +1347,17 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo // boundary that sentence was written for. Producers that owe a caller // an actionable 5xx sentence should say it without interpolating the // driver's — tracked separately. + // + // [#9232] The surviving `code` is the NARROWED one — see + // {@link thrownCodeFields}. This arm's old gate was bare truthiness, so + // it also admitted a non-string `code`; that limb is gone with the + // narrowing, and the four flat arms now ask one question. if (error.status >= 500) { return { status: error.status, body: { error: INTERNAL_ERROR_MESSAGE, - ...(error.code ? { code: error.code } : {}), + ...thrownCodeFields(error, error.status), }, }; } @@ -1270,11 +1367,12 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo const safeMsg = typeof error.message !== 'string' ? 'Request failed' : truncateClientMessage(error.message); + // [#9232] Narrowed, same as the three arms above. return { status: error.status, body: { error: safeMsg, - ...(error.code ? { code: error.code } : {}), + ...thrownCodeFields(error, error.status), ...(Array.isArray(error.issues) ? { issues: error.issues } : {}), }, }; diff --git a/packages/rest/src/meta-object-owd-gate.test.ts b/packages/rest/src/meta-object-owd-gate.test.ts index 239e29384b..334024fce2 100644 --- a/packages/rest/src/meta-object-owd-gate.test.ts +++ b/packages/rest/src/meta-object-owd-gate.test.ts @@ -376,6 +376,30 @@ describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:n * `not_overridable` is ITSELF scoped to `environmentId !== undefined`, so on * this topology the write sails past it and arrives at the posture gate * with nothing else in front of it. + * + * ## [#9232] Why the wire `code` is `PERMISSION_DENIED` and the gate's own + * ## spelling now rides `declaredCode` + * + * `objectPostureGate` throws `owd_widening_forbidden` — a lowercase, + * UNREGISTERED spelling. It is not an ADR-0112 member on two counts at + * once: D1 rules the value space `^[A-Z][A-Z0-9_]*$`, and the ledger does + * not carry it in any casing. Until #9232 the flat REST door passed it + * through verbatim, so this suite pinned a body that could never satisfy + * `ApiErrorSchema` — the door's own hole, recorded here as if it were the + * contract. + * + * Since #9232 that door narrows like every other: `code` carries the member + * the 403 derives (`PERMISSION_DENIED`) and the gate's own string survives, + * unchanged, in the open `declaredCode` channel beside it. Both are + * asserted below — the demote is only meaningful if the producer's spelling + * is still readable, which is the whole point of demoting rather than + * dropping. + * + * ⚠️ This is a REAL producer, not a test vehicle, and it is invisible to + * `check:dispatcher-error-vocabulary`: that scan matches SCREAMING_SNAKE + * shapes, so a lowercase code stamps no site it can see. Filed separately + * rather than fixed here — registering a code is the `packages/spec` lane's + * call, and this card narrows doors rather than editing the ledger. */ it('refuses an env overlay that widens a packaged object\'s internal OWD', async () => { // Declared baseline: `public_read`. The overlay asks for @@ -388,7 +412,10 @@ describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:n })); expect(res._status).toBe(403); - expect(res._json?.code).toBe('owd_widening_forbidden'); + // [#9232] The closed member the 403 derives, with the gate's own + // spelling demoted beside it rather than dropped. + expect(res._json?.code).toBe('PERMISSION_DENIED'); + expect(res._json?.declaredCode).toBe('owd_widening_forbidden'); expect(String(res._json?.error)).toContain('TIGHTEN'); expect(await storedRows('qa_packaged_account')).toEqual([]); }, 60_000); @@ -409,7 +436,8 @@ describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:n })); expect(res._status).toBe(403); - expect(res._json?.code).toBe('owd_widening_forbidden'); + expect(res._json?.code).toBe('PERMISSION_DENIED'); + expect(res._json?.declaredCode).toBe('owd_widening_forbidden'); expect(await storedRows('qa_packaged_account')).toEqual([]); }, 60_000); }); diff --git a/packages/rest/src/rest-4xx-message-truncation.test.ts b/packages/rest/src/rest-4xx-message-truncation.test.ts index 630b25dbbd..c7279a1eb4 100644 --- a/packages/rest/src/rest-4xx-message-truncation.test.ts +++ b/packages/rest/src/rest-4xx-message-truncation.test.ts @@ -159,11 +159,11 @@ describe('mapDataError: short 4xx messages are byte-for-byte unchanged (#5423)', const r = mapDataError( Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432 '.repeat(20)), { status: 502, - code: 'UPSTREAM_UNAVAILABLE', + code: 'CONNECTOR_UPSTREAM_UNAVAILABLE', }), ); expect(r.status).toBe(502); - expect(r.body.code).toBe('UPSTREAM_UNAVAILABLE'); + expect(r.body.code).toBe('CONNECTOR_UPSTREAM_UNAVAILABLE'); // Withheld, not truncated: no prefix of the original, no ellipsis. expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE); expect(String(r.body.error).endsWith('…')).toBe(false); diff --git a/packages/rest/src/rest-5xx-status-passthrough.test.ts b/packages/rest/src/rest-5xx-status-passthrough.test.ts index 07ac2aa0b7..f4512f59c9 100644 --- a/packages/rest/src/rest-5xx-status-passthrough.test.ts +++ b/packages/rest/src/rest-5xx-status-passthrough.test.ts @@ -92,11 +92,24 @@ function uncompilableAggregateError(func: string) { ); } -/** The issue body's own measurement, with the `code` its producer would declare. */ +/** + * The issue body's own measurement, with the `code` its producer would declare. + * + * [#9232] The vehicle was the invented spelling `UPSTREAM_UNAVAILABLE` until + * this door started narrowing thrown codes to the closed ADR-0112 vocabulary. + * It was never a registered member — no producer anywhere emits it, and the + * real code for this condition is the ledger's `CONNECTOR_UPSTREAM_UNAVAILABLE` + * — so it only ever passed here because nothing checked. What these cases pin + * is the STATUS/MESSAGE rule (a declared 5xx keeps its status and its code, and + * loses its prose); a registered vehicle keeps that subject intact instead of + * quietly turning every case into a vocabulary assertion. Deliberately NOT the + * status-derived code for 502 (`EXTERNAL_SERVICE_ERROR`), so "the producer's + * own code survives" can still fail. + */ function upstreamUnreachableError() { return Object.assign( new Error('connect ECONNREFUSED 10.0.0.5:5432 (internal pool)'), - { code: 'UPSTREAM_UNAVAILABLE', status: 502 }, + { code: 'CONNECTOR_UPSTREAM_UNAVAILABLE', status: 502 }, ); } @@ -129,7 +142,7 @@ describe('[#5582] mapDataError: a declared 5xx keeps its status and its code', ( const r = mapDataError(upstreamUnreachableError(), 'showcase_account'); expect(r.status).toBe(502); - expect(r.body.code).toBe('UPSTREAM_UNAVAILABLE'); + expect(r.body.code).toBe('CONNECTOR_UPSTREAM_UNAVAILABLE'); expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE); expect(JSON.stringify(r.body)).not.toContain('10.0.0.5'); expect(JSON.stringify(r.body)).not.toContain('5432'); @@ -159,10 +172,18 @@ describe('[#5582] mapDataError: a declared 5xx keeps its status and its code', ( }); it('the whole band passes through, not a hand-picked list of statuses', () => { + // [#9232] Vehicle was the placeholder `X_FAULT`, which the narrowed door + // now demotes — the deep-equal would see a `declaredCode` sibling and + // fail for a reason that has nothing to do with this case's subject + // (the STATUS band). A registered member keeps the body two-keyed and + // is not the status-derived code for any status in the loop, so + // "the producer's code survives" can still fail here. for (const status of [500, 501, 502, 503, 504, 507, 599]) { - const r = mapDataError(Object.assign(new Error('internal detail'), { status, code: 'X_FAULT' })); + const r = mapDataError( + Object.assign(new Error('internal detail'), { status, code: 'CONNECTOR_UPSTREAM_UNAVAILABLE' }), + ); expect(r.status).toBe(status); - expect(r.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'X_FAULT' }); + expect(r.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'CONNECTOR_UPSTREAM_UNAVAILABLE' }); } }); @@ -263,8 +284,8 @@ describe('[#5582] nothing of a 5xx message reaches the client', () => { }); it('a missing message changes nothing — the generic sentence is unconditional', () => { - const r = mapDataError({ status: 502, code: 'UPSTREAM_UNAVAILABLE' }); - expect(r.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'UPSTREAM_UNAVAILABLE' }); + const r = mapDataError({ status: 502, code: 'CONNECTOR_UPSTREAM_UNAVAILABLE' }); + expect(r.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'CONNECTOR_UPSTREAM_UNAVAILABLE' }); }); }); @@ -423,7 +444,7 @@ describe('[#5582] the CRUD data route, in process', () => { const res = await callDataList(rest, 'showcase_account'); expect(res.statusCode).toBe(502); - expect(res.body.code).toBe('UPSTREAM_UNAVAILABLE'); + expect(res.body.code).toBe('CONNECTOR_UPSTREAM_UNAVAILABLE'); expect(JSON.stringify(res.body)).not.toContain('10.0.0.5'); // No blind spot. 502 is an `isExpectedDataStatus` lifecycle outcome, so // it gets no "[REST] Unhandled error" line — `logWithheldServerFault` diff --git a/packages/rest/src/rest-field-visibility-fault-envelope.test.ts b/packages/rest/src/rest-field-visibility-fault-envelope.test.ts index 7d2041ba9a..ba3133102d 100644 --- a/packages/rest/src/rest-field-visibility-fault-envelope.test.ts +++ b/packages/rest/src/rest-field-visibility-fault-envelope.test.ts @@ -35,9 +35,11 @@ * * ## Why the vocabulary parse maps `{ code, error }` → `{ code, message }` * - * These data-door bodies are the FLAT dialect (`code` beside `error`), #7035's - * declared debt held by the `check:route-envelope` ratchet — the envelope - * POSITION is that card's business, not this one's. What THIS card owes is the + * These data-door bodies are the FLAT dialect (`code` beside `error`), declared + * debt held by the `check:route-envelope` ratchet — the envelope POSITION is + * that ratchet's business, not this one's. (⚠️ It was cited here as #7035's + * business; that card closed on 2026-08-10 with PR #7293, having converged three + * `/meta` 501 handlers and nothing else.) What THIS card owes is the * VOCABULARY: `ApiErrorSchema.code` is the closed union * (`StandardErrorCode ∪ ERROR_CODE_LEDGER`), so each case parses the wire * code + message through `ApiErrorSchema` slots to assert union membership, diff --git a/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts b/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts index 0bf1fab4e4..80faf20194 100644 --- a/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts +++ b/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts @@ -178,13 +178,21 @@ describe('[#7525] mapDataError: a hook refusal keeps the status it declared', () }); it('the whole 400-599 band is read off `statusCode`, not a hand-picked list', () => { + // [#9232] The vehicle was the invented spelling `HOOK_REFUSED` until + // this door started narrowing thrown codes to the closed ADR-0112 + // vocabulary. Nothing in the repo emits it; the ledger's registered + // hook code is `ERR_HOOK_TARGET_REBIND`, which is what a hook refusal + // actually carries. The subject here is the STATUS read (`statusCode` + // across the whole band), and a registered vehicle keeps it that way. + // It differs from every status-derived code in the loop, so the `code` + // assertion below can still fail. for (const statusCode of [400, 403, 404, 409, 422, 429, 451, 499]) { const err: any = new Error('refused by hook'); - err.code = 'HOOK_REFUSED'; + err.code = 'ERR_HOOK_TARGET_REBIND'; err.statusCode = statusCode; const r = mapDataError(err, 'showcase_task'); expect(r.status).toBe(statusCode); - expect(r.body.code).toBe('HOOK_REFUSED'); + expect(r.body.code).toBe('ERR_HOOK_TARGET_REBIND'); } }); @@ -206,7 +214,7 @@ describe('[#7525] mapDataError: a hook refusal keeps the status it declared', () // `errorResponseBase` read. Pinned so a future edit cannot silently // invert it. const err: any = new Error('refused'); - err.code = 'HOOK_REFUSED'; + err.code = 'ERR_HOOK_TARGET_REBIND'; err.status = 409; err.statusCode = 403; expect(mapDataError(err, 'showcase_task').status).toBe(409); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e74cd55e6e..ecc7e60708 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -86,9 +86,13 @@ import { // is the difference that actually matters and the one still open. This one // emits the declared, NESTED `{ success: false, error: { code, message } }` for // a refusal the handler DECIDED; `sendDeclaredFault` emits the same refusal in -// this package's FLAT `{ error, code }` dialect (open finding #7035). Both type -// `code` to the closed ADR-0112 vocabulary rather than `string`, so the choice -// between them is about POSITION only, never strictness. +// this package's FLAT `{ error, code }` dialect, whose convergence onto the +// nested position is held by the `check:route-envelope` ratchet (⚠️ NOT #7035, +// which closed on 2026-08-10 with PR #7293 after converging this file's three +// `/meta` 501 handlers only). Both type `code` to the closed ADR-0112 +// vocabulary rather than `string`, so the choice between them is about POSITION +// only, never strictness — and since #9232 that is true of THROWN codes at both +// doors too, not just of the two typed author-side responders. // Adding a call site here moves no `check:route-envelope` count: // the body literal lives in `@objectstack/types` (the pinned `SHARED_BUILDER`), // and this file is audited `dialectOnly` for the two non-conforming dialects it @@ -8946,14 +8950,21 @@ export class RestServer { // already publish (`respondSharingError`), so no new contract. // // ⚠️ Built through the SHARED `sendError` envelope, unlike the three - // arms above it. Those are #7035's declared debt — `code` beside - // `error` instead of inside it, so `body.error.code` reads - // `undefined` — held down by the `check:route-envelope` ratchet, - // which only ticks DOWN. A new arm copying its neighbours' shape is - // exactly what that ratchet exists to stop, so this one answers the - // envelope `BaseResponseSchema` declares. The asymmetry is the - // ratchet working; converting the other three is #8111's unfinished - // half for this route family, not a rider on this card. + // arms above it. Those are the flat dialect — `code` beside `error` + // instead of inside it, so `body.error.code` reads `undefined` — + // held down by the `check:route-envelope` ratchet, which only ticks + // DOWN. A new arm copying its neighbours' shape is exactly what that + // ratchet exists to stop, so this one answers the envelope + // `BaseResponseSchema` declares. The asymmetry is the ratchet + // working; converting the other three is outstanding envelope- + // position work owned by that ratchet, not a rider on this card. + // + // ⚠️ Two card citations stood here and both were stale by the time + // anyone read them: #7035 closed 2026-08-10 (PR #7293, three `/meta` + // 501 handlers), and #8111 closed 2026-08-12 (PR #8212) having + // converged the record-sharing family it named. The ratchet's own + // baseline is the live owner of what is left; a closed card number + // is not. if (msg.startsWith('SHARING_NOT_ENABLED')) { return sendEnvelopeError( res, 422, 'SHARING_NOT_ENABLED', diff --git a/packages/rest/src/rest-thrown-code-vocabulary.test.ts b/packages/rest/src/rest-thrown-code-vocabulary.test.ts new file mode 100644 index 0000000000..339e91a23a --- /dev/null +++ b/packages/rest/src/rest-thrown-code-vocabulary.test.ts @@ -0,0 +1,306 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#9232] The FLAT door speaks the closed ADR-0112 vocabulary too. +// +// --------------------------------------------------------------------------- +// What this file pins, and why both halves are load-bearing +// --------------------------------------------------------------------------- +// The 2026-08-16 ruling on #9106 made `error.code` a closed vocabulary at every +// door and demoted an unregistered thrown spelling to a `declaredCode` sibling. +// Its scope named the doors served by `resolveThrownHttpError` — the dispatcher +// exits and the direct-mount package registrar — so `packages/rest`'s FLAT +// responder, which puts `code` at the body's TOP level rather than in +// `error.code`, stayed outside it and went on passing a caught error's `code` +// through verbatim. The ADR then read "closed at every door" absolutely while an +// observable door contradicted it, which is the reader ambiguity #9106 was filed +// to remove, moved one door over. The 2026-08-17 ruling on #9232 closed it: +// body POSITION is not a carve-out from the vocabulary. +// +// Two halves, and the second is not decoration: +// +// ① an unregistered thrown `code` is DEMOTED — the status-derived member +// arrives in `code`, the producer's string in `declaredCode`; +// ② a REGISTERED thrown `code` still arrives in `code`, verbatim, with no +// `declaredCode` beside it. +// +// Half ② is what stops an over-eager narrowing from swallowing valid codes. A +// door that answered `INTERNAL_ERROR` for everything would satisfy half ① on +// every case in this file and would have destroyed the vocabulary it was +// written to protect — and it would do so silently, because a body carrying a +// registered code parses exactly like one carrying the right registered code. +// +// --------------------------------------------------------------------------- +// Why the assertions are `code` + `status` pairs, never `toThrow` +// --------------------------------------------------------------------------- +// These doors SEND (or return a body); they never throw. A `toThrow`-shaped +// assertion could not separate "answered with the wrong code" from "did not +// answer at all", and the wrong code IS the defect. Per ADR-0112 every case +// asserts the wire pair the envelope declares. +// +// --------------------------------------------------------------------------- +// Reverse verification, direction predicted BEFORE running +// --------------------------------------------------------------------------- +// Restoring any one of the four verbatim passthroughs (`{ code: error.code }`) +// turns the demote cases in §1 RED — they assert a body that only exists once +// the narrowing runs — and leaves every §2 case GREEN, because a registered +// code is passed through identically by both the old and the new arm. That +// asymmetry is the point of keeping §2: it is the half a regression does NOT +// redden, so it can only be defended by being written down. Confirmed by +// running it; see the PR. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; +// `.js` extension deliberately: this package resolves `nodenext`, so an +// extensionless relative import is a `tsc` error (TS2835). The neighbouring +// suites omit it and are part of this package's frozen TEST_DEBT — a new file +// must not add to a shrink-only ratchet. +import { mapDataError, sendThrownError, sendDeclaredFault } from './error-response.js'; + +// --------------------------------------------------------------------------- +// Vehicles +// --------------------------------------------------------------------------- + +/** + * A spelling the ledger deliberately does not know. Not a placeholder: what + * these cases pin is the DEMOTE, not any particular producer, and a code that + * someone might register later would silently turn every §1 case vacuous. + * The control below proves the union really rejects it. + */ +const UNREGISTERED = 'PACKAGE_IS_HAUNTED'; + +/** A registered member, for the half that must keep arriving verbatim. */ +const REGISTERED = 'RECORD_LOCKED'; + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + return res; +} + +/** Drive the flat door end to end and read the wire answer. */ +function wire(error: any, object?: string): { status: number; body: any } { + const res = makeRes(); + sendThrownError(res, error, object); + return { status: res.statusCode, body: res.body }; +} + +/** `.status` reaches `resolveErrorResponse`'s passthrough; `.statusCode` falls to `mapDataError`. */ +const thrownWithStatus = (status: number, code?: unknown) => + Object.assign(new Error('boom'), { status, ...(code !== undefined ? { code } : {}) }); +const thrownWithStatusCode = (statusCode: number, code?: unknown) => + Object.assign(new Error('boom'), { statusCode, ...(code !== undefined ? { code } : {}) }); + +/** + * The four flat arms a thrown error can leave through, each reached by the + * spelling/band combination named beside it. Enumerated rather than tested one + * at a time because the defect this card measured was FOUR verbatim + * passthroughs, and a fix that reached three of them would read as done. + */ +const ARMS = [ + { + name: 'resolveErrorResponse 4xx (`.status`)', + answer: (code: unknown) => wire(thrownWithStatus(409, code)), + status: 409, + }, + { + name: 'resolveErrorResponse 5xx (`.status`)', + answer: (code: unknown) => wire(thrownWithStatus(503, code)), + status: 503, + }, + { + name: 'mapDataError 4xx (`.statusCode`)', + answer: (code: unknown) => { + const r = mapDataError(thrownWithStatusCode(409, code)); + return { status: r.status, body: r.body }; + }, + status: 409, + }, + { + name: 'mapDataError 5xx (`.statusCode`)', + answer: (code: unknown) => { + const r = mapDataError(thrownWithStatusCode(503, code)); + return { status: r.status, body: r.body }; + }, + status: 503, + }, +] as const; + +let errorSpy: ReturnType; +beforeEach(() => { + // The 5xx arms log the withheld original through `logWithheldServerFault` + // (#5437). That is correct behaviour and not this file's subject. + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { errorSpy.mockRestore(); }); + +// --------------------------------------------------------------------------- +// Anti-vacuity: the union really is closed +// --------------------------------------------------------------------------- + +describe('#9232 — the control that makes the rest of this file evidence', () => { + it('the vehicle is genuinely unregistered and the comparison code genuinely registered', () => { + expect(ErrorCode.safeParse(UNREGISTERED).success).toBe(false); + expect(ErrorCode.safeParse(REGISTERED).success).toBe(true); + }); + + it('the two spellings really do produce different answers', () => { + // Without this, "demoted" and "passed through" could be the same string + // and every assertion below would agree trivially. + expect(standardErrorCodeForHttpStatus(409)).not.toBe(UNREGISTERED); + expect(standardErrorCodeForHttpStatus(409)).not.toBe(REGISTERED); + expect(standardErrorCodeForHttpStatus(503)).not.toBe(standardErrorCodeForHttpStatus(409)); + }); +}); + +// --------------------------------------------------------------------------- +// §1 — an unregistered thrown code is DEMOTED +// --------------------------------------------------------------------------- + +describe('#9232 §1 — a thrown code outside the vocabulary rides `declaredCode`', () => { + for (const arm of ARMS) { + it(`${arm.name}: \`code\` is the member the status derives, \`declaredCode\` the producer's string`, () => { + const { status, body } = arm.answer(UNREGISTERED); + + expect(status).toBe(arm.status); + expect(body.code).toBe(standardErrorCodeForHttpStatus(arm.status)); + expect(body.declaredCode).toBe(UNREGISTERED); + // The property the un-narrowed door could never have. + expect(ErrorCode.safeParse(body.code).success).toBe(true); + }); + + it(`${arm.name}: presence means demotion — never a member, never a copy of \`code\``, () => { + const { body } = arm.answer(UNREGISTERED); + // ⚠️ `toBeTypeOf` first, and it is not decoration: every assertion + // below is vacuously true of `undefined` (an absent field is not a + // union member and is not equal to `code`), so without it this case + // stayed GREEN under the reverse verification that reddened its + // sibling — a pin that cannot fail for the defect it names. + expect(body.declaredCode).toBeTypeOf('string'); + // `ApiErrorSchema.declaredCode`'s documented semantics, held at this + // door too so a consumer reading `declaredCode` at all knows the + // serving side's ledger did not recognise the spelling. + expect(ErrorCode.safeParse(body.declaredCode).success).toBe(false); + expect(body.declaredCode).not.toBe(body.code); + }); + } +}); + +// --------------------------------------------------------------------------- +// §2 — a registered thrown code is UNTOUCHED +// --------------------------------------------------------------------------- + +describe('#9232 §2 — a registered thrown code still arrives verbatim in `code`', () => { + for (const arm of ARMS) { + it(`${arm.name}: the spelling survives and nothing is demoted beside it`, () => { + const { status, body } = arm.answer(REGISTERED); + + expect(status).toBe(arm.status); + expect(body.code).toBe(REGISTERED); + // Repeating a recognised code in both slots would put two spellings + // of one fact on every refusal — the reason `demotedDeclaredCode` + // answers `undefined` for a member. + expect(body.declaredCode).toBeUndefined(); + }); + } +}); + +// --------------------------------------------------------------------------- +// §3 — what the narrowing deliberately does NOT change +// --------------------------------------------------------------------------- + +describe('#9232 §3 — the halves that must not move', () => { + for (const arm of ARMS) { + it(`${arm.name}: a throw with NO code still carries none — nothing is invented`, () => { + const { status, body } = arm.answer(undefined); + + expect(status).toBe(arm.status); + expect(body.code).toBeUndefined(); + expect(body.declaredCode).toBeUndefined(); + // ADR-0112 says the PRODUCER names the condition, so a + // half-declaration is honoured for the half that was declared. + // Narrowing the vocabulary must not start ADDING codes to bodies + // that carried none. + }); + } + + it('the 5xx prose is still withheld and the 4xx message still reaches the caller (#5437 / #5423)', () => { + // The narrowing touches the `code` fields only. If it had moved the + // message rules it would have re-opened a leak class while every + // vocabulary assertion above stayed green. + expect(wire(thrownWithStatus(503, UNREGISTERED)).body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(wire(thrownWithStatus(409, UNREGISTERED)).body.error).toBe('boom'); + }); + + it('`object` and `issues` still ride their arms', () => { + const withIssues = Object.assign(new Error('boom'), { + status: 400, code: UNREGISTERED, issues: [{ path: 'a' }], + }); + expect(wire(withIssues).body.issues).toEqual([{ path: 'a' }]); + expect(mapDataError(thrownWithStatusCode(409, UNREGISTERED), 'account').body.object).toBe('account'); + }); + + it('`sendDeclaredFault` is byte-identical — its `code: ErrorCode` cannot be demoted', () => { + // The author-side door types `code` to the closed union at COMPILE + // time, so every code it can emit is a member and §2's rule applies to + // all of them. This is what makes the narrowing invisible to the five + // author-declared emissions this repo routes through it. + const res = makeRes(); + sendDeclaredFault(res, { + code: 'FIELD_VISIBILITY_UNRESOLVED', + status: 503, + message: 'unresolved', + }); + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ + error: INTERNAL_ERROR_MESSAGE, + code: 'FIELD_VISIBILITY_UNRESOLVED', + }); + }); +}); + +// --------------------------------------------------------------------------- +// §4 — the one limb the narrowing closes on the way past +// --------------------------------------------------------------------------- + +describe('#9232 §4 — a non-string `code` is not a wire code', () => { + // `resolveErrorResponse`'s two arms gated on bare truthiness, so a numeric + // driver errno reached the flat body as `code: 1062` — a number in the + // field callers branch on, which is the drift #3842 removed at the other + // door and the loudest possible violation of a closed vocabulary. The + // shared resolver has always classed a non-string `code` as CONTEXT rather + // than a spelling, and all four flat arms now ask that one question. + it('a numeric errno reaches neither `code` nor `declaredCode`', () => { + for (const status of [409, 503]) { + const { body } = wire(thrownWithStatus(status, 1062)); + expect(body.code).toBeUndefined(); + expect(body.declaredCode).toBeUndefined(); + } + }); + + it('an empty-string `code` is not a declaration either', () => { + const { body } = wire(thrownWithStatus(409, '')); + expect(body.code).toBeUndefined(); + expect(body.declaredCode).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// §5 — the half that must never differ +// --------------------------------------------------------------------------- + +describe('#9232 §5 — the demote is computed against the status the client receives', () => { + it('every arm derives its code from its OWN resolved status, not from a fallback', () => { + // `thrownCodeFields` hands the boundary's already-resolved status to + // `resolveThrownHttpError` as the fallback. If a future edit dropped + // that argument, a throw whose status this door resolved would have its + // code derived from 500 instead — a wrong-but-registered code, which no + // schema parse can catch. Spanning four statuses is what makes this + // assertion able to fail. + for (const status of [400, 403, 404, 409, 501, 503]) { + const { body } = wire(thrownWithStatus(status, UNREGISTERED)); + expect(body.code).toBe(standardErrorCodeForHttpStatus(status)); + } + }); +}); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 190247387f..0d836ba5c9 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2402,12 +2402,12 @@ describe('mapDataError — schema/constraint envelopes', () => { const r = mapDataError( Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432 (internal pool)'), { status: 502, - code: 'UPSTREAM_UNAVAILABLE', + code: 'CONNECTOR_UPSTREAM_UNAVAILABLE', }), ); // The declared status and the machine-readable code both survive... expect(r.status).toBe(502); - expect(r.body.code).toBe('UPSTREAM_UNAVAILABLE'); + expect(r.body.code).toBe('CONNECTOR_UPSTREAM_UNAVAILABLE'); // ...and neither is the degraded answer this used to give. expect(r.status).not.toBe(500); expect(r.body.code).not.toBe('INTERNAL_ERROR'); diff --git a/packages/runtime/src/package-door-error-parity.test.ts b/packages/runtime/src/package-door-error-parity.test.ts index 4397b6cb18..a0ff2230a9 100644 --- a/packages/runtime/src/package-door-error-parity.test.ts +++ b/packages/runtime/src/package-door-error-parity.test.ts @@ -170,15 +170,31 @@ describe('#8016 — the dispatcher package door answers the shared mapping', () * symmetrically with this door", which was true on 2026-08-16 and is * the half #9106 changed; the ADR-0112 public-contract decision that * paragraph said was required is exactly the ruling this file now pins. - * - THROWN errors through `packages/rest`'s FLAT `sendThrownError`: still - * passed through verbatim, and out of #9106's ruled scope (which named - * the actions door and the resolver both doors above share). That path - * puts `code` at the body's TOP level rather than in `error.code`, so - * it is not the field this ruling closed. Filed as #9232 rather than - * fixed here; `check:dispatcher-error-vocabulary` sweeps its platform - * producers meanwhile. ⚠️ #9098's note pointed at #7035 for the - * envelope-position half — that card is CLOSED (PR #7293, three /meta - * 501 handlers), so #9232 is the live one to read. + * - THROWN errors through `packages/rest`'s FLAT `sendThrownError`: + * NARROWED TOO, since the #9232 ruling (maintainer, 2026-08-17). That + * path puts `code` at the body's TOP level rather than in `error.code`, + * which is why #9106's scope — the actions door and the resolver both + * doors above share — did not reach it by construction. It was filed as + * #9232 rather than fixed there, and #9232 ruled that body POSITION is + * NOT a carve-out from the vocabulary: an unregistered thrown spelling + * is demoted to a top-level `declaredCode` sibling in the flat body, + * computed by the same `resolveThrownHttpError` / `demotedDeclaredCode` + * pair this file reads. Pinned at that door by + * `packages/rest/src/rest-thrown-code-vocabulary.test.ts`. + * + * ⚠️ This paragraph carried the opposite claim until #9232 landed. #9098 + * wrote it as "NOT narrowed, deliberately and symmetrically with this + * door", which was true on 2026-08-16; #9106 narrowed THIS door the next + * day and took the symmetry with it, leaving a sentence that read as a + * deliberate design decision while describing nothing that existed. That + * gap is the same declared-≠-actual defect class as the door hole itself, + * which is why #9232's ruling required both to be fixed in one change: a + * pin whose prose asserts a symmetry the code abandoned teaches the next + * reader a rule the platform does not have. + * + * The envelope POSITION is a separate, still-open line — the flat dialect + * has not moved and is held by the `check:route-envelope` ratchet. It was + * explicitly NOT a precondition for the vocabulary fix. * * History: #8087 first ruled the verbatim spelling stays and gated the * producer set (`dispatcher-error-vocabulary.ts`,