Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="info">
**Source:** `packages/spec/src/api/errors.zod.ts`
Expand Down Expand Up @@ -361,11 +367,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.
Expand Down Expand Up @@ -802,7 +813,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` |
Expand Down
138 changes: 111 additions & 27 deletions packages/spec/src/api/error-catalog-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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);
});
});
99 changes: 99 additions & 0 deletions scripts/check-error-status-conformance.d.mts
Original file line number Diff line number Diff line change
@@ -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 `<repo-relative path>:<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<string>;
/** 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<string, string>;
/** The runtime side: emitted statuses, unresolved declarations, site count. */
derived: {
emitted: Map<string, Map<number, string[]>>;
unresolved: string[];
translated: TranslatedDeclaration[];
sites: number;
};
/** The doc side, as `parseDocumentedStatuses` returns it. */
doc: {
claimed: Map<string, Map<number, string[]>>;
covered: Map<string, Map<number, string[]>>;
documented: Set<string>;
unreadableHeadings: { path: string; line: number; code: string; why: string; text: string }[];
entries: DocEntry[];
};
};
Loading
Loading