Skip to content

Commit 0f7157b

Browse files
os-zhuangclaude
andauthored
feat(spec): the error-code ledger states its federation contract; makeApiErrorSchema(extraCodes) (#4805) (#7110)
The three objectstack-side items of the federated ERROR_CODE_LEDGER ruling (#4805, 2026-08-03, re-confirmed 2026-08-09). 1. `error-code-ledger.zod.ts` header: "this ledger registers framework packages only" becomes a stated RULE — with what a downstream product repo does instead (its own ledger, composed as `envelopeViolations` for shape + `code ∈ StandardErrorCode ∪ <its own ledger>` for vocabulary), and why the ruling went this way (a commercial vocabulary does not belong in an Apache-2.0 spec; per-code cross-repo friction breeds semantic reuse). Previously inferable only by scanning the package names. 2. `ApiErrorSchema.code`'s description: `StandardErrorCode ∪ ERROR_CODE_LEDGER` -> `StandardErrorCode` ∪ the ledger the serving side registers, naming `ERROR_CODE_LEDGER` as the framework packages' one. Description only. 3. New export `makeApiErrorSchema(extraCodes)`: the same envelope with `StandardErrorCode ∪ extraCodes` as the code vocabulary, so a downstream conformance suite gets one parse with a Zod issue path instead of a shape assertion plus a hand-written membership test. The envelope shape is `ApiErrorSchema`'s, reused rather than restated. Additive: `ApiErrorSchema` parses exactly what it parsed before. Regenerated: api-surface/api.json, export-origins/api.json (the new export), content/docs/references/api/{contract,error-code-ledger}.mdx (gen:schema && gen:docs). Claude-Session: https://claude.ai/code/session_01GmC5HAjupdHqdB9bzsYzPg Co-authored-by: os-zhuang <os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4c54037 commit 0f7157b

8 files changed

Lines changed: 243 additions & 9 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the error-code ledger states its federation contract; `makeApiErrorSchema(extraCodes)` (#4805)
6+
7+
`ERROR_CODE_LEDGER` registers framework packages only. That was true of every
8+
row in it and stated nowhere — a reader could only infer it by scanning the
9+
package names, which is exactly what a downstream product repo did not do
10+
before filing #4805. It is now a stated rule in the file header, together with
11+
what a downstream repo does instead.
12+
13+
**The rule.** A product repo built on the platform (`objectstack-ai/cloud`, or
14+
any other) does not register its codes here. It maintains its own ledger, in
15+
its own repo, and composes the validation itself: `envelopeViolations(body)`
16+
for the shape, and `code ∈ StandardErrorCode ∪ <its own ledger>` for the
17+
vocabulary. The deployed wire vocabulary stays closed and checkable either way,
18+
which is what ADR-0112's "no silent fourth state" asks for — it never asked for
19+
every entry to live physically in one file. The header also records why the
20+
ruling went this way rather than admitting downstream entries: a commercial
21+
vocabulary (billing states, plan gating, control-plane provisioning refusals)
22+
does not belong in an Apache-2.0 spec enumerating package names absent from
23+
this distribution, and a cross-repo PR plus a pin bump per code is friction
24+
that pushes authors toward reusing a semantically wrong existing code — less
25+
visible than inventing one.
26+
27+
**`ApiErrorSchema.code`'s description follows the same seam.** It said
28+
`StandardErrorCode ∪ ERROR_CODE_LEDGER`; it now says `StandardErrorCode` ∪ the
29+
ledger the serving side registers, naming `ERROR_CODE_LEDGER` as the framework
30+
packages' one. Description only — the parsed vocabulary is unchanged.
31+
32+
**New export: `makeApiErrorSchema(extraCodes)`.** The envelope with a
33+
caller-supplied vocabulary — `StandardErrorCode ∪ extraCodes` — so a downstream
34+
conformance suite gets one verdict with a Zod issue path instead of a shape
35+
assertion plus a hand-written membership test:
36+
37+
```ts
38+
const CloudApiError = makeApiErrorSchema(CLOUD_ERROR_CODES);
39+
CloudApiError.safeParse(body); // shape + vocabulary, one parse
40+
```
41+
42+
The envelope shape is `ApiErrorSchema`'s, reused rather than restated, so a
43+
field added to the base envelope reaches every downstream ledger with it.
44+
`ERROR_CODE_LEDGER`'s own entries are deliberately not folded in: a service
45+
that also relays framework-produced errors says so explicitly by passing them
46+
(`makeApiErrorSchema([...REGISTERED_ERROR_CODES, ...MY_CODES])`).
47+
48+
Additive throughout. `ApiErrorSchema` parses exactly what it parsed before — an
49+
extra code is accepted only through the factory, and a code neither standard
50+
nor supplied is still refused by both. Federating the ledger does not open the
51+
vocabulary; it moves where the other half of it is declared.

content/docs/references/api/contract.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +253 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ ERROR_CODE_LEDGER) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +253 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **message** | `string` || Readable error message |
3232
| **category** | `string` | optional | Error category (e.g. validation, authorization) |
3333
| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error |

content/docs/references/api/error-code-ledger.mdx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,48 @@ validates against. An unregistered code fails schema parse — which fails the
2020
envelope conformance suites — which fails CI. That friction is the point
2121
(ADR-0112: "no silent fourth state" for error codes, per ADR-0049/0078).
2222

23+
## Scope: THIS ledger registers framework packages only (#4805)
24+
25+
Every owner key below is a package published from this repository, and that
26+
is a RULE — not an accident of the current list, and not something a reader
27+
should have to infer by scanning the package names. A downstream product
28+
repo (`objectstack-ai/cloud`, or any product built on the platform) does
29+
**not** register its codes here. It maintains its OWN ledger, in its own
30+
repo, and composes the validation itself:
31+
32+
1. **shape**`envelopeViolations(body)` (`contract.zod.ts`), and
33+
2. **vocabulary**`code ∈ StandardErrorCode ∪ <its own ledger>`, which
34+
`makeApiErrorSchema(<its own ledger>)` (`contract.zod.ts`) gives as a
35+
single parse instead of the two-step assertion.
36+
37+
The deployed wire vocabulary stays closed and checkable either way — which
38+
is what ADR-0112's "no silent fourth state" asks for. It never asked for
39+
every entry to live physically in one file.
40+
41+
Why federated rather than admitting downstream entries (maintainer ruling on
42+
#4805, 2026-08-03, re-confirmed 2026-08-09; raised from cloud#930/#944):
43+
44+
- **A commercial vocabulary does not belong in an Apache-2.0 spec.** The
45+
codes worth registering are precisely the product-specific ones (billing
46+
and plan-gating states, control-plane provisioning refusals), and
47+
registering them here would have the OSS spec enumerate a closed-source
48+
product's states under package names absent from this distribution.
49+
- **Cadence mismatch breeds bypass.** A downstream code arrives with a
50+
downstream feature; making each one cost a cross-repo PR plus a pin bump
51+
pushes authors toward reusing a semantically wrong existing code, which is
52+
less visible than inventing one.
53+
54+
The corollary for THIS file: a PR adding an owner key for a package that is
55+
not published from this repository is out of scope by construction — the
56+
fix for that need is a ledger in the owning repo, composed as above. The
57+
one thing a downstream repo must NOT do is emit a code registered nowhere:
58+
that is the silent fourth state, wherever the ledger lives.
59+
2360
## Registering a new code
2461

25-
Add it to your package's entry (create the entry if your package has none),
26-
SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by
62+
Add it to your package's entry (create the entry if your package has none —
63+
a framework package; see the scope rule above if yours ships from another
64+
repo), SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by
2765
`error-code-ledger.test.ts`), with a trailing `//` comment when the name
2866
alone doesn't carry the meaning. Prefer a domain prefix for anything not
2967
self-evidently global (`ATTACHMENT_*`, `REPORT_*`, `SETTINGS_*`). If the

packages/spec/api-surface/api.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,7 @@
989989
"getAuthEndpointUrl (function)",
990990
"getDefaultRouteRegistrations (function)",
991991
"identityFreeEndpointGateFailure (function)",
992+
"makeApiErrorSchema (function)",
992993
"normalizeEndpointPath (function)",
993994
"readServiceSelfInfo (function)",
994995
"resolveDiscoveryEnvironment (function)",

packages/spec/export-origins/api.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,7 @@
989989
"getAuthEndpointUrl": "src/api/auth-endpoints.zod.ts#getAuthEndpointUrl (function)",
990990
"getDefaultRouteRegistrations": "src/api/plugin-rest-api.zod.ts#getDefaultRouteRegistrations (function)",
991991
"identityFreeEndpointGateFailure": "src/api/endpoint-publish-gate.ts#identityFreeEndpointGateFailure (function)",
992+
"makeApiErrorSchema": "src/api/contract.zod.ts#makeApiErrorSchema (function)",
992993
"normalizeEndpointPath": "src/api/endpoint.zod.ts#normalizeEndpointPath (function)",
993994
"readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)",
994995
"resolveDiscoveryEnvironment": "src/api/discovery.zod.ts#resolveDiscoveryEnvironment (function)",

packages/spec/src/api/contract.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import {
33
ApiErrorSchema,
4+
makeApiErrorSchema,
45
BaseResponseSchema,
56
CreateRequestSchema,
67
UpdateRequestSchema,
@@ -572,3 +573,64 @@ describe('QueryOptimizationConfigSchema', () => {
572573
expect(() => QueryOptimizationConfigSchema.parse({ preventNPlusOne: true })).toThrow();
573574
});
574575
});
576+
577+
/**
578+
* The federated-ledger factory (#4805). `ERROR_CODE_LEDGER` registers framework
579+
* packages only; a downstream product repo keeps its own ledger and needs the
580+
* same envelope with `StandardErrorCode ∪ <its own ledger>` as the vocabulary.
581+
*
582+
* The pins below are stated in both directions on purpose. The factory is only
583+
* worth having if it accepts something `ApiErrorSchema` refuses (otherwise it
584+
* is a synonym), and it is only SAFE if that is the sole difference — an
585+
* unregistered code must still fail, and `ApiErrorSchema` must not have been
586+
* widened by the factory's existence.
587+
*/
588+
describe('makeApiErrorSchema (federated ledger, #4805)', () => {
589+
const DOWNSTREAM_CODES = ['CONTACT_SALES_PLAN', 'PRODUCTION_ENV_LIMIT'] as const;
590+
const DownstreamApiError = makeApiErrorSchema(DOWNSTREAM_CODES);
591+
592+
it('accepts the standard catalog both ways', () => {
593+
for (const code of ['VALIDATION_ERROR', 'PERMISSION_DENIED'] as const) {
594+
expect(ApiErrorSchema.parse({ code, message: 'x' }).code).toBe(code);
595+
expect(DownstreamApiError.parse({ code, message: 'x' }).code).toBe(code);
596+
}
597+
});
598+
599+
it('accepts an extra code ONLY through the factory', () => {
600+
for (const code of DOWNSTREAM_CODES) {
601+
expect(DownstreamApiError.parse({ code, message: 'x' }).code).toBe(code);
602+
603+
const base = ApiErrorSchema.safeParse({ code, message: 'x' });
604+
expect(base.success).toBe(false);
605+
expect(base.error?.issues[0]?.path).toEqual(['code']);
606+
expect(base.error?.issues[0]?.code).toBe('invalid_value');
607+
}
608+
});
609+
610+
it('rejects an unregistered code both ways', () => {
611+
const body = { code: 'INVENTED_DIALECT_CODE', message: 'x' };
612+
613+
for (const schema of [ApiErrorSchema, DownstreamApiError]) {
614+
const result = schema.safeParse(body);
615+
expect(result.success).toBe(false);
616+
expect(result.error?.issues[0]?.path).toEqual(['code']);
617+
expect(result.error?.issues[0]?.code).toBe('invalid_value');
618+
}
619+
});
620+
621+
it('reuses the base envelope shape rather than restating it', () => {
622+
expect(Object.keys(DownstreamApiError.shape).sort())
623+
.toEqual(Object.keys(ApiErrorSchema.shape).sort());
624+
625+
const parsed = DownstreamApiError.parse({
626+
code: 'CONTACT_SALES_PLAN',
627+
message: 'Upgrade required',
628+
category: 'billing',
629+
httpStatus: 402,
630+
details: { plan: 'starter' },
631+
requestId: 'req_1',
632+
});
633+
expect(parsed.httpStatus).toBe(402);
634+
expect(parsed.requestId).toBe('req_1');
635+
});
636+
});

