From d3a3a9ee64df76bb207e3ea1155972e6d6d39d5a Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 9 Aug 2026 16:50:08 +0000 Subject: [PATCH] feat(spec): the error-code ledger states its federation contract; makeApiErrorSchema(extraCodes) (#4805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ∪ ` 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). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GmC5HAjupdHqdB9bzsYzPg --- .../error-code-ledger-federation-contract.md | 51 +++++++++++++++ content/docs/references/api/contract.mdx | 2 +- .../docs/references/api/error-code-ledger.mdx | 42 ++++++++++++- packages/spec/api-surface/api.json | 1 + packages/spec/export-origins/api.json | 1 + packages/spec/src/api/contract.test.ts | 62 +++++++++++++++++++ packages/spec/src/api/contract.zod.ts | 51 +++++++++++++-- .../spec/src/api/error-code-ledger.zod.ts | 42 ++++++++++++- 8 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 .changeset/error-code-ledger-federation-contract.md diff --git a/.changeset/error-code-ledger-federation-contract.md b/.changeset/error-code-ledger-federation-contract.md new file mode 100644 index 0000000000..00e4210929 --- /dev/null +++ b/.changeset/error-code-ledger-federation-contract.md @@ -0,0 +1,51 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): the error-code ledger states its federation contract; `makeApiErrorSchema(extraCodes)` (#4805) + +`ERROR_CODE_LEDGER` registers framework packages only. That was true of every +row in it and stated nowhere — a reader could only infer it by scanning the +package names, which is exactly what a downstream product repo did not do +before filing #4805. It is now a stated rule in the file header, together with +what a downstream repo does instead. + +**The rule.** A product repo built on the platform (`objectstack-ai/cloud`, or +any other) does not register its codes here. It maintains its own ledger, in +its own repo, and composes the validation itself: `envelopeViolations(body)` +for the shape, and `code ∈ StandardErrorCode ∪ ` for the +vocabulary. The deployed wire vocabulary stays closed and checkable either way, +which is what ADR-0112's "no silent fourth state" asks for — it never asked for +every entry to live physically in one file. The header also records why the +ruling went this way rather than admitting downstream entries: a commercial +vocabulary (billing states, plan gating, control-plane provisioning refusals) +does not belong in an Apache-2.0 spec enumerating package names absent from +this distribution, and a cross-repo PR plus a pin bump per code is friction +that pushes authors toward reusing a semantically wrong existing code — less +visible than inventing one. + +**`ApiErrorSchema.code`'s description follows the same seam.** It said +`StandardErrorCode ∪ ERROR_CODE_LEDGER`; it now says `StandardErrorCode` ∪ the +ledger the serving side registers, naming `ERROR_CODE_LEDGER` as the framework +packages' one. Description only — the parsed vocabulary is unchanged. + +**New export: `makeApiErrorSchema(extraCodes)`.** The envelope with a +caller-supplied vocabulary — `StandardErrorCode ∪ extraCodes` — so a downstream +conformance suite gets one verdict with a Zod issue path instead of a shape +assertion plus a hand-written membership test: + +```ts +const CloudApiError = makeApiErrorSchema(CLOUD_ERROR_CODES); +CloudApiError.safeParse(body); // shape + vocabulary, one parse +``` + +The envelope shape is `ApiErrorSchema`'s, reused rather than restated, so a +field added to the base envelope reaches every downstream ledger with it. +`ERROR_CODE_LEDGER`'s own entries are deliberately not folded in: a service +that also relays framework-produced errors says so explicitly by passing them +(`makeApiErrorSchema([...REGISTERED_ERROR_CODES, ...MY_CODES])`). + +Additive throughout. `ApiErrorSchema` parses exactly what it parsed before — an +extra code is accepted only through the factory, and a code neither standard +nor supplied is still refused by both. Federating the ledger does not open the +vocabulary; it moves where the other half of it is declared. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index c595678de2..eff69a51b9 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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) | +| **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) | | **message** | `string` | ✅ | Readable error message | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index da6acfdc1c..85cc1ccd7c 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -20,10 +20,48 @@ validates against. An unregistered code fails schema parse — which fails the envelope conformance suites — which fails CI. That friction is the point (ADR-0112: "no silent fourth state" for error codes, per ADR-0049/0078). +## Scope: THIS ledger registers framework packages only (#4805) + +Every owner key below is a package published from this repository, and that +is a RULE — not an accident of the current list, and not something a reader +should have to infer by scanning the package names. A downstream product +repo (`objectstack-ai/cloud`, or any product built on the platform) does +**not** register its codes here. It maintains its OWN ledger, in its own +repo, and composes the validation itself: + +1. **shape** — `envelopeViolations(body)` (`contract.zod.ts`), and +2. **vocabulary** — `code ∈ StandardErrorCode ∪ `, which + `makeApiErrorSchema()` (`contract.zod.ts`) gives as a + single parse instead of the two-step assertion. + +The deployed wire vocabulary stays closed and checkable either way — which +is what ADR-0112's "no silent fourth state" asks for. It never asked for +every entry to live physically in one file. + +Why federated rather than admitting downstream entries (maintainer ruling on +#4805, 2026-08-03, re-confirmed 2026-08-09; raised from cloud#930/#944): + +- **A commercial vocabulary does not belong in an Apache-2.0 spec.** The + codes worth registering are precisely the product-specific ones (billing + and plan-gating states, control-plane provisioning refusals), and + registering them here would have the OSS spec enumerate a closed-source + product's states under package names absent from this distribution. +- **Cadence mismatch breeds bypass.** A downstream code arrives with a + downstream feature; making each one cost a cross-repo PR plus a pin bump + pushes authors toward reusing a semantically wrong existing code, which is + less visible than inventing one. + +The corollary for THIS file: a PR adding an owner key for a package that is +not published from this repository is out of scope by construction — the +fix for that need is a ledger in the owning repo, composed as above. The +one thing a downstream repo must NOT do is emit a code registered nowhere: +that is the silent fourth state, wherever the ledger lives. + ## Registering a new code -Add it to your package's entry (create the entry if your package has none), -SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by +Add it to your package's entry (create the entry if your package has none — +a framework package; see the scope rule above if yours ships from another +repo), SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by `error-code-ledger.test.ts`), with a trailing `//` comment when the name alone doesn't carry the meaning. Prefer a domain prefix for anything not self-evidently global (`ATTACHMENT_*`, `REPORT_*`, `SETTINGS_*`). If the diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 54d92315d5..73a59c2347 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -989,6 +989,7 @@ "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", "identityFreeEndpointGateFailure (function)", + "makeApiErrorSchema (function)", "normalizeEndpointPath (function)", "readServiceSelfInfo (function)", "resolveDiscoveryEnvironment (function)", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index c8897be47b..05b1d4a5cd 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -989,6 +989,7 @@ "getAuthEndpointUrl": "src/api/auth-endpoints.zod.ts#getAuthEndpointUrl (function)", "getDefaultRouteRegistrations": "src/api/plugin-rest-api.zod.ts#getDefaultRouteRegistrations (function)", "identityFreeEndpointGateFailure": "src/api/endpoint-publish-gate.ts#identityFreeEndpointGateFailure (function)", + "makeApiErrorSchema": "src/api/contract.zod.ts#makeApiErrorSchema (function)", "normalizeEndpointPath": "src/api/endpoint.zod.ts#normalizeEndpointPath (function)", "readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)", "resolveDiscoveryEnvironment": "src/api/discovery.zod.ts#resolveDiscoveryEnvironment (function)", diff --git a/packages/spec/src/api/contract.test.ts b/packages/spec/src/api/contract.test.ts index 5b16c6fa22..7e2888a20a 100644 --- a/packages/spec/src/api/contract.test.ts +++ b/packages/spec/src/api/contract.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { ApiErrorSchema, + makeApiErrorSchema, BaseResponseSchema, CreateRequestSchema, UpdateRequestSchema, @@ -572,3 +573,64 @@ describe('QueryOptimizationConfigSchema', () => { expect(() => QueryOptimizationConfigSchema.parse({ preventNPlusOne: true })).toThrow(); }); }); + +/** + * The federated-ledger factory (#4805). `ERROR_CODE_LEDGER` registers framework + * packages only; a downstream product repo keeps its own ledger and needs the + * same envelope with `StandardErrorCode ∪ ` as the vocabulary. + * + * The pins below are stated in both directions on purpose. The factory is only + * worth having if it accepts something `ApiErrorSchema` refuses (otherwise it + * is a synonym), and it is only SAFE if that is the sole difference — an + * unregistered code must still fail, and `ApiErrorSchema` must not have been + * widened by the factory's existence. + */ +describe('makeApiErrorSchema (federated ledger, #4805)', () => { + const DOWNSTREAM_CODES = ['CONTACT_SALES_PLAN', 'PRODUCTION_ENV_LIMIT'] as const; + const DownstreamApiError = makeApiErrorSchema(DOWNSTREAM_CODES); + + it('accepts the standard catalog both ways', () => { + for (const code of ['VALIDATION_ERROR', 'PERMISSION_DENIED'] as const) { + expect(ApiErrorSchema.parse({ code, message: 'x' }).code).toBe(code); + expect(DownstreamApiError.parse({ code, message: 'x' }).code).toBe(code); + } + }); + + it('accepts an extra code ONLY through the factory', () => { + for (const code of DOWNSTREAM_CODES) { + expect(DownstreamApiError.parse({ code, message: 'x' }).code).toBe(code); + + const base = ApiErrorSchema.safeParse({ code, message: 'x' }); + expect(base.success).toBe(false); + expect(base.error?.issues[0]?.path).toEqual(['code']); + expect(base.error?.issues[0]?.code).toBe('invalid_value'); + } + }); + + it('rejects an unregistered code both ways', () => { + const body = { code: 'INVENTED_DIALECT_CODE', message: 'x' }; + + for (const schema of [ApiErrorSchema, DownstreamApiError]) { + const result = schema.safeParse(body); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(['code']); + expect(result.error?.issues[0]?.code).toBe('invalid_value'); + } + }); + + it('reuses the base envelope shape rather than restating it', () => { + expect(Object.keys(DownstreamApiError.shape).sort()) + .toEqual(Object.keys(ApiErrorSchema.shape).sort()); + + const parsed = DownstreamApiError.parse({ + code: 'CONTACT_SALES_PLAN', + message: 'Upgrade required', + category: 'billing', + httpStatus: 402, + details: { plan: 'starter' }, + requestId: 'req_1', + }); + expect(parsed.httpStatus).toBe(402); + expect(parsed.requestId).toBe('req_1'); + }); +}); diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index e8cbf23142..b5c427ad45 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { QuerySchema } from '../data/query.zod'; import { ErrorCode } from './error-code-ledger.zod'; +import { StandardErrorCode } from './errors.zod'; // ========================================== // 1. Base Envelopes @@ -12,11 +13,17 @@ import { lazySchema } from '../shared/lazy-schema'; export const ApiErrorSchema = lazySchema(() => z.object({ /** * Machine-readable semantic code (ADR-0112): a `StandardErrorCode` member or - * a code registered in `ERROR_CODE_LEDGER`. A closed set on purpose — an - * unregistered code fails parse, so the envelope conformance suites catch - * invented codes instead of letting a new dialect grow (#3841). + * a code the SERVING side has registered in its ledger. A closed set on + * purpose — an unregistered code fails parse, so the envelope conformance + * suites catch invented codes instead of letting a new dialect grow (#3841). + * + * The ledger is federated (#4805): this schema unions the standard catalog + * with `ERROR_CODE_LEDGER`, which registers FRAMEWORK packages only. A + * downstream product repo maintains its own ledger and validates against + * `StandardErrorCode ∪ ` — see {@link makeApiErrorSchema}, + * which is that union as a single parse. */ - code: ErrorCode.describe('Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ ERROR_CODE_LEDGER)'), + code: ErrorCode.describe('Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages)'), message: z.string().describe('Readable error message'), category: z.string().optional().describe('Error category (e.g. validation, authorization)'), /** @@ -37,6 +44,42 @@ export const ApiErrorSchema = lazySchema(() => z.object({ requestId: z.string().optional().describe('Request ID for tracking'), })); +/** + * The error envelope with a CALLER-SUPPLIED code vocabulary: + * `StandardErrorCode ∪ extraCodes` (#4805). + * + * For a downstream product repo — one whose packages are not registered in + * `ERROR_CODE_LEDGER`, which by rule holds framework packages only — checking + * a response body used to mean two separate assertions: `envelopeViolations` + * for the shape, then a hand-written membership test for the code. This is + * both in one parse, so a conformance suite gets ONE verdict with a Zod issue + * path pointing at the offending field. + * + * ```ts + * const CloudApiError = makeApiErrorSchema(CLOUD_ERROR_CODES); + * CloudApiError.safeParse(body); // shape + vocabulary, one verdict + * ``` + * + * The envelope shape is `ApiErrorSchema`'s, reused rather than restated, so a + * field added to the base envelope reaches every downstream ledger with it. + * + * `ERROR_CODE_LEDGER`'s own entries are deliberately NOT included: they are + * the framework packages' vocabulary, and a downstream service that also + * relays framework-produced errors states so explicitly by passing them in — + * `makeApiErrorSchema([...REGISTERED_ERROR_CODES, ...MY_CODES])`. + * + * `ApiErrorSchema` itself is unchanged: this is additive, and a code neither + * standard nor supplied here still fails parse. Federating the ledger does not + * open the vocabulary, it only moves where the other half of it is declared. + */ +export function makeApiErrorSchema(extraCodes: TExtra) { + const vocabulary: string[] = [...StandardErrorCode.options, ...extraCodes]; + return ApiErrorSchema.extend({ + code: (z.enum(vocabulary as [string, ...string[]]) as z.ZodType) + .describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'), + }); +} + /** * The envelope SKELETON — deliberately not the whole response contract. * diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index da81a2205d..fd60c2201f 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -16,10 +16,48 @@ * envelope conformance suites — which fails CI. That friction is the point * (ADR-0112: "no silent fourth state" for error codes, per ADR-0049/0078). * + * ## Scope: THIS ledger registers framework packages only (#4805) + * + * Every owner key below is a package published from this repository, and that + * is a RULE — not an accident of the current list, and not something a reader + * should have to infer by scanning the package names. A downstream product + * repo (`objectstack-ai/cloud`, or any product built on the platform) does + * **not** register its codes here. It maintains its OWN ledger, in its own + * repo, and composes the validation itself: + * + * 1. **shape** — `envelopeViolations(body)` (`contract.zod.ts`), and + * 2. **vocabulary** — `code ∈ StandardErrorCode ∪ `, which + * `makeApiErrorSchema()` (`contract.zod.ts`) gives as a + * single parse instead of the two-step assertion. + * + * The deployed wire vocabulary stays closed and checkable either way — which + * is what ADR-0112's "no silent fourth state" asks for. It never asked for + * every entry to live physically in one file. + * + * Why federated rather than admitting downstream entries (maintainer ruling on + * #4805, 2026-08-03, re-confirmed 2026-08-09; raised from cloud#930/#944): + * + * - **A commercial vocabulary does not belong in an Apache-2.0 spec.** The + * codes worth registering are precisely the product-specific ones (billing + * and plan-gating states, control-plane provisioning refusals), and + * registering them here would have the OSS spec enumerate a closed-source + * product's states under package names absent from this distribution. + * - **Cadence mismatch breeds bypass.** A downstream code arrives with a + * downstream feature; making each one cost a cross-repo PR plus a pin bump + * pushes authors toward reusing a semantically wrong existing code, which is + * less visible than inventing one. + * + * The corollary for THIS file: a PR adding an owner key for a package that is + * not published from this repository is out of scope by construction — the + * fix for that need is a ledger in the owning repo, composed as above. The + * one thing a downstream repo must NOT do is emit a code registered nowhere: + * that is the silent fourth state, wherever the ledger lives. + * * ## Registering a new code * - * Add it to your package's entry (create the entry if your package has none), - * SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by + * Add it to your package's entry (create the entry if your package has none — + * a framework package; see the scope rule above if yours ships from another + * repo), SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by * `error-code-ledger.test.ts`), with a trailing `//` comment when the name * alone doesn't carry the meaning. Prefer a domain prefix for anything not * self-evidently global (`ATTACHMENT_*`, `REPORT_*`, `SETTINGS_*`). If the