Skip to content

Commit eda6a60

Browse files
claude[bot]claude
andauthored
docs(api): the 409 unique-constraint entry is UNIQUE_VIOLATION on the wire, not DUPLICATE_RECORD (#15750)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4e6adf commit eda6a60

4 files changed

Lines changed: 301 additions & 38 deletions

File tree

content/docs/api/error-catalog.mdx

Lines changed: 14 additions & 3 deletions
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`
@@ -361,11 +367,16 @@ result set — a response indistinguishable from a successful query.
361367
**Fix:** Delete or reassign dependent records first, then retry the delete.
362368
**Retry:** `no_retry`
363369

364-
### `DUPLICATE_RECORD`
370+
### `UNIQUE_VIOLATION`
365371
**Cause:** A record with the same unique key already exists.
366372
**Fix:** Update the existing record instead, or use a different unique key value.
367373
**Retry:** `no_retry`
368374

375+
The engine throws `DuplicateRecordError`, whose in-process `code` is
376+
`DUPLICATE_RECORD`; the REST door translates that envelope at the boundary, so
377+
every route answers the wire code `UNIQUE_VIOLATION` and the in-process spelling
378+
never crosses HTTP.
379+
369380
### `LOCK_CONFLICT`
370381
**Cause:** The record is locked by another process or user.
371382
**Fix:** Wait for the lock to be released, or contact the lock holder.
@@ -802,7 +813,7 @@ async function handleApiCall() {
802813
| 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
803814
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
804815
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
805-
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD`, `DELETE_RESTRICTED` |
816+
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` |
806817
| 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 |
807818
| 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` |
808819
| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR` |

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+
};

0 commit comments

Comments
 (0)