Skip to content

Commit 0e73f38

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0112 — one error-code vocabulary: SCREAMING_SNAKE + standard catalog + extension ledger (#3841) (#3967)
* docs(adr): ADR-0112 — one error-code vocabulary: SCREAMING_SNAKE + standard catalog + extension ledger (#3841) Settles the spec decision in #3841: top-level error.code adopts SCREAMING_SNAKE via a two-tier vocabulary (closed StandardErrorCode catalog + ADR-0060-style registered extension ledger), ApiErrorSchema.code stops being z.string(), error-catalog.mdx becomes generated, field-level codes are explicitly split into their own follow-up decision, and the deviation from Prime Directive #3 is recorded with rationale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK * chore: empty changeset for ADR-0112 (docs only, releases nothing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK * docs(adr): ADR-0112 accepted; record the error-code exception in Prime Directive #3 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c3bcb42 commit 0e73f38

3 files changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
ADR-0112 (error-code vocabulary decision) — docs only, releases nothing.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`.
5656
3. **Naming:**
5757
- TS config keys → `camelCase` (`maxLength`, `defaultValue`)
5858
- Machine names (data values) → `snake_case` (`name: 'first_name'`)
59+
- Error codes → `SCREAMING_SNAKE` (`PERMISSION_DENIED`) — machine constants, not data values; scope and rationale in [ADR-0112](./docs/adr/0112-error-code-vocabulary-and-ledger.md). Not a general license to deviate.
5960
- Metadata type names → **singular** (`'agent'`, `'view'`, `'flow'`) — matches `MetadataTypeSchema` in `packages/spec/src/kernel/metadata-plugin.zod.ts`
6061
- REST endpoints → plural (`/api/v1/ai/agents`)
6162
4. **Imports:** Use `@objectstack/spec` namespaces or subpaths. Never relative `../../packages/spec`.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# ADR-0112: One error-code vocabulary — SCREAMING_SNAKE on the wire, a closed standard catalog plus a registered extension ledger
2+
3+
**Status**: Accepted (2026-07-30)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (declare-and-enforce — a declared property nothing enforces is worse than absent), [ADR-0060](./0060-conformance-ledger-platform-pattern.md) (the ledger pattern this ADR reuses for extension codes), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert declarations — a catalog no route validates against is exactly this), [ADR-0089](./0089-unify-visibility-predicate-naming.md) (the naming-unification precedent: one name per concept, enforced)
6+
**Consumers**: `@objectstack/spec` (`api/errors.zod.ts`, `api/contract.zod.ts`, generated manifests), `@objectstack/client` (re-exports `StandardErrorCode`; `StandardError` interface; the three-location code probe), `@objectstack/runtime` (`http-dispatcher.ts`), `@objectstack/rest` (`rest-server.ts`, import/export routes), `services/*` (storage, i18n, messaging, automation …), `packages/qa/dogfood`, objectui console (`RecordAttachmentsPanel` and every error-code branch), `content/docs/api/error-catalog.mdx`
7+
**Surfaced by**: [#3841](https://github.com/objectstack-ai/objectstack/issues/3841) (the vocabulary split), carried out of [#3689](https://github.com/objectstack-ai/objectstack/issues/3689); envelope groundwork in #3687 / #3837 is what makes this decidable now.
8+
9+
---
10+
11+
## TL;DR
12+
13+
The platform has **two live error-code vocabularies** for the same concepts — `StandardErrorCode` declares lowercase `permission_denied`, the wire majority emits `PERMISSION_DENIED` — and **three wire locations** where a semantic code may appear (`error.code`, `error.details.code`, `error.type`). Nothing arbitrates, because `ApiErrorSchema.code` is a bare `z.string()`: both dialects, and any typo or hallucinated code, parse fine and fail silently at the consumer's `===`.
14+
15+
**Decision.** Top-level `error.code` adopts **SCREAMING_SNAKE_CASE**, in a **two-tier vocabulary**:
16+
17+
1. a small, closed **standard catalog**`StandardErrorCode`, members renamed in place (breaking), mapping to HTTP semantics;
18+
2. **service extension codes**, each **registered in an error-code ledger** (ADR-0060 pattern). Conformance suites assert every emitted top-level code ∈ standard ∪ registered.
19+
20+
`ApiErrorSchema.code` stops being `z.string()`. `error-catalog.mdx` becomes **generated from the spec**, never hand-written again. Field-level codes (`FieldErrorSchema.code`) are **explicitly out of scope** — they are a de-facto separate vocabulary today and get their own decision; until then the field schema is widened to tell the truth. The target end-state (recorded here, landed in follow-ups) is **one location**: `error.code` carries the semantic code; the HTTP status lives on the transport and `error.httpStatus` only.
21+
22+
This is a **deliberate, recorded deviation** from Prime Directive #3 ("machine names (data values) → `snake_case`"): error codes are machine *constants*, not data values, and the deviation buys lexical-category separation from snake_case metadata identifiers.
23+
24+
---
25+
26+
## Context
27+
28+
### The drift
29+
30+
`packages/spec/src/api/errors.zod.ts:51` declares `StandardErrorCode` as a closed enum of **51 lowercase snake_case** codes (`validation_error`, `unauthenticated`, `permission_denied`, …). It is the declared type of `FieldErrorSchema.code` and `EnhancedApiErrorSchema.code`, and `content/docs/api/error-catalog.mdx` documents it as *the* catalog.
31+
32+
The wire disagrees. Counting distinct `code: '…'` literals in non-test source under `packages/` at the time of #3841: **≈140 SCREAMING_SNAKE** (`AUTH_REQUIRED`, `FILE_NOT_FOUND`, `ATTACHMENT_DOWNLOAD_DENIED`, `UPLOAD_SESSION_NOT_FOUND`, …) vs **≈100 lowercase** (`validation_error`, `driver_missing`, …; count includes some false positives). Exact numbers move week to week — nothing stops them — but the split's shape is not in doubt, and it runs through single request paths: `http-dispatcher.ts:1424` parks `{ code: 'PERMISSION_DENIED' }` in `details` while the enum names the same condition `permission_denied`.
33+
34+
Nothing arbitrates because the enforced schema never references the enum: `packages/spec/src/api/contract.zod.ts:12` declares `ApiErrorSchema.code` as `z.string()`. The catalog is a declaration no route validates against — ADR-0078's silently-inert trap, on the error contract.
35+
36+
### Three homes for the semantic code
37+
38+
Worse than two spellings: the code has three possible wire locations, and consumers must know which surface answered to know where to look.
39+
40+
| Location | Producer | Example |
41+
|---|---|---|
42+
| `error.code` | `@objectstack/rest` flat envelope; most services | `DELETE_RESTRICTED`, `AUTH_REQUIRED` |
43+
| `error.details.code` | runtime dispatcher — `error.code` itself is **occupied by the HTTP status number** (`http-dispatcher.ts:475-484`) | `{ code: 400, details: { code: 'VALIDATION_FAILED' } }` |
44+
| `error.type` (undeclared field) | dispatcher 404/405 paths (`http-dispatcher.ts:530-541`, `dispatcher-plugin.ts:340-349`, `domains/ai.ts`, `domains/mcp.ts`) | `type: 'ROUTE_NOT_FOUND'` |
45+
46+
The client SDK is the living cost: `packages/client/src/index.ts:4455-4459` probes all three locations in priority order, under a comment explaining that the branch our own docs teach (`if (err.code === 'VALIDATION_FAILED')`) *never matched* on dispatcher-served surfaces before the probe existed.
47+
48+
### Consumers branch on these strings
49+
50+
- objectui `packages/app-shell/src/views/RecordAttachmentsPanel.tsx:103-108` maps `ATTACHMENT_DOWNLOAD_DENIED` / `AUTH_REQUIRED` to user-facing copy.
51+
- `packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts` asserts on the same codes.
52+
- `packages/client` exposes `err.code` as the programmatic branch point and documents it.
53+
54+
Every consumer that exists today branches on the **SCREAMING** dialect. No consumer branch on the lowercase dialect has been found (to be re-verified per service during the sweep — see Rollout).
55+
56+
### The field-level vocabulary is a third thing entirely
57+
58+
`FieldErrorSchema.code` declares `StandardErrorCode`, but field-level emitters use vocabularies of their own: `packages/objectql/src/validation/record-validator.ts` and `rule-validator.ts` emit `required`, `max_length`, `invalid_email`, … (of which only `invalid_format` is in the enum); `packages/rest/src/import-coerce.ts` adds `reference_ambiguous`, `reference_not_found`; `packages/rest/src/rest-server.ts:89-96` passes **raw Zod issue codes** (`invalid_type`, `too_small`) straight through. The wire array is named `fields`, not the declared `fieldErrors`, and no schema validates it. This is a separate decision with different consumers (form UIs) and different emitters (validators), and jamming it into this ADR would either block the top-level fix or force a hasty field-level one.
59+
60+
### Why now
61+
62+
- **The window.** Error codes are values consumers string-match on; once external (AI-generated) apps branch on them, they are frozen forever. Today every branching consumer lives in objectstack/objectui — both in hand.
63+
- **The groundwork is fresh.** #3687/#3837 settled the *envelope* on the storage and i18n paths and left conformance suites (`error-envelope.conformance.test.ts`, `success-envelope.conformance.test.ts`) that parse against imported spec schemas. The moment `code` is a real type, those suites assert *values* with zero test changes.
64+
- **It blocks the rest.** The `error.code`-occupied-by-status defect (#3689 sibling, pinned in `http-dispatcher.test.ts` since #3687) cannot be fixed without a vocabulary to migrate to.
65+
- **Entropy has no gate.** This repo is developed by multiple concurrent agents; any new `code: '…'` literal in either dialect passes CI today. The mixed state self-replicates: agents copy whichever dialect they see.
66+
67+
### What comparable platforms do
68+
69+
| Platform | Top-level error code style | Notes |
70+
|---|---|---|
71+
| Salesforce | `SCREAMING_SNAKE` (`FIELD_CUSTOM_VALIDATION_EXCEPTION`, `INVALID_FIELD`, `DUPLICATE_VALUE`) | The genre ObjectStack sits in |
72+
| gRPC / Google Cloud | `SCREAMING_SNAKE` (`PERMISSION_DENIED`, `NOT_FOUND`); domain detail in `ErrorInfo.reason`, also SCREAMING | Two-tier: tiny closed code set + namespaced reasons — the structure this ADR adopts |
73+
| Stripe | lowercase snake (`card_declined`) | Payments API, not an app platform |
74+
75+
The enterprise-app-platform genre is uniformly SCREAMING. This matters twice: once for human familiarity, and once because **AI agents authoring apps on this platform carry that training prior** — the convention that matches the prior is the one agents get right when context is thin.
76+
77+
---
78+
79+
## Decision
80+
81+
Nine rulings, D1–D9.
82+
83+
**D1 — Top-level `error.code` speaks SCREAMING_SNAKE.** The value space is `^[A-Z][A-Z0-9_]*$`. This matches ≈140 of ≈240 existing distinct literals, every consumer branch in existence, and the genre. The ≈100 lowercase emitters are swept in follow-up batches (Rollout, batch 2).
84+
85+
**D2 — `StandardErrorCode` members are renamed in place (breaking).** Same 51 concepts, SCREAMING spelling (`permission_denied``PERMISSION_DENIED`). The enum remains the small, closed **standard catalog**: codes with platform-wide HTTP semantics (the `ErrorCategory`/status mapping). It does not try to swallow service-specific codes. `@objectstack/client` re-exports the type (`client/src/index.ts:12`) and uses it in `StandardError` (`:243`) — the client updates in the same PR.
86+
87+
**D3 — Service extension codes are registered, not free-typed.** Codes like `ATTACHMENT_DOWNLOAD_DENIED` or `UPLOAD_SESSION_NOT_FOUND` do not enter the standard catalog. Each service registers its codes in an **error-code ledger** (ADR-0060 pattern; same generation machinery as `api-surface.json` / `json-schema.manifest.json`). Registration carries the code, the owning service, and a one-line meaning. A recommended (not required) convention is a domain prefix (`ATTACHMENT_*`, `UPLOAD_*`). The ledger is the anti-bottleneck: adding a service code touches the service's own ledger entry, not the spec enum — but it is still a *deliberate, reviewable* act, which is the entropy gate this whole problem lacked.
88+
89+
**D4 — `ApiErrorSchema.code` stops being `z.string()`.** It becomes the generated union of the standard catalog and the registered ledger (`ErrorCode`, generated at build time — Zod-first per PD#1, enum generated from ledger + `StandardErrorCode`). Conformance suites thereby assert values for free. There is **no regex escape hatch** (see Alternatives — a casing regex passes hallucinated codes, which is precisely the AI failure mode this ADR exists to prevent).
90+
91+
**D5 — One location, eventually: `error.code` carries the semantic code.** Target end-state, recorded here so the follow-ups have a fixed destination: the HTTP status lives on the transport and (optionally) `error.httpStatus`; `error.code` is always the semantic string; `error.details.code` and `error.type` are retired as code carriers. The dispatcher-occupation fix (#3689 sibling) and `ROUTE_NOT_FOUND`-in-`type` retirement land as follow-ups (Rollout, batch 3). The client's three-location probe is deleted only after both.
92+
93+
**D6 — Field-level codes are explicitly a separate vocabulary, and the schema stops lying meanwhile.** `FieldErrorSchema.code` widens from `StandardErrorCode` to `z.string()` with a banner comment pointing at the follow-up issue. Widening is honest (emitters never complied) and prevents this ADR's rename from silently claiming field-level compliance it does not deliver. The field vocabulary (validator codes, import codes, Zod-passthrough, the `fields` vs `fieldErrors` naming, and a schema for the wire array) gets its own issue and, if the decision is non-obvious, its own ADR.
94+
95+
**D7 — `error-catalog.mdx` is generated from the spec.** The hand-written catalog is how docs and wire drifted apart in the first place (`docs/audits/2026-06-handwritten-docs-accuracy-followups.md:230` catalogues the casing drift; `:106` found a documented-only vocabulary with zero emitters). Generated docs are the only docs that cannot lie — and for AI authors, docs *are* context.
96+
97+
**D8 — Recorded deviation from Prime Directive #3.** PD#3 says machine names (data values) are `snake_case`. Error codes are ruled to be machine **constants**, a different lexical category from data values and metadata identifiers. The deviation is deliberate and buys visual disambiguation: in a platform where objects and fields are `snake_case` (`account`, `validation_rule`), an ALL-CAPS token is recognizably a platform constant and cannot be confused with a field API name. Genre precedent (Salesforce, gRPC) and AI-prior alignment reinforce it. This ruling covers **error codes only** — it is not a general license to deviate from PD#3.
98+
99+
**D9 — Adjacent spec-internal cleanups ride along.** Two same-class defects found while evaluating #3841 are folded into the batches because they share the fix surface: (a) `packages/spec` exports **two mutually incompatible** `ErrorCategory` and `RetryStrategy` types (`api/errors.zod.ts:29-39,147-152` vs `integration/connector.zod.ts:409-420,346-353`) — the connector-side pair gets renamed (`ConnectorErrorCategory`, `ConnectorRetryStrategy`) so one name means one thing; (b) the client reads `category`/`retryable` from the wrong nesting level (`client/src/index.ts:4473-4475` reads top-level where the contract puts them inside `error`) — fixed when the probe area is touched in batch 3.
100+
101+
---
102+
103+
## Alternatives rejected
104+
105+
**Adopt lowercase snake_case (issue option 2).** Consistent with PD#3 on its face, but: every existing consumer branch reads SCREAMING, so this changes strings the console and dogfood suite match on, coupling objectstack and objectui through a tolerant-dual-read deployment window (the objectui#2869 dance) for ~140 emitters instead of ~100; the migration window is longer, and mixed states self-replicate in a multi-agent repo; and it fights the genre prior AI authors bring. The "consistency" it buys is the wrong kind — see D8.
106+
107+
**Keep both vocabularies as declared siblings (issue option 3).** Honest about the accident, but institutionalizes it: every consumer forever needs to know which dialect a surface speaks, which is precisely the per-surface knowledge this ADR abolishes. The defensible kernel of option 3 — that field-level really is a different vocabulary — survives as D6, split by *structural level* (top-level vs field-level), not as two dialects of the same field.
108+
109+
**Casing regex instead of a closed type (`z.string().regex(/^[A-Z][A-Z0-9_]*$/)`).** Catches case errors only. The dominant authoring error — human and especially AI — is a *plausible but wrong* code (`NOT_FOUND` for `RECORD_NOT_FOUND`, `BAD_REQUEST` for `INVALID_REQUEST`), which a casing regex waves through. A closed generated union makes hallucinated codes fail at parse/typecheck. Rejected as the primary mechanism; the regex survives only as a ledger-admission lint (a registered code must match it).
110+
111+
**One giant closed enum of all ~140 codes in `packages/spec`.** Makes the spec a bottleneck for every service-level error and invites either high-friction spec churn or (worse) developers routing around the enum — reopening the hole. The two-tier ledger (D3) keeps the closed-world guarantee without centralizing every code.
112+
113+
---
114+
115+
## Consequences
116+
117+
- **Breaking (compile-time):** `StandardErrorCode` member rename breaks any importer branching on lowercase members — in-repo: `packages/spec/src/api/errors.test.ts`, `@objectstack/client`. Both update in the batch-1 PR. Generated manifests (`json-schema.manifest.json`, `api-surface.json`) regenerate.
118+
- **No runtime behavior change in batch 1.** SCREAMING emitters (the wire majority, and everything consumers branch on) are already compliant; batch 1 changes spec, types, docs, and the ledger only.
119+
- **The lowercase sweep (batch 2) does change wire values** (`validation_error``VALIDATION_ERROR`). Before each service's sweep, its lowercase codes are checked for consumer branches; none are known today, which is exactly why the sweep is cheap *now*.
120+
- **The conformance suites become value-asserting** with no test edits — the schema tightens under them (guard note in #3841, verified).
121+
- **New-code friction is introduced deliberately:** an unregistered code fails CI. This is the entropy gate; the ≈240-literal status quo is what its absence produces.
122+
- **Unblocked follow-ups:** the `error.code`-occupied-by-status fix; `error.type` retirement; deletion of the client's three-location probe and its explanatory comment block; the field-level vocabulary decision (D6's issue).
123+
- **Docs debt retired:** `error-catalog.mdx` regenerated; the two error-code items in the 2026-06 docs-accuracy audit close.
124+
125+
## Rollout
126+
127+
**Batch 1 — decision lands (with this ADR; no runtime change).**
128+
Rename `StandardErrorCode` members; introduce the error-code ledger + generated `ErrorCode` union; tighten `ApiErrorSchema.code` to it; widen `FieldErrorSchema.code` to `z.string()` + banner (D6) and open the field-vocabulary issue; regenerate `error-catalog.mdx` from spec and wire up its generation; update `@objectstack/client` re-exports/types; register all existing SCREAMING codes in the ledger (mechanical harvest of the ≈140).
129+
130+
**Batch 2 — lowercase emitter sweep (per service, independent PRs).**
131+
For each service: verify no consumer branches on its lowercase codes; convert emitters to registered SCREAMING codes; that service's conformance suite now passes value assertions. No cross-repo coordination needed — consumers already read SCREAMING.
132+
133+
**Batch 3 — location convergence (D5).**
134+
Dispatcher: semantic code moves into `error.code`, numeric status stops occupying it (`http-dispatcher.ts:475`), `error.type` retired as a code carrier; update the #3687 pin in `http-dispatcher.test.ts` from "documents the deviation" to "asserts the fix". objectui ships tolerant dual-read first where needed (objectui#2869 pattern). Then delete the client three-location probe and fix the `category`/`retryable` nesting read (D9b).
135+
136+
Batches 2 and 3 are independent of each other; both depend on batch 1.

0 commit comments

Comments
 (0)