From 6a561a004bede66c01301d91301634836f5844e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:38:58 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec):=20register=20every=20error=20co?= =?UTF-8?q?de=20that=20ships=20in=20dist=20=E2=80=94=20nine=20door:'none'?= =?UTF-8?q?=20codes=20enter=20ERROR=5FCODE=5FLEDGER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .changeset/register-shipped-error-codes.md | 21 ++ ...egistry-ownership-refusal-envelope.test.ts | 20 +- .../src/dispatcher-error-vocabulary.ts | 243 ++---------------- .../spec/src/api/error-code-ledger.test.ts | 29 +++ .../spec/src/api/error-code-ledger.zod.ts | 87 +++++++ .../kernel/plugin-ui-required-keys.test.ts | 11 + packages/spec/src/kernel/plugin.zod.ts | 11 +- .../spec/src/stack-refusal-envelopes.test.ts | 12 + packages/spec/src/stack.zod.ts | 25 +- scripts/check-dispatcher-error-vocabulary.mjs | 88 ++++++- 10 files changed, 313 insertions(+), 234 deletions(-) create mode 100644 .changeset/register-shipped-error-codes.md diff --git a/.changeset/register-shipped-error-codes.md b/.changeset/register-shipped-error-codes.md new file mode 100644 index 0000000000..3a47906852 --- /dev/null +++ b/.changeset/register-shipped-error-codes.md @@ -0,0 +1,21 @@ +--- +"@objectstack/spec": minor +--- + +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) + +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: + +| code | stamped by | `status` | reaches an HTTP door on this tree? | +|---|---|---|---| +| `OBJECT_OWNERSHIP_CONFLICT` | `@objectstack/objectql` (`SchemaRegistry.registerObject`, ADR-0029 D3) | 422 | no — every path aborts boot or is caught below any door | +| `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 | +| `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()` | + +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. + +**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 JSON schema and docs references carry them, 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. + +**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. + +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. diff --git a/packages/objectql/src/registry-ownership-refusal-envelope.test.ts b/packages/objectql/src/registry-ownership-refusal-envelope.test.ts index 1666a0af67..c155694f22 100644 --- a/packages/objectql/src/registry-ownership-refusal-envelope.test.ts +++ b/packages/objectql/src/registry-ownership-refusal-envelope.test.ts @@ -30,7 +30,8 @@ */ import { describe, it, expect } from 'vitest'; -import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js'; +import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { OBJECT_OWNERSHIP_CONFLICT_CODE, ObjectOwnershipConflictError, SchemaRegistry } from './registry.js'; const APP_PKG = 'app.myapp'; const OTHER_PKG = 'app.otherapp'; @@ -156,3 +157,20 @@ describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG); }); }); + +describe('#16449 — the ownership refusal code is a member of the closed ADR-0112 vocabulary', () => { + it('OBJECT_OWNERSHIP_CONFLICT parses against ErrorCode, and 422 cannot have derived it', () => { + // The #16404 ruling: a code that ships in `dist` is the published face, + // door or no door. No door answers with this refusal on this tree (every + // path aborts boot or is caught below any HTTP boundary), so the ledger + // row changes no body — what it fixes is the face: `e.code` on the thrown + // value is a member of the union a consumer's `switch` is exhaustive over, + // and should a door ever answer with it, `error.code` carries THIS code + // rather than the status-derived member plus `declaredCode`. + expect(ErrorCode.safeParse(OBJECT_OWNERSHIP_CONFLICT_CODE).success).toBe(true); + // The control that makes the first line discriminating: 422 does NOT + // derive this member, so a body carrying it would prove the producer's + // code was carried through, never re-derived from the status. + expect(standardErrorCodeForHttpStatus(422)).not.toBe(OBJECT_OWNERSHIP_CONFLICT_CODE); + }); +}); diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 89a6a0100a..0bf8fdb6bf 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -211,9 +211,21 @@ export type CodeVerdict = | 'foreign-vocabulary' /** * A refusal raised before any HTTP boundary exists — the CLI rethrows it - * and aborts. The ledger's own note ratifies this class by name: - * `MONGODB_MULTI_TENANT_UNSUPPORTED` was UNregistered by #8035 for exactly - * this reason, and "host boot matching is not wire vocabulary". + * and aborts. Until #16404 the ledger's own note ratified this class as + * NOT owed a row (`MONGODB_MULTI_TENANT_UNSUPPORTED` was UNregistered by + * #8035 on "host boot matching is not wire vocabulary"). + * + * [#16404] That exemption is gone: the published face is the ledger, and + * every code that ships in `dist` is registered there, door or no door. + * This verdict now records REACHABILITY only — no door answers with the + * code, so registering it changes no HTTP body — and a row carrying it is + * a registration OWED, exactly like `pending-registration`: the row + * ratchets out when the ledger row lands (#16449 took the nine measured on + * that card's tree; the rows still here are the follow-up named in the + * running log below). ⛔ Under `packages/spec/src/**` the gate refuses + * this verdict outright (`spec-face-unregistered`): a spec stamp site is a + * ledger member, a foreign vocabulary or a runtime-pinned template — + * nothing between. */ | 'boot-refusal' /** @@ -282,6 +294,18 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ // future unswept producer lands here as an `unclassified-site` finding and // gets a new row (then a spec-lane registration, then the row comes out // again). ── + // + // ── [#16449] Third full cycle, under the #16404 ruling (the ledger is the + // published face; every code shipped in `dist` is registered, door or no + // door): the nine `boot-refusal` rows measured unregistered on that card's + // tree — `OBJECT_OWNERSHIP_CONFLICT` (`@objectstack/objectql`), the seven + // `STACK_*` `defineStack` refusals and `PLUGIN_UI_REQUIRED_KEY_MISSING` + // (`@objectstack/spec`) — were registered and ratcheted out; the + // reachability each row recorded now lives on its ledger row. The + // `boot-refusal` rows still below are the same class and owe the same + // registration (FOLLOWUP_CARD); `packages/spec/src/**` can no longer carry + // one at all — `check:dispatcher-error-vocabulary` refuses any verdict + // there but `foreign-vocabulary` / `runtime-pinned`. ── // ── runtime-pinned: an interpolated family, checked where it can be ───── { @@ -800,31 +824,6 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ + '`message`. If a transport ever ANSWERS with this fact, the verdict becomes ' + 'pending-registration and the code belongs in the ledger batch.', }, - // [#16334] The spec's own code for the one CONDITIONAL requirement - // `PluginSchema` carries — `type: 'ui'` owes `staticPath` and `slug` — - // stamped by the schema's `superRefine` onto the zod ISSUE (`params.code`, - // and at the head of `message`), never onto a thrown error. The scanner - // sees it as an `objlitconst` site because the literal sits inside - // `ctx.addIssue({ …, params: { code } })` in a `.zod.ts` file. - { - code: 'PLUGIN_UI_REQUIRED_KEY_MISSING', - file: 'packages/spec/src/kernel/plugin.zod.ts', - shape: 'objlitconst', - door: 'none', - verdict: 'boot-refusal', - why: - 'Stamped on a zod ISSUE, not on a thrown error: `PluginSchema`\'s `superRefine` adds one ' - + '`custom` issue per missing key when a `type: \'ui\'` plugin omits `staticPath` or `slug`, ' - + 'with this code on `params.code` and at the head of `message`. MEASURED reachability: the ' - + 'only runtime caller of `PluginSchema` is `PluginLoader.validatePluginContract` ' - + '(`packages/core/src/plugin-loader.ts`, the `PLUGIN_CONTRACT_VIOLATION` row above), which ' - + 'reads the first issue\'s `path` and `message` and re-raises them inside the ' - + '`PLUGIN_CONTRACT_VIOLATION` envelope at `kernel.use()` — before bootstrap, and therefore ' - + 'before any HTTP boundary exists. So this code reaches a reader only as a substring of that ' - + 'boot refusal\'s message; no door answers with it and `error.code` never carries it. Same ' - + 'class and same reasoning as the row above. If a door ever answers with it, the verdict ' - + 'becomes pending-registration and it belongs in the ledger batch.', - }, // [ADR-0130 D4] The artifact load path's three wrapper refusals, added with the // N-package load path itself. The pre-HTTP reasoning is the one the rows above // cite; what is specific to these three is the second half recorded in each `why` @@ -948,194 +947,6 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' + 'pending-registration and it belongs in the ledger batch.' }, - { - code: 'OBJECT_OWNERSHIP_CONFLICT', - file: 'packages/objectql/src/registry.ts', - // [#16159] `classconst`, not `classfield`, since the literal became the - // exported `OBJECT_OWNERSHIP_CONFLICT_CODE` constant in the producer. The VALUE is - // byte-identical and the scanner resolves the constant back to it; only the - // spelling the scan matches on moved. The verdict below is untouched. - shape: 'classconst', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' + - 'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' + - 'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' + - 'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' + - 'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' + - 'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' + - 'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' + - 'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' + - 'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' + - 'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' + - 'entry and logs; and the import route in that same file, which catches and answers with its ' + - 'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' + - 'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' + - '(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' + - 'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' + - '(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' + - 'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' + - '`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' + - 'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' + - 'would propagate it, and has no production instantiation. So the code reaches a reader only ' + - 'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' + - 'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' + - 'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' + - 'batch.' - }, - { - code: 'STACK_CROSS_REFERENCE_INVALID', - file: 'packages/spec/src/stack.zod.ts', - shape: 'classfield', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0130 — the AUTHORING gate\'s cross-reference refusal, raised by `defineStack` when a ' + - 'stack\'s items name objects the stack does not define. One raise site for the whole rule ' + - 'family: `validateCrossReferences` returns every finding as a `string[]` and `defineStack` ' + - 'throws the collected set once, so the code names the family and the individual classes ride ' + - 'the error\'s `issues` field (the five REFUSED ADR-0130 matrix classes — action `objectName`, ' + - 'view `data.object`, permission-set `objects`, seed dataset `object`, import mapping ' + - '`targetObject` — plus the `hooks[].object` rule, and the wider duplicate-action-key, ' + - 'global-`update`-action and mapping `javascript`-transform findings the same aggregate ' + - 'carries). Before this envelope it threw a bare `Error`, so those classes were separable only ' + - 'by message text. ⭐ MEASURED, not inferred from the call graph: `defineStack` is an ' + - 'authoring/boot-time entry point, and no HTTP domain handler calls it. Every non-test ' + - 'occurrence of `defineStack` under `packages/runtime/src` and `packages/rest/src` (25 of them) ' + - 'is a docstring or comment; the shipped callers are the CLI (`os validate`, `os build`) and ' + - 'the `os serve` / `os migrate` host configs and `DevPlugin`, which load a stack module at ' + - 'boot, where a throw aborts before any HTTP boundary exists. The two HTTP install sites — ' + - '`POST /packages` in `packages/runtime/src/domains/packages.ts` and `protocol.installPackage` ' + - '— call `SchemaRegistry.installPackage`, which never calls `defineStack`. So the code reaches ' + - 'a reader only inside a message string, never as `error.code`. Its `status: 422` is the ' + - 'ADR-0112 envelope shape this repo\'s rejection tests assert on, not evidence of a door. If a ' + - 'door ever answers with this code itself, the verdict becomes pending-registration and it ' + - 'belongs in the ledger batch.' - }, - // ── [#15963] the six remaining `defineStack` refusals, one code each ── - // - // Same raiser, same reachability and same verdict as the - // STACK_CROSS_REFERENCE_INVALID row above, which was the ONE of seven - // `defineStack` refusal sites carrying an envelope. One row per code - // rather than one shared `STACK_VALIDATION_FAILED`: this `boot-refusal` - // class is already at one-row-per-refusal granularity (14 rows before - // these six), and the cross-reference row is an instance of it, not an - // exception. The reachability measurement was RE-TAKEN on the tree these - // landed against and is recorded once, on the STACK_SCHEMA_INVALID row; - // the five rows after it cite that reading by its numbers. - { - code: 'STACK_SCHEMA_INVALID', - file: 'packages/spec/src/stack.zod.ts', - shape: 'classfield', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0112 — the AUTHORING gate\'s SCHEMA refusal: `ObjectStackDefinitionSchema.safeParse` failed ' + - 'inside `defineStack`, thrown as `StackSchemaInvalidError` with the zod issues on `issues`. ' + - 'Its own arm rather than a reuse, on a reading taken before it was written: `packages/spec` ' + - 'has no zod-failure envelope to reuse (`formatZodError` / `safeParsePretty` return prose; no ' + - '`extends Error` there wraps a `ZodError`); the ledger\'s two zod-shaped refusals are both ' + - 'spelled `*_SCHEMA_INVALID` — `METADATA_SCHEMA_INVALID` (metadata-core\'s `SchemaValidationError`, ' + - 'the `issues`-carrying precedent; nothing in the tree assigns it a status) and ' + - '`FLOW_INPUT_SCHEMA_INVALID` (422 in `packages/runtime/src/flow-dispatch-status.ts`) — and the ' + - 'zod-shaped refusal `metadata-protocol` actually stamps at 422 is `INVALID_METADATA`; ' + - 'and the two other channels a zod failure travels on — `400 VALIDATION_ERROR` (request ' + - 'syntax) and `VALIDATION_FAILED` + `fields[]` (record validation, which ' + - '`validationFailureDetails` duck-types on `name === \'ValidationError\'`) — would each file an ' + - 'authored stack as something it is not. ⭐ MEASURED on the tree it landed against: every ' + - 'non-test occurrence of `defineStack` under `packages/runtime/src` and `packages/rest/src` ' + - '(33 of them) is a docstring, a comment or this table\'s own prose — zero call sites. The ' + - 'shipped callers are the CLI (`os validate`, `os build`) and the `os serve` / `os migrate` ' + - 'host configs and `DevPlugin`, which load a stack module at boot, where a throw aborts before ' + - 'any HTTP boundary exists; the two HTTP install sites call `SchemaRegistry.installPackage`, ' + - 'which never calls `defineStack`. So the code reaches a reader only inside a message string, ' + - 'never as `error.code`; its `status: 422` is the ADR-0112 envelope shape this repo\'s ' + - 'rejection tests assert on, not evidence of a door. If a door ever answers with this code ' + - 'itself, the verdict becomes pending-registration and it belongs in the ledger batch.' - }, - { - code: 'STACK_CAPABILITY_UNKNOWN', - file: 'packages/spec/src/stack.zod.ts', - shape: 'classfield', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0112 — `defineStack`\'s capability refusal, raised through `validateKnownCapabilities` when ' + - '`requires` names a token no runtime provides; one `issues` entry per ' + - 'distinct unknown token, thrown as `StackCapabilityUnknownError`. Reachability is the ' + - 'STACK_SCHEMA_INVALID reading on the same tree: 33 non-test `defineStack` occurrences under ' + - '`packages/runtime/src` + `packages/rest/src`, zero call sites; callers are the CLI and the ' + - 'boot-time host configs, where a throw aborts before any HTTP boundary exists. The code ' + - 'reaches a reader only inside a message string; `status: 422` is envelope shape, not a door. ' + - 'If a door ever answers with it, the verdict becomes pending-registration.' - }, - { - code: 'STACK_NAMESPACE_PREFIX_INVALID', - file: 'packages/spec/src/stack.zod.ts', - shape: 'classfield', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0112 — `defineStack`\'s namespace-prefix refusal, raised through `validateNamespacePrefix` ' + - 'when an object\'s name lacks the `manifest.namespace` prefix; one `issues` entry per object, ' + - 'the writing-style hint kept in the message only, thrown as `StackNamespacePrefixInvalidError`. ' + - 'Reachability is the STACK_SCHEMA_INVALID reading on the same tree: 33 non-test `defineStack` ' + - 'occurrences under `packages/runtime/src` + `packages/rest/src`, zero call sites; callers are ' + - 'the CLI and the boot-time host configs, where a throw aborts before any HTTP boundary exists. ' + - 'The code reaches a reader only inside a message string; `status: 422` is envelope shape, not ' + - 'a door. If a door ever answers with it, the verdict becomes pending-registration.' - }, - { - code: 'STACK_SINGLE_APP_VIOLATION', - file: 'packages/spec/src/stack.zod.ts', - shape: 'classfield', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0112 — `defineStack`\'s single-app refusal, raised through `validateSingleApp` when an `app` ' + - 'package declares more than one app (the banned "suite contains apps" shape, ADR-0019 D3); ' + - 'thrown as `StackSingleAppViolationError`. Reachability is the STACK_SCHEMA_INVALID reading on ' + - 'the same tree: 33 non-test `defineStack` occurrences under `packages/runtime/src` + ' + - '`packages/rest/src`, zero call sites; callers are the CLI and the boot-time host configs, ' + - 'where a throw aborts before any HTTP boundary exists. The code reaches a reader only inside ' + - 'a message string; `status: 422` is envelope shape, not a door. If a door ever answers with ' + - 'it, the verdict becomes pending-registration.' - }, - { - code: 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED', - file: 'packages/spec/src/stack.zod.ts', - shape: 'classfield', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0112 — `defineStack`\'s hierarchy-scope capability refusal, raised through ' + - '`validateHierarchyScopeCapability` when a permission grant uses a HIERARCHY scope while ' + - '`requires` omits `hierarchy-security` (ADR-0057 — the declared-capability class that fails ' + - 'CLOSED); one `issues` entry per grant, thrown as `StackHierarchyScopeCapabilityRequiredError`. ' + - 'Reachability is the STACK_SCHEMA_INVALID reading on the same tree: 33 non-test `defineStack` ' + - 'occurrences under `packages/runtime/src` + `packages/rest/src`, zero call sites; callers are ' + - 'the CLI and the boot-time host configs, where a throw aborts before any HTTP boundary exists. ' + - 'The code reaches a reader only inside a message string; `status: 422` is envelope shape, not ' + - 'a door. If a door ever answers with it, the verdict becomes pending-registration.' - }, - { - code: 'STACK_TRIGGER_CAPABILITY_REQUIRED', - file: 'packages/spec/src/stack.zod.ts', - shape: 'classfield', - door: 'none', - verdict: 'boot-refusal', - why: - 'ADR-0112 — `defineStack`\'s trigger capability refusal, raised through ' + - '`validateTriggerCapability` when an auto-launched flow is declared while `requires` omits ' + - '`triggers` (the declared-capability class that fails SILENT); one `issues` entry per ' + - 'flow, thrown as `StackTriggerCapabilityRequiredError`. Reachability is the ' + - 'STACK_SCHEMA_INVALID reading on the same tree: 33 non-test `defineStack` occurrences under ' + - '`packages/runtime/src` + `packages/rest/src`, zero call sites; callers are the CLI and the ' + - 'boot-time host configs, where a throw aborts before any HTTP boundary exists. The code ' + - 'reaches a reader only inside a message string; `status: 422` is envelope shape, not a door. ' + - 'If a door ever answers with it, the verdict becomes pending-registration.' - }, // ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ── // // The 29 rows below are the whole verdict cost of widening `codehelper` to diff --git a/packages/spec/src/api/error-code-ledger.test.ts b/packages/spec/src/api/error-code-ledger.test.ts index 19d0d56e4a..eccbdbd71d 100644 --- a/packages/spec/src/api/error-code-ledger.test.ts +++ b/packages/spec/src/api/error-code-ledger.test.ts @@ -231,6 +231,35 @@ describe('ErrorCode (standard ∪ registered)', () => { expect(standardSynonymOf('FLOW_INPUT_SCHEMA_INVALID')).toBeUndefined(); }); + it('accepts the #16449 batch — every code that ships in dist, door or no door (#16404)', () => { + // The #16404 ruling (director seat, decision batch #62, 2026-09-07, option + // D): the published face is this ledger, so a code that ships in `dist` is + // registered whether or not an HTTP door can answer with it. The nine + // measured unregistered on #16449's tree, each under the package that + // stamps it: the cross-package ownership refusal, the seven `defineStack` + // refusals and `PluginSchema`'s `ui` required-key issue code. + const batch: Record = { + OBJECT_OWNERSHIP_CONFLICT: '@objectstack/objectql', + PLUGIN_UI_REQUIRED_KEY_MISSING: '@objectstack/spec', + STACK_CAPABILITY_UNKNOWN: '@objectstack/spec', + STACK_CROSS_REFERENCE_INVALID: '@objectstack/spec', + STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED: '@objectstack/spec', + STACK_NAMESPACE_PREFIX_INVALID: '@objectstack/spec', + STACK_SCHEMA_INVALID: '@objectstack/spec', + STACK_SINGLE_APP_VIOLATION: '@objectstack/spec', + STACK_TRIGGER_CAPABILITY_REQUIRED: '@objectstack/spec', + }; + for (const [code, owner] of Object.entries(batch)) { + expect(ErrorCode.parse(code)).toBe(code); + expect(ERROR_CODE_LEDGER[owner], `${code} registered under ${owner}`).toContain(code); + // Domain-prefixed, none re-spells a standard member — registered plainly, no waiver. + expect(standardSynonymOf(code), `${code} needs no waiver`).toBeUndefined(); + } + // The card's ninth, `NAMESPACE_CONFLICT`, was already a row (#14748) — the + // one doored code of the batch, whose wire already carries it. + expect(ERROR_CODE_LEDGER['@objectstack/objectql']).toContain('NAMESPACE_CONFLICT'); + }); + it('rejects unregistered, lowercase, and numeric codes', () => { expect(() => ErrorCode.parse('TOTALLY_MADE_UP_CODE')).toThrow(); expect(() => ErrorCode.parse('validation_error')).toThrow(); // pre-ADR-0112 dialect diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 57350ea45e..b671a497dd 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -90,6 +90,39 @@ * registration is a recorded waiver, never drift. A code registered NOWHERE * (a tenant app's own spelling) still reaches the wire, in `declaredCode`. * + * ## Door or no door — every code that ships in `dist` is registered (#16404) + * + * Ruled by the director seat (decision batch #62, 2026-09-07, option D; + * maintainer 「同意」): **the published contract face for error codes is THIS + * ledger together with `StandardErrorCode`.** Any `code` that ships in a + * package's `dist` is registered here whether or not an HTTP door can ever + * answer with it — a thrown value's `code` is what a consumer's + * `catch (e) { switch (e.code) }` pins, and once shipped it cannot be renamed + * without breaking that consumer. Registering a code widens this face and is + * therefore a Clause-② change (`needs:contract-review`), door or no door; a + * code present in `dist` and absent here is a protocol gap, not a tier + * question. + * + * ONE shape, no second list: a `door: 'none'` code is a row like any other — + * the string under the package that stamps it, and a comment that states its + * `status` and the reachability reading ("no HTTP door on this tree; the + * thrown value is the boundary"). The dispatcher vocabulary's `boot-refusal` + * verdict (`packages/runtime/src/dispatcher-error-vocabulary.ts`) records + * that same reachability for the codes NOT yet registered, and a row here + * ratchets its vocabulary row out exactly as a `pending-registration` + * registration does. What registration changes for such a code is the face, + * not the wire: nothing demotes today, and if a door ever does answer with + * it, `error.code` carries the specific code instead of the status-derived + * member plus `declaredCode`. The `declaredCode` demotion (#9106) stays for + * genuinely unknown / third-party spellings only. + * + * `packages/spec/src/**` is held to this mechanically: + * `check:dispatcher-error-vocabulary` refuses to classify a stamp site under + * that tree as anything but `foreign-vocabulary` (a different vocabulary that + * merely spells itself `code`) or `runtime-pinned` — a `boot-refusal` or + * `pending-registration` row for a spec site is a finding + * (`spec-face-unregistered`), and the only way out is the row here. + * * A code emitted by several packages is listed once per emitting package — * the union dedupes; the per-package rows are provenance, not identity. * @@ -122,6 +155,12 @@ * best-effort catch that logs and continues. Its throw site and constant * (`MULTI_TENANT_UNSUPPORTED_CODE`, `@objectstack/driver-mongodb`) live on: * host boot matching is not wire vocabulary. + * ⚠️ That SECOND ground is superseded by #16404 ("Door or no door" above): a + * boot refusal that ships in `dist` is owed a row, so the codes left out or + * retired on the "not wire vocabulary" reasoning — the remaining + * `boot-refusal` rows of `dispatcher-error-vocabulary.ts` — are registrations + * owed under the ruling, not re-argued per card. What still retires a row is + * the FIRST ground only: no producer left anywhere in `packages/**` source. * Before deleting a row, check that no producer remains repo-wide AND * that no consumer — including `objectui` and `cloud` — reads the literal; * tests that merely CONSTRUCT the code are not producers, and a test pinned to @@ -659,6 +698,25 @@ export const ERROR_CODE_LEDGER = { // publish pre-flight); this one refuses the PREFIX itself, at install, as // already owned by someone else. 'NAMESPACE_CONFLICT', + // [#16449] ADR-0029 D3 — a package claims `own` on an object name a + // DIFFERENT package already owns, refused by `SchemaRegistry.registerObject` + // (`ObjectOwnershipConflictError`, `registry.ts`; `status: 422`; the + // remedy the message names is `extend`). Registered under the #16404 + // ruling — door or no door — and this one has NO door on this tree: every + // path to it aborts boot or is caught below any HTTP boundary + // (`ObjectQL.registerPlugin` and the metadata bridge `logger.warn`; + // metadata-protocol's `applyRegistryWriteThrough` `console.warn` and + // `loadMetaFromDb`'s per-record `errors` count — the only two non-test + // `registerObject` callers outside this package; the two HTTP install + // sites call `installPackage`, which never calls `registerObject`). So + // nothing demotes today; the row is the FACE — `e.code` on the thrown + // value is a member of the closed union a consumer pins — and should a + // door ever answer with it, `error.code` carries this code rather than + // `VALIDATION_ERROR` + `declaredCode`. Not a synonym of any standard + // member: `OWNERSHIP` is a token none carries, and the condition (two + // packages claiming one name) is neither bad input nor a missing + // precondition. + 'OBJECT_OWNERSHIP_CONFLICT', // [#11142/#11230] a by-id update carried an `options.where.id` that is not // the bound payload `data.id` — a truthy scalar naming a DIFFERENT row // (#11142), or a non-scalar predicate over a row SET (#11230, which also @@ -940,6 +998,35 @@ export const ERROR_CODE_LEDGER = { 'EXTERNAL_SCHEMA_MISMATCH', 'EXTERNAL_SCHEMA_MODE_VIOLATION', 'EXTERNAL_WRITE_FORBIDDEN', + // [#16449] The eight rows below are `door: 'none'` codes — raised at + // authoring / boot, before any HTTP boundary exists — registered under the + // #16404 ruling (door or no door; see the header). Each ships in this + // package's `dist`, so its spelling is the face a consumer's + // `catch (e) { switch (e.code) }` pins; none reaches a wire on this tree, + // so registering them changes no HTTP body. The `boot-refusal` rows they + // carried in `dispatcher-error-vocabulary.ts` ratcheted out with this + // batch — that reachability reading now lives here, one line per row. + // + // `PluginSchema`'s one conditional requirement — `type: 'ui'` owes + // `staticPath` and `slug` (#16334). Stamped by the schema's `superRefine` + // on the zod ISSUE (`params.code`, and at the head of `message`), never on + // a thrown error; `PluginLoader.validatePluginContract` re-raises the issue + // inside its `PLUGIN_CONTRACT_VIOLATION` envelope at `kernel.use()`. No + // `status` of its own — it rides that envelope's. + 'PLUGIN_UI_REQUIRED_KEY_MISSING', + // The seven `defineStack` refusals (#14552, #15963) — one code per raise + // site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the + // findings the site collected on `issues`. Raised by `os validate` / + // `os build` and the `os serve` / `os migrate` host configs, where a throw + // aborts before any HTTP boundary exists (measured: zero `defineStack` + // call sites under `packages/runtime/src` + `packages/rest/src`). + 'STACK_CAPABILITY_UNKNOWN', // `requires` names a token no runtime provides + 'STACK_CROSS_REFERENCE_INVALID', // items name objects the stack does not define (the ADR-0130 matrix, plus the duplicate-action-key / global-`update` / mapping-transform findings the same aggregate carries) + 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED', // a HIERARCHY permission scope while `requires` omits `hierarchy-security` + 'STACK_NAMESPACE_PREFIX_INVALID', // an object name lacks the `manifest.namespace` prefix + 'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally + 'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3) + 'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers` ], } as const satisfies Record; diff --git a/packages/spec/src/kernel/plugin-ui-required-keys.test.ts b/packages/spec/src/kernel/plugin-ui-required-keys.test.ts index 0fd779f498..4e81f5441c 100644 --- a/packages/spec/src/kernel/plugin-ui-required-keys.test.ts +++ b/packages/spec/src/kernel/plugin-ui-required-keys.test.ts @@ -38,6 +38,7 @@ import { PluginSchema, type PluginDefinition, } from './plugin.zod'; +import { ERROR_CODE_LEDGER, ErrorCode } from '../api/error-code-ledger.zod'; /** A `ui` plugin carrying both required keys — the calibration fixture. */ const UI_COMPLETE: PluginDefinition = { @@ -138,3 +139,13 @@ describe('C — SCOPE: only `type: \'ui\'` owes the two keys', () => { expect(NON_UI).toHaveLength(CORE_PLUGIN_TYPES.length); }); }); + +describe('D — the code is a member of the closed ADR-0112 vocabulary (#16449)', () => { + it('PLUGIN_UI_REQUIRED_KEY_MISSING parses against ErrorCode and is registered under @objectstack/spec', () => { + // The #16404 ruling: a code that ships in `dist` is the published face, + // door or no door. This one rides a zod issue and a boot refusal's + // message, never `error.code` at a door — registered all the same. + expect(ErrorCode.safeParse(PLUGIN_UI_REQUIRED_KEY_MISSING).success).toBe(true); + expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING); + }); +}); diff --git a/packages/spec/src/kernel/plugin.zod.ts b/packages/spec/src/kernel/plugin.zod.ts index 5e581efeaf..a5a4e9131f 100644 --- a/packages/spec/src/kernel/plugin.zod.ts +++ b/packages/spec/src/kernel/plugin.zod.ts @@ -146,11 +146,12 @@ export function isConsumerInstallable(type: string | undefined): boolean { * loader should stamp it onto `err.code` is the boot path's seam (#16049), * not this one. * - * Spelled the ADR-0112 way and deliberately NOT wire vocabulary: it is raised - * at authoring / `kernel.use()`, before any HTTP boundary exists — - * `door: 'none'` / `boot-refusal` in - * `packages/runtime/src/dispatcher-error-vocabulary.ts`, beside - * `PLUGIN_CONTRACT_VIOLATION`, the envelope it rides. + * Spelled the ADR-0112 way and registered in `ERROR_CODE_LEDGER` under + * `@objectstack/spec` (#16449, under the #16404 ruling: a code that ships in + * `dist` is the published face, door or no door). Not wire vocabulary in the + * door sense: it is raised at authoring / `kernel.use()`, before any HTTP + * boundary exists, and rides `PLUGIN_CONTRACT_VIOLATION`'s envelope — the + * ledger row records that reading. */ export const PLUGIN_UI_REQUIRED_KEY_MISSING = 'PLUGIN_UI_REQUIRED_KEY_MISSING'; diff --git a/packages/spec/src/stack-refusal-envelopes.test.ts b/packages/spec/src/stack-refusal-envelopes.test.ts index f94249555a..95a83a6d99 100644 --- a/packages/spec/src/stack-refusal-envelopes.test.ts +++ b/packages/spec/src/stack-refusal-envelopes.test.ts @@ -32,6 +32,7 @@ */ import { describe, it, expect } from 'vitest'; import { defineStack } from './stack.zod'; +import { ERROR_CODE_LEDGER, ErrorCode } from './api/error-code-ledger.zod'; /** The error shape every assertion below reads — the ADR-0112 envelope. */ type Envelope = Error & { code?: string; status?: number; issues?: readonly unknown[] }; @@ -239,6 +240,17 @@ describe('#15963 — every defineStack refusal carries an ADR-0112 envelope', () expect(refusal(config)?.name).not.toBe('ValidationError'); } }); + + it('every code is a member of the closed `ErrorCode` union, registered under @objectstack/spec (#16449)', () => { + // The #16404 ruling: a code that ships in `dist` is the published face, + // door or no door — so each refusal's spelling is a ledger row, and a + // consumer's `switch (e.code)` is exhaustive over the union it ships with. + for (const config of everySite) { + const code = refusal(config)?.code; + expect(ErrorCode.safeParse(code).success, `${code} parses against ErrorCode`).toBe(true); + expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain(code); + } + }); }); it('non-strict mode skips validation by contract — no envelope, no refusal', () => { diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 0d57813639..34043ef7ce 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1780,11 +1780,14 @@ function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[ * (`@objectstack/types`) duck-types a RECORD-validation failure on that `name` * and would answer `400 VALIDATION_FAILED` + `fields[]` for it. * - * ⛔ None of these is registered in the ADR-0112 ledger — no wire door raises - * them (see the first member's note). Each has its classification row in - * `packages/runtime/src/dispatcher-error-vocabulary.ts` as `door: 'none'` / - * `verdict: 'boot-refusal'`; `check:dispatcher-error-vocabulary` holds the two - * files equal in both directions. + * Every member is registered in the ADR-0112 ledger under `@objectstack/spec` + * (#16449, under the #16404 ruling: a `code` that ships in `dist` is the + * published face, door or no door — a consumer's `catch (e) { switch (e.code) }` + * pins the spelling the moment it ships). No wire door raises them — + * `defineStack` runs at authoring and boot time (see the first member's note) + * — so the registration widens `ErrorCode` without changing any HTTP body; + * `check:dispatcher-error-vocabulary` holds every `packages/spec/src` stamp + * site to that ledger. * * `issues` is HETEROGENEOUS across members, on purpose: the six semantic * refusals carry one string per finding, the schema arm carries the zod issue @@ -1841,12 +1844,12 @@ abstract class StackRefusalError extends Error { * repo's rejection pins alike — reads structurally rather than by `instanceof`. * Export it the day a consumer needs the narrowed type, as its own change. * - * ⛔ Not registered in the ADR-0112 ledger, for the same reason its two - * precedents are not: no wire door raises it. `defineStack` runs at authoring - * and boot time (`os validate`, `os build`, the `os serve` / `os migrate` host - * configs and `DevPlugin`); no HTTP domain handler calls it. The classification - * row lives in `packages/runtime/src/dispatcher-error-vocabulary.ts` as - * `door: 'none'` / `verdict: 'boot-refusal'`. + * Registered in the ADR-0112 ledger under `@objectstack/spec` since #16449 + * (the #16404 ruling; before it the code shipped unregistered — recorded as + * under-tiered on #14552 and not reopened). No wire door raises it: + * `defineStack` runs at authoring and boot time (`os validate`, `os build`, + * the `os serve` / `os migrate` host configs and `DevPlugin`); no HTTP domain + * handler calls it. The ledger row carries that reachability reading. * * Since #15963 the envelope half (`status`, `issues`) lives on * {@link StackRefusalError}, which every `defineStack` refusal now extends; diff --git a/scripts/check-dispatcher-error-vocabulary.mjs b/scripts/check-dispatcher-error-vocabulary.mjs index ba3f271ec8..27d76a68b2 100644 --- a/scripts/check-dispatcher-error-vocabulary.mjs +++ b/scripts/check-dispatcher-error-vocabulary.mjs @@ -52,6 +52,20 @@ * has since been registered fails — which is how #8846 landing ratchets this * list down instead of leaving stale rows promising work already done. * + * ## The spec face — `packages/spec/src/**` is a ledger member or a finding (#16449) + * + * The #16404 ruling (director seat, decision batch #62, 2026-09-07, option D) + * settled what "the published contract face" means for an error code: the + * ledger, door or no door — every code that ships in `dist` is registered + * there, because a thrown value's `code` is what a consumer pins. This gate is + * where that rule has teeth for the tree it was measured on: a stamp site under + * `packages/spec/src/` may be classified `foreign-vocabulary` (not an ADR-0112 + * code at all) or `runtime-pinned` (a template whose pin parses every member + * against the closed union), and NOTHING ELSE — a `boot-refusal` or + * `pending-registration` row there is a `spec-face-unregistered` finding, and + * the only way out is the ledger row (which makes the site disappear from the + * scan, as any registration does). See `SPEC_SOURCE_FACE` below. + * * ## Why textual, not AST * * The same reasoning `check-error-code-casing` records: the failure mode is a @@ -257,6 +271,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '[#14626] THE NESTED TEMPLATE, across all FOUR shared textual primitives.': 242, '[#13790] The INLINE literal EXPRESSION at an object-literal `code:`.': 40, '[#14742] THE REGEX LITERAL, across all FOUR shared textual primitives.': 39, + '[#16449] A packages/spec/src stamp site is a ledger member, never classified away.': 7, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -276,6 +291,23 @@ const LEDGER_ZOD = 'packages/spec/src/api/error-code-ledger.zod.ts'; const ERRORS_ZOD = 'packages/spec/src/api/errors.zod.ts'; const DECLARATION = 'packages/runtime/src/dispatcher-error-vocabulary.ts'; +/** + * [#16449] The SPEC FACE. `packages/spec/src/**` ships in `@objectstack/spec`'s + * `dist`, and the #16404 ruling (director seat, decision batch #62, 2026-09-07, + * option D) makes `ERROR_CODE_LEDGER` / `StandardErrorCode` the published + * contract face: every code that ships in `dist` is registered there, door or + * no door. So a stamp site under this prefix is a ledger member or it is a + * finding — the verdicts that classify a site AWAY from registration + * (`boot-refusal`, `pending-registration`, `sandbox-authored`) are refused + * there, as `spec-face-unregistered`. Two verdicts survive: `foreign-vocabulary` + * (a different vocabulary that merely spells itself `code` — a driver errno, a + * conversion outcome, a conformance fixture) and `runtime-pinned` (a template + * whose named pin parses every member against the closed union — the + * registration proof, done where a scan cannot). Pinned by `--self-test`. + */ +const SPEC_SOURCE_FACE = 'packages/spec/src/'; +const SPEC_FACE_VERDICTS = Object.freeze(new Set(['foreign-vocabulary', 'runtime-pinned'])); + // --------------------------------------------------------------------------- // The registered vocabulary — read from spec SOURCE, never from a build // --------------------------------------------------------------------------- @@ -2905,7 +2937,25 @@ export function reconcile({ sites, declared, registered, unresolved, declaredHel const siteKeys = new Set(sites.map(key)); for (const site of sites) { - if (declaredByKey.has(key(site))) continue; + const row = declaredByKey.get(key(site)); + if (row) { + // [#16449] A packages/spec/src site is a ledger member, a foreign + // vocabulary or a runtime-pinned template — no verdict may park it + // between (see SPEC_SOURCE_FACE). + if (site.file.startsWith(SPEC_SOURCE_FACE) && !SPEC_FACE_VERDICTS.has(row.verdict)) { + findings.push({ + kind: 'spec-face-unregistered', + text: + `${site.file} stamps unregistered code '${site.code}' (${site.shape}) and ${DECLARATION} ` + + `classifies it '${row.verdict}' — a verdict refused under ${SPEC_SOURCE_FACE}.\n` + + ` That tree ships in @objectstack/spec's dist, and the #16404 ruling makes ERROR_CODE_LEDGER ` + + `the published face: every code shipped in dist is registered there, door or no door. Register ` + + `'${site.code}' in ${LEDGER_ZOD} (this row then ratchets out as stale), or — only if it is not an ` + + `ADR-0112 error code at all — classify it 'foreign-vocabulary' with the evidence.`, + }); + } + continue; + } if (site.shape === 'objlittemplate') { findings.push({ kind: 'unclassified-site', @@ -5355,6 +5405,38 @@ function selfTest() { } } + // ── [#16449] The spec face: a packages/spec/src site is registered or foreign ── + battery('[#16449] A packages/spec/src stamp site is a ledger member, never classified away.'); + { + const specSite = { code: 'SPEC_ONLY_ONE', file: 'packages/spec/src/x.zod.ts', shape: 'classfield', door: 'none' }; + const otherSite = { ...specSite, file: 'packages/x/src/a.ts' }; + const rowFor = (site, verdict, extra = {}) => ({ ...site, verdict, why: 'self-test', ...extra }); + const specFace = (site, verdict, extra) => + reconcile({ sites: [site], declared: [rowFor(site, verdict, extra)], registered: new Set(), unresolved: [] }) + .filter((f) => f.kind === 'spec-face-unregistered'); + ok(specFace(specSite, 'boot-refusal').length === 1, + "a 'boot-refusal' row for a packages/spec/src site is a spec-face finding"); + ok(specFace(specSite, 'pending-registration').length === 1, + "a 'pending-registration' row for a packages/spec/src site is a spec-face finding"); + ok(specFace(specSite, 'foreign-vocabulary').length === 0, + "a 'foreign-vocabulary' row for a packages/spec/src site is admitted"); + ok(specFace(otherSite, 'boot-refusal').length === 0, + "the same 'boot-refusal' row OUTSIDE packages/spec/src is not a spec-face finding (control)"); + const tpl = { code: 'SPEC_*_FAILED', file: 'packages/spec/src/x.ts', shape: 'objlittemplate', door: 'none' }; + ok(specFace(tpl, 'runtime-pinned', { pin: 'scripts/check-dispatcher-error-vocabulary.mjs' }).length === 0, + "a 'runtime-pinned' template under packages/spec/src is admitted — its pin is the registration proof"); + const [finding] = specFace(specSite, 'boot-refusal'); + ok(Boolean(finding) && finding.text.includes('#16404') && finding.text.includes(LEDGER_ZOD), + 'the spec-face finding names the ruling and the ledger file — the remedy, not only the verdict'); + // Registration is the way out: a registered code derives no site at all. + const { sites: none } = deriveSites({ + registered: new Set(['SPEC_ONLY_ONE']), + files: [{ rel: 'packages/spec/src/x.zod.ts', source: `class E extends Error { readonly code = 'SPEC_ONLY_ONE'; }` }], + readFile: () => '', + }); + ok(none.length === 0, 'a registered code under packages/spec/src derives no site — the ledger row is the way out'); + } + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -5461,10 +5543,14 @@ function main() { findings.push(...checkDoorTyping({ doorSource, files })); const pending = declared.filter((d) => d.verdict === 'pending-registration'); + const specSites = sites.filter((s) => s.file.startsWith(SPEC_SOURCE_FACE)); const bounds = ` scope: ${files.length} non-test source files under ${SCAN_ROOT}/; ` + `${registered.size} registered codes (${ledger.size} ledger + ${standard.size} standard); ` + `${sites.length} unregistered code-stamping site(s) found; ${declared.length} classified.\n` + + ` [#16449] the spec face: ${specSites.length} stamp site(s) under ${SPEC_SOURCE_FACE}, every one ` + + `'foreign-vocabulary' or 'runtime-pinned' — any other verdict there is a finding: that tree ships in ` + + `@objectstack/spec's dist, and the #16404 ruling makes the ledger the published face, door or no door.\n` + ` door typing (#9098): ${REST_DOOR_FILE} checked for the typed author-side responder, the ` + `absence of a second \`sendError\`, and decided refusals bypassing it.\n` + ` the sandbox limb (author-thrown codes from metadata-app action code) is outside this scan ` + From f80e8f2ef606a321251935820c12cfd963c42742 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:54:19 +0000 Subject: [PATCH 2/4] docs(spec): regenerate the error-code ledger and contract references for the nine new codes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- content/docs/references/api/contract.mdx | 11 ++++- .../docs/references/api/error-code-ledger.mdx | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index d5db2646c4..c0d003a3b9 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' \| … +298 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +307 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **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) | | **message** | `string` | ✅ | Readable error message | | **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`. | @@ -244,6 +244,7 @@ const result = ApiErrorSchema.parse(data); * `OBJECT_API_DISABLED` * `OBJECT_API_METHOD_NOT_ALLOWED` * `OBJECT_OVERLAY_PACKAGE_MISMATCH` +* `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` * `OS_PROTOCOL_INCOMPATIBLE` @@ -261,6 +262,7 @@ const result = ApiErrorSchema.parse(data); * `PLUGIN_INSTALL_FAILED` * `PLUGIN_MANIFEST_INVALID` * `PLUGIN_REGISTER_FAILED` +* `PLUGIN_UI_REQUIRED_KEY_MISSING` * `PROJECT_MEMBERSHIP_REQUIRED` * `PROJECT_NOT_FOUND` * `PROJECT_PROVISIONING` @@ -315,6 +317,13 @@ const result = ApiErrorSchema.parse(data); * `SQL_DIALECT_EMISSION_UNSUPPORTED` * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` +* `STACK_CAPABILITY_UNKNOWN` +* `STACK_CROSS_REFERENCE_INVALID` +* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` +* `STACK_NAMESPACE_PREFIX_INVALID` +* `STACK_SCHEMA_INVALID` +* `STACK_SINGLE_APP_VIOLATION` +* `STACK_TRIGGER_CAPABILITY_REQUIRED` * `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 2cd6bcc965..9f5167cc53 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -94,6 +94,39 @@ is registered here — and if it merely re-spells a standard member, that registration is a recorded waiver, never drift. A code registered NOWHERE (a tenant app's own spelling) still reaches the wire, in `declaredCode`. +## Door or no door — every code that ships in `dist` is registered (#16404) + +Ruled by the director seat (decision batch #62, 2026-09-07, option D; +maintainer 「同意」): **the published contract face for error codes is THIS +ledger together with `StandardErrorCode`.** Any `code` that ships in a +package's `dist` is registered here whether or not an HTTP door can ever +answer with it — a thrown value's `code` is what a consumer's +`catch (e) { switch (e.code) }` pins, and once shipped it cannot be renamed +without breaking that consumer. Registering a code widens this face and is +therefore a Clause-② change (`needs:contract-review`), door or no door; a +code present in `dist` and absent here is a protocol gap, not a tier +question. + +ONE shape, no second list: a `door: 'none'` code is a row like any other — +the string under the package that stamps it, and a comment that states its +`status` and the reachability reading ("no HTTP door on this tree; the +thrown value is the boundary"). The dispatcher vocabulary's `boot-refusal` +verdict (`packages/runtime/src/dispatcher-error-vocabulary.ts`) records +that same reachability for the codes NOT yet registered, and a row here +ratchets its vocabulary row out exactly as a `pending-registration` +registration does. What registration changes for such a code is the face, +not the wire: nothing demotes today, and if a door ever does answer with +it, `error.code` carries the specific code instead of the status-derived +member plus `declaredCode`. The `declaredCode` demotion (#9106) stays for +genuinely unknown / third-party spellings only. + +`packages/spec/src/**` is held to this mechanically: +`check:dispatcher-error-vocabulary` refuses to classify a stamp site under +that tree as anything but `foreign-vocabulary` (a different vocabulary that +merely spells itself `code`) or `runtime-pinned` — a `boot-refusal` or +`pending-registration` row for a spec site is a finding +(`spec-face-unregistered`), and the only way out is the row here. + A code emitted by several packages is listed once per emitting package — the union dedupes; the per-package rows are provenance, not identity. @@ -126,6 +159,12 @@ aborts, and the one request-reachable trigger sits inside a documented best-effort catch that logs and continues. Its throw site and constant (`MULTI_TENANT_UNSUPPORTED_CODE`, `@objectstack/driver-mongodb`) live on: host boot matching is not wire vocabulary. +⚠️ That SECOND ground is superseded by #16404 ("Door or no door" above): a +boot refusal that ships in `dist` is owed a row, so the codes left out or +retired on the "not wire vocabulary" reasoning — the remaining +`boot-refusal` rows of `dispatcher-error-vocabulary.ts` — are registrations +owed under the ruling, not re-argued per card. What still retires a row is +the FIRST ground only: no producer left anywhere in `packages/**` source. Before deleting a row, check that no producer remains repo-wide AND that no consumer — including `objectui` and `cloud` — reads the literal; tests that merely CONSTRUCT the code are not producers, and a test pinned to @@ -360,6 +399,7 @@ const result = ErrorCode.parse(data); * `OBJECT_API_DISABLED` * `OBJECT_API_METHOD_NOT_ALLOWED` * `OBJECT_OVERLAY_PACKAGE_MISMATCH` +* `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` * `OS_PROTOCOL_INCOMPATIBLE` @@ -377,6 +417,7 @@ const result = ErrorCode.parse(data); * `PLUGIN_INSTALL_FAILED` * `PLUGIN_MANIFEST_INVALID` * `PLUGIN_REGISTER_FAILED` +* `PLUGIN_UI_REQUIRED_KEY_MISSING` * `PROJECT_MEMBERSHIP_REQUIRED` * `PROJECT_NOT_FOUND` * `PROJECT_PROVISIONING` @@ -431,6 +472,13 @@ const result = ErrorCode.parse(data); * `SQL_DIALECT_EMISSION_UNSUPPORTED` * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` +* `STACK_CAPABILITY_UNKNOWN` +* `STACK_CROSS_REFERENCE_INVALID` +* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` +* `STACK_NAMESPACE_PREFIX_INVALID` +* `STACK_SCHEMA_INVALID` +* `STACK_SINGLE_APP_VIOLATION` +* `STACK_TRIGGER_CAPABILITY_REQUIRED` * `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` From c35fbe58f156e36ffd84a1d0ff485fe3198e63c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 15:26:57 +0000 Subject: [PATCH 3/4] 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 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .changeset/register-shipped-error-codes.md | 2 +- packages/runtime/src/dispatcher-error-vocabulary.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/register-shipped-error-codes.md b/.changeset/register-shipped-error-codes.md index 3a47906852..26b6d8e365 100644 --- a/.changeset/register-shipped-error-codes.md +++ b/.changeset/register-shipped-error-codes.md @@ -14,7 +14,7 @@ Under the #16404 ruling (director seat, decision batch #62, 2026-09-07, option D 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. -**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 JSON schema and docs references carry them, 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. +**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. **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. diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 0bf8fdb6bf..cf4f742811 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -303,7 +303,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ // (`@objectstack/spec`) — were registered and ratcheted out; the // reachability each row recorded now lives on its ledger row. The // `boot-refusal` rows still below are the same class and owe the same - // registration (FOLLOWUP_CARD); `packages/spec/src/**` can no longer carry + // registration (#16649); `packages/spec/src/**` can no longer carry // one at all — `check:dispatcher-error-vocabulary` refuses any verdict // there but `foreign-vocabulary` / `runtime-pinned`. ── From db0ad84790f2ce94d62b3858c9f3572a54e22fe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 17:22:56 +0000 Subject: [PATCH 4/4] docs(spec): regenerate the ledger and contract references on the merged tree Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index c0d003a3b9..d8ce065810 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' \| … +307 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **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) | | **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) | | **message** | `string` | ✅ | Readable error message | | **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`. | @@ -93,6 +93,7 @@ const result = ApiErrorSchema.parse(data); * `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` +* `ANALYTICS_DATE_RANGE_UNRECOGNIZED` * `ANALYTICS_QUERY_FAILED` * `APPROVAL_ACTIONS_FAILED` * `APPROVAL_APPROVE_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 9f5167cc53..5a33e412ce 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -248,6 +248,7 @@ const result = ErrorCode.parse(data); * `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` +* `ANALYTICS_DATE_RANGE_UNRECOGNIZED` * `ANALYTICS_QUERY_FAILED` * `APPROVAL_ACTIONS_FAILED` * `APPROVAL_APPROVE_FAILED`