Skip to content

Commit cf335cb

Browse files
committed
feat(ci): guard against new lowercase error codes (ADR-0112, #4003)
The ledger's admission test enforces casing on every code someone REGISTERS. Nothing enforced the ones nobody registers — and an unregistered lowercase literal in a code position is invisible twice: to the ledger, which has nothing to check, and to the schema, on any route that never parses its own response. That is how 208 lowercase literals accumulated across 10 packages. Without a guard, number 209 lands the same way. `scripts/check-error-code-casing.mjs` scans packages/ for four code positions — emission, property assignment, comparison, and literal-union type — which are the forms batch 2 actually got bitten by, comparison and union-type being the two that fail silently or two packages away. The design problem is that `code` is also an ordinary domain field name: a license plan code, a seed industry code, a locale. A guard that flags those gets switched off within a week, so a hit only counts when an error-shaped neighbour (error/message/throw/status/issues/...) appears within a few lines, and the D6 families are recognised structurally — a `field`/`param`/`path`/`target` key in the same record, checked over a window because `{ code, field, message }` is routinely spread across three lines. Three escape hatches, in descending preference: - structural: the D6 shapes above, no annotation needed; - `// adr0112-ok: <reason>` at the site, for a file that legitimately holds both vocabularies (protocol.ts throws a catalog code and writes an audit row beside it) — the reason is required, a bare marker does not count; - EXEMPT_FILES, for a file that OWNS a non-catalog vocabulary (a diagnostics producer, OData's foreign error vocabulary), each line carrying its reason. `--self-test` runs 17 cases (each violating form, each exemption route, each known false-positive shape) before the scan, so a green run means the matcher was checked, not just quiet. End-to-end verified by reintroducing `code: 'object_not_found'` in rest-server.ts: the guard fails, and passes again when reverted. Two real fixes fell out of turning it on: the `'locked'` fixtures in two publish tests now spell what the protocol actually throws (ITEM_LOCKED), and the audit-row sites carry their D6b marker so the divergence reads as a decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK
1 parent 57ce701 commit cf335cb

7 files changed

Lines changed: 262 additions & 4 deletions

File tree

.github/workflows/lint.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ jobs:
114114
- name: Response-envelope guard
115115
run: pnpm check:route-envelope
116116

117+
# Error-code casing guard (ADR-0112, #4003). The ledger's admission test
118+
# enforces casing on every code someone REGISTERS; this catches the ones
119+
# nobody registers — an unregistered lowercase literal in a code position
120+
# is invisible to both the ledger and to the schema on any route that does
121+
# not parse its own response. That is how 208 of them accumulated across
122+
# 10 packages before batch 2. Runs its own --self-test first.
123+
- name: Error-code casing guard
124+
run: pnpm check:error-code-casing
125+
117126
# Release-notes drift guard: the platform is one version-locked train, so
118127
# every released @objectstack/spec major must have a curated, navigable
119128
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"check:org-identifier": "node scripts/check-org-identifier.mjs",
3636
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs",
3737
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
38+
"check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs",
3839
"check:console-sha": "node scripts/check-console-sha.mjs",
3940
"check:release-notes": "node scripts/check-release-notes.mjs",
4041
"check:node-version": "node scripts/check-node-version.mjs"

packages/metadata-protocol/src/protocol.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4089,6 +4089,7 @@ export class ObjectStackProtocolImplementation implements
40894089
organizationId: args.organizationId ?? null,
40904090
operation: args.operation,
40914091
outcome: 'denied',
4092+
// adr0112-ok: D6b — persisted audit column, its own vocabulary
40924093
code: 'item_locked',
40934094
lockState: state.lock,
40944095
actor: args.actor,
@@ -4127,6 +4128,7 @@ export class ObjectStackProtocolImplementation implements
41274128
organizationId: args.organizationId ?? null,
41284129
operation: 'delete',
41294130
outcome: 'denied',
4131+
// adr0112-ok: D6b — persisted audit column, its own vocabulary
41304132
code: 'item_locked',
41314133
lockState: state.lock,
41324134
actor: args.actor,

