Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -965,6 +965,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