From 447fcf93ca0e5c81ba7aea57e0a2d4ac21377c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:16:56 +0000 Subject: [PATCH 1/2] docs(api): the 409 unique-constraint entry is UNIQUE_VIOLATION on the wire (#15631) `content/docs/api/error-catalog.mdx` catalogued `DUPLICATE_RECORD` under `## Conflict Errors (409)` and in the HTTP Status Quick Reference. Per the maintainer ruling on #14723 (2026-09-03) a unique-constraint refusal has ONE wire spelling on every route, `UNIQUE_VIOLATION`; `DuplicateRecordError.code` stays `DUPLICATE_RECORD` in-process only, translated at the REST door (`packages/rest/src/error-response.ts`, the `DuplicateRecordError` arm of `structuredCodeAnswer`). A client branching on the catalogued constant never matched. The entry is renamed to the wire code with a one-sentence cross-reference to the in-process spelling, and the quick-reference row follows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- content/docs/api/error-catalog.mdx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index dc208c30e3..25772cfdbd 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -352,11 +352,16 @@ result set — a response indistinguishable from a successful query. **Fix:** Delete or reassign dependent records first, then retry the delete. **Retry:** `no_retry` -### `DUPLICATE_RECORD` +### `UNIQUE_VIOLATION` **Cause:** A record with the same unique key already exists. **Fix:** Update the existing record instead, or use a different unique key value. **Retry:** `no_retry` +The engine throws `DuplicateRecordError`, whose in-process `code` is +`DUPLICATE_RECORD`; the REST door translates that envelope at the boundary, so +every route answers the wire code `UNIQUE_VIOLATION` and the in-process spelling +never crosses HTTP. + ### `LOCK_CONFLICT` **Cause:** The record is locked by another process or user. **Fix:** Wait for the lock to be released, or contact the lock holder. @@ -793,7 +798,7 @@ async function handleApiCall() { | 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` | | 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` | | 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` | -| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD`, `DELETE_RESTRICTED` | +| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` | | 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code | | 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` | | 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR` | From 3e66cce2208f01e32321654eb399cf0bbb41dc69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:21:13 +0000 Subject: [PATCH 2/2] test(spec): re-point the ADR-0112 D7 catalog guard at the published wire face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --- content/docs/api/error-catalog.mdx | 8 +- .../spec/src/api/error-catalog-docs.test.ts | 138 ++++++++++++++---- scripts/check-error-status-conformance.d.mts | 99 +++++++++++++ scripts/check-error-status-conformance.mjs | 85 ++++++++++- 4 files changed, 294 insertions(+), 36 deletions(-) create mode 100644 scripts/check-error-status-conformance.d.mts diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 68ef63bbcc..1220ba4caa 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -3,7 +3,13 @@ title: Error Code Catalog description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies --- -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. +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. + +This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the +`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the +boundary, and the catalog carries [error-code ledger](/docs/references/api/error-code-ledger) codes the +enum does not. A translated code is documented under the spelling clients receive, and named in that +entry's cross-reference sentence so the in-process one stays findable. **Source:** `packages/spec/src/api/errors.zod.ts` diff --git a/packages/spec/src/api/error-catalog-docs.test.ts b/packages/spec/src/api/error-catalog-docs.test.ts index 3b26bfbb54..9487bf4f84 100644 --- a/packages/spec/src/api/error-catalog-docs.test.ts +++ b/packages/spec/src/api/error-catalog-docs.test.ts @@ -2,42 +2,126 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { StandardErrorCode } from './errors.zod'; +import { deriveWireFace } from '../../../../scripts/check-error-status-conformance.mjs'; /** - * ADR-0112 D7 guard: the hand-written error catalog page and the enum can - * never disagree about which codes exist. The page keeps its hand-written - * Cause/Fix prose (that part cannot be generated), but every `### \`CODE\`` - * heading must be a `StandardErrorCode` member and every member must have a - * heading — the exact drift #3841 was filed about. + * ADR-0112 D7 guard: the hand-written error catalog page and the codes that + * actually exist can never disagree. The page keeps its hand-written Cause/Fix + * prose (that part cannot be generated), but its entries and the code set are + * held equal in both directions — the drift #3841 was filed about. + * + * ## What this compares against, and why it is no longer the ENUM (#15631) + * + * It used to be `StandardErrorCode`, and that premise broke in two places at + * once: + * + * - **A translated code is not on the wire.** `DuplicateRecordError` declares + * `code = 'DUPLICATE_RECORD'`, and the REST door translates that envelope at + * the boundary, so every route answers `UNIQUE_VIOLATION` (#14723). The enum + * keeps the in-process spelling; the wire never carries it. Demanding a + * `### \`DUPLICATE_RECORD\`` heading on a page that documents the wire is + * demanding the page publish a code no client can ever receive — which is + * this card's original defect, and is refused. + * - **A ledger code IS on the wire.** `INVALID_REQUEST` is not an enum member, + * yet the catalog publishes two `/meta` entries for it with a `400`. The old + * guard's "every heading is an enum member" should have failed on them and + * did not: its regex was anchored (`/^### \`CODE\`$/`) and both headings + * carry a descriptive suffix. They passed by ACCIDENT, not by design. + * + * The maintainer ruling on #15631 (2026-09-07) settles both with one rule: the + * catalog page catalogs the **wire face**, and the guard compares headings + * against it in both directions. The wire face is `deriveWireFace()`'s + * `wireCodes` — the reconciled vocabulary (enum members plus the ledger codes + * the docs have reached) minus the codes a door translates away. + * + * ## Why the derivation is IMPORTED rather than repeated + * + * `scripts/check-error-status-conformance.mjs` already derives the translation + * census from the door's own source, and its header argues at length against the + * second hand-written copy of a table. A list of translated codes maintained + * here would be exactly that copy, and would go stale in silence the day a door + * gains or loses an arm — so the ruling requires this guard to read the set from + * that one place. Matching headings by that module's `ENTRY_HEADING_SHAPES` (via + * `catalogEntries`) rather than by a regex of this file's own is the same move, + * and it is what closes the `INVALID_REQUEST` suffix accident: an unread heading + * is an UNCHECKED heading. */ -describe('error-catalog.mdx ↔ StandardErrorCode', () => { - const page = readFileSync( - resolve(__dirname, '../../../../content/docs/api/error-catalog.mdx'), - 'utf8' - ); - // Only SCREAMING headings are catalog entries — lowercase headings (if any - // ever appear) would be field-level docs, which live in #3977's catalog. - const headings = [...page.matchAll(/^### `([A-Z][A-Z0-9_]*)`$/gm)].map(m => m[1]); - - it('every catalog heading is a StandardErrorCode member', () => { - const members = new Set(StandardErrorCode.options); - for (const heading of headings) { - expect(members.has(heading), `docs heading \`${heading}\` is not in StandardErrorCode`).toBe(true); +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '../../../..'); +const page = readFileSync(join(REPO_ROOT, 'content/docs/api/error-catalog.mdx'), 'utf8'); +const face = deriveWireFace(REPO_ROOT); + +/** The lines of the entry opening at 1-based `line`, up to the next heading. */ +function entryBody(line: number): string { + const lines = page.split('\n'); + const out: string[] = []; + for (let i = line; i < lines.length && !/^#{1,3}\s/.test(lines[i]); i++) out.push(lines[i]); + return out.join('\n'); +} + +const lineOf = (where: string): number => Number(where.slice(where.lastIndexOf(':') + 1)); + +describe('error-catalog.mdx ↔ the published wire face', () => { + // The instrument must be SEEING something. Every assertion below is a + // universal over a derived collection, so all three pass vacuously on a + // derivation that went blind — a moved anchor in the scanned source, a page + // whose heading level changed — and a blind run is not a clean one. + it('the derivation is not empty, and it agrees with the enum it parsed', () => { + expect(face.catalogEntries.length).toBeGreaterThan(40); + expect(face.wireCodes.length).toBeGreaterThan(40); + expect([...face.members].sort()).toEqual([...StandardErrorCode.options].sort()); + }); + + it('every catalog heading is a wire code', () => { + for (const entry of face.catalogEntries) { + expect( + face.wireCodes.includes(entry.code), + `${entry.where}: heading \`${entry.code}\` is not a code this platform puts on the wire` + + `${face.translatedCodes.has(entry.code) + ? ` — the door translates it away, so the page must document its WIRE spelling ` + + `(${face.translated.find((t) => t.code === entry.code)?.toCode}) instead and name ` + + `\`${entry.code}\` in that entry's cross-reference sentence` + : ''}`, + ).toBe(true); + } + }); + + it('every wire code has a catalog heading', () => { + const documented = new Set(face.catalogEntries.map((e) => e.code)); + for (const code of face.wireCodes) { + expect(documented.has(code), `wire code \`${code}\` has no catalog entry`).toBe(true); } }); - it('every StandardErrorCode member has a catalog heading', () => { - const documented = new Set(headings); - for (const member of StandardErrorCode.options) { - expect(documented.has(member), `StandardErrorCode member \`${member}\` has no docs entry`).toBe(true); + // The other half of the exemption. A translated member drops out of + // `wireCodes` and is therefore exempt from the heading demand above — so + // without this, its in-process spelling could vanish from the page entirely + // and every assertion here would still pass. The ruling requires it to stay + // FINDABLE, under the wire code that replaced it. + it('every translated code is named under its wire code’s entry', () => { + expect(face.translated.length).toBeGreaterThan(0); + for (const t of face.translated) { + const entry = face.catalogEntries.find((e) => e.code === t.toCode); + expect( + entry, + `the door translates \`${t.code}\` to \`${t.toCode}\` (${t.arm}), but the catalog has no ` + + `\`${t.toCode}\` entry to cross-reference it from`, + ).toBeTruthy(); + expect( + entryBody(lineOf(entry!.where)).includes(t.code), + `${entry!.where}: the \`${t.toCode}\` entry does not name \`${t.code}\`. The door translates ` + + `that envelope at the boundary (${t.arm}), so the in-process spelling has no entry of its ` + + `own and this cross-reference is the only place a reader can find it.`, + ).toBe(true); } }); - it('the advertised member count matches the enum', () => { - const claim = page.match(/\*\*(\d+) standardized error codes\*\*/); - expect(claim, 'catalog page no longer states its member count').toBeTruthy(); - expect(Number(claim![1])).toBe(StandardErrorCode.options.length); + it('the advertised code count matches the wire face', () => { + const claim = page.match(/\*\*(\d+) error codes reachable on the wire\*\*/); + expect(claim, 'catalog page no longer states how many wire codes it documents').toBeTruthy(); + expect(Number(claim![1])).toBe(face.wireCodes.length); }); }); diff --git a/scripts/check-error-status-conformance.d.mts b/scripts/check-error-status-conformance.d.mts new file mode 100644 index 0000000000..3701cab3cd --- /dev/null +++ b/scripts/check-error-status-conformance.d.mts @@ -0,0 +1,99 @@ +// Types for the ONE derivation `check-error-status-conformance.mjs` publishes to +// its second consumer — the same problem, and the same fix, as +// `js-comment-mask.d.mts` and `check-regen-pending.d.mts` next door (#5475, +// #10398). +// +// The module itself stays `.mjs`: it is a root gate script with a `--self-test` +// and a `--update` entry point run with bare `node`, and every root script here +// is authored that way. What changed is that +// `packages/spec/src/api/error-catalog-docs.test.ts` — the ADR-0112 D7 catalog +// guard — now imports it from inside a tsc program (`tsconfig.test.json`), where +// an untyped `.mjs` import is TS7016: the derivation silently becomes `any`, and +// reading `.wireCode` off a misspelled property would type-check clean while the +// guard asserted over `undefined`. +// +// ⛔ ONE export deliberately. The module exports two dozen internals for its own +// `--self-test`, and declaring them here would invite the guard to re-assemble +// the derivation itself — which is the second copy the #15631 ruling forbids. +// `deriveWireFace` is the whole supported surface. +// +// Declared rather than inferred (no `allowJs`) because the module sits at the +// repo root, outside the consuming program's `rootDir`. `check-declaration-mirrors` +// holds the name, kind and required arity below equal to the module's; the TYPES +// are hand-kept, so keep this file small enough that doing so stays trivial. + +/** + * One entry the doc parser READ on a page — a heading naming an error code in + * any shape `ENTRY_HEADING_SHAPES` recognises, bare or with a descriptive + * suffix. `where` is `:<1-based line>`. + */ +export interface DocEntry { + code: string; + where: string; +} + +/** + * One row of the TRANSLATION CENSUS: a class whose thrown `code` a door + * translates away before it reaches HTTP, so `code` is an in-process contract + * and `toCode` is what the wire actually carries. + */ +export interface TranslatedDeclaration { + code: string; + toCode: string; + status: number; + className: string; + where: string; + arm: string; +} + +/** + * The whole derivation: the corpus walk, the runtime side, the doc side, the + * reconciled vocabulary, and the wire face left once the door's translations + * are subtracted. + * + * Only the members the D7 catalog guard consumes are typed precisely; the + * derivation's internal halves (`sources`, `derived`, `doc`) are declared as + * the module returns them but are not part of the supported surface. + * + * @param repoRoot Directory every repo-relative path is resolved against; + * defaults to the process cwd (`'.'`). Paths INSIDE the result stay + * repo-relative regardless of what is passed here. + */ +export function deriveWireFace(repoRoot?: string): { + /** Every `StandardErrorCode` member, parsed out of `errors.zod.ts`. */ + members: string[]; + /** Members plus every other code a scanned page publishes a status for. */ + vocabulary: string[]; + /** The non-member half of `vocabulary` — ledger codes the docs have reached. */ + docPublishedBeyondStandard: string[]; + /** + * `vocabulary` minus every translated code: the codes that can appear in an + * envelope ON THE WIRE, which is the face the catalog page catalogs. + */ + wireCodes: string[]; + /** The translation census, reported rather than dropped. */ + translated: TranslatedDeclaration[]; + /** `translated`'s in-process spellings, as a set. */ + translatedCodes: Set; + /** Every entry the parser read on the catalog page, in source order. */ + catalogEntries: DocEntry[]; + /** Repo-relative path of the catalog page, so a consumer need not respell it. */ + catalogPath: string; + /** Repo-relative path → source text, for the scanned corpus. */ + sources: Map; + /** The runtime side: emitted statuses, unresolved declarations, site count. */ + derived: { + emitted: Map>; + unresolved: string[]; + translated: TranslatedDeclaration[]; + sites: number; + }; + /** The doc side, as `parseDocumentedStatuses` returns it. */ + doc: { + claimed: Map>; + covered: Map>; + documented: Set; + unreadableHeadings: { path: string; line: number; code: string; why: string; text: string }[]; + entries: DocEntry[]; + }; +}; diff --git a/scripts/check-error-status-conformance.mjs b/scripts/check-error-status-conformance.mjs index 07d5536b15..41eeb5ad76 100644 --- a/scripts/check-error-status-conformance.mjs +++ b/scripts/check-error-status-conformance.mjs @@ -1805,15 +1805,63 @@ function walk(dir, out) { } } -function main() { - const update = process.argv.includes('--update'); - +/** + * The source corpus every derivation in this file reads: `SCAN_ROOT` walked + * under `repoRoot`, keyed by repo-relative path. + * + * @param {string} repoRoot directory the scan root is resolved against + * @returns {Map} repo-relative path → source text + */ +function scanSources(repoRoot) { const files = []; - walk(SCAN_ROOT, files); + walk(join(repoRoot, SCAN_ROOT), files); const sources = new Map(); - for (const f of files.sort()) sources.set(relative('.', f).replace(/\\/g, '/'), readFileSync(f, 'utf8')); + for (const f of files.sort()) sources.set(relative(repoRoot, f).replace(/\\/g, '/'), readFileSync(f, 'utf8')); + return sources; +} - const errorsZod = readFileSync(ERRORS_ZOD, 'utf8'); +/** + * THE derivation, in one place: the corpus walk, the runtime side, the doc + * side, the reconciled vocabulary — and the WIRE FACE that vocabulary leaves + * once the door's translations are subtracted. + * + * `main()` is a consumer of this rather than an inlining of it, because a + * SECOND consumer now needs the same answer: + * `packages/spec/src/api/error-catalog-docs.test.ts`, the ADR-0112 D7 catalog + * guard. The #15631 ruling (2026-09-07) re-points that guard from the ENUM at + * the published wire face, and requires it to read the translation set "from + * the one place the conformance script already derives it — ⛔ no second + * hand-written list". A `DUPLICATE_RECORD` the door translates away is an + * in-process spelling; a guard carrying its own list of those would be exactly + * the second copy of a table this file's header argues against, and it would go + * stale in silence the day a door gains or loses an arm. + * + * ## `wireCodes` — what the catalog page is a catalog OF + * + * `vocabulary` is every `StandardErrorCode` member plus every other code a + * scanned page publishes a status for (the ledger codes that have reached the + * docs). Subtracting `translatedCodes` from it leaves the codes that can appear + * in an envelope on the wire — which is the face the catalog page documents, + * and the face the D7 guard compares its headings against in BOTH directions. + * + * The subtraction is what makes the two halves of the ruling consistent: the + * translated member is exempt from "must have a heading" BECAUSE it is not a + * wire code, not by a special case written next to it. + * + * `catalogEntries` are the entries the doc parser READ on the catalog page, in + * every shape `ENTRY_HEADING_SHAPES` recognises. The guard matches headings by + * that same rule rather than by a bare-heading regex of its own — which is what + * closes the `INVALID_REQUEST` suffix accident the #15631 ruling names: those + * two entries were invisible to the old guard only because they carry a + * descriptive suffix, and an unread heading is an UNCHECKED heading. + * + * @param {string} repoRoot directory every repo-relative path is resolved + * against; the paths inside the result stay repo-relative regardless. + */ +export function deriveWireFace(repoRoot = '.') { + const sources = scanSources(repoRoot); + + const errorsZod = readFileSync(join(repoRoot, ERRORS_ZOD), 'utf8'); const members = parseStandardErrorCodes(errorsZod); const index = buildConstantIndex(sources); const translations = deriveDoorTranslations(sources); @@ -1828,11 +1876,32 @@ function main() { } const doc = parseDocumentedStatuses({ - handling: readFileSync(DOC_HANDLING, 'utf8'), - catalog: readFileSync(DOC_CATALOG, 'utf8'), + handling: readFileSync(join(repoRoot, DOC_HANDLING), 'utf8'), + catalog: readFileSync(join(repoRoot, DOC_CATALOG), 'utf8'), }); const { vocabulary, docPublishedBeyondStandard } = reconciledVocabulary({ members, ...doc }); const translatedCodes = new Set(derived.translated.map((t) => t.code)); + + return { + sources, + members, + derived, + doc, + vocabulary, + docPublishedBeyondStandard, + translated: derived.translated, + translatedCodes, + wireCodes: vocabulary.filter((code) => !translatedCodes.has(code)), + catalogEntries: doc.entries.filter((e) => e.where.startsWith(`${DOC_CATALOG}:`)), + catalogPath: DOC_CATALOG, + }; +} + +function main() { + const update = process.argv.includes('--update'); + + const { sources, members, derived, doc, vocabulary, docPublishedBeyondStandard, translatedCodes } = + deriveWireFace(); const result = reconcile({ vocabulary, emitted: derived.emitted, translatedCodes, ...doc }); const baseline = existsSync(BASELINE_PATH)