From 48e462172fe90a0f7ad0323f8f1ef8261250b5ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 14:05:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(spec):=20every=20defineStack=20refusal?= =?UTF-8?q?=20carries=20an=20ADR-0112=20envelope=20=E2=80=94=20six=20STACK?= =?UTF-8?q?=5F*=20codes=20beside=20STACK=5FCROSS=5FREFERENCE=5FINVALID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six remaining bare-Error refusal sites in defineStack (schema parse, capability, namespace-prefix, single-app, hierarchy-scope capability, trigger capability) now throw module-local envelope classes sharing a StackRefusalError base: status 422, one code per site, findings on issues. Message text is byte-for-byte unchanged at every site. The schema arm is its own code (STACK_SCHEMA_INVALID) on a reading taken before writing it: spec has no zod-failure envelope to reuse, the ledger's two zod-shaped refusals are both *_SCHEMA_INVALID at 422, and the request- syntax (VALIDATION_ERROR) and record-validation (VALIDATION_FAILED, duck- typed on name === 'ValidationError') channels would each mis-file an authored stack. One classification row per new code in the runtime dispatcher error-code vocabulary (door none, verdict boot-refusal), with the reachability measurement re-taken on this tree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .changeset/stack-refusal-envelopes.md | 26 ++ .../src/dispatcher-error-vocabulary.ts | 120 +++++++++ .../spec/src/stack-refusal-envelopes.test.ts | 247 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 192 +++++++++++++- 4 files changed, 572 insertions(+), 13 deletions(-) create mode 100644 .changeset/stack-refusal-envelopes.md create mode 100644 packages/spec/src/stack-refusal-envelopes.test.ts diff --git a/.changeset/stack-refusal-envelopes.md b/.changeset/stack-refusal-envelopes.md new file mode 100644 index 0000000000..40035fdf28 --- /dev/null +++ b/.changeset/stack-refusal-envelopes.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": patch +"@objectstack/runtime": patch +--- + +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` at 422 (`METADATA_SCHEMA_INVALID`, `FLOW_INPUT_SCHEMA_INVALID`); and 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, and its `issues` carries the zod issues structurally (path, code, message per entry) rather than the formatted lines the message already renders. + +Not narrowed, not widened: `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). + +`@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. diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 0d3038f5e6..ed927db569 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -940,6 +940,126 @@ 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: + '#15963 — 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` at 422 (`METADATA_SCHEMA_INVALID`, `FLOW_INPUT_SCHEMA_INVALID`); ' + + '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: + '#15963 — `defineStack`\'s capability refusal, raised through `validateKnownCapabilities` when ' + + '`requires` names a token no runtime provides (framework#3265/#3308); 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: + '#15963 — `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: + '#15963 — `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: + '#15963 — `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: + '#15963 — `defineStack`\'s trigger capability refusal, raised through ' + + '`validateTriggerCapability` when an auto-launched flow is declared while `requires` omits ' + + '`triggers` (#14153 — 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/stack-refusal-envelopes.test.ts b/packages/spec/src/stack-refusal-envelopes.test.ts new file mode 100644 index 0000000000..f94249555a --- /dev/null +++ b/packages/spec/src/stack-refusal-envelopes.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every refusal `defineStack` raises carries an ADR-0112 envelope (#15963). + * + * ## What was wrong + * + * `defineStack` has seven refusal sites. After #14552 one of them — the + * cross-reference refusal, pinned in `stack-cross-reference-envelope.test.ts` + * — carried `code` / `status`; the other six threw `new Error(message)` with + * both `undefined`. A consumer that had learned to branch on `error.code` + * therefore read `undefined` from six of the seven, which reads as "not a + * validation refusal" rather than "a refusal with no code yet" — exactly the + * silent-tolerance shape the envelope exists to remove. + * + * ## What is pinned + * + * Per site: the ENVELOPE (`code`, `status: 422`); the message header + * byte-for-byte (this change adds fields, it rewords nothing — the pins in + * `stack.test.ts` and `stack-requires.test.ts` read that prose); `issues` + * carrying one entry per finding; and the CONTROL — the same fixture with the + * one offending detail removed is accepted, so a refusal cannot satisfy the + * assertions for the wrong reason. Then a census over all seven sites: seven + * distinct `STACK_*` codes, every `status` 422, and no `name` spelled + * `ValidationError`, which `validationFailureDetails` (`@objectstack/types`) + * would duck-type as a RECORD-validation failure and answer as + * `400 VALIDATION_FAILED` + `fields[]`. + * + * The schema arm is asserted on its own: its `issues` are the zod issues + * themselves (path + message per entry), not formatted lines, and their count + * is the count the header states. + */ +import { describe, it, expect } from 'vitest'; +import { defineStack } from './stack.zod'; + +/** The error shape every assertion below reads — the ADR-0112 envelope. */ +type Envelope = Error & { code?: string; status?: number; issues?: readonly unknown[] }; + +/** The thrown value, or `null` when the stack is accepted. */ +function refusal(config: unknown, options?: { strict?: boolean }): Envelope | null { + try { + defineStack(config as never, options); + return null; + } catch (e) { + return e as Envelope; + } +} + +const manifest = { + id: 'com.example.refusalenvelopes', + name: 'refusal-envelopes-test', + version: '1.0.0', + type: 'app' as const, + namespace: 'probe', +}; + +/** The stack's ONE declared object, correctly prefixed for `manifest.namespace`. */ +const task = { + name: 'probe_task', + label: 'Task', + fields: { title: { type: 'text' as const, label: 'Title' } }, +}; + +const app = (name: string) => ({ + name, + label: name, + navigation: [{ id: `nav_${name}`, type: 'object' as const, label: 'Tasks', objectName: task.name }], +}); + +/** A `record_change` flow — auto-launched, so it owes `requires: ['triggers']`. */ +const recordFlow = { + name: 'task_fanout', + label: 'task_fanout', + type: 'record_change', + nodes: [ + { + id: 'start', + type: 'start', + label: 'start', + config: { objectName: task.name, triggerType: 'record-after-create' }, + }, + { id: 'end', type: 'end', label: 'end' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; + +/** + * One row per semantic refusal site. `refused` and `accepted` differ by the one + * detail the site checks; `finding` is a verbatim fragment of the one `issues` + * entry the refusal must carry. + */ +const rows: Array<{ + site: string; + code: string; + header: string; + refused: Record; + accepted: Record; + finding: string; +}> = [ + { + site: 'capability — `requires` names a token no runtime provides', + code: 'STACK_CAPABILITY_UNKNOWN', + header: 'defineStack capability validation failed', + refused: { manifest, objects: [task], requires: ['automations'] }, + accepted: { manifest, objects: [task], requires: ['automation'] }, + finding: "'automations' is not a known platform capability", + }, + { + site: 'namespace-prefix — an object name lacks the manifest.namespace prefix', + code: 'STACK_NAMESPACE_PREFIX_INVALID', + header: 'defineStack namespace-prefix validation failed', + refused: { manifest, objects: [{ ...task, name: 'task' }] }, + accepted: { manifest, objects: [task] }, + finding: "Rename it to 'probe_task'", + }, + { + site: 'single-app — an app package declares more than one app', + code: 'STACK_SINGLE_APP_VIOLATION', + header: 'defineStack single-app validation failed', + refused: { manifest, objects: [task], apps: [app('app_one'), app('app_two')] }, + accepted: { manifest, objects: [task], apps: [app('app_one')] }, + finding: 'at most one app', + }, + { + site: 'hierarchy-scope capability — a HIERARCHY scope without hierarchy-security', + code: 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED', + header: 'defineStack hierarchy-scope capability validation failed', + refused: { + manifest, + objects: [task], + permissions: [ + { name: 'managers', label: 'Managers', objects: { [task.name]: { allowRead: true, readScope: 'unit_and_below' } } }, + ], + }, + accepted: { + manifest, + objects: [task], + requires: ['hierarchy-security'], + permissions: [ + { name: 'managers', label: 'Managers', objects: { [task.name]: { allowRead: true, readScope: 'unit_and_below' } } }, + ], + }, + finding: "uses readScope='unit_and_below', a HIERARCHY scope", + }, + { + site: 'trigger capability — an auto-launched flow without triggers', + code: 'STACK_TRIGGER_CAPABILITY_REQUIRED', + header: 'defineStack trigger capability validation failed', + refused: { manifest, objects: [task], requires: ['automation'], flows: [recordFlow] }, + accepted: { manifest, objects: [task], requires: ['automation', 'triggers'], flows: [recordFlow] }, + finding: "flow 'task_fanout' declares a 'record_change' trigger", + }, +]; + +describe('#15963 — every defineStack refusal carries an ADR-0112 envelope', () => { + for (const row of rows) { + describe(row.site, () => { + it(`refuses with code ${row.code} and status 422`, () => { + const refused = refusal(row.refused); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe(row.code); + expect(refused?.status).toBe(422); + }); + + it('keeps the message header byte-for-byte, with the issue count', () => { + const refused = refusal(row.refused); + expect(refused?.message).toMatch(new RegExp(`^${row.header} \\(1 issue\\):`)); + expect(refused?.message).toContain(row.finding); + }); + + it('carries the finding in `issues`, one entry per finding', () => { + const refused = refusal(row.refused); + expect(refused?.issues).toHaveLength(1); + expect(refused?.issues?.[0]).toContain(row.finding); + }); + + it('the same stack without the one offending detail is ACCEPTED — the control', () => { + expect(refusal(row.accepted)).toBeNull(); + }); + }); + } + + describe('namespace-prefix — the hint is message-only', () => { + it('the writing-style hint stays in the message and out of `issues`', () => { + const refused = refusal(rows[1].refused); + const hint = 'Every object.name must be'; + expect(refused?.message).toContain(hint); + expect(refused?.issues?.[0]).not.toContain(hint); + }); + }); + + describe('schema — the zod parse itself failed (judged separately, see StackSchemaInvalidError)', () => { + const refused = { manifest: {} }; + const accepted = { manifest, objects: [task] }; + + it('refuses with code STACK_SCHEMA_INVALID and status 422', () => { + const envelope = refusal(refused); + expect(envelope).toBeInstanceOf(Error); + expect(envelope?.code).toBe('STACK_SCHEMA_INVALID'); + expect(envelope?.status).toBe(422); + }); + + it('keeps the message header byte-for-byte and carries the zod issues STRUCTURALLY, count matching the header', () => { + const envelope = refusal(refused); + const match = /^defineStack validation failed \((\d+) issues?\):/.exec(envelope?.message ?? ''); + expect(match).not.toBeNull(); + const count = Number(match?.[1]); + expect(count).toBeGreaterThan(0); + expect(envelope?.issues).toHaveLength(count); + for (const issue of envelope?.issues ?? []) { + const zodIssue = issue as { path?: unknown; message?: unknown }; + expect(Array.isArray(zodIssue.path)).toBe(true); + expect(typeof zodIssue.message).toBe('string'); + } + }); + + it('a stack that parses is ACCEPTED — the control', () => { + expect(refusal(accepted)).toBeNull(); + }); + }); + + describe('census over all seven refusal sites', () => { + const crossReference = { manifest, objects: [task], data: [{ object: 'missing_object', records: [] }] }; + const everySite = [...rows.map((r) => r.refused), { manifest: {} }, crossReference]; + + it('seven sites, seven distinct STACK_* codes, every status 422', () => { + const envelopes = everySite.map((config) => refusal(config)); + for (const envelope of envelopes) { + expect(envelope).toBeInstanceOf(Error); + expect(envelope?.code).toMatch(/^STACK_[A-Z_]+$/); + expect(envelope?.status).toBe(422); + } + expect(new Set(envelopes.map((e) => e?.code)).size).toBe(7); + expect(envelopes.map((e) => e?.code)).toContain('STACK_CROSS_REFERENCE_INVALID'); + }); + + it('no site is named `ValidationError` — the record-validation duck-type in @objectstack/types', () => { + for (const config of everySite) { + expect(refusal(config)?.name).not.toBe('ValidationError'); + } + }); + }); + + it('non-strict mode skips validation by contract — no envelope, no refusal', () => { + expect(refusal({ requires: ['automations'] }, { strict: false })).toBeNull(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 159aa311ac..8d26555e0e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1745,6 +1745,51 @@ function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[ return errors; } +/** + * [ADR-0112 · #15963] The envelope every refusal `defineStack` raises shares: + * `status: 422` — an unprocessable authored entity, not a server fault, the + * reading {@link StackCrossReferenceError} took from its two precedents — ONE + * `code` per refusal site, and the findings that site collected in `issues`, + * one entry per finding. Until #15963 only the cross-reference refusal carried + * the envelope; its six siblings a few lines apart threw `new Error(message)` + * with `code` and `status` both `undefined`, so a consumer that had learned to + * branch on `error.code` read `undefined` from six of the seven and could not + * tell "not a refusal" from "a refusal with no code yet". + * + * ⭐ One code per site, never a shared `STACK_VALIDATION_FAILED` catch-all: + * the ledger's `boot-refusal` class is already at one-row-per-refusal + * granularity (14 rows on the tree this landed against), and + * `STACK_CROSS_REFERENCE_INVALID` is an instance of that granularity, not an + * exception to it. Each member's `code` is spelled `STACK__` + * in the ledger's own suffix vocabulary. + * + * ⛔ Module-local, like its first member: `packages/spec/src/index.ts` + * re-exports this module with `export *`, and the ADR-0112 contract is the + * `code` / `status` pair, read structurally by every consumer. + * + * ⛔ Never name a member `ValidationError`: `validationFailureDetails` + * (`@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. + */ +abstract class StackRefusalError extends Error { + abstract readonly code: string; + readonly status = 422; + /** One entry per finding, in the order the validator collected them. */ + readonly issues: readonly TIssue[]; + + protected constructor(name: string, message: string, issues: readonly TIssue[]) { + super(message); + this.name = name; + this.issues = issues; + } +} + /** * [ADR-0112] The cross-reference refusal `defineStack` raises when a stack's * items name objects the stack does not define — carried as an envelope @@ -1788,17 +1833,131 @@ function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[ * 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'`. + * + * Since #15963 the envelope half (`status`, `issues`) lives on + * {@link StackRefusalError}, which every `defineStack` refusal now extends; + * this class keeps its `code`, its `name` and its message byte-for-byte. */ -class StackCrossReferenceError extends Error { +class StackCrossReferenceError extends StackRefusalError { readonly code = 'STACK_CROSS_REFERENCE_INVALID'; - readonly status = 422; - /** One entry per finding, in the order `validateCrossReferences` collected them. */ - readonly issues: readonly string[]; constructor(message: string, issues: readonly string[]) { - super(message); - this.name = 'StackCrossReferenceError'; - this.issues = issues; + super('StackCrossReferenceError', message, issues); + } +} + +/** + * [ADR-0112 · #15963] The SCHEMA refusal — `ObjectStackDefinitionSchema.safeParse` + * itself failed. ⭐ Judged on its own rather than copied from the five semantic + * cross-checks below, because it is a different kind of failure: an aggregate + * of zod issues against the schema the stack declares, not a rule evaluated on + * an already-parsed stack. The reading that settled its shape, taken on the + * tree this landed against: + * + * - `packages/spec` has NO zod-failure envelope to reuse. `formatZodError` / + * `safeParsePretty` are prose formatters (they return a string); every + * `extends Error` in this package is a domain refusal and none wraps a + * `ZodError`. So "reuse spec's existing zod-failure channel" names a + * channel that does not exist — the only existing channel is the message. + * - The two zod-shaped refusals the ADR-0112 ledger DOES carry are both spelled + * `*_SCHEMA_INVALID` and both answer 422: `METADATA_SCHEMA_INVALID` + * (`SchemaValidationError` in `@objectstack/metadata-core` — "a put's spec + * fails Zod validation against the canonical schema", carrying the zod + * `issues` structurally) and `FLOW_INPUT_SCHEMA_INVALID` ("a node's config + * contradicts the schema the definition itself declares"). An authored stack + * failing its own schema is that class exactly. + * - The other two channels a zod failure travels on in this repo are both the + * wrong vocabulary here. `400 VALIDATION_ERROR` is the REQUEST-syntax bucket + * (`packages/rest` answers a malformed body with it, passing raw zod issue + * codes through as field errors — ADR-0112 D6); `defineStack` serves no + * request. `VALIDATION_FAILED` + `fields[]` is objectql's RECORD-validation + * vocabulary, and `validationFailureDetails` (`@objectstack/types`) + * recognises it by duck-typing on `code` OR `name === 'ValidationError'` — + * reusing it would make "this stack is mis-authored" indistinguishable from + * "this record write was refused" to every reader of the dispatcher's + * envelope, the exact conflation an `error.code` exists to prevent. + * + * So: its own arm, `STACK_SCHEMA_INVALID`, `status: 422` like every other + * `defineStack` refusal, and `issues` carrying the zod issues STRUCTURALLY + * (path + code + message per entry — what `SchemaValidationError.issues` + * carries and what `zodIssuesToFields` consumes) rather than the formatted + * lines: the message already renders those, and the envelope's job is the + * machine-readable half. The issue COUNT the header states is `issues.length` + * either way. The message text is byte-for-byte what the bare `Error` carried. + */ +class StackSchemaInvalidError extends StackRefusalError { + readonly code = 'STACK_SCHEMA_INVALID'; + + constructor(message: string, issues: readonly z.core.$ZodIssue[]) { + super('StackSchemaInvalidError', message, issues); + } +} + +/** + * [ADR-0112 · #15963] `requires` names a token no runtime provides — + * {@link validateKnownCapabilities}. One entry per DISTINCT unknown token, in + * declaration order. + */ +class StackCapabilityUnknownError extends StackRefusalError { + readonly code = 'STACK_CAPABILITY_UNKNOWN'; + + constructor(message: string, issues: readonly string[]) { + super('StackCapabilityUnknownError', message, issues); + } +} + +/** + * [ADR-0112 · #15963] An object's name lacks the `manifest.namespace` prefix — + * {@link validateNamespacePrefix}. The writing-style hint the message appends + * stays in the message only; `issues` carries the per-object findings. + */ +class StackNamespacePrefixInvalidError extends StackRefusalError { + readonly code = 'STACK_NAMESPACE_PREFIX_INVALID'; + + constructor(message: string, issues: readonly string[]) { + super('StackNamespacePrefixInvalidError', message, issues); + } +} + +/** + * [ADR-0112 · #15963] An `app` package declares more than one app — the banned + * "suite contains apps" shape, ADR-0019 D3 — {@link validateSingleApp}. + * Spelled `_VIOLATION` like the ledger's other rule-violation refusals + * (`UNIQUE_VIOLATION`, `EXTERNAL_SCHEMA_MODE_VIOLATION`). + */ +class StackSingleAppViolationError extends StackRefusalError { + readonly code = 'STACK_SINGLE_APP_VIOLATION'; + + constructor(message: string, issues: readonly string[]) { + super('StackSingleAppViolationError', message, issues); + } +} + +/** + * [ADR-0112 · #15963] A permission grant uses a HIERARCHY scope while `requires` + * omits `hierarchy-security` — {@link validateHierarchyScopeCapability}, the + * declared-capability class that fails CLOSED. Spelled `_REQUIRED` like the + * ledger's other "a declaration is owed and absent" refusals + * (`TENANT_SCOPE_REQUIRED`, `WRITABLE_PACKAGE_REQUIRED`). + */ +class StackHierarchyScopeCapabilityRequiredError extends StackRefusalError { + readonly code = 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED'; + + constructor(message: string, issues: readonly string[]) { + super('StackHierarchyScopeCapabilityRequiredError', message, issues); + } +} + +/** + * [ADR-0112 · #15963] An auto-launched flow is declared while `requires` omits + * `triggers` — {@link validateTriggerCapability}, the declared-capability class + * that fails SILENT. Same `_REQUIRED` spelling as its hierarchy sibling. + */ +class StackTriggerCapabilityRequiredError extends StackRefusalError { + readonly code = 'STACK_TRIGGER_CAPABILITY_REQUIRED'; + + constructor(message: string, issues: readonly string[]) { + super('StackTriggerCapabilityRequiredError', message, issues); } } @@ -2550,7 +2709,13 @@ export function defineStack( }); if (!result.success) { - throw new Error(formatZodError(result.error, 'defineStack validation failed')); + // [ADR-0112 · #15963] The message is byte-for-byte what the bare `Error` + // carried; the zod issues ride `issues` structurally. See + // {@link StackSchemaInvalidError} for why this arm is its own code. + throw new StackSchemaInvalidError( + formatZodError(result.error, 'defineStack validation failed'), + result.error.issues, + ); } // REJECT any unknown capability token (framework#3265/#3308): no runtime @@ -2563,7 +2728,7 @@ export function defineStack( if (capErrors.length > 0) { const header = `defineStack capability validation failed (${capErrors.length} issue${capErrors.length === 1 ? '' : 's'}):`; const lines = capErrors.map((e) => ` ✗ ${e}`); - throw new Error(`${header}\n\n${lines.join('\n')}`); + throw new StackCapabilityUnknownError(`${header}\n\n${lines.join('\n')}`, capErrors); } const crossRefErrors = validateCrossReferences(data); @@ -2581,28 +2746,29 @@ export function defineStack( const header = `defineStack namespace-prefix validation failed (${nsErrors.length} issue${nsErrors.length === 1 ? '' : 's'}):`; const lines = nsErrors.map((e) => ` ✗ ${e}`); const hint = `\n\nEvery object.name must be \`\${manifest.namespace}_\${shortName}\`. This is the only supported writing style — the platform does not provide ns() helpers or factory wrappers.`; - throw new Error(`${header}\n\n${lines.join('\n')}${hint}`); + // The hint stays in the message only; `issues` is the per-object findings. + throw new StackNamespacePrefixInvalidError(`${header}\n\n${lines.join('\n')}${hint}`, nsErrors); } const appErrors = validateSingleApp(data); if (appErrors.length > 0) { const header = `defineStack single-app validation failed (${appErrors.length} issue${appErrors.length === 1 ? '' : 's'}):`; const lines = appErrors.map((e) => ` ✗ ${e}`); - throw new Error(`${header}\n\n${lines.join('\n')}`); + throw new StackSingleAppViolationError(`${header}\n\n${lines.join('\n')}`, appErrors); } const hierErrors = validateHierarchyScopeCapability(data); if (hierErrors.length > 0) { const header = `defineStack hierarchy-scope capability validation failed (${hierErrors.length} issue${hierErrors.length === 1 ? '' : 's'}):`; const lines = hierErrors.map((e) => ` ✗ ${e}`); - throw new Error(`${header}\n\n${lines.join('\n')}`); + throw new StackHierarchyScopeCapabilityRequiredError(`${header}\n\n${lines.join('\n')}`, hierErrors); } const triggerErrors = validateTriggerCapability(data); if (triggerErrors.length > 0) { const header = `defineStack trigger capability validation failed (${triggerErrors.length} issue${triggerErrors.length === 1 ? '' : 's'}):`; const lines = triggerErrors.map((e) => ` ✗ ${e}`); - throw new Error(`${header}\n\n${lines.join('\n')}`); + throw new StackTriggerCapabilityRequiredError(`${header}\n\n${lines.join('\n')}`, triggerErrors); } return mergeActionsIntoObjects(data); From 20556ee3afdd3f729c43746c1c1911dd564b863e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 14:30:51 +0000 Subject: [PATCH 2/3] fix(runtime): strip tracker ids from the six new vocabulary rows' string prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:doc-authoring refuses an issue id inside sibling-package string prose (a runtime string reaches authors who cannot resolve #NNNN); the ADR anchor stays, the tracker ids move out of the strings. The comment header above the rows keeps its id — comments are the reader who can. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../runtime/src/dispatcher-error-vocabulary.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index ed927db569..0280e3436d 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -958,7 +958,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ door: 'none', verdict: 'boot-refusal', why: - '#15963 — the AUTHORING gate\'s SCHEMA refusal: `ObjectStackDefinitionSchema.safeParse` failed ' + + '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 ' + @@ -985,8 +985,8 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ door: 'none', verdict: 'boot-refusal', why: - '#15963 — `defineStack`\'s capability refusal, raised through `validateKnownCapabilities` when ' + - '`requires` names a token no runtime provides (framework#3265/#3308); one `issues` entry per ' + + '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 ' + @@ -1001,7 +1001,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ door: 'none', verdict: 'boot-refusal', why: - '#15963 — `defineStack`\'s namespace-prefix refusal, raised through `validateNamespacePrefix` ' + + '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` ' + @@ -1017,7 +1017,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ door: 'none', verdict: 'boot-refusal', why: - '#15963 — `defineStack`\'s single-app refusal, raised through `validateSingleApp` when an `app` ' + + '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` + ' + @@ -1033,7 +1033,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ door: 'none', verdict: 'boot-refusal', why: - '#15963 — `defineStack`\'s hierarchy-scope capability refusal, raised through ' + + '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`. ' + @@ -1050,9 +1050,9 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ door: 'none', verdict: 'boot-refusal', why: - '#15963 — `defineStack`\'s trigger capability refusal, raised through ' + + 'ADR-0112 — `defineStack`\'s trigger capability refusal, raised through ' + '`validateTriggerCapability` when an auto-launched flow is declared while `requires` omits ' + - '`triggers` (#14153 — the declared-capability class that fails SILENT); one `issues` entry per ' + + '`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 ' + From e6f7ccb54a4dc6ae37afdcd9f49f8fb0e146d699 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:19:10 +0000 Subject: [PATCH 3/3] chore(changeset): grade the six new STACK_* codes minor; correct the 422 claim on METADATA_SCHEMA_INVALID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② is yes (six new error codes ship in spec's dist and cannot be renamed once consumers branch on them), and a purely additive widening of a published package's public surface takes at least minor — the commit type may raise a bump but never lower it below what the act requires (maintainer ruling 2026-09-04, decision batch #35). Both packages move from patch to minor; the changeset records why. Review advisory A1: nothing in the tree assigns METADATA_SCHEMA_INVALID a status — it stays the issues-carrying precedent, FLOW_INPUT_SCHEMA_INVALID carries the 422 (flow-dispatch-status.ts), and the zod-shaped refusal metadata-protocol stamps at 422 is INVALID_METADATA. A2: the base class docblock states that issues is heterogeneous by design. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .changeset/stack-refusal-envelopes.md | 10 +++++---- .../src/dispatcher-error-vocabulary.ts | 5 ++++- packages/spec/src/stack.zod.ts | 22 ++++++++++++++----- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.changeset/stack-refusal-envelopes.md b/.changeset/stack-refusal-envelopes.md index 40035fdf28..b4a60319e1 100644 --- a/.changeset/stack-refusal-envelopes.md +++ b/.changeset/stack-refusal-envelopes.md @@ -1,6 +1,6 @@ --- -"@objectstack/spec": patch -"@objectstack/runtime": patch +"@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) @@ -19,8 +19,10 @@ fix(spec): every `defineStack` refusal carries an ADR-0112 envelope — six new 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` at 422 (`METADATA_SCHEMA_INVALID`, `FLOW_INPUT_SCHEMA_INVALID`); and 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, and its `issues` carries the zod issues structurally (path, code, message per entry) rather than the formatted lines the message already renders. +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, not widened: `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). +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. diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 0280e3436d..e9ded52585 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -963,7 +963,10 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '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` at 422 (`METADATA_SCHEMA_INVALID`, `FLOW_INPUT_SCHEMA_INVALID`); ' + + '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 ' + diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 8d26555e0e..a91429a500 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1776,6 +1776,11 @@ function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[ * `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. + * + * `issues` is HETEROGENEOUS across members, on purpose: the six semantic + * refusals carry one string per finding, the schema arm carries the zod issue + * objects — `TIssue` names which, and a reader branches on `code` before it + * reads `issues`, never on the element shape. */ abstract class StackRefusalError extends Error { abstract readonly code: string; @@ -1860,12 +1865,17 @@ class StackCrossReferenceError extends StackRefusalError { * `ZodError`. So "reuse spec's existing zod-failure channel" names a * channel that does not exist — the only existing channel is the message. * - The two zod-shaped refusals the ADR-0112 ledger DOES carry are both spelled - * `*_SCHEMA_INVALID` and both answer 422: `METADATA_SCHEMA_INVALID` - * (`SchemaValidationError` in `@objectstack/metadata-core` — "a put's spec - * fails Zod validation against the canonical schema", carrying the zod - * `issues` structurally) and `FLOW_INPUT_SCHEMA_INVALID` ("a node's config - * contradicts the schema the definition itself declares"). An authored stack - * failing its own schema is that class exactly. + * `*_SCHEMA_INVALID`: `METADATA_SCHEMA_INVALID` (`SchemaValidationError` in + * `@objectstack/metadata-core` — "a put's spec fails Zod validation against + * the canonical schema", carrying the zod `issues` structurally; its docstring + * says 422 but nothing in the tree assigns it a status) and + * `FLOW_INPUT_SCHEMA_INVALID` ("a node's config contradicts the schema the + * definition itself declares", answered 422 by + * `packages/runtime/src/flow-dispatch-status.ts`). The zod-shaped refusal + * `metadata-protocol` actually stamps at 422 is `INVALID_METADATA` + * (`protocol.ts`, `runtime-authoring-gate.ts`). An authored stack failing its + * own schema is that class exactly: the `issues` shape from the first, the + * 422 from the other two. * - The other two channels a zod failure travels on in this repo are both the * wrong vocabulary here. `400 VALIDATION_ERROR` is the REQUEST-syntax bucket * (`packages/rest` answers a malformed body with it, passing raw zod issue