Skip to content

Commit 3e66cce

Browse files
committed
test(spec): re-point the ADR-0112 D7 catalog guard at the published wire face
The guard asserted that the error-catalog page's `### `CODE`` headings and the `StandardErrorCode` enum agree in both directions. That premise broke twice: - A translated code is not on the wire. `DuplicateRecordError` declares `code = 'DUPLICATE_RECORD'` and the REST door translates the envelope at the boundary, so every route answers `UNIQUE_VIOLATION` (#14723). Demanding a `DUPLICATE_RECORD` heading on a page that documents the wire demands the page publish a code no client can receive. - A ledger code IS on the wire. `INVALID_REQUEST` is not an enum member, yet the catalog publishes two `/meta` entries for it. "Every heading is an enum member" should have failed on them and did not: the old regex was anchored and both headings carry a descriptive suffix. They passed by accident. Per the maintainer ruling on #15631, the page catalogs the WIRE FACE and the guard compares against that, in both directions. An enum member the translation census marks as translated is exempt from "must have a heading" — because it is not a wire code, not by a special case — and must instead be named by the cross-reference sentence under its wire code's entry, which the guard now asserts. The wire face and the translation set come from the one place that already derives them. `check-error-status-conformance.mjs` grows `deriveWireFace()` — the corpus walk, the runtime side, the doc side, the reconciled vocabulary and that vocabulary minus the door's translations — and `main()` becomes a consumer of it rather than an inlining of it. A second hand-written list of translated codes here would be exactly the copy that file's header argues against. Matching headings by that module's `ENTRY_HEADING_SHAPES` rather than by a regex of the test's own is the same move, and is what closes the `INVALID_REQUEST` suffix accident. The advertised count on the page becomes what the page now promises: 51 codes reachable on the wire, not 50 enum members. `scripts/check-error-status-conformance.d.mts` declares the one supported export for the TS consumer, per the `check-declaration-mirrors` convention. Gate output and `--self-test` are byte-identical before and after the refactor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH
1 parent 0dd0dc3 commit 3e66cce

4 files changed

Lines changed: 294 additions & 36 deletions

File tree

content/docs/api/error-catalog.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ title: Error Code Catalog
33
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
44
---
55

6-
ObjectStack uses a structured error system with **9 error categories** and **50 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
6+
ObjectStack uses a structured error system with **9 error categories** and **51 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
7+
8+
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
9+
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
10+
boundary, and the catalog carries [error-code ledger](/docs/references/api/error-code-ledger) codes the
11+
enum does not. A translated code is documented under the spelling clients receive, and named in that
12+
entry's cross-reference sentence so the in-process one stays findable.
713

814
<Callout type="info">
915
**Source:** `packages/spec/src/api/errors.zod.ts`

packages/spec/src/api/error-catalog-docs.test.ts

Lines changed: 111 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,126 @@
22

33
import { describe, it, expect } from 'vitest';
44
import { readFileSync } from 'node:fs';
5-
import { resolve } from 'node:path';
5+
import { dirname, join, resolve } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
67
import { StandardErrorCode } from './errors.zod';
8+
import { deriveWireFace } from '../../../../scripts/check-error-status-conformance.mjs';
79

