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
16 changes: 16 additions & 0 deletions .changeset/stack-cross-reference-refusal-envelope.md
Original file line number Diff line number Diff line change
@@ -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).
40 changes: 24 additions & 16 deletions packages/objectql/src/registry-cross-package-item-classes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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');
});
});
Expand Down
29 changes: 29 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions packages/spec/src/stack-cross-reference-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof defineStack>[0]): Envelope | null {
try {
defineStack(config);
return null;
} catch (e) {
return e as Envelope;
}
}

const stackWith = (extra: Record<string, unknown>) =>
({ manifest, objects: [declared], ...extra }) as unknown as Parameters<typeof defineStack>[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<string, unknown>; 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);
});
});
Loading
Loading