Skip to content

Commit aef0da4

Browse files
committed
fix(spec): composeStacks conflict refusals carry ADR-0112 envelopes
The six authored-entity refusals `composeStacks` raises — every one under the literal `composeStacks conflict:` message prefix, five of them inside helper functions rather than `composeStacks`' own body — threw `new Error(message)` with `code` and `status` both `undefined`, while the `defineStack` family in the same file has carried the envelope since #15963. Each site now throws its own `StackRefusalError` member: one code per refusal site (the granularity the sibling family landed with), `status: 422`, and the finding the site collected on `issues`. Message text is byte-for-byte what the bare `Error` carried. The internal-bookkeeping invariant (`composeStacks internal error: no source stack recorded for composed object …`) stays a bare `Error` deliberately: it is the code finding its own records inconsistent, not an authored entity being refused, and a 422 would blame the author for our defect. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93cdc43 commit aef0da4

3 files changed

Lines changed: 498 additions & 19 deletions

File tree

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,8 @@ export const ERROR_CODE_LEDGER = {
12791279
'EXTERNAL_SCHEMA_MISMATCH',
12801280
'EXTERNAL_SCHEMA_MODE_VIOLATION',
12811281
'EXTERNAL_WRITE_FORBIDDEN',
1282-
// [#16449] The eight rows below are `door: 'none'` codes — raised at
1282+
// [#16449] The eight rows below — through `STACK_TRIGGER_CAPABILITY_REQUIRED`
1283+
// — are `door: 'none'` codes — raised at
12831284
// authoring / boot, before any HTTP boundary exists — registered under the
12841285
// #16404 ruling (door or no door; see the header). Each ships in this
12851286
// package's `dist`, so its spelling is the face a consumer's
@@ -1308,6 +1309,29 @@ export const ERROR_CODE_LEDGER = {
13081309
'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally
13091310
'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3)
13101311
'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers`
1312+
// [#16348] The COMPOSITION half of the same family, and `door: 'none'` on
1313+
// the same reading: the six `composeStacks` refusals, one code per raise
1314+
// site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the
1315+
// findings the site collected on `issues`. Every message carries the
1316+
// literal `composeStacks conflict:` prefix, which is how the family is
1317+
// located — five of the six raise inside helper functions, not in
1318+
// `composeStacks`' own body. Raised where `defineStack` is: `os validate` /
1319+
// `os build` and a hand-written `objectstack.config.ts`, before any HTTP
1320+
// boundary exists (measured: zero `composeStacks` CALL sites under
1321+
// `packages/runtime/src` + `packages/rest/src` — 7 non-test occurrences,
1322+
// all doc comments or message prose in one file; positive control
1323+
// `defineStack`, same two trees, 31 occurrences across 8 files).
1324+
// ⛔ NOT registered, and the absence is load-bearing: the seventh bare
1325+
// `Error` in that file, `composeStacks internal error: no source stack
1326+
// recorded for composed object …`, is an internal-bookkeeping invariant
1327+
// rather than an authored-entity refusal — a 422 would blame the author
1328+
// for our defect. Its disposition is its own decision.
1329+
'STACK_COMPOSE_ACTION_KEY_COLLISION', // two stacks declare the same action key — the collision `defineStack` refuses within one stack, one composition step later
1330+
'STACK_COMPOSE_COLLECTION_CONFLICT', // under `objectConflict: 'merge'`, an object-level collection other than `fields` is declared with different values by two stacks
1331+
'STACK_COMPOSE_FUNCTION_CONFLICT', // two stacks define a handler under the same name; handlers resolve by name at boot
1332+
'STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT', // `functions` authored in the map form by one stack and the array form by another
1333+
'STACK_COMPOSE_KEY_CONFLICT', // a single-valued top-level key is declared with different values by two stacks
1334+
'STACK_COMPOSE_OBJECT_CONFLICT', // the same object name is defined by more than one stack under the default `objectConflict: 'error'`
13111335
],
13121336
} as const satisfies Record<string, readonly string[]>;
13131337

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Every authored-entity refusal `composeStacks` raises carries an ADR-0112
5+
* envelope (#16348).
6+
*
7+
* ## What was wrong
8+
*
9+
* `defineStack`'s seven refusal sites got `code` / `status` in #15963. Its
10+
* sibling in the same file did not: `composeStacks` refused six authored-entity
11+
* conflicts with `new Error(message)`, both fields `undefined`. Two refusal
12+
* families that are the same thing to an author — a stack refused at authoring
13+
* time — and two different things to a consumer branching on `error.code`.
14+
*
15+
* Five of the six raise inside HELPER functions 300-800 lines above
16+
* `composeStacks`' own body, which is why the family is located by its
17+
* `composeStacks conflict:` MESSAGE PREFIX and not by reading the function the
18+
* card names.
19+
*
20+
* ## What is pinned
21+
*
22+
* Per site: the ENVELOPE (`code`, `status: 422`); the message prefix
23+
* byte-for-byte, through the finding the site now also carries structurally;
24+
* `issues`, one entry per finding; and the CONTROL — the same composition with
25+
* the one offending detail removed is ACCEPTED, so a refusal cannot satisfy the
26+
* assertions for the wrong reason.
27+
*
28+
* The full message TEXT of these six is pinned, unchanged, by the suites that
29+
* already read it: `compose-stacks-merge-collection-refusal.test.ts` and
30+
* `compose-stacks-action-key-collision.test.ts` assert whole messages with
31+
* `toBe`, `compose-stacks-i18n-merge.test.ts` and
32+
* `compose-key-dispositions-export.pin.test.ts` assert the prose of the
33+
* single-value refusal. This change adds fields; it rewords no sentence, and
34+
* those suites are what proves it.
35+
*
36+
* Then a census over the whole file: six distinct `STACK_COMPOSE_*` codes, every
37+
* `status` 422, no `name` spelled `ValidationError` (which
38+
* `validationFailureDetails` in `@objectstack/types` duck-types as a RECORD
39+
* validation failure and answers `400 VALIDATION_FAILED` + `fields[]` for),
40+
* every code a member of the closed `ErrorCode` union — and the A/B SPLIT
41+
* itself: exactly ONE bare `Error` is left in `stack.zod.ts`, the internal
42+
* bookkeeping invariant, which stays bare deliberately. A seventh authored-entity
43+
* refusal added bare would fail that census.
44+
*/
45+
import { describe, it, expect } from 'vitest';
46+
import { readFileSync } from 'node:fs';
47+
import { fileURLToPath } from 'node:url';
48+
import { composeStacks, defineStack, type ObjectStackDefinition } from './stack.zod';
49+
import { ERROR_CODE_LEDGER, ErrorCode } from './api/error-code-ledger.zod';
50+
51+
/** The error shape every assertion below reads — the ADR-0112 envelope. */
52+
type Envelope = Error & { code?: string; status?: number; issues?: readonly unknown[] };
53+
54+
/** The thrown value, or `null` when the composition is accepted. */
55+
function refusal(fn: () => unknown): Envelope | null {
56+
try {
57+
fn();
58+
return null;
59+
} catch (e) {
60+
return e as Envelope;
61+
}
62+
}
63+
64+
const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const });
65+
66+
const act = (name: string, extra: Record<string, unknown> = {}) =>
67+
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });
68+
69+
// `as const` on the field type is load-bearing (see stack.test.ts): hoisted
70+
// without it the literal widens to `string`, which the input type refuses.
71+
const obj = (name: string, extra: Record<string, unknown> = {}) => ({
72+
name,
73+
label: name,
74+
fields: { title: { type: 'text' as const } },
75+
...extra,
76+
});
77+
78+
/** A stack object that is NOT schema-validated — lets a case declare a partial key. */
79+
function raw(overrides: Record<string, unknown>): ObjectStackDefinition {
80+
return defineStack(overrides as never, { strict: false });
81+
}
82+
83+
const A0 = "'com.example.a' (stack #0)";
84+
const B1 = "'com.example.b' (stack #1)";
85+
86+
/** The prefix the whole family carries, and the string this card located it by. */
87+
const PREFIX = 'composeStacks conflict: ';
88+
89+
/**
90+
* One row per refusal site. `refused` and `accepted` differ by the one detail
91+
* the site checks; `finding` is the ENTIRE entry the refusal must carry in
92+
* `issues`, and also the text that must follow the prefix in the message.
93+
*/
94+
const rows: Array<{
95+
site: string;
96+
code: string;
97+
finding: string;
98+
refused: () => unknown;
99+
accepted: () => unknown;
100+
}> = [
101+
{
102+
site: 'single-valued top-level key — two stacks declare it differently (composeSingleValue)',
103+
code: 'STACK_COMPOSE_KEY_CONFLICT',
104+
finding: `top-level key 'i18n' is declared with different values by ${A0} and ${B1}.`,
105+
// `raw`: a partial `i18n` is the subject, not the shape — the sibling
106+
// i18n suite reaches for the same escape for the same reason.
107+
refused: () =>
108+
composeStacks([
109+
raw({ manifest: mf('com.example.a'), i18n: { defaultLocale: 'en' } }),
110+
raw({ manifest: mf('com.example.b'), i18n: { defaultLocale: 'zh-CN' } }),
111+
]),
112+
accepted: () =>
113+
composeStacks([
114+
raw({ manifest: mf('com.example.a'), i18n: { defaultLocale: 'en' } }),
115+
raw({ manifest: mf('com.example.b'), i18n: { defaultLocale: 'en' } }),
116+
]),
117+
},
118+
{
119+
site: "functions shape — the map form in one stack, the array form in another (composeFunctions)",
120+
code: 'STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT',
121+
finding: `top-level key 'functions' is declared in the map form by ${A0} and in the array form by ${B1}.`,
122+
refused: () =>
123+
composeStacks([
124+
raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }),
125+
raw({ manifest: mf('com.example.b'), functions: [{ name: 'export' }] }),
126+
]),
127+
accepted: () =>
128+
composeStacks([
129+
raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }),
130+
raw({ manifest: mf('com.example.b'), functions: { export: () => 2 } }),
131+
]),
132+
},
133+
{
134+
site: 'function name — two stacks define one handler name (composeFunctions)',
135+
code: 'STACK_COMPOSE_FUNCTION_CONFLICT',
136+
finding: `function 'refresh' is defined by both ${A0} and ${B1}.`,
137+
refused: () =>
138+
composeStacks([
139+
raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }),
140+
raw({ manifest: mf('com.example.b'), functions: { refresh: () => 2 } }),
141+
]),
142+
accepted: () =>
143+
composeStacks([
144+
raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }),
145+
raw({ manifest: mf('com.example.b'), functions: { export: () => 2 } }),
146+
]),
147+
},
148+
{
149+
site: "object collection under objectConflict: 'merge' (refuseUnmergeableCollections)",
150+
code: 'STACK_COMPOSE_COLLECTION_CONFLICT',
151+
finding:
152+
`object 'shared' is defined in multiple stacks and its 'actions' ` +
153+
`is declared with different values by ${A0} and ${B1}.`,
154+
refused: () =>
155+
composeStacks(
156+
[
157+
defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { actions: [act('approve')] })] }),
158+
defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { actions: [act('archive')] })] }),
159+
],
160+
{ objectConflict: 'merge' },
161+
),
162+
accepted: () =>
163+
composeStacks(
164+
[
165+
defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { actions: [act('approve')] })] }),
166+
defineStack({ manifest: mf('com.example.b'), objects: [obj('shared')] }),
167+
],
168+
{ objectConflict: 'merge' },
169+
),
170+
},
171+
{
172+
site: "object name under the default objectConflict: 'error' (mergeObjects)",
173+
code: 'STACK_COMPOSE_OBJECT_CONFLICT',
174+
finding: "object 'shared' is defined in multiple stacks.",
175+
refused: () =>
176+
composeStacks([
177+
defineStack({ manifest: mf('com.example.a'), objects: [obj('shared')] }),
178+
defineStack({ manifest: mf('com.example.b'), objects: [obj('shared')] }),
179+
]),
180+
accepted: () =>
181+
composeStacks([
182+
defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')] }),
183+
defineStack({ manifest: mf('com.example.b'), objects: [obj('b_item')] }),
184+
]),
185+
},
186+
{
187+
site: 'cross-stack action key collision (collectComposedActionKeyCollisions)',
188+
code: 'STACK_COMPOSE_ACTION_KEY_COLLISION',
189+
finding:
190+
"Action key 'global:shared_refresh' is declared by 2 stacks: " +
191+
`${A0} at stack.actions[0] and ${B1} at stack.actions[0].`,
192+
refused: () =>
193+
composeStacks([
194+
defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')], actions: [act('shared_refresh')] }),
195+
defineStack({ manifest: mf('com.example.b'), objects: [obj('b_item')], actions: [act('shared_refresh')] }),
196+
]),
197+
accepted: () =>
198+
composeStacks([
199+
defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')], actions: [act('a_refresh')] }),
200+
defineStack({ manifest: mf('com.example.b'), objects: [obj('b_item')], actions: [act('b_refresh')] }),
201+
]),
202+
},
203+
];
204+
205+
describe('#16348 — every composeStacks conflict refusal carries an ADR-0112 envelope', () => {
206+
for (const row of rows) {
207+
describe(row.site, () => {
208+
it(`refuses with code ${row.code} and status 422`, () => {
209+
const refused = refusal(row.refused);
210+
expect(refused).toBeInstanceOf(Error);
211+
expect(refused?.code).toBe(row.code);
212+
expect(refused?.status).toBe(422);
213+
});
214+
215+
it('carries the finding in `issues`, one entry per finding', () => {
216+
const refused = refusal(row.refused);
217+
expect(refused?.issues).toEqual([row.finding]);
218+
});
219+
220+
it('keeps the message byte-for-byte — the family prefix, then the finding', () => {
221+
const message = refusal(row.refused)?.message ?? '';
222+
// The action-key collision renders its findings as `✗` lines under a
223+
// counted header; the other five open with the finding itself.
224+
const opening =
225+
row.code === 'STACK_COMPOSE_ACTION_KEY_COLLISION'
226+
? `${PREFIX}cross-stack action key collision (1 issue):`
227+
: `${PREFIX}${row.finding}`;
228+
expect(message.startsWith(opening)).toBe(true);
229+
expect(message).toContain(row.finding);
230+
});
231+
232+
it('the same composition without the one offending detail is ACCEPTED — the control', () => {
233+
expect(refusal(row.accepted)).toBeNull();
234+
});
235+
});
236+
}
237+
238+
describe('census over all six composition refusal sites', () => {
239+
it('six sites, six distinct STACK_COMPOSE_* codes, every status 422', () => {
240+
const envelopes = rows.map((row) => refusal(row.refused));
241+
for (const envelope of envelopes) {
242+
expect(envelope).toBeInstanceOf(Error);
243+
expect(envelope?.code).toMatch(/^STACK_COMPOSE_[A-Z_]+$/);
244+
expect(envelope?.status).toBe(422);
245+
}
246+
expect(new Set(envelopes.map((e) => e?.code)).size).toBe(6);
247+
});
248+
249+
it('no site is named `ValidationError` — the record-validation duck-type in @objectstack/types', () => {
250+
for (const row of rows) {
251+
expect(refusal(row.refused)?.name).not.toBe('ValidationError');
252+
}
253+
});
254+
255+
it('every code is a member of the closed `ErrorCode` union, registered under @objectstack/spec', () => {
256+
// The #16404 ruling: a code that ships in `dist` is the published face,
257+
// door or no door — so each refusal's spelling is a ledger row, and a
258+
// consumer's `switch (e.code)` is exhaustive over the union it ships with.
259+
for (const row of rows) {
260+
const code = refusal(row.refused)?.code;
261+
expect(ErrorCode.safeParse(code).success, `${code} parses against ErrorCode`).toBe(true);
262+
expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain(code);
263+
}
264+
});
265+
266+
it('every message still carries the `composeStacks conflict:` prefix the family is located by', () => {
267+
for (const row of rows) {
268+
expect(refusal(row.refused)?.message.startsWith(PREFIX)).toBe(true);
269+
}
270+
});
271+
});
272+
273+
describe('the A/B split — the internal invariant stays a bare Error', () => {
274+
// A source census, because the class B site is unreachable from the public
275+
// API by construction: it fires only when `mergeObjects` records an object
276+
// and `collectComposedActionKeyCollisions` then fails to find it, which is
277+
// an edit to this file rather than an authored input. What can regress is
278+
// a SEVENTH authored-entity refusal arriving bare — this is what catches it.
279+
const source = readFileSync(fileURLToPath(new URL('./stack.zod.ts', import.meta.url)), 'utf8');
280+
281+
it('exactly one bare `throw new Error(` is left in stack.zod.ts', () => {
282+
const bare = source.split('\n').filter((line) => line.includes('throw new Error('));
283+
expect(bare).toHaveLength(1);
284+
expect(bare[0]).toContain('composeStacks internal error: no source stack recorded');
285+
});
286+
287+
it('it is an internal-invariant message, NOT an authored-entity refusal', () => {
288+
// `composeStacks conflict:` is the authored-entity family; the internal
289+
// invariant deliberately carries a different prefix and no envelope,
290+
// because a 422 would tell an author their stack is unprocessable when
291+
// the defect is ours.
292+
const bare = source.split('\n').filter((line) => line.includes('throw new Error('))[0] ?? '';
293+
expect(bare).not.toContain(PREFIX);
294+
});
295+
});
296+
});

0 commit comments

Comments
 (0)