810
/**
9-
* ADR-0112 D7 guard: the hand-written error catalog page and the enum can
10-
* never disagree about which codes exist. The page keeps its hand-written
11-
* Cause/Fix prose (that part cannot be generated), but every `### \`CODE\``
12-
* heading must be a `StandardErrorCode` member and every member must have a
13-
* heading — the exact drift #3841 was filed about.
11+
* ADR-0112 D7 guard: the hand-written error catalog page and the codes that
12+
* actually exist can never disagree. The page keeps its hand-written Cause/Fix
13+
* prose (that part cannot be generated), but its entries and the code set are
14+
* held equal in both directions — the drift #3841 was filed about.
15+
*
16+
* ## What this compares against, and why it is no longer the ENUM (#15631)
17+
*
18+
* It used to be `StandardErrorCode`, and that premise broke in two places at
19+
* once:
20+
*
21+
* - **A translated code is not on the wire.** `DuplicateRecordError` declares
22+
* `code = 'DUPLICATE_RECORD'`, and the REST door translates that envelope at
23+
* the boundary, so every route answers `UNIQUE_VIOLATION` (#14723). The enum
24+
* keeps the in-process spelling; the wire never carries it. Demanding a
25+
* `### \`DUPLICATE_RECORD\`` heading on a page that documents the wire is
26+
* demanding the page publish a code no client can ever receive — which is
27+
* this card's original defect, and is refused.
28+
* - **A ledger code IS on the wire.** `INVALID_REQUEST` is not an enum member,
29+
* yet the catalog publishes two `/meta` entries for it with a `400`. The old
30+
* guard's "every heading is an enum member" should have failed on them and
31+
* did not: its regex was anchored (`/^### \`CODE\`$/`) and both headings
32+
* carry a descriptive suffix. They passed by ACCIDENT, not by design.
33+
*
34+
* The maintainer ruling on #15631 (2026-09-07) settles both with one rule: the
35+
* catalog page catalogs the **wire face**, and the guard compares headings
36+
* against it in both directions. The wire face is `deriveWireFace()`'s
37+
* `wireCodes` — the reconciled vocabulary (enum members plus the ledger codes
38+
* the docs have reached) minus the codes a door translates away.
39+
*
40+
* ## Why the derivation is IMPORTED rather than repeated
41+
*
42+
* `scripts/check-error-status-conformance.mjs` already derives the translation
43+
* census from the door's own source, and its header argues at length against the
44+
* second hand-written copy of a table. A list of translated codes maintained
45+
* here would be exactly that copy, and would go stale in silence the day a door
46+
* gains or loses an arm — so the ruling requires this guard to read the set from
47+
* that one place. Matching headings by that module's `ENTRY_HEADING_SHAPES` (via
48+
* `catalogEntries`) rather than by a regex of this file's own is the same move,
49+
* and it is what closes the `INVALID_REQUEST` suffix accident: an unread heading
50+
* is an UNCHECKED heading.
1451
*/
15-
describe('error-catalog.mdx ↔ StandardErrorCode', () => {
16-
const page = readFileSync(
17-
resolve(__dirname, '../../../../content/docs/api/error-catalog.mdx'),
18-
'utf8'
19-
);
20-
// Only SCREAMING headings are catalog entries — lowercase headings (if any
21-
// ever appear) would be field-level docs, which live in #3977's catalog.
22-
const headings = [...page.matchAll(/^### `([A-Z][A-Z0-9_]*)`$/gm)].map(m => m[1]);
23-
24-
it('every catalog heading is a StandardErrorCode member', () => {
25-
const members = new Set<string>(StandardErrorCode.options);
26-
for (const heading of headings) {
27-
expect(members.has(heading), `docs heading \`${heading}\` is not in StandardErrorCode`).toBe(true);
52+
const HERE = dirname(fileURLToPath(import.meta.url));
53+
const REPO_ROOT = resolve(HERE, '../../../..');
54+
const page = readFileSync(join(REPO_ROOT, 'content/docs/api/error-catalog.mdx'), 'utf8');
55+
const face = deriveWireFace(REPO_ROOT);
56+
57+
/** The lines of the entry opening at 1-based `line`, up to the next heading. */
58+
function entryBody(line: number): string {
59+
const lines = page.split('\n');
60+
const out: string[] = [];
61+
for (let i = line; i < lines.length && !/^#{1,3}\s/.test(lines[i]); i++) out.push(lines[i]);
62+
return out.join('\n');
63+
}
64+
65+
const lineOf = (where: string): number => Number(where.slice(where.lastIndexOf(':') + 1));
66+
67+
describe('error-catalog.mdx ↔ the published wire face', () => {
68+
// The instrument must be SEEING something. Every assertion below is a
69+
// universal over a derived collection, so all three pass vacuously on a
70+
// derivation that went blind — a moved anchor in the scanned source, a page
71+
// whose heading level changed — and a blind run is not a clean one.
72+
it('the derivation is not empty, and it agrees with the enum it parsed', () => {
73+
expect(face.catalogEntries.length).toBeGreaterThan(40);
74+
expect(face.wireCodes.length).toBeGreaterThan(40);
75+
expect([...face.members].sort()).toEqual([...StandardErrorCode.options].sort());
76+
});
77+
78+
it('every catalog heading is a wire code', () => {
79+
for (const entry of face.catalogEntries) {
80+
expect(
81+
face.wireCodes.includes(entry.code),
82+
`${entry.where}: heading \`${entry.code}\` is not a code this platform puts on the wire`
83+
+ `${face.translatedCodes.has(entry.code)
84+
? ` — the door translates it away, so the page must document its WIRE spelling `
85+
+ `(${face.translated.find((t) => t.code === entry.code)?.toCode}) instead and name `
86+
+ `\`${entry.code}\` in that entry's cross-reference sentence`
87+
: ''}`,
88+
).toBe(true);
89+
}
90+
});
91+
92+
it('every wire code has a catalog heading', () => {
93+
const documented = new Set(face.catalogEntries.map((e) => e.code));
94+
for (const code of face.wireCodes) {
95+
expect(documented.has(code), `wire code \`${code}\` has no catalog entry`).toBe(true);
2896
}
2997
});
3098

31-
it('every StandardErrorCode member has a catalog heading', () => {
32-
const documented = new Set(headings);
33-
for (const member of StandardErrorCode.options) {
34-
expect(documented.has(member), `StandardErrorCode member \`${member}\` has no docs entry`).toBe(true);
99+
// The other half of the exemption. A translated member drops out of
100+
// `wireCodes` and is therefore exempt from the heading demand above — so
101+
// without this, its in-process spelling could vanish from the page entirely
102+
// and every assertion here would still pass. The ruling requires it to stay
103+
// FINDABLE, under the wire code that replaced it.
104+
it('every translated code is named under its wire code’s entry', () => {
105+
expect(face.translated.length).toBeGreaterThan(0);
106+
for (const t of face.translated) {
107+
const entry = face.catalogEntries.find((e) => e.code === t.toCode);
108+
expect(
109+
entry,
110+
`the door translates \`${t.code}\` to \`${t.toCode}\` (${t.arm}), but the catalog has no `
111+
+ `\`${t.toCode}\` entry to cross-reference it from`,
112+
).toBeTruthy();
113+
expect(
114+
entryBody(lineOf(entry!.where)).includes(t.code),
115+
`${entry!.where}: the \`${t.toCode}\` entry does not name \`${t.code}\`. The door translates `
116+
+ `that envelope at the boundary (${t.arm}), so the in-process spelling has no entry of its `
117+
+ `own and this cross-reference is the only place a reader can find it.`,
118+
).toBe(true);
35119
}
36120
});
37121

38-
it('the advertised member count matches the enum', () => {
39-
const claim = page.match(/\*\*(\d+) standardized error codes\*\*/);
40-
expect(claim, 'catalog page no longer states its member count').toBeTruthy();
41-
expect(Number(claim![1])).toBe(StandardErrorCode.options.length);
122+
it('the advertised code count matches the wire face', () => {
123+
const claim = page.match(/\*\*(\d+) error codes reachable on the wire\*\*/);
124+
expect(claim, 'catalog page no longer states how many wire codes it documents').toBeTruthy();
125+
expect(Number(claim![1])).toBe(face.wireCodes.length);
42126
});
43127
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Types for the ONE derivation `check-error-status-conformance.mjs` publishes to
2+
// its second consumer — the same problem, and the same fix, as
3+
// `js-comment-mask.d.mts` and `check-regen-pending.d.mts` next door (#5475,
4+
// #10398).
5+
//
6+
// The module itself stays `.mjs`: it is a root gate script with a `--self-test`
7+
// and a `--update` entry point run with bare `node`, and every root script here
8+
// is authored that way. What changed is that
9+
// `packages/spec/src/api/error-catalog-docs.test.ts` — the ADR-0112 D7 catalog
10+
// guard — now imports it from inside a tsc program (`tsconfig.test.json`), where
11+
// an untyped `.mjs` import is TS7016: the derivation silently becomes `any`, and
12+
// reading `.wireCode` off a misspelled property would type-check clean while the
13+
// guard asserted over `undefined`.
14+
//
15+
// ⛔ ONE export deliberately. The module exports two dozen internals for its own
16+
// `--self-test`, and declaring them here would invite the guard to re-assemble
17+
// the derivation itself — which is the second copy the #15631 ruling forbids.
18+
// `deriveWireFace` is the whole supported surface.
19+
//
20+
// Declared rather than inferred (no `allowJs`) because the module sits at the
21+
// repo root, outside the consuming program's `rootDir`. `check-declaration-mirrors`
22+
// holds the name, kind and required arity below equal to the module's; the TYPES
23+
// are hand-kept, so keep this file small enough that doing so stays trivial.
24+
25+
/**
26+
* One entry the doc parser READ on a page — a heading naming an error code in
27+
* any shape `ENTRY_HEADING_SHAPES` recognises, bare or with a descriptive
28+
* suffix. `where` is `<repo-relative path>:<1-based line>`.
29+
*/
30+
export interface DocEntry {
31+
code: string;
32+
where: string;
33+
}
34+
35+
/**
36+
* One row of the TRANSLATION CENSUS: a class whose thrown `code` a door
37+
* translates away before it reaches HTTP, so `code` is an in-process contract
38+
* and `toCode` is what the wire actually carries.
39+
*/
40+
export interface TranslatedDeclaration {
41+
code: string;
42+
toCode: string;
43+
status: number;
44+
className: string;
45+
where: string;
46+
arm: string;
47+
}
48+
49+
/**
50+
* The whole derivation: the corpus walk, the runtime side, the doc side, the
51+
* reconciled vocabulary, and the wire face left once the door's translations
52+
* are subtracted.
53+
*
54+
* Only the members the D7 catalog guard consumes are typed precisely; the
55+
* derivation's internal halves (`sources`, `derived`, `doc`) are declared as
56+
* the module returns them but are not part of the supported surface.
57+
*
58+
* @param repoRoot Directory every repo-relative path is resolved against;
59+
* defaults to the process cwd (`'.'`). Paths INSIDE the result stay
60+
* repo-relative regardless of what is passed here.
61+
*/
62+
export function deriveWireFace(repoRoot?: string): {
63+
/** Every `StandardErrorCode` member, parsed out of `errors.zod.ts`. */
64+
members: string[];
65+
/** Members plus every other code a scanned page publishes a status for. */
66+
vocabulary: string[];
67+
/** The non-member half of `vocabulary` — ledger codes the docs have reached. */
68+
docPublishedBeyondStandard: string[];
69+
/**
70+
* `vocabulary` minus every translated code: the codes that can appear in an
71+
* envelope ON THE WIRE, which is the face the catalog page catalogs.
72+
*/
73+
wireCodes: string[];
74+
/** The translation census, reported rather than dropped. */
75+
translated: TranslatedDeclaration[];
76+
/** `translated`'s in-process spellings, as a set. */
77+
translatedCodes: Set<string>;
78+
/** Every entry the parser read on the catalog page, in source order. */
79+
catalogEntries: DocEntry[];
80+
/** Repo-relative path of the catalog page, so a consumer need not respell it. */
81+
catalogPath: string;
82+
/** Repo-relative path → source text, for the scanned corpus. */
83+
sources: Map<string, string>;
84+
/** The runtime side: emitted statuses, unresolved declarations, site count. */
85+
derived: {
86+
emitted: Map<string, Map<number, string[]>>;
87+
unresolved: string[];
88+
translated: TranslatedDeclaration[];
89+
sites: number;
90+
};
91+
/** The doc side, as `parseDocumentedStatuses` returns it. */
92+
doc: {
93+
claimed: Map<string, Map<number, string[]>>;
94+
covered: Map<string, Map<number, string[]>>;
95+
documented: Set<string>;
96+
unreadableHeadings: { path: string; line: number; code: string; why: string; text: string }[];
97+
entries: DocEntry[];
98+
};
99+
};

scripts/check-error-status-conformance.mjs

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,15 +1805,63 @@ function walk(dir, out) {
18051805
}
18061806
}
18071807

1808-
function main() {
1809-
const update = process.argv.includes('--update');
1810-
1808+
/**
1809+
* The source corpus every derivation in this file reads: `SCAN_ROOT` walked
1810+
* under `repoRoot`, keyed by repo-relative path.
1811+
*
1812+
* @param {string} repoRoot directory the scan root is resolved against
1813+
* @returns {Map<string, string>} repo-relative path → source text
1814+
*/
1815+
function scanSources(repoRoot) {
18111816
const files = [];
1812-
walk(SCAN_ROOT, files);
1817+
walk(join(repoRoot, SCAN_ROOT), files);
18131818
const sources = new Map();
1814-
for (const f of files.sort()) sources.set(relative('.', f).replace(/\\/g, '/'), readFileSync(f, 'utf8'));
1819+
for (const f of files.sort()) sources.set(relative(repoRoot, f).replace(/\\/g, '/'), readFileSync(f, 'utf8'));
1820+
return sources;
1821+
}
18151822

1816-
const errorsZod = readFileSync(ERRORS_ZOD, 'utf8');
1823+
/**
1824+
* THE derivation, in one place: the corpus walk, the runtime side, the doc
1825+
* side, the reconciled vocabulary — and the WIRE FACE that vocabulary leaves
1826+
* once the door's translations are subtracted.
1827+
*
1828+
* `main()` is a consumer of this rather than an inlining of it, because a
1829+
* SECOND consumer now needs the same answer:
1830+
* `packages/spec/src/api/error-catalog-docs.test.ts`, the ADR-0112 D7 catalog
1831+
* guard. The #15631 ruling (2026-09-07) re-points that guard from the ENUM at
1832+
* the published wire face, and requires it to read the translation set "from
1833+
* the one place the conformance script already derives it — ⛔ no second
1834+
* hand-written list". A `DUPLICATE_RECORD` the door translates away is an
1835+
* in-process spelling; a guard carrying its own list of those would be exactly
1836+
* the second copy of a table this file's header argues against, and it would go
1837+
* stale in silence the day a door gains or loses an arm.
1838+
*
1839+
* ## `wireCodes` — what the catalog page is a catalog OF
1840+
*
1841+
* `vocabulary` is every `StandardErrorCode` member plus every other code a
1842+
* scanned page publishes a status for (the ledger codes that have reached the
1843+
* docs). Subtracting `translatedCodes` from it leaves the codes that can appear
1844+
* in an envelope on the wire — which is the face the catalog page documents,
1845+
* and the face the D7 guard compares its headings against in BOTH directions.
1846+
*
1847+
* The subtraction is what makes the two halves of the ruling consistent: the
1848+
* translated member is exempt from "must have a heading" BECAUSE it is not a
1849+
* wire code, not by a special case written next to it.
1850+
*
1851+
* `catalogEntries` are the entries the doc parser READ on the catalog page, in
1852+
* every shape `ENTRY_HEADING_SHAPES` recognises. The guard matches headings by
1853+
* that same rule rather than by a bare-heading regex of its own — which is what
1854+
* closes the `INVALID_REQUEST` suffix accident the #15631 ruling names: those
1855+
* two entries were invisible to the old guard only because they carry a
1856+
* descriptive suffix, and an unread heading is an UNCHECKED heading.
1857+
*
1858+
* @param {string} repoRoot directory every repo-relative path is resolved
1859+
* against; the paths inside the result stay repo-relative regardless.
1860+
*/
1861+
export function deriveWireFace(repoRoot = '.') {
1862+
const sources = scanSources(repoRoot);
1863+
1864+
const errorsZod = readFileSync(join(repoRoot, ERRORS_ZOD), 'utf8');
18171865
const members = parseStandardErrorCodes(errorsZod);
18181866
const index = buildConstantIndex(sources);
18191867
const translations = deriveDoorTranslations(sources);
@@ -1828,11 +1876,32 @@ function main() {
18281876
}
18291877

18301878
const doc = parseDocumentedStatuses({
1831-
handling: readFileSync(DOC_HANDLING, 'utf8'),
1832-
catalog: readFileSync(DOC_CATALOG, 'utf8'),
1879+
handling: readFileSync(join(repoRoot, DOC_HANDLING), 'utf8'),
1880+
catalog: readFileSync(join(repoRoot, DOC_CATALOG), 'utf8'),
18331881
});
18341882
const { vocabulary, docPublishedBeyondStandard } = reconciledVocabulary({ members, ...doc });
18351883
const translatedCodes = new Set(derived.translated.map((t) => t.code));
1884+
1885+
return {
1886+
sources,
1887+
members,
1888+
derived,
1889+
doc,
1890+
vocabulary,
1891+
docPublishedBeyondStandard,
1892+
translated: derived.translated,
1893+
translatedCodes,
1894+
wireCodes: vocabulary.filter((code) => !translatedCodes.has(code)),
1895+
catalogEntries: doc.entries.filter((e) => e.where.startsWith(`${DOC_CATALOG}:`)),
1896+
catalogPath: DOC_CATALOG,
1897+
};
1898+
}
1899+
1900+
function main() {
1901+
const update = process.argv.includes('--update');
1902+
1903+
const { sources, members, derived, doc, vocabulary, docPublishedBeyondStandard, translatedCodes } =
1904+
deriveWireFace();
18361905
const result = reconcile({ vocabulary, emitted: derived.emitted, translatedCodes, ...doc });
18371906

18381907
const baseline = existsSync(BASELINE_PATH)

0 commit comments

Comments
 (0)