packages/objectql/src/protocol-lock-enforcement.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ describe('ADR-0010 L3 lock enforcement — audit trail', () => {
210210
// Lowercase on purpose: the audit column is its own persisted
211211
// vocabulary, not the ADR-0112 error catalog the thrown code above
212212
// now follows. See sys-metadata-audit.object.ts.
213+
// adr0112-ok: D6b — persisted audit column, not the error catalog
213214
code: 'item_locked',
214215
lock_state: 'full',
215216
actor: 'user_42',

packages/objectql/src/protocol-package-lifecycle.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ describe('protocol.discardPackageDrafts', () => {
4444
{ type: 'view', name: 'course_list' },
4545
]);
4646
(deleteMetaItem as any).mockImplementation(async (req: any) => {
47-
if (req.name === 'course_list') throw Object.assign(new Error('locked'), { code: 'locked' });
47+
if (req.name === 'course_list') throw Object.assign(new Error('ITEM_LOCKED'), { code: 'ITEM_LOCKED' });
4848
return { success: true };
4949
});
5050
const res = await protocol.discardPackageDrafts({ packageId: 'app.edu' });
5151
expect(res.discardedCount).toBe(1);
5252
expect(res.failedCount).toBe(1);
53-
expect(res.failed[0]).toMatchObject({ name: 'course_list', code: 'locked' });
53+
expect(res.failed[0]).toMatchObject({ name: 'course_list', code: 'ITEM_LOCKED' });
5454
expect(res.success).toBe(false);
5555
});
5656

packages/objectql/src/protocol-publish-package-drafts.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ describe('protocol.publishPackageDrafts (ADR-0033 / ADR-0067 D2)', () => {
121121
{ type: 'view', name: 'course_list' },
122122
]);
123123
promote.mockImplementation(async (req: any) => {
124-
if (req.name === 'student') throw Object.assign(new Error('locked'), { code: 'locked' });
124+
if (req.name === 'student') throw Object.assign(new Error('ITEM_LOCKED'), { code: 'ITEM_LOCKED' });
125125
return promoteOk(req);
126126
});
127127

