From aef0da4f95f14ce5c6a63403ad59c3b3cbd9aa1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:44:54 +0000 Subject: [PATCH 1/3] fix(spec): composeStacks conflict refusals carry ADR-0112 envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../spec/src/api/error-code-ledger.zod.ts | 26 +- .../compose-stacks-refusal-envelopes.test.ts | 296 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 195 ++++++++++-- 3 files changed, 498 insertions(+), 19 deletions(-) create mode 100644 packages/spec/src/compose-stacks-refusal-envelopes.test.ts diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 5765706edb7..3ada7bf7114 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1279,7 +1279,8 @@ export const ERROR_CODE_LEDGER = { 'EXTERNAL_SCHEMA_MISMATCH', 'EXTERNAL_SCHEMA_MODE_VIOLATION', 'EXTERNAL_WRITE_FORBIDDEN', - // [#16449] The eight rows below are `door: 'none'` codes — raised at + // [#16449] The eight rows below — through `STACK_TRIGGER_CAPABILITY_REQUIRED` + // — are `door: 'none'` codes — raised at // authoring / boot, before any HTTP boundary exists — registered under the // #16404 ruling (door or no door; see the header). Each ships in this // package's `dist`, so its spelling is the face a consumer's @@ -1308,6 +1309,29 @@ export const ERROR_CODE_LEDGER = { 'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally 'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3) 'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers` + // [#16348] The COMPOSITION half of the same family, and `door: 'none'` on + // the same reading: the six `composeStacks` refusals, one code per raise + // site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the + // findings the site collected on `issues`. Every message carries the + // literal `composeStacks conflict:` prefix, which is how the family is + // located — five of the six raise inside helper functions, not in + // `composeStacks`' own body. Raised where `defineStack` is: `os validate` / + // `os build` and a hand-written `objectstack.config.ts`, before any HTTP + // boundary exists (measured: zero `composeStacks` CALL sites under + // `packages/runtime/src` + `packages/rest/src` — 7 non-test occurrences, + // all doc comments or message prose in one file; positive control + // `defineStack`, same two trees, 31 occurrences across 8 files). + // ⛔ NOT registered, and the absence is load-bearing: the seventh bare + // `Error` in that file, `composeStacks internal error: no source stack + // recorded for composed object …`, is an internal-bookkeeping invariant + // rather than an authored-entity refusal — a 422 would blame the author + // for our defect. Its disposition is its own decision. + 'STACK_COMPOSE_ACTION_KEY_COLLISION', // two stacks declare the same action key — the collision `defineStack` refuses within one stack, one composition step later + 'STACK_COMPOSE_COLLECTION_CONFLICT', // under `objectConflict: 'merge'`, an object-level collection other than `fields` is declared with different values by two stacks + 'STACK_COMPOSE_FUNCTION_CONFLICT', // two stacks define a handler under the same name; handlers resolve by name at boot + 'STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT', // `functions` authored in the map form by one stack and the array form by another + 'STACK_COMPOSE_KEY_CONFLICT', // a single-valued top-level key is declared with different values by two stacks + 'STACK_COMPOSE_OBJECT_CONFLICT', // the same object name is defined by more than one stack under the default `objectConflict: 'error'` ], } as const satisfies Record; diff --git a/packages/spec/src/compose-stacks-refusal-envelopes.test.ts b/packages/spec/src/compose-stacks-refusal-envelopes.test.ts new file mode 100644 index 00000000000..12b5371faae --- /dev/null +++ b/packages/spec/src/compose-stacks-refusal-envelopes.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every authored-entity refusal `composeStacks` raises carries an ADR-0112 + * envelope (#16348). + * + * ## What was wrong + * + * `defineStack`'s seven refusal sites got `code` / `status` in #15963. Its + * sibling in the same file did not: `composeStacks` refused six authored-entity + * conflicts with `new Error(message)`, both fields `undefined`. Two refusal + * families that are the same thing to an author — a stack refused at authoring + * time — and two different things to a consumer branching on `error.code`. + * + * Five of the six raise inside HELPER functions 300-800 lines above + * `composeStacks`' own body, which is why the family is located by its + * `composeStacks conflict:` MESSAGE PREFIX and not by reading the function the + * card names. + * + * ## What is pinned + * + * Per site: the ENVELOPE (`code`, `status: 422`); the message prefix + * byte-for-byte, through the finding the site now also carries structurally; + * `issues`, one entry per finding; and the CONTROL — the same composition with + * the one offending detail removed is ACCEPTED, so a refusal cannot satisfy the + * assertions for the wrong reason. + * + * The full message TEXT of these six is pinned, unchanged, by the suites that + * already read it: `compose-stacks-merge-collection-refusal.test.ts` and + * `compose-stacks-action-key-collision.test.ts` assert whole messages with + * `toBe`, `compose-stacks-i18n-merge.test.ts` and + * `compose-key-dispositions-export.pin.test.ts` assert the prose of the + * single-value refusal. This change adds fields; it rewords no sentence, and + * those suites are what proves it. + * + * Then a census over the whole file: six distinct `STACK_COMPOSE_*` codes, every + * `status` 422, no `name` spelled `ValidationError` (which + * `validationFailureDetails` in `@objectstack/types` duck-types as a RECORD + * validation failure and answers `400 VALIDATION_FAILED` + `fields[]` for), + * every code a member of the closed `ErrorCode` union — and the A/B SPLIT + * itself: exactly ONE bare `Error` is left in `stack.zod.ts`, the internal + * bookkeeping invariant, which stays bare deliberately. A seventh authored-entity + * refusal added bare would fail that census. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { composeStacks, defineStack, type ObjectStackDefinition } from './stack.zod'; +import { ERROR_CODE_LEDGER, ErrorCode } from './api/error-code-ledger.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 composition is accepted. */ +function refusal(fn: () => unknown): Envelope | null { + try { + fn(); + return null; + } catch (e) { + return e as Envelope; + } +} + +const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const }); + +const act = (name: string, extra: Record = {}) => + ({ name, label: name, type: 'script' as const, target: 'noop', ...extra }); + +// `as const` on the field type is load-bearing (see stack.test.ts): hoisted +// without it the literal widens to `string`, which the input type refuses. +const obj = (name: string, extra: Record = {}) => ({ + name, + label: name, + fields: { title: { type: 'text' as const } }, + ...extra, +}); + +/** A stack object that is NOT schema-validated — lets a case declare a partial key. */ +function raw(overrides: Record): ObjectStackDefinition { + return defineStack(overrides as never, { strict: false }); +} + +const A0 = "'com.example.a' (stack #0)"; +const B1 = "'com.example.b' (stack #1)"; + +/** The prefix the whole family carries, and the string this card located it by. */ +const PREFIX = 'composeStacks conflict: '; + +/** + * One row per refusal site. `refused` and `accepted` differ by the one detail + * the site checks; `finding` is the ENTIRE entry the refusal must carry in + * `issues`, and also the text that must follow the prefix in the message. + */ +const rows: Array<{ + site: string; + code: string; + finding: string; + refused: () => unknown; + accepted: () => unknown; +}> = [ + { + site: 'single-valued top-level key — two stacks declare it differently (composeSingleValue)', + code: 'STACK_COMPOSE_KEY_CONFLICT', + finding: `top-level key 'i18n' is declared with different values by ${A0} and ${B1}.`, + // `raw`: a partial `i18n` is the subject, not the shape — the sibling + // i18n suite reaches for the same escape for the same reason. + refused: () => + composeStacks([ + raw({ manifest: mf('com.example.a'), i18n: { defaultLocale: 'en' } }), + raw({ manifest: mf('com.example.b'), i18n: { defaultLocale: 'zh-CN' } }), + ]), + accepted: () => + composeStacks([ + raw({ manifest: mf('com.example.a'), i18n: { defaultLocale: 'en' } }), + raw({ manifest: mf('com.example.b'), i18n: { defaultLocale: 'en' } }), + ]), + }, + { + site: "functions shape — the map form in one stack, the array form in another (composeFunctions)", + code: 'STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT', + finding: `top-level key 'functions' is declared in the map form by ${A0} and in the array form by ${B1}.`, + refused: () => + composeStacks([ + raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }), + raw({ manifest: mf('com.example.b'), functions: [{ name: 'export' }] }), + ]), + accepted: () => + composeStacks([ + raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }), + raw({ manifest: mf('com.example.b'), functions: { export: () => 2 } }), + ]), + }, + { + site: 'function name — two stacks define one handler name (composeFunctions)', + code: 'STACK_COMPOSE_FUNCTION_CONFLICT', + finding: `function 'refresh' is defined by both ${A0} and ${B1}.`, + refused: () => + composeStacks([ + raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }), + raw({ manifest: mf('com.example.b'), functions: { refresh: () => 2 } }), + ]), + accepted: () => + composeStacks([ + raw({ manifest: mf('com.example.a'), functions: { refresh: () => 1 } }), + raw({ manifest: mf('com.example.b'), functions: { export: () => 2 } }), + ]), + }, + { + site: "object collection under objectConflict: 'merge' (refuseUnmergeableCollections)", + code: 'STACK_COMPOSE_COLLECTION_CONFLICT', + finding: + `object 'shared' is defined in multiple stacks and its 'actions' ` + + `is declared with different values by ${A0} and ${B1}.`, + refused: () => + composeStacks( + [ + defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { actions: [act('approve')] })] }), + defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { actions: [act('archive')] })] }), + ], + { objectConflict: 'merge' }, + ), + accepted: () => + composeStacks( + [ + defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { actions: [act('approve')] })] }), + defineStack({ manifest: mf('com.example.b'), objects: [obj('shared')] }), + ], + { objectConflict: 'merge' }, + ), + }, + { + site: "object name under the default objectConflict: 'error' (mergeObjects)", + code: 'STACK_COMPOSE_OBJECT_CONFLICT', + finding: "object 'shared' is defined in multiple stacks.", + refused: () => + composeStacks([ + defineStack({ manifest: mf('com.example.a'), objects: [obj('shared')] }), + defineStack({ manifest: mf('com.example.b'), objects: [obj('shared')] }), + ]), + accepted: () => + composeStacks([ + defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')] }), + defineStack({ manifest: mf('com.example.b'), objects: [obj('b_item')] }), + ]), + }, + { + site: 'cross-stack action key collision (collectComposedActionKeyCollisions)', + code: 'STACK_COMPOSE_ACTION_KEY_COLLISION', + finding: + "Action key 'global:shared_refresh' is declared by 2 stacks: " + + `${A0} at stack.actions[0] and ${B1} at stack.actions[0].`, + refused: () => + composeStacks([ + defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')], actions: [act('shared_refresh')] }), + defineStack({ manifest: mf('com.example.b'), objects: [obj('b_item')], actions: [act('shared_refresh')] }), + ]), + accepted: () => + composeStacks([ + defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')], actions: [act('a_refresh')] }), + defineStack({ manifest: mf('com.example.b'), objects: [obj('b_item')], actions: [act('b_refresh')] }), + ]), + }, +]; + +describe('#16348 — every composeStacks conflict 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('carries the finding in `issues`, one entry per finding', () => { + const refused = refusal(row.refused); + expect(refused?.issues).toEqual([row.finding]); + }); + + it('keeps the message byte-for-byte — the family prefix, then the finding', () => { + const message = refusal(row.refused)?.message ?? ''; + // The action-key collision renders its findings as `✗` lines under a + // counted header; the other five open with the finding itself. + const opening = + row.code === 'STACK_COMPOSE_ACTION_KEY_COLLISION' + ? `${PREFIX}cross-stack action key collision (1 issue):` + : `${PREFIX}${row.finding}`; + expect(message.startsWith(opening)).toBe(true); + expect(message).toContain(row.finding); + }); + + it('the same composition without the one offending detail is ACCEPTED — the control', () => { + expect(refusal(row.accepted)).toBeNull(); + }); + }); + } + + describe('census over all six composition refusal sites', () => { + it('six sites, six distinct STACK_COMPOSE_* codes, every status 422', () => { + const envelopes = rows.map((row) => refusal(row.refused)); + for (const envelope of envelopes) { + expect(envelope).toBeInstanceOf(Error); + expect(envelope?.code).toMatch(/^STACK_COMPOSE_[A-Z_]+$/); + expect(envelope?.status).toBe(422); + } + expect(new Set(envelopes.map((e) => e?.code)).size).toBe(6); + }); + + it('no site is named `ValidationError` — the record-validation duck-type in @objectstack/types', () => { + for (const row of rows) { + expect(refusal(row.refused)?.name).not.toBe('ValidationError'); + } + }); + + it('every code is a member of the closed `ErrorCode` union, registered under @objectstack/spec', () => { + // The #16404 ruling: a code that ships in `dist` is the published face, + // door or no door — so each refusal's spelling is a ledger row, and a + // consumer's `switch (e.code)` is exhaustive over the union it ships with. + for (const row of rows) { + const code = refusal(row.refused)?.code; + expect(ErrorCode.safeParse(code).success, `${code} parses against ErrorCode`).toBe(true); + expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain(code); + } + }); + + it('every message still carries the `composeStacks conflict:` prefix the family is located by', () => { + for (const row of rows) { + expect(refusal(row.refused)?.message.startsWith(PREFIX)).toBe(true); + } + }); + }); + + describe('the A/B split — the internal invariant stays a bare Error', () => { + // A source census, because the class B site is unreachable from the public + // API by construction: it fires only when `mergeObjects` records an object + // and `collectComposedActionKeyCollisions` then fails to find it, which is + // an edit to this file rather than an authored input. What can regress is + // a SEVENTH authored-entity refusal arriving bare — this is what catches it. + const source = readFileSync(fileURLToPath(new URL('./stack.zod.ts', import.meta.url)), 'utf8'); + + it('exactly one bare `throw new Error(` is left in stack.zod.ts', () => { + const bare = source.split('\n').filter((line) => line.includes('throw new Error(')); + expect(bare).toHaveLength(1); + expect(bare[0]).toContain('composeStacks internal error: no source stack recorded'); + }); + + it('it is an internal-invariant message, NOT an authored-entity refusal', () => { + // `composeStacks conflict:` is the authored-entity family; the internal + // invariant deliberately carries a different prefix and no envelope, + // because a 422 would tell an author their stack is unprocessable when + // the defect is ours. + const bare = source.split('\n').filter((line) => line.includes('throw new Error('))[0] ?? ''; + expect(bare).not.toContain(PREFIX); + }); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ee2c569c6fa..6972d7f8da1 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2145,6 +2145,147 @@ class StackTriggerCapabilityRequiredError extends StackRefusalError { } } +/** + * [ADR-0112 · #16348] The COMPOSITION half of the refusal family above. `composeStacks` + * refuses six authored-entity conflicts, every one of them carrying the + * literal `composeStacks conflict:` message prefix, and until this change + * every one threw `new Error(message)` with `code` and `status` both + * `undefined` — the silent shape the `defineStack` family shed one screen up, + * in this same file. Two refusal families that are the same thing to an author + * and two different things to a consumer is the asymmetry these six close. + * + * The envelope is the SAME one — {@link StackRefusalError}: `status: 422`, an + * unprocessable authored entity rather than a server fault, and the findings + * the site collected on `issues`, one entry per finding. The granularity is + * the one the per-stack family landed with and the triage reading that set it: + * ONE code per refusal site, never a shared `STACK_COMPOSE_CONFLICT` + * catch-all. The `boot-refusal` class in the dispatcher vocabulary was already + * at one-row-per-refusal granularity when that reading was taken, and + * {@link StackCrossReferenceError} is an instance of that granularity rather + * than an exception to it. + * + * ⭐ Why every member spells `STACK_COMPOSE_*` instead of continuing the + * per-stack family's `STACK__`: what these six refuse is a + * disagreement BETWEEN stacks, a condition `defineStack` cannot raise at all — + * each input is legal on its own, and the fix is always in the composition or + * in one of the two authors' packages, never in a single malformed stack. A + * bare `STACK_OBJECT_CONFLICT` would read as "this stack's object is + * malformed" and send a consumer to the wrong half of the artifact. + * {@link StackCrossReferenceError} is the deliberate exception in the other + * direction: its two raise sites (the per-stack pass and the ARTIFACT pass + * inside {@link composeStacks}) share one code because they are one rule + * family evaluated over two scopes, so the code names the rule and the message + * header names the pass. + * + * ⛔ The seventh bare `Error` in this file stays bare, and that is a reading + * rather than an omission: `composeStacks internal error: no source stack + * recorded for composed object …` inside + * {@link collectComposedActionKeyCollisions} is the code discovering its own + * bookkeeping is inconsistent, not an authored entity being refused. A 422 + * would tell an author their stack is unprocessable when the defect is ours. + * Whether it takes a 500-class envelope of its own is a separate decision. + * + * Every member is registered in the ADR-0112 ledger under `@objectstack/spec` + * (the #16404 ruling: a `code` that ships in `dist` is the published face, + * door or no door — a consumer's `catch (e) { switch (e.code) }` pins the + * spelling the moment it ships). `door: 'none'`, re-measured on the tree this + * landed against: `composeStacks` occurs 7 times in non-test + * `packages/runtime/src` + `packages/rest/src` source, all of them doc + * comments or message prose in one file and NONE of them a call site, with + * `defineStack` lighting the same probe 31 times across 8 files as the + * positive control. The only non-test caller on the tree is an example's + * `objectstack.config.ts`, which is authoring time by construction. + * + * ⛔ Module-local like every member above, and for the same reason: + * `packages/spec/src/index.ts` re-exports this module with `export *`, so an + * exported class would widen the published api-surface, while the ADR-0112 + * contract is the `code` / `status` pair every reader takes structurally. + * + * Message text is byte-for-byte what each bare `Error` carried. This adds the + * machine-readable half; it rewords no sentence, and the message-substring + * pins across this repo read the prose they always did. + */ +class StackComposeKeyConflictError extends StackRefusalError { + readonly code = 'STACK_COMPOSE_KEY_CONFLICT'; + + constructor(message: string, issues: readonly string[]) { + super('StackComposeKeyConflictError', message, issues); + } +} + +/** + * [ADR-0112 · #16348] `functions` is authored in the map form by one stack and the + * array form by another — {@link composeFunctions}. The two shapes are merged + * in kind and never converted, so the refusal is about the SHAPE, which is + * what the code says; a same-shape duplicate name is its sibling below. + */ +class StackComposeFunctionsShapeConflictError extends StackRefusalError { + readonly code = 'STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT'; + + constructor(message: string, issues: readonly string[]) { + super('StackComposeFunctionsShapeConflictError', message, issues); + } +} + +/** + * [ADR-0112 · #16348] Two stacks define a handler under the same name — + * {@link composeFunctions}. Handlers resolve by name at boot, so composing + * them would let one silently shadow the other. + */ +class StackComposeFunctionConflictError extends StackRefusalError { + readonly code = 'STACK_COMPOSE_FUNCTION_CONFLICT'; + + constructor(message: string, issues: readonly string[]) { + super('StackComposeFunctionConflictError', message, issues); + } +} + +/** + * [ADR-0112 · #16348] Under `objectConflict: 'merge'`, a later stack declares an + * object-level collection the composed object already carries with a + * DIFFERENT value — {@link refuseUnmergeableCollections}. Only `fields` is + * shallow-merged; every other collection would be replaced wholesale. + * Spelled for the collection, not the object: the object itself composes fine, + * one of its collections does not. + */ +class StackComposeCollectionConflictError extends StackRefusalError { + readonly code = 'STACK_COMPOSE_COLLECTION_CONFLICT'; + + constructor(message: string, issues: readonly string[]) { + super('StackComposeCollectionConflictError', message, issues); + } +} + +/** + * [ADR-0112 · #16348] The same object name is defined by more than one stack under the + * DEFAULT `objectConflict: 'error'` strategy — {@link mergeObjects}. The + * message names the two options that resolve it; the refusal is the strategy + * doing its job, which is why it carries the same envelope as the rest rather + * than a distinct class of its own. + */ +class StackComposeObjectConflictError extends StackRefusalError { + readonly code = 'STACK_COMPOSE_OBJECT_CONFLICT'; + + constructor(message: string, issues: readonly string[]) { + super('StackComposeObjectConflictError', message, issues); + } +} + +/** + * [ADR-0112 · #16348] Two stacks declare the same action key — the collision + * `defineStack` refuses within one stack, arriving one composition step later + * ({@link collectComposedActionKeyCollisions}). `issues` carries exactly what + * that walk collected: one entry per colliding key, naming every declaring + * stack and site, the same list the message renders as `✗` lines. + */ +class StackComposeActionKeyCollisionError extends StackRefusalError { + readonly code = 'STACK_COMPOSE_ACTION_KEY_COLLISION'; + + constructor(message: string, issues: readonly string[]) { + super('StackComposeActionKeyCollisionError', message, issues); + } +} + /** * Seed data → object references (#18202, ARTIFACT-SCOPED — see * {@link DefineStackOptions.artifactObjects}). @@ -3282,9 +3423,11 @@ function composeSingleValue( const held = (stacks[holder] as Record)[key]; if (deepEqualAuthored(held, value)) continue; - throw new Error( - `composeStacks conflict: top-level key '${key}' is declared with different values by ` + - `${stackLabel(stacks[holder], holder)} and ${stackLabel(stacks[i], i)}.\n` + + const finding = + `top-level key '${key}' is declared with different values by ` + + `${stackLabel(stacks[holder], holder)} and ${stackLabel(stacks[i], i)}.`; + throw new StackComposeKeyConflictError( + `composeStacks conflict: ${finding}\n` + `composeStacks does not pick a winner for single-valued top-level configuration: ` + `overriding would silently drop whichever declaration lost — a stricter setting ` + `(an 'api.enforceProjectMembership' 403 gate, a 'server.security.rateLimit' budget), ` + @@ -3292,6 +3435,7 @@ function composeSingleValue( `against — and deep-merging would produce a value neither stack declared.\n` + `Fix: make the two '${key}' declarations identical, or remove it from every stack ` + `except the one that should own it.`, + [finding], ); } @@ -3331,14 +3475,17 @@ function composeFunctions( const arrayForm = declaring.filter((d) => Array.isArray(d.value)); if (arrayForm.length !== 0 && arrayForm.length !== declaring.length) { const mapSide = declaring.find((d) => !Array.isArray(d.value))!; - throw new Error( - `composeStacks conflict: top-level key 'functions' is declared in the map form by ` + - `${stackLabel(stacks[mapSide.index], mapSide.index)} and in the array form by ` + - `${stackLabel(stacks[arrayForm[0].index], arrayForm[0].index)}.\n` + + const finding = + `top-level key 'functions' is declared in the map form by ` + + `${stackLabel(stacks[mapSide.index], mapSide.index)} and in the array form by ` + + `${stackLabel(stacks[arrayForm[0].index], arrayForm[0].index)}.`; + throw new StackComposeFunctionsShapeConflictError( + `composeStacks conflict: ${finding}\n` + `The two shapes cannot be merged without losing information (an array entry carries ` + `'packageId', the map entry does not).\n` + `Fix: author 'functions' in the same shape in both stacks — the map form ` + `({ my_handler: fn }) is preferred.`, + [finding], ); } @@ -3346,12 +3493,15 @@ function composeFunctions( const claim = (name: string, index: number): void => { const first = seen.get(name); if (first !== undefined) { - throw new Error( - `composeStacks conflict: function '${name}' is defined by both ` + - `${stackLabel(stacks[first], first)} and ${stackLabel(stacks[index], index)}.\n` + + const finding = + `function '${name}' is defined by both ` + + `${stackLabel(stacks[first], first)} and ${stackLabel(stacks[index], index)}.`; + throw new StackComposeFunctionConflictError( + `composeStacks conflict: ${finding}\n` + `Handlers are resolved by name at boot, so one would silently shadow the other.\n` + `Fix: rename one of them (prefix it with its package, e.g. 'crm_${name}'), or ` + `declare it in exactly one stack.`, + [finding], ); } seen.set(name, index); @@ -3633,10 +3783,12 @@ function refuseUnmergeableCollections( } if (deepEqualAuthored(held[key], value)) continue; - throw new Error( - `composeStacks conflict: object '${name}' is defined in multiple stacks and its '${key}' ` + - `is declared with different values by ${stackLabel(stacks[holder], holder)} and ` + - `${stackLabel(stacks[index], index)}.\n` + + const finding = + `object '${name}' is defined in multiple stacks and its '${key}' ` + + `is declared with different values by ${stackLabel(stacks[holder], holder)} and ` + + `${stackLabel(stacks[index], index)}.`; + throw new StackComposeCollectionConflictError( + `composeStacks conflict: ${finding}\n` + `objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection ` + `(${[...objectCollectionKeys()].join(', ')}) is not merged: the later declaration would ` + `replace the earlier one wholesale, silently dropping every entry ` + @@ -3644,6 +3796,7 @@ function refuseUnmergeableCollections( `Fix: declare '${key}' on '${name}' in exactly one of the two stacks, make the two ` + `declarations identical, or use { objectConflict: 'override' } to hand the whole object ` + `to the later stack.`, + [finding], ); } } @@ -3711,11 +3864,14 @@ function mergeObjects( } switch (strategy) { - case 'error': - throw new Error( - `composeStacks conflict: object '${obj.name}' is defined in multiple stacks. ` + + case 'error': { + const finding = `object '${obj.name}' is defined in multiple stacks.`; + throw new StackComposeObjectConflictError( + `composeStacks conflict: ${finding} ` + `Use { objectConflict: 'override' } or { objectConflict: 'merge' } to resolve.`, + [finding], ); + } case 'override': { // Replace in-place in the result array const idx = result.indexOf(existing); @@ -4262,7 +4418,10 @@ export function composeStacks( // would make every bound action collide with itself. const actionCollisions = collectComposedActionKeyCollisions(stacks, objects, actionsOwner); if (actionCollisions.length > 0) { - throw new Error(formatComposedActionKeyCollisions(actionCollisions)); + throw new StackComposeActionKeyCollisionError( + formatComposedActionKeyCollisions(actionCollisions), + actionCollisions, + ); } // 7. Bind every standalone action to its object — ONCE. Each input built by From 94d96729073723f13383259df26e9e82e10a212d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:56:34 +0000 Subject: [PATCH 2/3] chore(changeset): composeStacks refusal envelopes Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/composestacks-refusal-envelopes.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .changeset/composestacks-refusal-envelopes.md diff --git a/.changeset/composestacks-refusal-envelopes.md b/.changeset/composestacks-refusal-envelopes.md new file mode 100644 index 00000000000..e2189ac51df --- /dev/null +++ b/.changeset/composestacks-refusal-envelopes.md @@ -0,0 +1,57 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): every `composeStacks` conflict refusal carries an ADR-0112 envelope — six new `STACK_COMPOSE_*` codes beside the `defineStack` family + +`composeStacks` refuses six authored-entity conflicts, and until now every one of them threw +`new Error(message)` with `code` and `status` both `undefined`. The `defineStack` family in the +same file has carried the envelope since #15963, so `packages/spec/src/stack.zod.ts` held two +refusal families that are the same thing to an author — a stack refused at authoring time, +through the same callers — and two different things to a consumer branching on `error.code`. + +Every message in the family carries the literal `composeStacks conflict:` prefix, which is how it +is located: FIVE of the six raise inside helper functions 300-800 lines above `composeStacks`' +own body, so reading the function the defect is named after finds one of them. + +| refusal | raised by | code | +| :--- | :--- | :--- | +| a single-valued top-level key declared with different values by two stacks | `composeSingleValue` | `STACK_COMPOSE_KEY_CONFLICT` | +| `functions` authored in the map form by one stack, the array form by another | `composeFunctions` | `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT` | +| two stacks defining one handler name | `composeFunctions` | `STACK_COMPOSE_FUNCTION_CONFLICT` | +| under `objectConflict: 'merge'`, an object-level collection other than `fields` declared differently | `refuseUnmergeableCollections` | `STACK_COMPOSE_COLLECTION_CONFLICT` | +| the same object name in two stacks under the default `objectConflict: 'error'` | `mergeObjects` | `STACK_COMPOSE_OBJECT_CONFLICT` | +| a cross-stack action key collision | `collectComposedActionKeyCollisions` | `STACK_COMPOSE_ACTION_KEY_COLLISION` | + +Each carries `status: 422` — an unprocessable authored entity, not a server fault — and the +findings the site collected in `issues`, one entry per finding. **Message text is byte-for-byte +unchanged at every site**: this adds the machine-readable half, it rewords no sentence, and the +message pins across the repo read the prose they always did. + +One code per refusal site rather than a shared `STACK_COMPOSE_CONFLICT` catch-all — the +granularity the `defineStack` family landed with, and the granularity the ADR-0112 ledger's +boot-refusal class already had before it. The `STACK_COMPOSE_*` spelling says what the +per-stack family's spellings cannot: the defect is a disagreement BETWEEN stacks, each of which +is legal on its own, so the fix is in the composition rather than in one malformed stack. +`STACK_CROSS_REFERENCE_INVALID` stays the deliberate exception in the other direction — its +per-stack and artifact passes share one code because they are one rule family over two scopes. + +All six are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec`, under the ruling that +every code shipped in `dist` is the published face, door or no door. No wire door raises them: +`composeStacks` runs at authoring and boot time, and the reading was re-measured here — zero +`composeStacks` call sites under `packages/runtime/src` + `packages/rest/src` (7 non-test +occurrences, all doc comments or message prose in one file), with `defineStack` lighting the +same probe 31 times across 8 files as the positive control. + +Not narrowed: `composeStacks` accepts and refuses exactly the inputs it did before, and no export +changes — the error classes stay module-local, as every member of the `defineStack` family is, +because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112 +contract is the `code` / `status` pair read structurally. + +⛔ The seventh bare `Error` in that file is deliberately untouched: +`composeStacks internal error: no source stack recorded for composed object …` is the code +discovering its own bookkeeping is inconsistent, not an authored entity being refused. Filing it +at 422 would tell an author their stack is invalid when the defect is ours. Whether it takes a +500-class envelope of its own is a separate decision. + +Clause-②: yes From 669bdae64c333ca0f568fca1627fdc1cbe3b6dde Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 07:05:04 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the docs references for the six new ledger rows Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 8 +++++++- content/docs/references/api/error-code-ledger.mdx | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 1264d0c9010..27848a09d9a 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +325 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +331 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -335,6 +335,12 @@ const result = ApiErrorSchema.parse(data); * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` * `STACK_CAPABILITY_UNKNOWN` +* `STACK_COMPOSE_ACTION_KEY_COLLISION` +* `STACK_COMPOSE_COLLECTION_CONFLICT` +* `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT` +* `STACK_COMPOSE_FUNCTION_CONFLICT` +* `STACK_COMPOSE_KEY_CONFLICT` +* `STACK_COMPOSE_OBJECT_CONFLICT` * `STACK_CROSS_REFERENCE_INVALID` * `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` * `STACK_NAMESPACE_PREFIX_INVALID` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 8c14dac61bf..e6047e050b6 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -503,6 +503,12 @@ const result = ErrorCode.parse(data); * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` * `STACK_CAPABILITY_UNKNOWN` +* `STACK_COMPOSE_ACTION_KEY_COLLISION` +* `STACK_COMPOSE_COLLECTION_CONFLICT` +* `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT` +* `STACK_COMPOSE_FUNCTION_CONFLICT` +* `STACK_COMPOSE_KEY_CONFLICT` +* `STACK_COMPOSE_OBJECT_CONFLICT` * `STACK_CROSS_REFERENCE_INVALID` * `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` * `STACK_NAMESPACE_PREFIX_INVALID`