Skip to content

Commit 2e7238d

Browse files
committed
Merge origin/main (f50c394) into claude/issue-15350-datasource-tenancy-posture
Picks up #16002, which repairs the check:merge-driver / check-regen-pending.mjs --self-test failure that reddened this PR, origin/main's own push build and five other PRs. Not a change to this card's diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
2 parents 42d8941 + f50c394 commit 2e7238d

6 files changed

Lines changed: 357 additions & 18 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552)
7+
8+
`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.
9+
10+
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.
11+
12+
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.
13+
14+
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.
15+
16+
`@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).

packages/objectql/src/registry-cross-package-item-classes.test.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,24 @@
3535
* both, and the matrix's verdict is the EFFECTIVE one — refused at authoring
3636
* means the module cannot be written, whatever the registry would have done.
3737
*
38-
* ## ⚠️ The authoring gate throws a BARE `Error` — there is no ADR-0112 envelope
38+
* ## The authoring gate carries an ADR-0112 envelope (#14552)
3939
*
40-
* `defineStack` aggregates its cross-reference errors into `new Error(...)`.
41-
* There is no `code` and no `status` to assert, so these rows assert the
42-
* message — which IS the contract here, since the message is the only thing
43-
* that distinguishes one refusal from another — and then assert the ABSENCE of
44-
* the envelope explicitly, in one place, so the gap is pinned rather than
45-
* merely unmentioned. Same shape of gap as #14367 (`registerObject`'s bare
46-
* `Error`), one door over.
40+
* `defineStack` aggregates its cross-reference errors and — since #14552 —
41+
* raises them as `StackCrossReferenceError`: `code:
42+
* 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, one entry per finding in
43+
* `issues`, with the message text byte-for-byte unchanged. The rows below
44+
* still assert the MESSAGE, because the message is what distinguishes one item
45+
* class from another (the code names the rule FAMILY — there is one raise site
46+
* for all of them, and a single refusal can carry findings from several
47+
* classes at once). `ENVELOPE PRESENCE` then asserts the envelope itself, in
48+
* one place. Repaired the same way as #14367 (`registerObject`'s bare `Error`)
49+
* and #14474 (`NamespaceConflictError`), one door over.
4750
*
48-
* ⛔ If `ENVELOPE ABSENCE` below goes red, an envelope has ARRIVED. That is an
49-
* improvement: update this pin and the #14122 §4 matrix row. Do not delete the
50-
* assertion to make it green.
51+
* ⛔ If `ENVELOPE PRESENCE` below goes red, the envelope has been REMOVED or
52+
* its code renamed — a regression, not a cleanup. Restore it rather than
53+
* relaxing the assertion; five message-substring pins in this tree read the
54+
* prose it fences, and the #14122 §4 matrix row records the envelope as
55+
* present.
5156
*
5257
* ## This file measures. It does not prescribe.
5358
*
@@ -248,14 +253,17 @@ describe('#14122 §4 continuity — the method reproduces an already-measured ru
248253
expect(authoringVerdict(hookItem, true)).toBeUndefined();
249254
});
250255

251-
it('ENVELOPE ABSENCE — the authoring gate carries no ADR-0112 `code` / `status`', () => {
256+
it('ENVELOPE PRESENCE — the authoring gate carries the ADR-0112 `code` / `status` (#14552)', () => {
252257
// Pinned once, here, rather than repeated on every refusing row. See the
253-
// file header: red here means an envelope ARRIVED (good) — update the pin
254-
// and the §4 matrix, do not delete the assertion.
258+
// file header: red here means the envelope was REMOVED or renamed — a
259+
// regression. Restore it, do not relax the assertion.
255260
const refused = authoringVerdict(hookItem);
256261
expect(refused).toBeInstanceOf(Error);
257-
expect(refused?.code).toBeUndefined();
258-
expect(refused?.status).toBeUndefined();
262+
expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID');
263+
expect(refused?.status).toBe(422);
264+
// The message text is unchanged by the envelope — this pin fences both
265+
// halves at once, which is what makes it a regression detector for the
266+
// five message-substring pins elsewhere in the tree.
259267
expect(refused?.message).toContain('defineStack cross-reference validation failed');
260268
});
261269
});

packages/runtime/src/dispatcher-error-vocabulary.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,35 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
904904
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
905905
'batch.'
906906
},
907+
{
908+
code: 'STACK_CROSS_REFERENCE_INVALID',
909+
file: 'packages/spec/src/stack.zod.ts',
910+
shape: 'classfield',
911+
door: 'none',
912+
verdict: 'boot-refusal',
913+
why:
914+
'ADR-0130 — the AUTHORING gate\'s cross-reference refusal, raised by `defineStack` when a ' +
915+
'stack\'s items name objects the stack does not define. One raise site for the whole rule ' +
916+
'family: `validateCrossReferences` returns every finding as a `string[]` and `defineStack` ' +
917+
'throws the collected set once, so the code names the family and the individual classes ride ' +
918+
'the error\'s `issues` field (the five REFUSED ADR-0130 matrix classes — action `objectName`, ' +
919+
'view `data.object`, permission-set `objects`, seed dataset `object`, import mapping ' +
920+
'`targetObject` — plus the `hooks[].object` rule, and the wider duplicate-action-key, ' +
921+
'global-`update`-action and mapping `javascript`-transform findings the same aggregate ' +
922+
'carries). Before this envelope it threw a bare `Error`, so those classes were separable only ' +
923+
'by message text. ⭐ MEASURED, not inferred from the call graph: `defineStack` is an ' +
924+
'authoring/boot-time entry point, and no HTTP domain handler calls it. Every non-test ' +
925+
'occurrence of `defineStack` under `packages/runtime/src` and `packages/rest/src` (25 of them) ' +
926+
'is a docstring or comment; the shipped callers are the CLI (`os validate`, `os build`) and ' +
927+
'the `os serve` / `os migrate` host configs and `DevPlugin`, which load a stack module at ' +
928+
'boot, where a throw aborts before any HTTP boundary exists. The two HTTP install sites — ' +
929+
'`POST /packages` in `packages/runtime/src/domains/packages.ts` and `protocol.installPackage` ' +
930+
'— call `SchemaRegistry.installPackage`, which never calls `defineStack`. So the code reaches ' +
931+
'a reader only inside a message string, never as `error.code`. Its `status: 422` is the ' +
932+
'ADR-0112 envelope shape this repo\'s rejection tests assert on, not evidence of a door. If a ' +
933+
'door ever answers with this code itself, the verdict becomes pending-registration and it ' +
934+
'belongs in the ledger batch.'
935+
},
907936
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
908937
//
909938
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `defineStack`'s cross-reference refusal carries an ADR-0112 envelope
5+
* (`code` + `status`), not a bare `Error`.
6+
*
7+
* ## What was wrong
8+
*
9+
* `validateCrossReferences` collects every dangling reference a stack declares
10+
* — an item naming an object the stack does not define — and `defineStack`
11+
* raised the collected set as `new Error(message)`. `code` and `status` were
12+
* both `undefined`, so the five REFUSED item classes of the ADR-0130 matrix
13+
* (action `objectName`, view `data.object`, permission-set `objects`, seed
14+
* dataset `object`, import mapping `targetObject`) plus the `hooks[].object`
15+
* rule were distinguishable only by MESSAGE TEXT. ADR-0112 makes `code` /
16+
* `status` the machine-readable half of a refusal precisely so that prose does
17+
* not have to be load-bearing; `os validate`, `os build` and any AI author
18+
* reading the refusal had nothing else to match on.
19+
*
20+
* ## Why ONE code and not five
21+
*
22+
* There is exactly ONE raise site: `validateCrossReferences` returns a
23+
* `string[]` and `defineStack` throws the whole set as a single aggregated
24+
* error. A refusal can therefore carry issues from SEVERAL classes at once,
25+
* which is why the code names the rule family (the cross-reference gate) and
26+
* not one member of it — a per-class code on an aggregate throw would have to
27+
* pick one of several true answers. The individual classes stay legible in
28+
* `issues`, one entry per finding, which is the machine-readable form of what
29+
* previously existed only as newline-joined prose.
30+
*
31+
* The set is also WIDER than "undefined object": the same aggregate carries the
32+
* duplicate-action-key and global-`update`-action findings, and the mapping
33+
* `javascript`-transform refusal. `STACK_CROSS_REFERENCE_UNDEFINED_OBJECT`
34+
* would be false for those, so the family spelling is the honest one.
35+
*
36+
* ## What is pinned
37+
*
38+
* The ENVELOPE (`code`, `status`), never the message alone — a bare
39+
* `toThrow()` cannot tell "refused for the right reason" from "refused because
40+
* the fixture is broken", and both precedents for this defect class
41+
* (`ObjectOwnershipConflictError`, `NamespaceConflictError`) are asserted the
42+
* same way. The message text is pinned as UNCHANGED beside it: this change adds
43+
* fields, it does not reword a sentence, and five message-substring pins
44+
* elsewhere in the tree read it.
45+
*/
46+
import { describe, it, expect } from 'vitest';
47+
import { defineStack } from './stack.zod';
48+
49+
const manifest = {
50+
id: 'com.example.crossrefenvelope',
51+
name: 'cross-reference-envelope-test',
52+
version: '1.0.0',
53+
type: 'app' as const,
54+
};
55+
56+
/**
57+
* The stack's ONE declared object. Every fixture below names `missing_object`
58+
* instead — the single difference between a refused stack and an accepted one.
59+
*/
60+
const declared = {
61+
name: 'probe_item',
62+
label: 'Probe Item',
63+
fields: { title: { type: 'text' as const } },
64+
};
65+
66+
/** The name no fixture declares, so every reference to it dangles. */
67+
const MISSING = 'missing_object';
68+
69+
/** The error shape every assertion below reads — the ADR-0112 envelope. */
70+
type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] };
71+
72+
/** The thrown value, or `null` when the stack is accepted. */
73+
function refusal(config: Parameters<typeof defineStack>[0]): Envelope | null {
74+
try {
75+
defineStack(config);
76+
return null;
77+
} catch (e) {
78+
return e as Envelope;
79+
}
80+
}
81+
82+
const stackWith = (extra: Record<string, unknown>) =>
83+
({ manifest, objects: [declared], ...extra }) as unknown as Parameters<typeof defineStack>[0];
84+
85+
/**
86+
* One row per refused item class. `message` is the verbatim line the aggregate
87+
* must still contain — the byte-for-byte fence on the prose.
88+
*/
89+
const rows: Array<{ label: string; config: Record<string, unknown>; message: string }> = [
90+
{
91+
label: 'hooks[].object (#14122 §4 rule R4)',
92+
config: {
93+
hooks: [{ name: 'probe_hook', object: MISSING, events: ['afterInsert'], handler: 'noop' }],
94+
},
95+
message: `Hook 'probe_hook' references object '${MISSING}' which is not defined in objects.`,
96+
},
97+
{
98+
label: 'view data.object',
99+
config: {
100+
views: [
101+
{
102+
name: 'probe_view',
103+
label: 'Probe View',
104+
list: { columns: [{ field: 'title' }], data: { provider: 'object', object: MISSING } },
105+
},
106+
],
107+
},
108+
message: `View[0].list references object '${MISSING}' which is not defined in objects.`,
109+
},
110+
{
111+
label: 'seed dataset object',
112+
config: { data: [{ object: MISSING, records: [] }] },
113+
message: `Seed data references object '${MISSING}' which is not defined in objects.`,
114+
},
115+
{
116+
label: 'import mapping targetObject',
117+
config: {
118+
mappings: [{ name: 'probe_mapping', targetObject: MISSING, fieldMapping: [] }],
119+
},
120+
message: `Mapping 'probe_mapping' targets object '${MISSING}' which is not defined in objects.`,
121+
},
122+
{
123+
label: 'permission set objects',
124+
config: {
125+
permissions: [{ name: 'probe_perm', label: 'Probe Perm', objects: { [MISSING]: { allowRead: true } } }],
126+
},
127+
message: `Permission 'probe_perm' grants on object '${MISSING}' which is not defined in objects.`,
128+
},
129+
{
130+
label: 'action objectName',
131+
config: {
132+
actions: [{ name: 'probe_action', label: 'Probe Action', type: 'script', target: 'noop', objectName: MISSING }],
133+
},
134+
message: `Action 'probe_action' references object '${MISSING}' which is not defined in objects.`,
135+
},
136+
];
137+
138+
describe('#14552 — defineStack cross-reference refusals carry an ADR-0112 envelope', () => {
139+
for (const row of rows) {
140+
describe(row.label, () => {
141+
it('refuses with code STACK_CROSS_REFERENCE_INVALID and status 422', () => {
142+
const refused = refusal(stackWith(row.config));
143+
expect(refused).toBeInstanceOf(Error);
144+
expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID');
145+
expect(refused?.status).toBe(422);
146+
});
147+
148+
it('keeps the message text byte-for-byte, header and line', () => {
149+
const refused = refusal(stackWith(row.config));
150+
expect(refused?.message).toContain('defineStack cross-reference validation failed');
151+
expect(refused?.message).toContain(row.message);
152+
});
153+
154+
it('carries the finding in `issues`, one entry per finding', () => {
155+
const refused = refusal(stackWith(row.config));
156+
expect(refused?.issues).toContain(row.message);
157+
});
158+
});
159+
}
160+
161+
it('the same object declared makes the stack ACCEPTED — the fixtures differ by one name', () => {
162+
// The control: without it, a fixture broken in some unrelated way would
163+
// satisfy every refusal assertion above for the wrong reason.
164+
const accepted = refusal(
165+
stackWith({ data: [{ object: declared.name, records: [] }] }),
166+
);
167+
expect(accepted).toBeNull();
168+
});
169+
170+
it('an aggregate spanning TWO classes carries one code and BOTH findings', () => {
171+
// Why the code names the rule family and not one item class: a single
172+
// throw can carry findings from several classes at once.
173+
const refused = refusal(
174+
stackWith({
175+
data: [{ object: MISSING, records: [] }],
176+
mappings: [{ name: 'probe_mapping', targetObject: MISSING, fieldMapping: [] }],
177+
}),
178+
);
179+
expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID');
180+
expect(refused?.issues).toHaveLength(2);
181+
});
182+
});

0 commit comments

Comments
 (0)