Skip to content

Commit 479ec31

Browse files
Parse every body the dispatcher door emits: gate the error-code vocabulary against the ADR-0112 ledger (#9099)
* test(dispatcher-error-vocabulary): parse every body the dispatcher door emits (#8087) ADR-0112 closes `error.code` — `ApiErrorSchema.code` parses against `StandardErrorCode` union `ERROR_CODE_LEDGER` — but the dispatcher door did not enforce it. `errorFromThrown` puts `resolveThrownHttpError(e).declaredCode` on the wire un-narrowed, and its conformance suite parsed only the cases it drove, so suites pinned bodies the schema rejects while CI stayed green. Maintainer ruling 2026-08-12: option B, delivered as a gate rather than a sweep. A one-time sweep is a snapshot that decays; an unswept producer just re-opens the hole. - scripts/check-dispatcher-error-vocabulary.mjs derives every site stamping a code the ledger does not know, over packages non-test source, in four published shapes with cross-file constant resolution. Unresolved is reported, never dropped. - packages/runtime/src/dispatcher-error-vocabulary.ts records the verdict for each site WITH its evidence. Reachability is not decidable from source: a boot refusal and a live wire code are written identically. Reconciled in both directions, so a new producer fails and a row whose code has since been registered fails too. - error-envelope.conformance.test.ts drives every dispatcher-reachable member of the derivation through the real builder and parses the body. That is the "parse every body it emits" half, and it covers producers nobody adds a case for. In-lane collapse: STORAGE_FAILURE had no producer anywhere — two fixtures invented it — so both pins move to DATABASE_ERROR, a StandardErrorCode that is deliberately NOT what 500 derives, so the assertions still distinguish promoted from derived. Measured and reported rather than guessed: SandboxError carries a metadata app's OWN code across the QuickJS boundary by design, so this door has a limb whose vocabulary is authored by tenants at runtime. DUPLICATE is its witness and is deliberately left un-respelled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza * fix(runtime): spread the frozen pending lists for arrayContaining (#8087) TEST_DEBT is a shrink-only ratchet sitting exactly at its measurement, so the readonly-array argument was a real +1 that had to be fixed rather than banked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e569cac commit 479ec31

10 files changed

Lines changed: 1116 additions & 28 deletions

.github/workflows/lint.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,25 @@ jobs:
497497
- name: Documented HTTP status matches the status the runtime emits
498498
run: pnpm check:error-status-conformance
499499

500+
# #8087: ADR-0112 closes `error.code`, and the dispatcher door did not
501+
# enforce it — `errorFromThrown` puts the producer's string on the wire
502+
# un-narrowed, and its conformance suite parsed only the cases it drove,
503+
# so three suites pinned bodies `ApiErrorSchema` rejects with CI green.
504+
# Maintainer ruling 2026-08-12: option B, delivered as a GATE rather than
505+
# a sweep — a one-time sweep is a snapshot that decays, and "an unswept
506+
# producer just re-opens the hole". The scan finds every site stamping a
507+
# code the ledger does not know; the declared table
508+
# (packages/runtime/src/dispatcher-error-vocabulary.ts) records the verdict
509+
# WITH its evidence, because reachability is not decidable from source —
510+
# a boot refusal and a live wire code are written identically. Reconciled
511+
# in both directions, so a new producer fails AND a row whose code has
512+
# since been registered fails (that is how the spec half, #8846, ratchets
513+
# the list down). No `paths:` filter, deliberately: the producers live in
514+
# any package, and the registered vocabulary lives in packages/spec.
515+
# Runs its own --self-test first.
516+
- name: Dispatcher error-code vocabulary guard
517+
run: pnpm check:dispatcher-error-vocabulary
518+
500519
# Namespace-wildcard fall-through guard (#4116). A handler mounted on
501520
# `<prefix>/*` claims the whole namespace, and Hono's first-registered
502521
# handler that answers wins — so a TERMINAL wildcard makes every other

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
6767
"check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs",
6868
"check:error-status-conformance": "node scripts/check-error-status-conformance.mjs --self-test && node scripts/check-error-status-conformance.mjs",
69+
"check:dispatcher-error-vocabulary": "node scripts/check-dispatcher-error-vocabulary.mjs --self-test && node scripts/check-dispatcher-error-vocabulary.mjs",
6970
"check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs",
7071
"check:meta-type-normalized": "node scripts/check-meta-type-normalized.mjs --self-test && node scripts/check-meta-type-normalized.mjs",
7172
"check:filter-alias-parity": "node scripts/check-filter-alias-parity.mjs --self-test && node scripts/check-filter-alias-parity.mjs",

packages/runtime/src/dispatcher-error-vocabulary.ts

Lines changed: 335 additions & 0 deletions
Large diffs are not rendered by default.

packages/runtime/src/dispatcher-validation-error.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,16 +173,30 @@ describe('#3918 — HttpDispatcher.errorFromThrown maps VALIDATION_FAILED', () =
173173
// Anti-regression for the #3867 tier this sits next to: an ordinary
174174
// throw still takes the caller's 500 fallback and its `issues`/`code`
175175
// details shape.
176+
// [#8087] The vehicle was `STORAGE_FAILURE`, which no producer in this
177+
// repo emits — the fixture invented it — and which the ledger therefore
178+
// does not register, so this pin asserted a body `ApiErrorSchema`
179+
// rejects. Collapsed to `DATABASE_ERROR`, a StandardErrorCode: the
180+
// producer here is `metadata-protocol`, whose vocabulary IS
181+
// ledger-governed, so an unregistered string was a fixture bug rather
182+
// than a wire value worth keeping.
183+
//
184+
// Deliberately NOT the status-derived code: 500 derives `INTERNAL_ERROR`
185+
// (`standardErrorCodeForHttpStatus`), so using it would make the
186+
// assertion below pass whether the code was promoted or merely derived
187+
// — a green test over nothing. `DATABASE_ERROR` is registered AND
188+
// distinct from the derived one, so the pin still proves what it was
189+
// written to prove: the producer's own code wins.
176190
const err = Object.assign(new Error('publish backend unavailable'), {
177-
code: 'STORAGE_FAILURE',
191+
code: 'DATABASE_ERROR',
178192
issues: [{ path: 'a', message: 'b', code: 'c' }],
179193
});
180194
const res = await publishPackage(err);
181195

182196
expect(res.status).toBe(500);
183-
// [#3842] `STORAGE_FAILURE` is the error's own code and now reaches the
184-
// declared field instead of `details`; `issues` stays context.
185-
expect(res.body.error.code).toBe('STORAGE_FAILURE');
197+
// [#3842] the error's own code reaches the declared field instead of
198+
// `details`; `issues` stays context.
199+
expect(res.body.error.code).toBe('DATABASE_ERROR');
186200
expect(res.body.error.details).toEqual({
187201
issues: [{ path: 'a', message: 'b', code: 'c' }],
188202
});

packages/runtime/src/error-envelope.conformance.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,22 @@
2727

2828
import { describe, it, expect, vi } from 'vitest';
2929
import { readFileSync } from 'node:fs';
30-
import { ApiErrorSchema, BaseResponseSchema, DispatcherErrorCode, envelopeViolations } from '@objectstack/spec/api';
30+
import {
31+
ApiErrorSchema,
32+
BaseResponseSchema,
33+
DispatcherErrorCode,
34+
ErrorCode,
35+
envelopeViolations,
36+
standardErrorCodeForHttpStatus,
37+
} from '@objectstack/spec/api';
3138
import { HttpDispatcher } from './http-dispatcher.js';
3239
import { buildApiError, splitSemanticCode } from './error-envelope.js';
40+
import {
41+
PENDING_AT_DISPATCHER_DOOR,
42+
PENDING_LEDGER_REGISTRATION,
43+
SANDBOX_AUTHORED_LIMB,
44+
UNREGISTERED_CODE_SITES,
45+
} from './dispatcher-error-vocabulary.js';
3346

3447
/** Minimal kernel — these branches fail before any service is reached. */
3548
function makeDispatcher(kernel: any = { context: { getService: () => null } }) {
@@ -193,6 +206,131 @@ describe('#3842 — every dispatcher error exit answers in the declared envelope
193206
});
194207
});
195208

209+
/* ────────────────────────────────────────────────────────────────────────────
210+
* [#8087] Direction 3 — the vocabulary, not just the cases this file drives
211+
*
212+
* The suite above parses the bodies it DRIVES, which is how three suites came
213+
* to pin bodies `ApiErrorSchema` rejects without anything noticing: a
214+
* conformance suite can only ever cover the branches that existed the day it
215+
* was written. `check:dispatcher-error-vocabulary` derives the whole set of
216+
* codes this door can emit from source; this block drives every
217+
* dispatcher-reachable member of that derivation through the REAL builder and
218+
* parses the result.
219+
*
220+
* That is the "parse EVERY body it emits" half of the maintainer ruling
221+
* (2026-08-12, option B as a gate) — the gate finds the set, and this asserts
222+
* on it, so a producer added next month is driven here without anyone adding
223+
* a case for it.
224+
* ──────────────────────────────────────────────────────────────────────────── */
225+
describe('#8087 — every code the dispatcher door can emit is parsed against ApiErrorSchema', () => {
226+
/** The real error path: a producer throw, resolved and built exactly as production does. */
227+
const emitFor = (code: string, status: number) =>
228+
(makeDispatcher() as any).errorFromThrown(
229+
Object.assign(new Error('a producer refused'), { code, status }),
230+
500,
231+
);
232+
233+
it('drives a code from the derivation rather than a list written by hand', () => {
234+
// Guards the wiring itself: if the derivation ever produced nothing,
235+
// every per-code assertion below would vacuously pass and this suite
236+
// would go quiet exactly when it had the most to say.
237+
expect(PENDING_AT_DISPATCHER_DOOR.length).toBeGreaterThan(0);
238+
// Spread: both lists are `readonly string[]`, and `arrayContaining`
239+
// takes a mutable one — passing the frozen list straight in is a tsc
240+
// error that only the TEST_DEBT ratchet would have caught.
241+
expect(PENDING_LEDGER_REGISTRATION).toEqual(expect.arrayContaining([...PENDING_AT_DISPATCHER_DOOR]));
242+
});
243+
244+
for (const code of PENDING_AT_DISPATCHER_DOOR) {
245+
it(`'${code}' reaches the wire verbatim, and ApiErrorSchema rejects it on \`code\` alone`, () => {
246+
const response = emitFor(code, 500);
247+
248+
// Verbatim — option B was ruled, so the door does NOT narrow. A
249+
// failure here means someone quietly implemented option A.
250+
expect(response.body.error.code).toBe(code);
251+
252+
// The body is STRUCTURALLY conformant — right envelope, status
253+
// mirrored, `details` context only — so the one thing standing
254+
// between it and its declared schema is the missing ledger row.
255+
// That is precisely the claim handed to #8846.
256+
expect(envelopeViolations(response.body)).toEqual([]);
257+
expect(response.body.success).toBe(false);
258+
expect(response.body.error.httpStatus).toBe(response.status);
259+
260+
const parsed = ApiErrorSchema.safeParse(response.body.error);
261+
expect(parsed.success).toBe(false);
262+
// Rejected on `code` and nothing else — an entry that failed for a
263+
// second reason would be a different defect wearing this one's label.
264+
expect([...new Set((parsed.error?.issues ?? []).map((i) => i.path.join('.')))]).toEqual(['code']);
265+
266+
// MEASURED while writing this: the damage is not confined to the
267+
// nested error object. `BaseResponseSchema` embeds `ApiErrorSchema`,
268+
// so the WHOLE response body fails to parse — one unregistered
269+
// string invalidates the envelope every consumer validates against,
270+
// for the same single reason and no other.
271+
const envelope = BaseResponseSchema.safeParse(response.body);
272+
expect(envelope.success).toBe(false);
273+
expect([...new Set((envelope.error?.issues ?? []).map((i) => i.path.join('.')))]).toEqual(['error.code']);
274+
});
275+
}
276+
277+
it('the same drive with a REGISTERED code is fully conformant — the control', () => {
278+
// Without this, "ApiErrorSchema rejects it" above would also be
279+
// satisfied by a builder that emits a broken envelope for everything.
280+
const error = expectConformantError(emitFor('DATABASE_ERROR', 500));
281+
expect(error.code).toBe('DATABASE_ERROR');
282+
// And not merely the status-derived answer — 500 derives INTERNAL_ERROR,
283+
// so this proves the producer's code was carried, not invented.
284+
expect(standardErrorCodeForHttpStatus(500)).not.toBe('DATABASE_ERROR');
285+
});
286+
287+
it('every pending code is still unregistered — the row comes out when #8846 lands', () => {
288+
// The ratchet's test-side half. When the spec lane registers one of
289+
// these, this goes red and the stale row must be deleted rather than
290+
// left promising work already done.
291+
for (const code of PENDING_LEDGER_REGISTRATION) {
292+
expect(ErrorCode.safeParse(code).success, `${code} is registered now — drop its row`).toBe(false);
293+
}
294+
});
295+
296+
it('the status-derived limb cannot produce an unregistered code, by construction', () => {
297+
// The third limb of `buildApiError`'s precedence needs no ledger row and
298+
// no gate: `standardErrorCodeForHttpStatus` returns a StandardErrorCode
299+
// for every input, so a branch that spells no code of its own is always
300+
// parseable. Asserted rather than assumed, across the bands the
301+
// dispatcher actually answers with.
302+
for (const status of [400, 401, 403, 404, 405, 409, 415, 422, 428, 429, 500, 501, 503, 504, 507]) {
303+
const derived = standardErrorCodeForHttpStatus(status);
304+
expect(ErrorCode.safeParse(derived).success, `${status} derived an unregistered ${derived}`).toBe(true);
305+
}
306+
});
307+
308+
it('records the sandbox limb as open rather than pretending the door is closed', () => {
309+
// `SandboxError` carries a metadata app's OWN `.code` across the QuickJS
310+
// boundary on purpose (#7867), and `domains/actions.ts` serves it through
311+
// `errorFromThrown`. So this door's vocabulary has a limb authored by
312+
// tenants at runtime, which no ledger can enumerate — the honest bound on
313+
// what "closed" can mean here, and the reason the witness below is NOT
314+
// re-spelled to a registered code.
315+
const witness = SANDBOX_AUTHORED_LIMB.witness;
316+
expect(ErrorCode.safeParse(witness).success).toBe(false);
317+
expect(emitFor(witness, 400).body.error.code).toBe(witness);
318+
// It is deliberately absent from the registration hand-off: registering
319+
// it would close nothing, since the next app picks a different string.
320+
expect(PENDING_LEDGER_REGISTRATION).not.toContain(witness);
321+
});
322+
323+
it('classifies every derived site — no verdict is left to a default', () => {
324+
for (const site of UNREGISTERED_CODE_SITES) {
325+
expect(site.why.length, `${site.code} at ${site.file} carries no evidence`).toBeGreaterThan(40);
326+
// A site that reaches a door must be on its way to a ledger row;
327+
// anything else must say which non-wire vocabulary it belongs to.
328+
if (site.door !== 'none') expect(site.verdict).toBe('pending-registration');
329+
else expect(site.verdict).not.toBe('pending-registration');
330+
}
331+
});
332+
});
333+
196334
describe('#3842 — buildApiError precedence', () => {
197335
it('prefers an explicit code over a promoted one over a derived one', () => {
198336
expect(buildApiError({ message: 'm', httpStatus: 403, code: 'EXPLICIT' }).code).toBe('EXPLICIT');

packages/runtime/src/http-dispatcher.error-leak.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,22 @@ describe('#3867 follow-up — HttpDispatcher.error() does not return raw driver
106106
// The semantic code and the per-field `issues` the UI maps back to inputs
107107
// are never free-form driver prose, so the 5xx guard must not touch them
108108
// — it only ever replaces `message`.
109+
// [#8087] Was `STORAGE_FAILURE` — a code the fixture invented, that no
110+
// producer emits and the ledger does not register, so the pinned body
111+
// could never satisfy `ApiErrorSchema`. `DATABASE_ERROR` is a
112+
// StandardErrorCode and is NOT what 500 derives (`INTERNAL_ERROR`), so
113+
// the assertion still distinguishes "promoted from the throw" from
114+
// "derived from the status". See dispatcher-error-vocabulary.ts.
109115
const err = Object.assign(new Error(SQL_DUMP), {
110116
status: 500,
111-
code: 'STORAGE_FAILURE',
117+
code: 'DATABASE_ERROR',
112118
issues: [{ path: 'name', message: 'taken', code: 'duplicate' }],
113119
});
114120
const result: any = await putMeta(err);
115121

116122
expect(result.response.body.error.message).toBe('Internal server error');
117123
// [#3842] The code is in the declared field; `details` keeps the issues.
118-
expect(result.response.body.error.code).toBe('STORAGE_FAILURE');
124+
expect(result.response.body.error.code).toBe('DATABASE_ERROR');
119125
expect(result.response.body.error.details).toMatchObject({
120126
issues: [{ path: 'name', message: 'taken', code: 'duplicate' }],
121127
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -760,13 +760,20 @@ export class HttpDispatcher {
760760
*/
761761
private errorFromThrown(e: any, fallbackStatus = 500) {
762762
const thrown = resolveThrownHttpError(e, fallbackStatus);
763-
// `declaredCode`, NOT the narrowed `code`: this door's `error.code` is
764-
// not closed in practice and three suites pin that — `STORAGE_FAILURE`,
765-
// `FLOW_FAILED` and `DUPLICATE` are all unregistered and all expected on
766-
// the wire verbatim. The REST door takes the narrowed spelling because
767-
// its own conformance suite parses its bodies against the ledger. Both
768-
// spellings come from the one resolver, so the difference is a stated
769-
// one; see its module note.
763+
// `declaredCode`, NOT the narrowed `code`: this door puts a producer's
764+
// own string on the wire. The REST door takes the narrowed spelling
765+
// because its own conformance suite parses its bodies against the
766+
// ledger. Both spellings come from the one resolver, so the difference
767+
// is a stated one; see its module note.
768+
//
769+
// [#8087] Ruled option B (maintainer, 2026-08-12): the verbatim spelling
770+
// STAYS, and the set of producers emitting codes the ledger does not
771+
// know is now measured and gated rather than named in a comment that
772+
// rots — `./dispatcher-error-vocabulary.ts` carries the classified list
773+
// and `pnpm check:dispatcher-error-vocabulary` fails on an unswept
774+
// producer added later. `error-envelope.conformance.test.ts` drives
775+
// every dispatcher-reachable member of that list through this method and
776+
// parses the body, which is the "parse every body it emits" half.
770777
return this.error(thrown.message, thrown.status, thrown.details, thrown.declaredCode);
771778
}
772779

packages/runtime/src/package-door-error-parity.test.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,17 +127,29 @@ describe('#8016 — the dispatcher package door answers the shared mapping', ()
127127
* The one place the two doors' codes differ, pinned so it stays a stated
128128
* difference rather than a drift.
129129
*
130-
* This door puts a producer's code on the wire verbatim — `STORAGE_FAILURE`,
131-
* `FLOW_FAILED` and `DUPLICATE` are all outside
132-
* `StandardErrorCode ∪ ERROR_CODE_LEDGER` and all pinned by existing suites
133-
* here. The REST door cannot: `sendError` takes the closed `ErrorCode`, and
134-
* that door's conformance suite parses its bodies against the ledger, so an
135-
* unregistered code there is a failing test rather than a wire answer.
130+
* This door puts a producer's code on the wire verbatim. The REST door
131+
* cannot: `@objectstack/types`' `sendError` takes the closed `ErrorCode`,
132+
* and that door's conformance suite parses its bodies against the ledger, so
133+
* an unregistered code there is a failing test rather than a wire answer.
136134
*
137-
* The STATUS agrees either way, which is what #8016 was about. Whether this
138-
* door's `error.code` should be closed too is a live contract question
139-
* (`ApiErrorSchema` would reject these bodies) and is filed separately — it
140-
* is not a decision this fix took.
135+
* The STATUS agrees either way, which is what #8016 was about.
136+
*
137+
* [#8087] The three codes this comment used to name are no longer one list.
138+
* The maintainer ruled option B — keep the verbatim spelling, and make the
139+
* set of unregistered producers a MEASURED, gated one instead of a
140+
* hand-maintained sentence that goes stale (this one had):
141+
*
142+
* - `FLOW_FAILED` has a real producer and is awaiting a ledger entry
143+
* (#8846); it stays verbatim, which is the whole point of option B.
144+
* - `STORAGE_FAILURE` had no producer anywhere — two fixtures invented it
145+
* — so it was collapsed to a registered code rather than registered.
146+
* - `DUPLICATE` is AUTHOR-thrown: it crosses the sandbox boundary from a
147+
* metadata app's own action code, so no ledger can enumerate it.
148+
*
149+
* `packages/runtime/src/dispatcher-error-vocabulary.ts` is the live list and
150+
* `pnpm check:dispatcher-error-vocabulary` keeps it honest. The vehicle
151+
* below stays a deliberately unregistered string, because what this case
152+
* pins is the SPELLING DIFFERENCE, not any particular producer.
141153
*/
142154
it('an unregistered code reaches this door verbatim, and the narrowed spelling differs', () => {
143155
const error = thrown('dialect', { status: 409, code: 'PACKAGE_IS_HAUNTED' });

0 commit comments

Comments
 (0)