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
51 changes: 51 additions & 0 deletions .changeset/error-code-ledger-federation-contract.md
Original file line number Diff line number Diff line change
@@ -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 ∪ <its own ledger>` 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.
2 changes: 1 addition & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
42 changes: 40 additions & 2 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ∪ <its own ledger>`, which
`makeApiErrorSchema(<its own ledger>)` (`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
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,7 @@
"getAuthEndpointUrl (function)",
"getDefaultRouteRegistrations (function)",
"identityFreeEndpointGateFailure (function)",
"makeApiErrorSchema (function)",
"normalizeEndpointPath (function)",
"readServiceSelfInfo (function)",
"resolveDiscoveryEnvironment (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
62 changes: 62 additions & 0 deletions packages/spec/src/api/contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
ApiErrorSchema,
makeApiErrorSchema,
BaseResponseSchema,
CreateRequestSchema,
UpdateRequestSchema,
Expand Down Expand Up @@ -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 ∪ <its own ledger>` 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');
});
});
51 changes: 47 additions & 4 deletions packages/spec/src/api/contract.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ∪ <its own ledger>` — 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)'),
/**
Expand All @@ -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<const TExtra extends readonly string[]>(extraCodes: TExtra) {
const vocabulary: string[] = [...StandardErrorCode.options, ...extraCodes];
return ApiErrorSchema.extend({
code: (z.enum(vocabulary as [string, ...string[]]) as z.ZodType<StandardErrorCode | TExtra[number]>)
.describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'),
});
}

/**
* The envelope SKELETON — deliberately not the whole response contract.
*
Expand Down
42 changes: 40 additions & 2 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ∪ <its own ledger>`, which
* `makeApiErrorSchema(<its own ledger>)` (`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
Expand Down
Loading