packages/spec/src/api/contract.zod.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { QuerySchema } from '../data/query.zod';
55
import { ErrorCode } from './error-code-ledger.zod';
6+
import { StandardErrorCode } from './errors.zod';
67

78
// ==========================================
89
// 1. Base Envelopes
@@ -12,11 +13,17 @@ import { lazySchema } from '../shared/lazy-schema';
1213
export const ApiErrorSchema = lazySchema(() => z.object({
1314
/**
1415
* Machine-readable semantic code (ADR-0112): a `StandardErrorCode` member or
15-
* a code registered in `ERROR_CODE_LEDGER`. A closed set on purpose — an
16-
* unregistered code fails parse, so the envelope conformance suites catch
17-
* invented codes instead of letting a new dialect grow (#3841).
16+
* a code the SERVING side has registered in its ledger. A closed set on
17+
* purpose — an unregistered code fails parse, so the envelope conformance
18+
* suites catch invented codes instead of letting a new dialect grow (#3841).
19+
*
20+
* The ledger is federated (#4805): this schema unions the standard catalog
21+
* with `ERROR_CODE_LEDGER`, which registers FRAMEWORK packages only. A
22+
* downstream product repo maintains its own ledger and validates against
23+
* `StandardErrorCode ∪ <its own ledger>` — see {@link makeApiErrorSchema},
24+
* which is that union as a single parse.
1825
*/
19-
code: ErrorCode.describe('Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ ERROR_CODE_LEDGER)'),
26+
code: ErrorCode.describe('Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages)'),
2027
message: z.string().describe('Readable error message'),
2128
category: z.string().optional().describe('Error category (e.g. validation, authorization)'),
2229
/**
@@ -37,6 +44,42 @@ export const ApiErrorSchema = lazySchema(() => z.object({
3744
requestId: z.string().optional().describe('Request ID for tracking'),
3845
}));
3946

47+
/**
48+
* The error envelope with a CALLER-SUPPLIED code vocabulary:
49+
* `StandardErrorCode ∪ extraCodes` (#4805).
50+
*
51+
* For a downstream product repo — one whose packages are not registered in
52+
* `ERROR_CODE_LEDGER`, which by rule holds framework packages only — checking
53+
* a response body used to mean two separate assertions: `envelopeViolations`
54+
* for the shape, then a hand-written membership test for the code. This is
55+
* both in one parse, so a conformance suite gets ONE verdict with a Zod issue
56+
* path pointing at the offending field.
57+
*
58+
* ```ts
59+
* const CloudApiError = makeApiErrorSchema(CLOUD_ERROR_CODES);
60+
* CloudApiError.safeParse(body); // shape + vocabulary, one verdict
61+
* ```
62+
*
63+
* The envelope shape is `ApiErrorSchema`'s, reused rather than restated, so a
64+
* field added to the base envelope reaches every downstream ledger with it.
65+
*
66+
* `ERROR_CODE_LEDGER`'s own entries are deliberately NOT included: they are
67+
* the framework packages' vocabulary, and a downstream service that also
68+
* relays framework-produced errors states so explicitly by passing them in —
69+
* `makeApiErrorSchema([...REGISTERED_ERROR_CODES, ...MY_CODES])`.
70+
*
71+
* `ApiErrorSchema` itself is unchanged: this is additive, and a code neither
72+
* standard nor supplied here still fails parse. Federating the ledger does not
73+
* open the vocabulary, it only moves where the other half of it is declared.
74+
*/
75+
export function makeApiErrorSchema<const TExtra extends readonly string[]>(extraCodes: TExtra) {
76+
const vocabulary: string[] = [...StandardErrorCode.options, ...extraCodes];
77+
return ApiErrorSchema.extend({
78+
code: (z.enum(vocabulary as [string, ...string[]]) as z.ZodType<StandardErrorCode | TExtra[number]>)
79+
.describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'),
80+
});
81+
}
82+
4083
/**
4184
* The envelope SKELETON — deliberately not the whole response contract.
4285
*

packages/spec/src/api/error-code-ledger.zod.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,48 @@
1616
* envelope conformance suites — which fails CI. That friction is the point
1717
* (ADR-0112: "no silent fourth state" for error codes, per ADR-0049/0078).
1818
*
19+
* ## Scope: THIS ledger registers framework packages only (#4805)
20+
*
21+
* Every owner key below is a package published from this repository, and that
22+
* is a RULE — not an accident of the current list, and not something a reader
23+
* should have to infer by scanning the package names. A downstream product
24+
* repo (`objectstack-ai/cloud`, or any product built on the platform) does
25+
* **not** register its codes here. It maintains its OWN ledger, in its own
26+
* repo, and composes the validation itself:
27+
*
28+
* 1. **shape** — `envelopeViolations(body)` (`contract.zod.ts`), and
29+
* 2. **vocabulary** — `code ∈ StandardErrorCode ∪ <its own ledger>`, which
30+
* `makeApiErrorSchema(<its own ledger>)` (`contract.zod.ts`) gives as a
31+
* single parse instead of the two-step assertion.
32+
*
33+
* The deployed wire vocabulary stays closed and checkable either way — which
34+
* is what ADR-0112's "no silent fourth state" asks for. It never asked for
35+
* every entry to live physically in one file.
36+
*
37+
* Why federated rather than admitting downstream entries (maintainer ruling on
38+
* #4805, 2026-08-03, re-confirmed 2026-08-09; raised from cloud#930/#944):
39+
*
40+
* - **A commercial vocabulary does not belong in an Apache-2.0 spec.** The
41+
* codes worth registering are precisely the product-specific ones (billing
42+
* and plan-gating states, control-plane provisioning refusals), and
43+
* registering them here would have the OSS spec enumerate a closed-source
44+
* product's states under package names absent from this distribution.
45+
* - **Cadence mismatch breeds bypass.** A downstream code arrives with a
46+
* downstream feature; making each one cost a cross-repo PR plus a pin bump
47+
* pushes authors toward reusing a semantically wrong existing code, which is
48+
* less visible than inventing one.
49+
*
50+
* The corollary for THIS file: a PR adding an owner key for a package that is
51+
* not published from this repository is out of scope by construction — the
52+
* fix for that need is a ledger in the owning repo, composed as above. The
53+
* one thing a downstream repo must NOT do is emit a code registered nowhere:
54+
* that is the silent fourth state, wherever the ledger lives.
55+
*
1956
* ## Registering a new code
2057
*
21-
* Add it to your package's entry (create the entry if your package has none),
22-
* SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by
58+
* Add it to your package's entry (create the entry if your package has none —
59+
* a framework package; see the scope rule above if yours ships from another
60+
* repo), SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by
2361
* `error-code-ledger.test.ts`), with a trailing `//` comment when the name
2462
* alone doesn't carry the meaning. Prefer a domain prefix for anything not
2563
* self-evidently global (`ATTACHMENT_*`, `REPORT_*`, `SETTINGS_*`). If the

0 commit comments

Comments
 (0)