Skip to content

Commit abae16a

Browse files
os-trumpclaude
andauthored
feat(spec): register every error code that ships in dist — nine door: 'none' codes enter ERROR_CODE_LEDGER under the #16404 ruling (#16449) (#16652)
* feat(spec): register every error code that ships in dist — nine door:'none' codes enter ERROR_CODE_LEDGER Under the #16404 ruling (option D) the ledger is the published face: every code shipped in dist is registered, door or no door. OBJECT_OWNERSHIP_CONFLICT (objectql), the seven STACK_* defineStack refusals and PLUGIN_UI_REQUIRED_KEY_MISSING (spec) gain rows; their boot-refusal classification rows ratchet out of dispatcher-error-vocabulary.ts, and check:dispatcher-error-vocabulary now refuses any verdict but foreign-vocabulary / runtime-pinned under packages/spec/src (the pin that keeps the class closed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf * docs(spec): regenerate the error-code ledger and contract references for the nine new codes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf * chore(runtime,changeset): name the follow-up card for the remaining boot-refusal rows; state the artifact reading in the changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf * docs(spec): regenerate the ledger and contract references on the merged tree Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7629f4d commit abae16a

12 files changed

Lines changed: 371 additions & 235 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): register every error code that ships in `dist``OBJECT_OWNERSHIP_CONFLICT`, the seven `STACK_*` `defineStack` refusals and `PLUGIN_UI_REQUIRED_KEY_MISSING` enter `ERROR_CODE_LEDGER` (#16449)
6+
7+
Under the #16404 ruling (director seat, decision batch #62, 2026-09-07, option D; maintainer 「同意」) **the published contract face for error codes is `ERROR_CODE_LEDGER` / `StandardErrorCode`**: every `code` that ships in a package's `dist` is registered there, door or no door, because a consumer's `catch (e) { switch (e.code) }` pins the spelling the moment it ships and nothing could flag a later rename. Nine codes were shipping unregistered on this tree and now have rows, each under the package that stamps it:
8+
9+
| code | stamped by | `status` | reaches an HTTP door on this tree? |
10+
|---|---|---|---|
11+
| `OBJECT_OWNERSHIP_CONFLICT` | `@objectstack/objectql` (`SchemaRegistry.registerObject`, ADR-0029 D3) | 422 | no — every path aborts boot or is caught below any door |
12+
| `STACK_SCHEMA_INVALID` · `STACK_CAPABILITY_UNKNOWN` · `STACK_CROSS_REFERENCE_INVALID` · `STACK_NAMESPACE_PREFIX_INVALID` · `STACK_SINGLE_APP_VIOLATION` · `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` · `STACK_TRIGGER_CAPABILITY_REQUIRED` | `@objectstack/spec` (`defineStack`, #14552 / #15963) | 422 | no — raised by `os validate` / `os build` and the host configs at boot |
13+
| `PLUGIN_UI_REQUIRED_KEY_MISSING` | `@objectstack/spec` (`PluginSchema`'s `superRefine`, on the zod issue's `params.code`, #16334) | rides `PLUGIN_CONTRACT_VIOLATION`'s | no — raised at `kernel.use()` |
14+
15+
The card's ninth, `NAMESPACE_CONFLICT`, was already registered by #14748 and already answers `error.code: NAMESPACE_CONFLICT` at `POST /api/v1/packages`; this release changes nothing there.
16+
17+
**Wire consequence, stated plainly.** For a code that reaches an HTTP door, registration changes what a client reads: `error.code` becomes the specific code instead of the standard member the status derives (`VALIDATION_ERROR` for 422) with the producer's spelling demoted beside it in `declaredCode`. That is the ruling's intended effect — a consumer can branch on the real code — and it is what the Clause-② review judges. Measured on this tree, **none of the nine has such a door** (the table's last column; `OBJECT_OWNERSHIP_CONFLICT`'s reading was re-taken: the only two non-test `registerObject` callers outside `objectql`, both in `metadata-protocol`, catch it and log), so **no HTTP body changes with this release**. What changes is the face: `ErrorCode` — the union `ApiErrorSchema.code` parses against — gains nine members, `REGISTERED_ERROR_CODES` lists them, the generated docs references carry them (`check:generated` found nothing else stale — no authorable schema and no JSON-schema artifact reads this union), and each refusal's `e.code` is now a member of the union a consumer's exhaustive `switch` is written over. Should a door ever answer with one of these codes, the wire carries the specific code from then on. The `declaredCode` demotion (#9106) remains for genuinely unknown / third-party spellings only.
18+
19+
**Why `minor`, and no `BREAKING` banner.** Nothing is removed or renamed; every existing body parses exactly as before. The change is a purely additive widening of a published surface (nine new `ErrorCode` members), which the 2026-09-04 ruling on #15294 requires to be at least `minor`. The one consumer-visible cost is type-level: an exhaustive `switch` over the `ErrorCode` TYPE gains nine cases to cover — additive, and the shape the ruling asks for.
20+
21+
Also in this release, as the mechanism that keeps the class closed: `check:dispatcher-error-vocabulary` now refuses to classify a `packages/spec/src/**` stamp site as anything but `foreign-vocabulary` or `runtime-pinned` (`spec-face-unregistered`) — a code raised under the spec tree is a ledger member or it fails CI — and the ledger's header records the ruling as the "door or no door" rule in its own words. The nine `boot-refusal` classification rows in `dispatcher-error-vocabulary.ts` ratcheted out with the registrations, their reachability reading now carried on the ledger rows.

content/docs/references/api/contract.mdx

Lines changed: 10 additions & 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' \| … +299 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +308 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` || Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -245,6 +245,7 @@ const result = ApiErrorSchema.parse(data);
245245
* `OBJECT_API_DISABLED`
246246
* `OBJECT_API_METHOD_NOT_ALLOWED`
247247
* `OBJECT_OVERLAY_PACKAGE_MISMATCH`
248+
* `OBJECT_OWNERSHIP_CONFLICT`
248249
* `OBJECT_PACKAGE_DISABLED`
249250
* `OPENAPI_UNAVAILABLE`
250251
* `OS_PROTOCOL_INCOMPATIBLE`
@@ -262,6 +263,7 @@ const result = ApiErrorSchema.parse(data);
262263
* `PLUGIN_INSTALL_FAILED`
263264
* `PLUGIN_MANIFEST_INVALID`
264265
* `PLUGIN_REGISTER_FAILED`
266+
* `PLUGIN_UI_REQUIRED_KEY_MISSING`
265267
* `PROJECT_MEMBERSHIP_REQUIRED`
266268
* `PROJECT_NOT_FOUND`
267269
* `PROJECT_PROVISIONING`
@@ -316,6 +318,13 @@ const result = ApiErrorSchema.parse(data);
316318
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
317319
* `SSO_REGISTER_FAILED`
318320
* `SSO_REGISTER_FORBIDDEN`
321+
* `STACK_CAPABILITY_UNKNOWN`
322+
* `STACK_CROSS_REFERENCE_INVALID`
323+
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
324+
* `STACK_NAMESPACE_PREFIX_INVALID`
325+
* `STACK_SCHEMA_INVALID`
326+
* `STACK_SINGLE_APP_VIOLATION`
327+
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
319328
* `STORED_TYPE_NOT_CANONICAL`
320329
* `STORE_UNAVAILABLE`
321330
* `SUGGESTION_CONFIRM_FAILED`

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,39 @@ is registered here — and if it merely re-spells a standard member, that
9494
registration is a recorded waiver, never drift. A code registered NOWHERE
9595
(a tenant app's own spelling) still reaches the wire, in `declaredCode`.
9696

97+
## Door or no door — every code that ships in `dist` is registered (#16404)
98+
99+
Ruled by the director seat (decision batch #62, 2026-09-07, option D;
100+
maintainer 「同意」): **the published contract face for error codes is THIS
101+
ledger together with `StandardErrorCode`.** Any `code` that ships in a
102+
package's `dist` is registered here whether or not an HTTP door can ever
103+
answer with it — a thrown value's `code` is what a consumer's
104+
`catch (e) { switch (e.code) }` pins, and once shipped it cannot be renamed
105+
without breaking that consumer. Registering a code widens this face and is
106+
therefore a Clause-② change (`needs:contract-review`), door or no door; a
107+
code present in `dist` and absent here is a protocol gap, not a tier
108+
question.
109+
110+
ONE shape, no second list: a `door: 'none'` code is a row like any other —
111+
the string under the package that stamps it, and a comment that states its
112+
`status` and the reachability reading ("no HTTP door on this tree; the
113+
thrown value is the boundary"). The dispatcher vocabulary's `boot-refusal`
114+
verdict (`packages/runtime/src/dispatcher-error-vocabulary.ts`) records
115+
that same reachability for the codes NOT yet registered, and a row here
116+
ratchets its vocabulary row out exactly as a `pending-registration`
117+
registration does. What registration changes for such a code is the face,
118+
not the wire: nothing demotes today, and if a door ever does answer with
119+
it, `error.code` carries the specific code instead of the status-derived
120+
member plus `declaredCode`. The `declaredCode` demotion (#9106) stays for
121+
genuinely unknown / third-party spellings only.
122+
123+
`packages/spec/src/**` is held to this mechanically:
124+
`check:dispatcher-error-vocabulary` refuses to classify a stamp site under
125+
that tree as anything but `foreign-vocabulary` (a different vocabulary that
126+
merely spells itself `code`) or `runtime-pinned` — a `boot-refusal` or
127+
`pending-registration` row for a spec site is a finding
128+
(`spec-face-unregistered`), and the only way out is the row here.
129+
97130
A code emitted by several packages is listed once per emitting package —
98131
the union dedupes; the per-package rows are provenance, not identity.
99132

@@ -126,6 +159,12 @@ aborts, and the one request-reachable trigger sits inside a documented
126159
best-effort catch that logs and continues. Its throw site and constant
127160
(`MULTI_TENANT_UNSUPPORTED_CODE`, `@objectstack/driver-mongodb`) live on:
128161
host boot matching is not wire vocabulary.
162+
⚠️ That SECOND ground is superseded by #16404 ("Door or no door" above): a
163+
boot refusal that ships in `dist` is owed a row, so the codes left out or
164+
retired on the "not wire vocabulary" reasoning — the remaining
165+
`boot-refusal` rows of `dispatcher-error-vocabulary.ts` — are registrations
166+
owed under the ruling, not re-argued per card. What still retires a row is
167+
the FIRST ground only: no producer left anywhere in `packages/**` source.
129168
Before deleting a row, check that no producer remains repo-wide AND
130169
that no consumer — including `objectui` and `cloud` — reads the literal;
131170
tests that merely CONSTRUCT the code are not producers, and a test pinned to
@@ -361,6 +400,7 @@ const result = ErrorCode.parse(data);
361400
* `OBJECT_API_DISABLED`
362401
* `OBJECT_API_METHOD_NOT_ALLOWED`
363402
* `OBJECT_OVERLAY_PACKAGE_MISMATCH`
403+
* `OBJECT_OWNERSHIP_CONFLICT`
364404
* `OBJECT_PACKAGE_DISABLED`
365405
* `OPENAPI_UNAVAILABLE`
366406
* `OS_PROTOCOL_INCOMPATIBLE`
@@ -378,6 +418,7 @@ const result = ErrorCode.parse(data);
378418
* `PLUGIN_INSTALL_FAILED`
379419
* `PLUGIN_MANIFEST_INVALID`
380420
* `PLUGIN_REGISTER_FAILED`
421+
* `PLUGIN_UI_REQUIRED_KEY_MISSING`
381422
* `PROJECT_MEMBERSHIP_REQUIRED`
382423
* `PROJECT_NOT_FOUND`
383424
* `PROJECT_PROVISIONING`
@@ -432,6 +473,13 @@ const result = ErrorCode.parse(data);
432473
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
433474
* `SSO_REGISTER_FAILED`
434475
* `SSO_REGISTER_FORBIDDEN`
476+
* `STACK_CAPABILITY_UNKNOWN`
477+
* `STACK_CROSS_REFERENCE_INVALID`
478+
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
479+
* `STACK_NAMESPACE_PREFIX_INVALID`
480+
* `STACK_SCHEMA_INVALID`
481+
* `STACK_SINGLE_APP_VIOLATION`
482+
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
435483
* `STORED_TYPE_NOT_CANONICAL`
436484
* `STORE_UNAVAILABLE`
437485
* `SUGGESTION_CONFIRM_FAILED`

packages/objectql/src/registry-ownership-refusal-envelope.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
*/
3131

3232
import { describe, it, expect } from 'vitest';
33-
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';
33+
import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
34+
import { OBJECT_OWNERSHIP_CONFLICT_CODE, ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';
3435

3536
const APP_PKG = 'app.myapp';
3637
const OTHER_PKG = 'app.otherapp';
@@ -156,3 +157,20 @@ describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope
156157
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
157158
});
158159
});
160+
161+
describe('#16449 — the ownership refusal code is a member of the closed ADR-0112 vocabulary', () => {
162+
it('OBJECT_OWNERSHIP_CONFLICT parses against ErrorCode, and 422 cannot have derived it', () => {
163+
// The #16404 ruling: a code that ships in `dist` is the published face,
164+
// door or no door. No door answers with this refusal on this tree (every
165+
// path aborts boot or is caught below any HTTP boundary), so the ledger
166+
// row changes no body — what it fixes is the face: `e.code` on the thrown
167+
// value is a member of the union a consumer's `switch` is exhaustive over,
168+
// and should a door ever answer with it, `error.code` carries THIS code
169+
// rather than the status-derived member plus `declaredCode`.
170+
expect(ErrorCode.safeParse(OBJECT_OWNERSHIP_CONFLICT_CODE).success).toBe(true);
171+
// The control that makes the first line discriminating: 422 does NOT
172+
// derive this member, so a body carrying it would prove the producer's
173+
// code was carried through, never re-derived from the status.
174+
expect(standardErrorCodeForHttpStatus(422)).not.toBe(OBJECT_OWNERSHIP_CONFLICT_CODE);
175+
});
176+
});

0 commit comments

Comments
 (0)