Skip to content
28 changes: 28 additions & 0 deletions .changeset/stack-refusal-envelopes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/spec": minor
"@objectstack/runtime": minor
---

fix(spec): every `defineStack` refusal carries an ADR-0112 envelope — six new `STACK_*` codes beside `STACK_CROSS_REFERENCE_INVALID` (#15963)

`defineStack` has seven refusal sites. After #14552 one of them — the cross-reference refusal — carried `code` / `status`; the other six still threw `new Error(message)` with both `undefined`. A consumer that had learned to branch on `error.code` from the cross-reference refusal read `undefined` from its six neighbours, which reads as "not a validation refusal" rather than "a refusal with no code yet" — the silent-tolerance shape ADR-0112's envelope exists to remove. Every site now throws an envelope, `status: 422`, one code per refusal, the findings the site collected on `issues`:

| Refusal (header text, unchanged) | Raiser | `code` |
|---|---|---|
| `defineStack validation failed` | `ObjectStackDefinitionSchema.safeParse` | `STACK_SCHEMA_INVALID` |
| `defineStack capability validation failed` | `validateKnownCapabilities` | `STACK_CAPABILITY_UNKNOWN` |
| `defineStack cross-reference validation failed` | `validateCrossReferences` | `STACK_CROSS_REFERENCE_INVALID` (#14552, unchanged) |
| `defineStack namespace-prefix validation failed` | `validateNamespacePrefix` | `STACK_NAMESPACE_PREFIX_INVALID` |
| `defineStack single-app validation failed` | `validateSingleApp` | `STACK_SINGLE_APP_VIOLATION` |
| `defineStack hierarchy-scope capability validation failed` | `validateHierarchyScopeCapability` | `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` |
| `defineStack trigger capability validation failed` | `validateTriggerCapability` | `STACK_TRIGGER_CAPABILITY_REQUIRED` |

Message text is byte-for-byte unchanged at every site — this adds the machine-readable half, it does not reword a sentence; the message pins across the tree still read the prose they always did. One code per site rather than one shared `STACK_VALIDATION_FAILED`: the dispatcher vocabulary's `boot-refusal` class was already at one-row-per-refusal granularity (14 rows), and `STACK_CROSS_REFERENCE_INVALID` is an instance of that granularity, not an exception to it.

The schema arm was judged separately rather than copied from the five semantic cross-checks, because it is an aggregate of zod issues against the schema the stack declares, not a rule evaluated on a parsed stack. The reading: `@objectstack/spec` has no zod-failure envelope to reuse (`formatZodError` / `safeParsePretty` return prose); the two zod-shaped refusals the ledger already carries 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` (answered 422 by the runtime's flow-dispatch table) — and the zod-shaped refusal `metadata-protocol` actually stamps at 422 is `INVALID_METADATA`; the two other channels a zod failure travels on — `400 VALIDATION_ERROR` (request syntax) and `VALIDATION_FAILED` + `fields[]` (record validation, duck-typed on `name === 'ValidationError'`) — would each file an authored stack as something it is not. So it is its own code at 422, and its `issues` carries the zod issues structurally (path, code, message per entry) rather than the formatted lines the message already renders. `issues` is therefore heterogeneous across the seven: strings for the six semantic refusals, zod issue objects for the schema arm; a reader branches on `code` first.

Not narrowed: `defineStack` accepts and refuses exactly the inputs it did before, and no export changes — the error classes stay module-local, as `StackCrossReferenceError` did, because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112 contract is the `code` / `status` pair read structurally. None of the six is registered in `ERROR_CODE_LEDGER`, for the reason the precedent was not: no wire door raises them — `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it (re-measured: every non-test `defineStack` occurrence under `packages/runtime/src` and `packages/rest/src` is a docstring, a comment or the vocabulary table's own prose).

**Why `minor`, not the `patch` the #14552 precedent took.** Six new `STACK_*` spellings ship in `packages/spec/dist/index.js` and `dist/index.mjs`; nothing reaches `.d.ts`, but once shipped a consumer's `catch (e) { switch (e.code) … }` depends on them and they cannot be renamed without breaking it. That is a purely additive widening of a published package's public surface, and the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) requires at least `minor` for it: the commit type may raise a bump but never lower it below what the act requires, so a `fix(` that widens the surface is `minor`. The `patch` precedent is pre-rule; this level is required by that ruling, not chosen by taste.

`@objectstack/runtime` carries one classification row per new code in the dispatcher error-code vocabulary (`door: 'none'`, `verdict: 'boot-refusal'` — the measured verdict), which `pnpm check:dispatcher-error-vocabulary` enforces in both directions.
123 changes: 123 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,129 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'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
Expand Down
Loading
Loading