From c9aff672081c23286326feb5924e8b3282c887d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:33:04 +0000 Subject: [PATCH 1/2] fix(spec): a refusing defineStack / composeStacks carries the conversions it applied on its refusal defineStack stamps the ADR-0087 conversion record, as it stood at the throw, on every ADR-0112 refusal it throws after its conversion pass (both modes); composeStacks stamps its inputs' records on its refusals. Same Symbol.for('objectstack.stack.conversions') key; stackConversionsOf reads it off a caught refusal. No second conversion pass, no stderr capture. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../spec/src/stack-conversions-record.test.ts | 281 +++++++++++++++++- packages/spec/src/stack-provenance.ts | 105 ++++++- packages/spec/src/stack.zod.ts | 109 +++++-- 3 files changed, 463 insertions(+), 32 deletions(-) diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts index 952e78e74d4..3bd2e633ba3 100644 --- a/packages/spec/src/stack-conversions-record.test.ts +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -21,7 +21,13 @@ * - it is dropped exactly where the mark is dropped, and a forged record on * an unmarked value is not read; * - `composeStacks`: the inputs' records concatenated in input order, one - * application once, at every arity and through nesting. + * application once, at every arity and through nesting; + * - a producer that REFUSES carries the record on its ADR-0112 refusal, as it + * stood at the throw: every `defineStack` refusal site (both modes), the + * `composeStacks` refusals (its inputs' records), the empty record when + * nothing was converted, and B's own notice when a refusing + * `defineStack(B)` inside `composeStacks([...])` had its stderr line + * swallowed by the warn-once. A non-refusal throw carries none. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { @@ -276,3 +282,276 @@ describe('composeStacks records its inputs’ records, in input order, one appli expect(stackConversionsOf(composed)).toEqual([]); }); }); + +/** The thrown value (the test fails when nothing is thrown). */ +function thrown(run: () => unknown): Error & { code?: string; status?: number; issues?: readonly unknown[] } { + try { + run(); + } catch (error) { + return error as Error & { code?: string; status?: number; issues?: readonly unknown[] }; + } + throw new Error('expected a refusal, but the call returned'); +} + +/** The record property as the stamp left it, or `undefined` when there is none. */ +const ownRecord = (value: object) => Object.getOwnPropertyDescriptor(value, Symbol.for('objectstack.stack.conversions')); + +describe('a refusing producer carries the conversions it applied on its refusal', () => { + it("⭐ triage pin: a convert-then-refuse call's error answers page-header-subtitle-alias", () => { + quiet(); + const error = thrown(() => + defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + ); + expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN'); + expect(error.status).toBe(422); + const record = stackConversionsOf(error); + expect(record.map(substance)).toEqual([HEADER_NOTICE]); + // The element is the whole `ConversionNotice`, the same shape a built stack carries. + expect(Object.keys(record[0]).sort()).toEqual( + ['code', 'conversionId', 'from', 'message', 'path', 'retiresIn', 'surface', 'to', 'toMajor'].sort(), + ); + // An error is not a built stack: the record rides without the mark. + expect(hasStackProvenance(error)).toBe(false); + }); + + it('⭐ triage pin: a refusal with no conversion answers an empty record — stamped, not merely absent', () => { + quiet(); + const error = thrown(() => + defineStack({ ...source('rf', 'subtitle'), requires: ['no-such-capability'] } as never), + ); + expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN'); + expect(stackConversionsOf(error)).toEqual([]); + // Anti-vacuity: `[]` is the refusal's own (empty) record, so the guard + // covered this throw — an unstamped error answers `[]` too. + const desc = ownRecord(error); + expect(desc, 'the refusal carries the record property').toBeDefined(); + expect(desc?.value).toEqual([]); + expect(Object.isFrozen(desc?.value)).toBe(true); + }); + + it('the refusal is otherwise the same refusal: code, status, name, message and issues are unchanged', () => { + quiet(); + const converting = thrown(() => + defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + ); + const canonical = thrown(() => + defineStack({ ...source('rf', 'subtitle'), requires: ['no-such-capability'] } as never), + ); + for (const key of ['code', 'status', 'name', 'message'] as const) { + expect(converting[key], key).toBe(canonical[key]); + } + expect(converting.issues).toEqual(canonical.issues); + expect(converting.message).toMatch(/^defineStack capability validation failed \(1 issue\):/); + }); + + it('the record on a refusal is invisible to data readers and frozen, like the one on a stack', () => { + quiet(); + const error = thrown(() => + defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + ); + const desc = ownRecord(error); + expect(desc?.enumerable).toBe(false); + expect(desc?.writable).toBe(false); + expect(desc?.configurable).toBe(false); + expect(Object.keys(error).some((k) => k.includes('conversion'))).toBe(false); + const record = stackConversionsOf(error); + expect(record, 'anti-vacuity: there is a record to freeze').toHaveLength(1); + expect(Object.isFrozen(record)).toBe(true); + expect(record.every((n) => Object.isFrozen(n))).toBe(true); + }); + + it('the record is what was applied so far: a built stack handed straight back and then refused keeps the record it arrived with', () => { + quiet(); + // Built without validation, so the capability refusal is still owed. + const inner = defineStack( + { ...source('rf', 'description'), requires: ['no-such-capability'] } as never, + { strict: false }, + ); + expect(stackConversionsOf(inner), 'anti-vacuity: the inner build recorded one').toHaveLength(1); + // Handed straight back, strict: the pass finds nothing left to convert, + // and the refusal carries the record the input arrived with. + const error = thrown(() => defineStack(inner as never)); + expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN'); + expect(stackConversionsOf(error)).toEqual(stackConversionsOf(inner)); + // Control: a spread copy drops the record with the mark, and its source is + // already canonical — so this call applied nothing, and says so. The + // record is the producer's, never reconstructed. + const copied = thrown(() => defineStack({ ...inner } as never)); + expect(copied.code).toBe('STACK_CAPABILITY_UNKNOWN'); + expect(stackConversionsOf(copied)).toEqual([]); + }); + + it("⭐ triage pin: in composeStacks([defineStack(A), defineStack(B)]) the refusing B's error carries B's own notice, though its stderr line was swallowed", () => { + const warn = quiet(); + const printed: string[][] = []; + const built: unknown[] = []; + /** `defineStack`, recording the stderr lines THIS call printed. */ + const define = (config: unknown) => { + const start = warn.mock.calls.length; + try { + const stack = defineStack(config as never); + built.push(stack); + return stack; + } finally { + printed.push(warn.mock.calls.slice(start).map((c) => String(c[0]))); + } + }; + const headerLines = (lines: string[]) => + lines.filter((l) => l.includes("conversion 'page-header-subtitle-alias'") && l.includes(HEADER_PATH)); + + // A and B author the same notice path, so their warn-once key is one key. + const error = thrown(() => + composeStacks([ + define(source('ca', 'description')), + define({ ...source('cb', 'description'), requires: ['no-such-capability'] }), + ] as ObjectStackDefinition[]), + ); + + // Where it surfaces: B's own `defineStack` refusal, thrown while the array + // was being built — `composeStacks` never ran. + expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN'); + expect(error.message).toMatch(/^defineStack capability validation failed/); + expect(built, 'only A was built').toHaveLength(1); + expect(printed).toHaveLength(2); + // B printed nothing: the warn-once set had the key already (from A, or + // from an earlier build of the same path in this process). + expect(headerLines(printed[1])).toEqual([]); + // …and its refusal still carries B's own notice — exactly one, and not A's object. + const record = stackConversionsOf(error); + expect(record.map(substance)).toEqual([HEADER_NOTICE]); + const aRecord = stackConversionsOf(built[0]); + expect(aRecord, 'anti-vacuity: A recorded the same notice').toHaveLength(1); + expect(record[0]).not.toBe(aRecord[0]); + }); +}); + +describe('every refusal site in defineStack carries the record — census, both modes', () => { + const manifest = { + id: 'com.example.refusalrecord', + name: 'refusal-record-test', + version: '1.0.0', + type: 'app' as const, + namespace: 'probe', + }; + const task = { name: 'probe_task', label: 'Task', fields: { title: { type: 'text' as const, label: 'Title' } } }; + const app = (name: string) => ({ + name, + label: name, + navigation: [{ id: `nav_${name}`, type: 'object' as const, label: 'Tasks', objectName: task.name }], + }); + const recordFlow = { + name: 'task_fanout', + label: 'task_fanout', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'start', config: { objectName: task.name, triggerType: 'record-after-create' } }, + { id: 'end', type: 'end', label: 'end' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; + /** The converting page every row carries: `description` on a `page:header`. */ + const pages = source('probe', 'description').pages; + const grant = { name: 'managers', label: 'Managers', objects: { [task.name]: { allowRead: true, readScope: 'unit_and_below' } } }; + + const rows: Array<{ site: string; code: string; config: Record; strict?: false }> = [ + { site: 'schema parse', code: 'STACK_SCHEMA_INVALID', config: { manifest: {}, pages } }, + { site: 'capability', code: 'STACK_CAPABILITY_UNKNOWN', config: { manifest, objects: [task], pages, requires: ['automations'] } }, + { + site: 'cross-reference', + code: 'STACK_CROSS_REFERENCE_INVALID', + config: { manifest, objects: [task], pages, data: [{ object: 'missing_object', records: [] }] }, + }, + { site: 'namespace prefix', code: 'STACK_NAMESPACE_PREFIX_INVALID', config: { manifest, objects: [{ ...task, name: 'task' }], pages } }, + { site: 'single app', code: 'STACK_SINGLE_APP_VIOLATION', config: { manifest, objects: [task], pages, apps: [app('app_one'), app('app_two')] } }, + { site: 'hierarchy-scope capability', code: 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED', config: { manifest, objects: [task], pages, permissions: [grant] } }, + { + site: 'trigger capability', + code: 'STACK_TRIGGER_CAPABILITY_REQUIRED', + config: { manifest, objects: [task], pages, requires: ['automation'], flows: [recordFlow] }, + }, + { site: 'bound-action merge, strict: false — objects not an array', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: 'nope', pages }, strict: false }, + { site: 'bound-action merge, strict: false — an objects entry not an object', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: [null], pages }, strict: false }, + { site: 'bound-action merge, strict: false — actions not an array', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: [task], actions: 7, pages }, strict: false }, + ]; + + for (const row of rows) { + it(`${row.site}: ${row.code} carries the header notice`, () => { + quiet(); + const error = thrown(() => defineStack(row.config as never, row.strict === false ? { strict: false } : undefined)); + expect(error.code).toBe(row.code); + expect(error.status).toBe(422); + expect(stackConversionsOf(error).map(substance)).toEqual([HEADER_NOTICE]); + }); + } + + it('the census reaches every code defineStack refuses with: seven distinct codes', () => { + expect(new Set(rows.map((r) => r.code)).size).toBe(7); + }); +}); + +describe('a composeStacks refusal carries its inputs’ records', () => { + const build = (ns: string, headerKey: 'description' | 'subtitle', objectNs = ns) => { + quiet(); + const stack = source(ns, headerKey); + return defineStack( + { ...stack, objects: [{ ...stack.objects[0], name: `${objectNs}_thing` }] } as never, + { strict: false }, + ); + }; + + it('an object conflict: the inputs’ records, in input order — the record the artifact would have carried', () => { + const a = build('ka', 'description', 'kk'); + const b = build('kb', 'subtitle', 'kk'); + const c = build('kc', 'description', 'kk'); + const error = thrown(() => composeStacks([a, b, c] as ObjectStackDefinition[])); + expect(error.code).toBe('STACK_COMPOSE_OBJECT_CONFLICT'); + expect(error.status).toBe(422); + expect(stackConversionsOf(error)).toEqual([...stackConversionsOf(a), ...stackConversionsOf(c)]); + expect(stackConversionsOf(error).map(substance)).toEqual([HEADER_NOTICE, HEADER_NOTICE]); + }); + + it('an unbuilt input: the built inputs’ records ride the provenance refusal', () => { + const a = build('pa', 'description'); + const error = thrown(() => composeStacks([a, { ...a }] as ObjectStackDefinition[])); + expect(error.code).toBe('STACK_PROVENANCE_MISSING'); + expect(stackConversionsOf(error)).toEqual(stackConversionsOf(a)); + expect(stackConversionsOf(error), 'anti-vacuity').toHaveLength(1); + }); + + it('⭐ control: inputs that converted nothing refuse with an empty record — stamped', () => { + const error = thrown(() => + composeStacks([build('za', 'subtitle', 'zz'), build('zb', 'subtitle', 'zz')] as ObjectStackDefinition[]), + ); + expect(error.code).toBe('STACK_COMPOSE_OBJECT_CONFLICT'); + expect(stackConversionsOf(error)).toEqual([]); + expect(ownRecord(error)?.value).toEqual([]); + }); + + it('a non-refusal throw is not given a record: the options parse error carries none', () => { + const a = build('oa', 'description'); + const b = build('ob', 'subtitle'); + const error = thrown(() => + composeStacks([a, b] as ObjectStackDefinition[], { objectConflict: 'nope' } as never), + ); + expect(error.code, 'not an ADR-0112 refusal').toBeUndefined(); + expect(ownRecord(error)).toBeUndefined(); + expect(stackConversionsOf(error)).toEqual([]); + }); +}); + +describe('stackConversionsOf reads a refusal’s record and nothing that merely looks like one', () => { + it('an Error nobody stamped answers []', () => { + expect(stackConversionsOf(new Error('x'))).toEqual([]); + }); + + it('a record inherited through the prototype, not stamped on the error itself, is not read', () => { + quiet(); + const refusal = thrown(() => + defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + ); + expect(stackConversionsOf(refusal), 'anti-vacuity: the stamped refusal is read').toHaveLength(1); + const heir = Object.create(refusal) as Error; + expect(heir).toBeInstanceOf(Error); + expect(stackConversionsOf(heir)).toEqual([]); + }); +}); diff --git a/packages/spec/src/stack-provenance.ts b/packages/spec/src/stack-provenance.ts index b42824619de..89278c468c9 100644 --- a/packages/spec/src/stack-provenance.ts +++ b/packages/spec/src/stack-provenance.ts @@ -79,6 +79,20 @@ import type { ConversionNotice } from './conversions/types.js'; * nor the compiled artifact carries it; every copy that drops the mark drops * the record with it, and an unmarked value has no record ({@link * stackConversionsOf} answers `[]`). + * + * ## The same record on a refusal + * + * A producer that REFUSES returns no stack, yet the conversions it applied + * before refusing are just as real — and a door that catches the refusal is + * where the author reads both. So a producer stamps the record of what it + * applied so far, under the same symbol key and with the same properties, on + * the ADR-0112 refusal it throws (the `StackRefusalError` family in + * `stack.zod.ts`), and {@link stackConversionsOf} reads it off the caught + * error. It is the producer's own record at the throw, never a second + * conversion pass, and never the stderr line (which is warn-once per process, + * so a refusal whose notice an earlier stack already printed would lose it). + * The refusal carries NO mark: an error is not a built stack, so + * {@link hasStackProvenance} still answers `false` for it. */ /** The producers whose output is a judged stack. */ @@ -135,13 +149,50 @@ export function markStackProvenance( writable: false, configurable: false, }); + stampConversionRecord(target as object, conversions); + return target; +} + +/** + * Stamp the record of the ADR-0087 conversions a producer applied BEFORE it + * refused on the refusal it is about to throw, and return that refusal — the + * refusing half of {@link markStackProvenance}: same key, same properties, + * same frozen `ConversionNotice[]`, and no mark (see the module header). + * + * `conversions` is the record as it stands at the throw — what was applied so + * far — so a refusal whose source needed no conversion carries the empty + * record, stamped, rather than none: every refusal a producer throws answers + * the same question the same way. + * + * The first stamp wins. A refusal that already carries its own record is + * returned unchanged: the innermost producer's record is the one that + * describes what was applied to the source that refused, and the property is + * non-configurable, so a second stamp could not replace it anyway. A + * non-extensible error cannot take the property and is returned as-is. + * + * Internal to the two producers: NOT re-exported from the package entry. + */ +export function markRefusalConversions( + refusal: E, + conversions: readonly ConversionNotice[], +): E { + if (Object.prototype.hasOwnProperty.call(refusal, STACK_CONVERSIONS)) return refusal; + if (!Object.isExtensible(refusal)) return refusal; + stampConversionRecord(refusal, conversions); + return refusal; +} + +/** + * The one writer of the record's property, shared by both stamps so the stack + * and the refusal can never carry two shapes of it. + */ +function stampConversionRecord(target: object, conversions: readonly ConversionNotice[]): void { Object.defineProperty(target, STACK_CONVERSIONS, { value: conversions.length === 0 ? NO_CONVERSIONS : Object.freeze(conversions.map((notice) => Object.freeze(notice))), enumerable: false, writable: false, configurable: false, }); - return target; } /** @@ -179,21 +230,49 @@ export function hasStackProvenance(value: unknown): boolean { * once). A notice's `path` is relative to the `defineStack` call that * applied it — the source the author wrote — not to the composed artifact. * A single input is returned as-is, record included. + * - **A refusal** — the ADR-0112 error a producer THROWS instead of returning + * a stack (`code` + `status: 422`) — carries the conversions that producer + * applied before it refused: the record the stack would have carried, as it + * stood at the throw. A door reads it off the error its catch-all caught, + * `stackConversionsOf(error)`. `defineStack` stamps every refusal it throws + * after its conversion pass, in both modes; `composeStacks` stamps every + * refusal it throws with its inputs' records, by the rule above. A refusal + * whose source needed no conversion answers `[]`. + * + * `[]` for a value no producer returned or threw (the doors refuse an + * unbuilt stack anyway, with `STACK_PROVENANCE_MISSING`), and for a stack + * whose source needed no conversion. Like the mark, the record does not + * survive a spread or JSON copy — read it off the value the producer returned + * or threw, BEFORE any merge or serialisation. * - * `[]` for a value no producer returned (the doors refuse it anyway, with - * `STACK_PROVENANCE_MISSING`), and for a stack whose source needed no - * conversion. Like the mark, the record does not survive a spread or JSON - * copy — read it off the value the producer returned, BEFORE any merge. - * - * ⚠️ What it cannot hold: a `defineStack` call that REFUSES returns no stack, - * so the conversions it applied before refusing reach stderr only; and a key - * merged onto the stack after the producer ran (a config module's named - * export, say) was never seen by it, which is why a door still runs its own - * pass over the merged stack and folds this record in beside that pass's - * findings. + * ⚠️ What it cannot hold: a key merged onto the stack after the producer ran + * (a config module's named export, say) was never seen by it, which is why a + * door still runs its own pass over the merged stack and folds this record in + * beside that pass's findings. And a throw that is NOT one of the producers' + * ADR-0112 refusals carries no record, however many conversions were applied + * before it: an error some other layer raised, a non-refusal error raised + * inside a producer (`composeStacks`' own options parse throws a bare zod + * error), or anything thrown while a config module loads outside a producer. + * The stderr line is no substitute for any of these — it is warn-once per + * process, so a notice an earlier stack already printed is not printed again. */ export function stackConversionsOf(value: unknown): readonly ConversionNotice[] { - if (!hasStackProvenance(value)) return NO_CONVERSIONS; + if (!hasStackProvenance(value) && !isStampedRefusal(value)) return NO_CONVERSIONS; const record = (value as Record)[STACK_CONVERSIONS]; return Array.isArray(record) ? (record as readonly ConversionNotice[]) : NO_CONVERSIONS; } + +/** + * An error carrying the record as its OWN property — what + * {@link markRefusalConversions} leaves on a producer's refusal. The record is + * read off a value a producer stamped it on, and only two kinds exist: a + * built stack (under the mark) and an error it threw. A plain object carrying + * the key without the mark is neither, and is not read. + * + * `instanceof Error`, not the refusal class: a CLI and the config it loads may + * resolve two copies of this package, so the refusal's class is not the + * reader's — but both run in one realm, where `Error` is one constructor. + */ +function isStampedRefusal(value: unknown): boolean { + return value instanceof Error && Object.prototype.hasOwnProperty.call(value, STACK_CONVERSIONS); +} diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index f65940912d1..f916adad1fe 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -13,7 +13,7 @@ import { hasPlatformObjectPrefix } from './system/constants/platform-object-name import { objectStackErrorMap, formatZodError } from './shared/error-map.zod'; import { strictObject } from './shared/strict-object'; import { deepEqualAuthored } from './shared/deep-equal'; -import { markStackProvenance, hasStackProvenance, stackConversionsOf } from './stack-provenance'; +import { markStackProvenance, markRefusalConversions, hasStackProvenance, stackConversionsOf } from './stack-provenance'; import { normalizeStackInput, MAP_SUPPORTED_FIELDS, @@ -3587,6 +3587,60 @@ function warnEmailTemplateLocaleFloor(data: ObjectStackDefinition): void { export function defineStack( config: ObjectStackDefinitionInput, options?: DefineStackOptions, +): ObjectStackDefinition { + // Every ADR-0087 D2 conversion this call applies is RECORDED, beside the + // provenance mark on the stack it returns (`stackConversionsOf`, + // `stack-provenance.ts`): the stack leaves here already canonical, so a door + // that reports conversions — the `--json` `conversions` field, + // `os validate --strict` — can learn what was converted only from this + // producer. The record starts from the input's own record, so a built stack + // handed straight back here keeps what its first build applied (the pass + // finds nothing left to convert on it); it is not subject to the stderr + // warn-once. + // + // A call that REFUSES returns no stack, so the record rides on the refusal + // instead: every ADR-0112 refusal thrown below — the schema parse, the six + // cross-field refusals, and the bound-action merge's shape refusal that ends + // BOTH modes — carries the conversions applied so far, and + // `stackConversionsOf(error)` reads them off the caught error. The same + // array, as it stands at the throw: never a second conversion pass. + const appliedConversions: ConversionNotice[] = [...stackConversionsOf(config)]; + try { + return buildDefinedStack(config, options, appliedConversions); + } catch (error) { + throw withRefusalConversions(error, () => appliedConversions); + } +} + +/** + * [ADR-0087 · ADR-0112] The one rule for which throw carries a producer's + * conversion record, applied in each producer's `catch` before it rethrows: a + * member of the {@link StackRefusalError} family — the producers' own answer + * to authored input — is stamped with the record + * (`markRefusalConversions`, `stack-provenance.ts`); anything else is returned + * untouched. Either way it is the SAME object, rethrown, so its `code`, + * `status`, `issues`, message and stack are exactly what the throw site built. + * A non-refusal throw is not the producer's answer (a bare zod error from an + * options parse, an internal invariant), so it is not given a record that + * would read as one. + * + * `conversions` is lazy so a producer computes its record only when there is + * a refusal to carry it. + */ +function withRefusalConversions(error: unknown, conversions: () => readonly ConversionNotice[]): unknown { + if (error instanceof StackRefusalError) markRefusalConversions(error, conversions()); + return error; +} + +/** + * The body of {@link defineStack}. `appliedConversions` is the caller's + * record, pushed to as the conversion pass runs, so the caller holds what was + * applied so far whichever line below throws. + */ +function buildDefinedStack( + config: ObjectStackDefinitionInput, + options: DefineStackOptions | undefined, + appliedConversions: ConversionNotice[], ): ObjectStackDefinition { // Default to strict=true for safety (validate by default) const strict = options?.strict !== false; @@ -3595,17 +3649,8 @@ export function defineStack( // surface every ADR-0087 D2 conversion the pass had to apply. Unlike the alias // warning below this runs in BOTH modes: a conversion happens whether or not // we go on to parse, so `strict: false` does not make the old shape any less - // retiring. - // - // Each notice is also RECORDED on the stack returned below, beside the - // provenance mark (`stackConversionsOf`, `stack-provenance.ts`): the stack - // leaves here already canonical, so a door that reports conversions — the - // `--json` `conversions` field, `os validate --strict` — can learn what was - // converted only from this producer. The record starts from the input's own - // record, so a built stack handed straight back here keeps what its first - // build applied (the pass below finds nothing left to convert on it); it - // is not subject to the stderr warn-once. - const appliedConversions: ConversionNotice[] = [...stackConversionsOf(config)]; + // retiring. Each notice is pushed to the caller's record (see + // {@link defineStack}) as well as printed, warn-once, on stderr. const normalized = normalizeStackInput(config as Record, { onConversionNotice: (notice) => { appliedConversions.push(notice); @@ -5118,6 +5163,37 @@ function collectArtifactCrossReferenceErrors( export function composeStacks( stacks: ObjectStackDefinition[], options?: ComposeStacksOptions, +): ObjectStackDefinition { + // [ADR-0087 · ADR-0112] A composition that REFUSES carries the conversion + // record the artifact would have carried — its inputs' records, by the rule + // the return below uses ({@link composedConversions}) — so a door that + // catches a composition conflict still reports what the inputs' own + // `defineStack` calls converted. Composition converts nothing itself, so the + // record is complete from the first line; `stackConversionsOf(error)` reads + // it off the caught refusal. + try { + return composeBuiltStacks(stacks, options); + } catch (error) { + throw withRefusalConversions(error, () => composedConversions(stacks)); + } +} + +/** + * The conversion record of a composition: its inputs' records, concatenated + * in input order, one application once — a `Set` over the (frozen, + * identity-kept) notices counts the same built stack passed twice once. Each + * notice's `path` stays relative to the `defineStack` call that applied it. + * One formula for the artifact {@link composeStacks} returns and for the + * refusal it throws. + */ +function composedConversions(stacks: readonly unknown[]): readonly ConversionNotice[] { + return [...new Set(stacks.flatMap((stack) => stackConversionsOf(stack)))]; +} + +/** The body of {@link composeStacks}. */ +function composeBuiltStacks( + stacks: ObjectStackDefinition[], + options?: ComposeStacksOptions, ): ObjectStackDefinition { // 0. [#20367 ruling B] Every input must be a stack a producer built. The // per-stack refusals run only inside `defineStack`, so an input that never @@ -5333,12 +5409,9 @@ export function composeStacks( // a producer's output too: a nested `composeStacks` or an author-time door // accepts it. // - // Its conversion record is its inputs' records, concatenated in input order. + // Its conversion record is its inputs' records ({@link composedConversions}). // Composition converts nothing itself — every input arrived canonical from // its own `defineStack` — so this is the whole of what was applied to build - // the artifact. A `Set` over the (frozen, identity-kept) notices counts one - // application once when the same built stack is passed twice; each notice's - // `path` stays relative to the `defineStack` call that applied it. - const conversions = [...new Set(stacks.flatMap((stack) => stackConversionsOf(stack)))]; - return markStackProvenance(artifact, 'composeStacks', conversions) as ObjectStackDefinition; + // the artifact. + return markStackProvenance(artifact, 'composeStacks', composedConversions(stacks)) as ObjectStackDefinition; } From 9deca562963059cd3070719a310aa63851e6083f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:46:47 +0000 Subject: [PATCH 2/2] chore(changeset): spec patch for the refusal-carried conversion record Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20618-refusal-carries-conversions.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/20618-refusal-carries-conversions.md diff --git a/.changeset/20618-refusal-carries-conversions.md b/.changeset/20618-refusal-carries-conversions.md new file mode 100644 index 00000000000..66ff1a35be2 --- /dev/null +++ b/.changeset/20618-refusal-carries-conversions.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": patch +--- + +**A `defineStack` or `composeStacks` call that refuses now carries the ADR-0087 conversions it applied on the error it throws, so `stackConversionsOf(error)` reads them off a caught refusal.** + +`defineStack` rewrites a deprecated metadata spelling to its canonical shape before it validates, and records each conversion on the stack it returns (`stackConversionsOf(stack)`). A call that then refused returned no stack, so the conversions it had applied were lost: they reached stderr only, as a warn-once line that a second stack with the same path does not print again. A tool that catches the refusal, such as a `--json` door, had no way to report both the refusal and the retiring spelling. + +- `defineStack` (strict and `strict: false`) stamps the conversions applied so far on every ADR-0112 refusal it throws after its conversion pass: the schema parse, the six cross-field refusals and the bound-action merge's shape refusal. The record is the same `ConversionNotice[]` a built stack carries, under the same symbol key, non-enumerable and frozen. A refusal whose source needed no conversion carries an empty record. +- `composeStacks` stamps its inputs' records on every refusal it throws, by the same rule it uses for the artifact it returns. +- `stackConversionsOf(value)` now also reads the record off such a refusal: `catch (error) { const conversions = stackConversionsOf(error); }`. It still answers `[]` for any other value, including a plain `Error` and a throw that is not one of these refusals. + +Nothing is accepted or refused differently. Each refusal keeps its `code`, `status`, `name`, message and `issues`, and `hasStackProvenance` still answers `false` for it. No export is added. + +Clause-②: no