@@ -137,7 +137,7 @@ describe('protocol.publishPackageDrafts (ADR-0033 / ADR-0067 D2)', () => {
137137
expect(res.publishedCount).toBe(0);
138138
expect(res.published).toEqual([]);
139139
expect(res.failedCount).toBe(3);
140-
expect(res.failed.find((f) => f.name === 'student')).toMatchObject({ code: 'locked', error: 'locked' });
140+
expect(res.failed.find((f) => f.name === 'student')).toMatchObject({ code: 'ITEM_LOCKED', error: 'ITEM_LOCKED' });
141141
expect(res.failed.find((f) => f.name === 'course')).toMatchObject({ code: 'BATCH_ABORTED' });
142142
expect(res.failed.find((f) => f.name === 'course_list')).toMatchObject({ code: 'BATCH_ABORTED' });
143143
});
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
3+
4+
/**
5+
* Error-code casing guard (ADR-0112, #4003).
6+
*
7+
* ## What it guards
8+
*
9+
* `error.code` is a closed set: `StandardErrorCode` ∪ `ERROR_CODE_LEDGER`, all
10+
* SCREAMING_SNAKE, and `ApiErrorSchema.code` validates against it. The ledger's
11+
* own test enforces the casing of every code someone **registers**.
12+
*
13+
* That leaves the hole this guard closes. An unregistered lowercase string in a
14+
* code position is invisible to the ledger — there is nothing to check the
15+
* casing of — and invisible to the schema on any route that doesn't parse its
16+
* own response. That is precisely how 208 lowercase literals accumulated across
17+
* 10 packages before batch 2 swept them (#4003). Without a guard, number 209
18+
* lands the same way: quietly, in a package nobody is looking at.
19+
*
20+
* ## Why textual, not AST
21+
*
22+
* The failure mode here is a string literal in one of a handful of syntactic
23+
* positions, and batch 2 learned the hard way that the positions are more
24+
* varied than they look: emission (`code: 'x'`), property assignment
25+
* (`err.code = 'x'`), comparison (`code === 'x'`), computed ternaries, literal
26+
* union *types*, and test assertions. An AST pass buys nothing over a regex for
27+
* "is this literal lowercase_snake" while costing a parse of every file — and
28+
* the union-type case is a type annotation, which the AST shapes for the value
29+
* cases would miss anyway.
30+
*
31+
* ## The vocabularies this does NOT govern
32+
*
33+
* ADR-0112 D6/D6b/D6c draw the line: the catalog governs the code a failing
34+
* REQUEST answers with. Three neighbours legitimately stay lowercase, and each
35+
* is skipped by a rule below rather than by a blanket ignore, so a new file in
36+
* one of those families still has to say which family it joins:
37+
*
38+
* - **D6 — field/param-addressed** (`{ field, code }`, `{ param, code }`):
39+
* validator vocabularies, #3977's scope.
40+
* - **D6b — persisted**: `sys_metadata_audit.code` is audit history; old rows
41+
* keep their spelling forever and the column also holds `ok`.
42+
* - **D6c — diagnostics**: probe/diff records that ship as payload of a 200.
43+
*
44+
* Zod's own issue codes (`invalid_type`, `too_small`, `custom`, …) are a fourth
45+
* — they are Zod's API, not ours.
46+
*
47+
* Run `--self-test` to check the matcher against known-good and known-bad
48+
* samples before trusting a green run.
49+
*/
50+
51+
import { readFileSync, readdirSync, statSync } from 'node:fs';
52+
import { join, relative, sep } from 'node:path';
53+
54+
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
55+
const SCAN_ROOTS = ['packages'];
56+
57+
/**
58+
* Files exempt as a whole, each because it OWNS one of the non-catalog
59+
* vocabularies above. A path earns a line here only with the reason; a blanket
60+
* directory ignore is what lets a real emitter hide.
61+
*/
62+
const EXEMPT_FILES = new Map([
63+
// D6 — field-addressed validator vocabularies (#3977)
64+
['packages/objectql/src/validation/record-validator.ts', 'D6 field-level validator codes'],
65+
['packages/objectql/src/validation/rule-validator.ts', 'D6 field-level validator codes'],
66+
['packages/rest/src/import-coerce.ts', 'D6 field-level import coercion codes'],
67+
['packages/rest/src/import-runner.ts', 'D6 field-level import row codes'],
68+
['packages/plugins/plugin-sharing/src/rule-criteria.ts', 'D6 field-level; top-level code is VALIDATION_FAILED'],
69+
['packages/spec/src/ui/action-params.zod.ts', 'D6 param-addressed action-param issues'],
70+
// D6b — persisted audit column
71+
['packages/metadata-core/src/objects/sys-metadata-audit.object.ts', 'D6b persisted audit vocabulary'],
72+
['packages/spec/src/api/errors.test.ts', 'D6 FieldError tests spell field-level codes'],
73+
['packages/objectql/src/validation/skip-provenance.test.ts', 'D6 field-level assertions'],
74+
['packages/rest/src/import-runner-selfref.test.ts', 'D6 field-level import codes'],
75+
// D6c — diagnostics payloads of a 200
76+
['packages/metadata-protocol/src/build-probes.ts', 'D6c runtime build-probe diagnostics'],
77+
['packages/metadata-protocol/src/metadata-diagnostics.ts', 'D6c spec-validation diagnostics'],
78+
['packages/objectql/src/build-probes.test.ts', 'D6c build-probe diagnostics tests'],
79+
['packages/objectql/src/metadata-diagnostics.test.ts', 'D6c diagnostics tests'],
80+
['packages/objectql/scripts/dry-run-hash-compat.ts', 'D6c findings report of a dev script'],
81+
['packages/objectql/src/dry-run-hash-compat.test.ts', 'D6c findings report tests'],
82+
// Zod's own vocabulary, and this file's own samples
83+
['packages/spec/src/shared/error-map.zod.ts', "Zod issue codes, not ours"],
84+
['packages/spec/src/api/odata.zod.ts', "OData's own error vocabulary, a foreign protocol"],
85+
['packages/spec/src/api/odata.test.ts', "OData's own error vocabulary, a foreign protocol"],
86+
['packages/spec/src/system/license.test.ts', 'plan/feature codes are domain data; the nearby toThrow() is the tripwire'],
87+
// The ledger and its test spell codes for a living
88+
['packages/spec/src/api/error-code-ledger.zod.ts', 'the ledger itself'],
89+
['packages/spec/src/api/error-code-ledger.test.ts', 'the ledger admission test'],
90+
]);
91+
92+
/** Literals that are never an error code, however they look. */
93+
const NOT_CODES = new Set([
94+
// Zod issue codes and API constants
95+
'custom', 'invalid_type', 'invalid_value', 'invalid_format', 'invalid_union',
96+
'too_small', 'too_big', 'unrecognized_keys', 'invalid_key', 'invalid_element',
97+
'invalid_arguments', 'invalid_return_type', 'not_multiple_of',
98+
// service/AI/queue status vocabularies that share the word "code"
99+
'unavailable', 'default', 'string', 'ok',
100+
]);
101+
102+
const CODE_POSITION_PATTERNS = [
103+
// emission and object literals: code: 'x'
104+
{ name: 'emission', re: /\bcode\s*:\s*'([a-z][a-z0-9_]*)'/g },
105+
// property assignment: err.code = 'x'
106+
{ name: 'assignment', re: /\.code\s*=\s*'([a-z][a-z0-9_]*)'/g },
107+
// comparison — the silent one: code === 'x'
108+
{ name: 'comparison', re: /\bcode\s*(?:===|!==)\s*'([a-z][a-z0-9_]*)'/g },
109+
// literal-union type: code?: 'x' | 'y' (the one that breaks a consumer's dts)
110+
{ name: 'union-type', re: /\bcode\??\s*:\s*'([a-z][a-z0-9_]*)'\s*\|/g },
111+
];
112+
113+
function walk(dir, out = []) {
114+
for (const entry of readdirSync(dir)) {
115+
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo' || entry === 'coverage') continue;
116+
const full = join(dir, entry);
117+
const st = statSync(full);
118+
if (st.isDirectory()) walk(full, out);
119+
else if (/\.(ts|tsx)$/.test(entry) && !/\.d\.ts$/.test(entry)) out.push(full);
120+
}
121+
return out;
122+
}
123+
124+
/** Strip line and block comments so a docblock naming an old code is not a hit. */
125+
function stripComments(src) {
126+
return src
127+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
128+
.replace(/(^|[^:])\/\/[^\n]*/g, (m, p) => p + m.slice(p.length).replace(/./g, ' '));
129+
}
130+
131+
export function findViolations(src, file) {
132+
const text = stripComments(src);
133+
const hits = [];
134+
for (const { name, re } of CODE_POSITION_PATTERNS) {
135+
re.lastIndex = 0;
136+
let m;
137+
while ((m = re.exec(text)) !== null) {
138+
const literal = m[1];
139+
if (NOT_CODES.has(literal)) continue;
140+
const lineStart = text.lastIndexOf('\n', m.index) + 1;
141+
const lineEnd = text.indexOf('\n', m.index);
142+
const line = text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
143+
const lineNo = text.slice(0, m.index).split('\n').length;
144+
// `typeof x.code === 'number'` and friends: a type guard, not a code
145+
if (/typeof\s+[\w.?]*code\s*(?:===|!==)/.test(line)) continue;
146+
// D6: the literal sits in a field/param/path-addressed record. Check a
147+
// small window, not just the hit line — `{ code, field, message }` is
148+
// routinely spread over three lines, and the discriminating key is as
149+
// likely to be below the code as beside it.
150+
const allLines = text.split('\n');
151+
const window = allLines.slice(Math.max(0, lineNo - 3), lineNo + 2).join('\n');
152+
if (/\b(field|param|path|target)\s*[:,=]/.test(window)) continue;
153+
// `code` is also a plain domain field name — a license plan code, an
154+
// industry code, a locale. Require an error-shaped neighbour before
155+
// calling a lowercase literal a violation, or the guard starts policing
156+
// seed data and gets switched off.
157+
if (!/\b(error|message|throw|reject|httpStatus|statusCode|status|issues|failed|denied|refus)/i.test(window)) continue;
158+
// not an error code at all: locale/language/currency/country code fields
159+
if (/\b(locale|language|currency|country|label)\b/.test(line)) continue;
160+
// site-level opt-out for a file that legitimately holds BOTH vocabularies
161+
// (e.g. protocol.ts throws a catalog code and writes an audit row beside
162+
// it). Must name a reason, on the hit line or the line above it.
163+
const rawLines = src.split('\n');
164+
const own = rawLines[lineNo - 1] ?? '';
165+
const prev = rawLines[lineNo - 2] ?? '';
166+
if (/adr0112-ok:\s*\S/.test(own) || /adr0112-ok:\s*\S/.test(prev)) continue;
167+
hits.push({ file, line: lineNo, literal, form: name });
168+
}
169+
}
170+
return hits;
171+
}
172+
173+
function selfTest() {
174+
const cases = [
175+
// [source, expectedHitCount, label]
176+
[`return c.json({ error: { code: 'not_found', message: 'x' } }, 404);`, 1, 'emission'],
177+
[`const err = new Error('locked'); (err as any).code = 'item_locked';`, 1, 'assignment'],
178+
[`if (err?.code === 'destructive_change') throw err;`, 1, 'comparison'],
179+
[`interface R { error?: string; code?: 'forbidden' | 'invalid_signal'; }`, 1, 'union type'],
180+
[`error: { code: 'RESOURCE_NOT_FOUND', message: 'x' }`, 0, 'SCREAMING passes'],
181+
[`ctx.addIssue({ code: 'custom', message: 'x' });`, 0, "Zod's own code"],
182+
[`issues.push({ field: 'email', code: 'invalid_email', message: 'x' });`, 0, 'D6 field-addressed'],
183+
[`// legacy servers sent code: 'not_found' here`, 0, 'comment is not code'],
184+
[`if (info.services.i18n.status === 'unavailable') {}`, 0, 'status vocabulary'],
185+
[`locales.push({ code: 'en', label: 'en', isDefault: true });`, 0, 'locale code is not an error code'],
186+
[`expect(f.field === 'organization_id' && f.code === 'required').toBe(true);`, 0, 'D6 via field ==='],
187+
[`code: 'item_locked', // adr0112-ok: D6b persisted audit column`, 0, 'inline opt-out, same line'],
188+
[`// adr0112-ok: D6b persisted audit column\n code: 'item_locked',`, 0, 'inline opt-out, line above'],
189+
[`throw Object.assign(new Error('x'), { code: 'item_locked' }); // adr0112-ok:`, 1, 'opt-out without a reason does not count'],
190+
[`const plan = PlanSchema.parse({ code: 'pro_v1', features: [] });`, 0, 'license plan code is domain data'],
191+
[`records: [{ code: 'tech', name: 'Technology' }],`, 0, 'seed industry code is domain data'],
192+
[`{ code: 'required', message: 'x', target: 'email' }`, 0, "D6 via OData's target"],
193+
];
194+
let failed = 0;
195+
for (const [src, want, label] of cases) {
196+
const got = findViolations(src, 'self-test.ts').length;
197+
if (got !== want) {
198+
console.error(` ✗ self-test "${label}": expected ${want} hit(s), got ${got}`);
199+
failed++;
200+
}
201+
}
202+
if (failed) {
203+
console.error(`\n✗ check-error-code-casing self-test failed (${failed} case(s)).`);
204+
process.exit(1);
205+
}
206+
console.log(`✓ check-error-code-casing self-test: ${cases.length} cases pass.`);
207+
}
208+
209+
function main() {
210+
if (process.argv.includes('--self-test')) return selfTest();
211+
212+
const files = SCAN_ROOTS.flatMap((r) => walk(join(ROOT, r)));
213+
const violations = [];
214+
for (const full of files) {
215+
const rel = relative(ROOT, full).split(sep).join('/');
216+
if (EXEMPT_FILES.has(rel)) continue;
217+
violations.push(...findViolations(readFileSync(full, 'utf8'), rel));
218+
}
219+
220+
if (violations.length === 0) {
221+
console.log(`✓ no lowercase error codes in ${files.length} scanned file(s) (ADR-0112).`);
222+
return;
223+
}
224+
225+
console.error(`\n✗ lowercase error-code literal(s) in a code position (ADR-0112 D1):\n`);
226+
for (const v of violations) {
227+
console.error(` ${v.file}:${v.line} '${v.literal}' (${v.form})`);
228+
}
229+
console.error(`
230+
error.code is a closed set of SCREAMING_SNAKE values — StandardErrorCode
231+
(packages/spec/src/api/errors.zod.ts) for generic conditions, ERROR_CODE_LEDGER
232+
(packages/spec/src/api/error-code-ledger.zod.ts) for service-specific ones.
233+
234+
- a generic condition (not found / permission / validation / rate limit) should
235+
use the standard catalog rather than register a synonym;
236+
- anything else gets a SCREAMING code registered under its owning package.
237+
238+
If this literal is NOT an error.code — a field/param-addressed validator code
239+
(D6), a persisted column (D6b), or a diagnostics record shipped inside a 200
240+
(D6c) — add the file to EXEMPT_FILES in this script WITH its reason.
241+
`);
242+
process.exit(1);
243+
}
244+
245+
main();

0 commit comments

Comments
 (0)