From 77a5e5a47286d8b1989f23338552758f64880463 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:52:03 +0000 Subject: [PATCH 1/3] test(spec): pin the ADR-0112 envelope on defineStack's cross-reference refusal (#14552) RED against origin/main d4c2cb196: 13 failed / 7 passed. The 7 passing are the message-text pins, which prove every fixture reaches the cross-reference gate for the right reason; the 13 failures are the envelope assertions themselves (code and status both undefined). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../stack-cross-reference-envelope.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/spec/src/stack-cross-reference-envelope.test.ts diff --git a/packages/spec/src/stack-cross-reference-envelope.test.ts b/packages/spec/src/stack-cross-reference-envelope.test.ts new file mode 100644 index 0000000000..606746e18e --- /dev/null +++ b/packages/spec/src/stack-cross-reference-envelope.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defineStack`'s cross-reference refusal carries an ADR-0112 envelope + * (`code` + `status`), not a bare `Error`. + * + * ## What was wrong + * + * `validateCrossReferences` collects every dangling reference a stack declares + * — an item naming an object the stack does not define — and `defineStack` + * raised the collected set as `new Error(message)`. `code` and `status` were + * both `undefined`, so the five REFUSED item classes of the ADR-0130 matrix + * (action `objectName`, view `data.object`, permission-set `objects`, seed + * dataset `object`, import mapping `targetObject`) plus the `hooks[].object` + * rule were distinguishable only by MESSAGE TEXT. ADR-0112 makes `code` / + * `status` the machine-readable half of a refusal precisely so that prose does + * not have to be load-bearing; `os validate`, `os build` and any AI author + * reading the refusal had nothing else to match on. + * + * ## Why ONE code and not five + * + * There is exactly ONE raise site: `validateCrossReferences` returns a + * `string[]` and `defineStack` throws the whole set as a single aggregated + * error. A refusal can therefore carry issues from SEVERAL classes at once, + * which is why the code names the rule family (the cross-reference gate) and + * not one member of it — a per-class code on an aggregate throw would have to + * pick one of several true answers. The individual classes stay legible in + * `issues`, one entry per finding, which is the machine-readable form of what + * previously existed only as newline-joined prose. + * + * The set is also WIDER than "undefined object": the same aggregate carries the + * duplicate-action-key and global-`update`-action findings, and the mapping + * `javascript`-transform refusal. `STACK_CROSS_REFERENCE_UNDEFINED_OBJECT` + * would be false for those, so the family spelling is the honest one. + * + * ## What is pinned + * + * The ENVELOPE (`code`, `status`), never the message alone — a bare + * `toThrow()` cannot tell "refused for the right reason" from "refused because + * the fixture is broken", and both precedents for this defect class + * (`ObjectOwnershipConflictError`, `NamespaceConflictError`) are asserted the + * same way. The message text is pinned as UNCHANGED beside it: this change adds + * fields, it does not reword a sentence, and five message-substring pins + * elsewhere in the tree read it. + */ +import { describe, it, expect } from 'vitest'; +import { defineStack } from './stack.zod'; + +const manifest = { + id: 'com.example.crossrefenvelope', + name: 'cross-reference-envelope-test', + version: '1.0.0', + type: 'app' as const, +}; + +/** + * The stack's ONE declared object. Every fixture below names `missing_object` + * instead — the single difference between a refused stack and an accepted one. + */ +const declared = { + name: 'probe_item', + label: 'Probe Item', + fields: { title: { type: 'text' as const } }, +}; + +/** The name no fixture declares, so every reference to it dangles. */ +const MISSING = 'missing_object'; + +/** The error shape every assertion below reads — the ADR-0112 envelope. */ +type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] }; + +/** The thrown value, or `null` when the stack is accepted. */ +function refusal(config: Parameters[0]): Envelope | null { + try { + defineStack(config); + return null; + } catch (e) { + return e as Envelope; + } +} + +const stackWith = (extra: Record) => + ({ manifest, objects: [declared], ...extra }) as unknown as Parameters[0]; + +/** + * One row per refused item class. `message` is the verbatim line the aggregate + * must still contain — the byte-for-byte fence on the prose. + */ +const rows: Array<{ label: string; config: Record; message: string }> = [ + { + label: 'hooks[].object (#14122 §4 rule R4)', + config: { + hooks: [{ name: 'probe_hook', object: MISSING, events: ['afterInsert'], handler: 'noop' }], + }, + message: `Hook 'probe_hook' references object '${MISSING}' which is not defined in objects.`, + }, + { + label: 'view data.object', + config: { + views: [ + { + name: 'probe_view', + label: 'Probe View', + list: { columns: [{ field: 'title' }], data: { provider: 'object', object: MISSING } }, + }, + ], + }, + message: `View[0].list references object '${MISSING}' which is not defined in objects.`, + }, + { + label: 'seed dataset object', + config: { data: [{ object: MISSING, records: [] }] }, + message: `Seed data references object '${MISSING}' which is not defined in objects.`, + }, + { + label: 'import mapping targetObject', + config: { + mappings: [{ name: 'probe_mapping', targetObject: MISSING, fieldMapping: [] }], + }, + message: `Mapping 'probe_mapping' targets object '${MISSING}' which is not defined in objects.`, + }, + { + label: 'permission set objects', + config: { + permissions: [{ name: 'probe_perm', label: 'Probe Perm', objects: { [MISSING]: { allowRead: true } } }], + }, + message: `Permission 'probe_perm' grants on object '${MISSING}' which is not defined in objects.`, + }, + { + label: 'action objectName', + config: { + actions: [{ name: 'probe_action', label: 'Probe Action', type: 'script', target: 'noop', objectName: MISSING }], + }, + message: `Action 'probe_action' references object '${MISSING}' which is not defined in objects.`, + }, +]; + +describe('#14552 — defineStack cross-reference refusals carry an ADR-0112 envelope', () => { + for (const row of rows) { + describe(row.label, () => { + it('refuses with code STACK_CROSS_REFERENCE_INVALID and status 422', () => { + const refused = refusal(stackWith(row.config)); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + }); + + it('keeps the message text byte-for-byte, header and line', () => { + const refused = refusal(stackWith(row.config)); + expect(refused?.message).toContain('defineStack cross-reference validation failed'); + expect(refused?.message).toContain(row.message); + }); + + it('carries the finding in `issues`, one entry per finding', () => { + const refused = refusal(stackWith(row.config)); + expect(refused?.issues).toContain(row.message); + }); + }); + } + + it('the same object declared makes the stack ACCEPTED — the fixtures differ by one name', () => { + // The control: without it, a fixture broken in some unrelated way would + // satisfy every refusal assertion above for the wrong reason. + const accepted = refusal( + stackWith({ data: [{ object: declared.name, records: [] }] }), + ); + expect(accepted).toBeNull(); + }); + + it('an aggregate spanning TWO classes carries one code and BOTH findings', () => { + // Why the code names the rule family and not one item class: a single + // throw can carry findings from several classes at once. + const refused = refusal( + stackWith({ + data: [{ object: MISSING, records: [] }], + mappings: [{ name: 'probe_mapping', targetObject: MISSING, fieldMapping: [] }], + }), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toHaveLength(2); + }); +}); From 00ae794e614a8fd6267044d408a2cf68416474e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:56:44 +0000 Subject: [PATCH 2/3] fix(spec): defineStack's cross-reference refusal carries an ADR-0112 envelope (#14552) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...egistry-cross-package-item-classes.test.ts | 40 +++++++----- .../src/dispatcher-error-vocabulary.ts | 29 +++++++++ packages/spec/src/stack.zod.ts | 62 ++++++++++++++++++- 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/packages/objectql/src/registry-cross-package-item-classes.test.ts b/packages/objectql/src/registry-cross-package-item-classes.test.ts index 511c920723..6ac4d64441 100644 --- a/packages/objectql/src/registry-cross-package-item-classes.test.ts +++ b/packages/objectql/src/registry-cross-package-item-classes.test.ts @@ -35,19 +35,24 @@ * both, and the matrix's verdict is the EFFECTIVE one — refused at authoring * means the module cannot be written, whatever the registry would have done. * - * ## ⚠️ The authoring gate throws a BARE `Error` — there is no ADR-0112 envelope + * ## ✅ The authoring gate carries an ADR-0112 envelope (#14552) * - * `defineStack` aggregates its cross-reference errors into `new Error(...)`. - * There is no `code` and no `status` to assert, so these rows assert the - * message — which IS the contract here, since the message is the only thing - * that distinguishes one refusal from another — and then assert the ABSENCE of - * the envelope explicitly, in one place, so the gap is pinned rather than - * merely unmentioned. Same shape of gap as #14367 (`registerObject`'s bare - * `Error`), one door over. + * `defineStack` aggregates its cross-reference errors and — since #14552 — + * raises them as `StackCrossReferenceError`: `code: + * 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, one entry per finding in + * `issues`, with the message text byte-for-byte unchanged. The rows below + * still assert the MESSAGE, because the message is what distinguishes one item + * class from another (the code names the rule FAMILY — there is one raise site + * for all of them, and a single refusal can carry findings from several + * classes at once). `ENVELOPE PRESENCE` then asserts the envelope itself, in + * one place. Repaired the same way as #14367 (`registerObject`'s bare `Error`) + * and #14474 (`NamespaceConflictError`), one door over. * - * ⛔ If `ENVELOPE ABSENCE` below goes red, an envelope has ARRIVED. That is an - * improvement: update this pin and the #14122 §4 matrix row. Do not delete the - * assertion to make it green. + * ⛔ If `ENVELOPE PRESENCE` below goes red, the envelope has been REMOVED or + * its code renamed — a regression, not a cleanup. Restore it rather than + * relaxing the assertion; five message-substring pins in this tree read the + * prose it fences, and the #14122 §4 matrix row records the envelope as + * present. * * ## This file measures. It does not prescribe. * @@ -248,14 +253,17 @@ describe('#14122 §4 continuity — the method reproduces an already-measured ru expect(authoringVerdict(hookItem, true)).toBeUndefined(); }); - it('ENVELOPE ABSENCE — the authoring gate carries no ADR-0112 `code` / `status`', () => { + it('ENVELOPE PRESENCE — the authoring gate carries the ADR-0112 `code` / `status` (#14552)', () => { // Pinned once, here, rather than repeated on every refusing row. See the - // file header: red here means an envelope ARRIVED (good) — update the pin - // and the §4 matrix, do not delete the assertion. + // file header: red here means the envelope was REMOVED or renamed — a + // regression. Restore it, do not relax the assertion. const refused = authoringVerdict(hookItem); expect(refused).toBeInstanceOf(Error); - expect(refused?.code).toBeUndefined(); - expect(refused?.status).toBeUndefined(); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + // The message text is unchanged by the envelope — this pin fences both + // halves at once, which is what makes it a regression detector for the + // five message-substring pins elsewhere in the tree. expect(refused?.message).toContain('defineStack cross-reference validation failed'); }); }); diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index e1e7e53eff..03da276c25 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -904,6 +904,35 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '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.' + }, // ── [#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.zod.ts b/packages/spec/src/stack.zod.ts index f600508fcd..dc669dcb9b 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1691,6 +1691,63 @@ function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[ return errors; } +/** + * [ADR-0112] The cross-reference refusal `defineStack` raises when a stack's + * items name objects the stack does not define — carried as an envelope + * (`code` + `status`), never a bare `Error`. + * + * Before it carried one, all five REFUSED item classes of the ADR-0130 matrix + * (action `objectName`, view `data.object`, permission-set `objects`, seed + * dataset `object`, import mapping `targetObject`) plus the `hooks[].object` + * rule threw `new Error(message)` with `code` and `status` both `undefined`, + * so they were distinguishable only by MESSAGE TEXT — the fragile shape the + * ADR-0112 envelope exists to remove, and one that five message-substring pins + * had already come to depend on. + * + * ⭐ Why the code names the rule FAMILY and not one item class: there is + * exactly ONE raise site. {@link validateCrossReferences} returns every + * finding as a `string[]` and `defineStack` throws the whole set at once, so a + * single refusal can carry findings from several classes together — a + * per-class code would have to pick one of several true answers. The classes + * stay machine-readable in {@link StackCrossReferenceError.issues}, one entry + * per finding, which is the structured form of what was previously only + * newline-joined prose. The family is also WIDER than "undefined object": the + * same aggregate carries the duplicate-action-key, global-`update`-action and + * mapping `javascript`-transform findings, so a + * `…_UNDEFINED_OBJECT` spelling would be false for those. + * + * `status: 422` matches both precedents for this defect class + * (`ObjectOwnershipConflictError`, `NamespaceConflictError` in + * `packages/objectql/src/registry.ts`) — an unprocessable authored entity, not + * a server fault. + * + * ⛔ Deliberately NOT exported. `packages/spec/src/index.ts` re-exports this + * module with `export *`, so exporting this class would widen the PUBLISHED + * api-surface of the contract package; the ADR-0112 contract is the `code` / + * `status` fields, which every reader — `resolveThrownHttpError` and this + * 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'`. + */ +class StackCrossReferenceError extends Error { + 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; + } +} + /** * Perform strict cross-reference validation on a parsed stack definition. * Returns an array of error messages (empty if valid). @@ -2459,7 +2516,10 @@ export function defineStack( if (crossRefErrors.length > 0) { const header = `defineStack cross-reference validation failed (${crossRefErrors.length} issue${crossRefErrors.length === 1 ? '' : 's'}):`; const lines = crossRefErrors.map((e) => ` ✗ ${e}`); - throw new Error(`${header}\n\n${lines.join('\n')}`); + // [ADR-0112 · #14552] The message is byte-for-byte what the bare `Error` + // carried — this adds the envelope's fields, it does not reword a + // sentence. See {@link StackCrossReferenceError}. + throw new StackCrossReferenceError(`${header}\n\n${lines.join('\n')}`, crossRefErrors); } const nsErrors = validateNamespacePrefix(data); From 24af8e52a937c5e3ee23c5b8f1d2adb4082a220a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:21:05 +0000 Subject: [PATCH 3/3] chore: changeset for the defineStack cross-reference envelope (#14552) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../stack-cross-reference-refusal-envelope.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/stack-cross-reference-refusal-envelope.md diff --git a/.changeset/stack-cross-reference-refusal-envelope.md b/.changeset/stack-cross-reference-refusal-envelope.md new file mode 100644 index 0000000000..98d2f78249 --- /dev/null +++ b/.changeset/stack-cross-reference-refusal-envelope.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": patch +"@objectstack/runtime": patch +--- + +fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552) + +`validateCrossReferences` — reached through `defineStack` — refuses a stack whose items name an object the stack does not define. That refusal was `new Error(message)` with `code` and `status` both `undefined`, so all five REFUSED item classes of the ADR-0130 matrix (action `objectName`, view `data.object`, permission-set `objects`, seed dataset `object`, import mapping `targetObject`) plus the `hooks[].object` rule (#14122 §4 rule R4) were distinguishable only by MESSAGE TEXT. It now throws `StackCrossReferenceError`, carrying `code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, and one entry per finding in `issues`. The message text is byte-for-byte unchanged: this adds fields rather than rewriting a sentence, and five message-substring pins in the tree read that prose. + +ADR-0112 makes `code` / `status` the machine-readable half of every refusal. Without them `os validate`, `os build` and any AI author reading the refusal could only pattern-match prose — the fragile shape the envelope exists to remove, made worse here because the message had already become load-bearing for those pins. + +Why ONE code rather than five: there is exactly one raise site. `validateCrossReferences` returns every finding as a `string[]` and `defineStack` throws the collected set at once, so a single refusal can carry findings from several classes together and a per-class code would have to pick one of several true answers. The classes stay machine-readable in `issues`. The family is also wider than "undefined object" — the same aggregate carries the duplicate-action-key, global-`update`-action and mapping `javascript`-transform findings — so a `…_UNDEFINED_OBJECT` spelling would have been false for those. + +Not narrowed, not widened: no accept-set changes and no export changes. `defineStack` accepts and refuses exactly the inputs it did before, and `StackCrossReferenceError` is deliberately module-local — `packages/spec/src/index.ts` re-exports that module with `export *`, so exporting the class would widen the published api-surface of the contract package, and the ADR-0112 contract is the `code` / `status` fields, which every reader reads structurally rather than by `instanceof`. No ledger registration either, for the same reason its two precedents (`ObjectOwnershipConflictError` #14367, `NamespaceConflictError` #14474) carry none: no wire door raises it. `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it. + +`@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none` — the measured verdict, not the expected one).