From 6e3d9cc17d74c86cf4c9faca82221fc27eb5b977 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:06:26 +0000 Subject: [PATCH 1/5] fix(spec): composeStacks refuses a non-array concatenated collection instead of dropping it Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 --- packages/spec/src/stack.zod.ts | 174 ++++++++++++++++++++------------- 1 file changed, 104 insertions(+), 70 deletions(-) diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 66d0db3fe4c..9b46ea3999b 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -13,7 +13,12 @@ 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 { normalizeStackInput, type MetadataCollectionInput, type MapSupportedField } from './shared/metadata-collection.zod'; +import { + normalizeStackInput, + MAP_SUPPORTED_FIELDS, + type MetadataCollectionInput, + type MapSupportedField, +} from './shared/metadata-collection.zod'; import type { ConversionNotice } from './conversions/types.js'; import { formatUnknownAuthoringKey } from './data/authoring-key-lint'; import { lintUnknownAuthoringKeys, lintUnknownStackKeys } from './kernel/metadata-authoring-lint'; @@ -2453,11 +2458,14 @@ class StackComposeActionKeyCollisionError extends StackRefusalError { * or a scalar. Those shapes are SKIPPED, never dereferenced: a rule whose job * is to resolve object references must not turn a malformed collection into a * bare `TypeError` with no `code` and no `status` — the refusal discipline of - * this file is the ADR-0112 envelope. A non-array `data` gets the same word - * {@link composeStacks}'s concat pass gives it ({@link warnMalformedCollectionKey}, - * #5005, deduplicated per key so the two passes speak once); an entry that is - * not an object, or whose `object` is not a string, carries no object - * reference for this rule to resolve and is simply not this rule's finding. + * this file is the ADR-0112 envelope. A non-array `data` no longer reaches + * here from either caller — the strict parse rejects it before + * {@link validateCrossReferences}, and {@link composeStacks}'s step 3 refuses it + * (#19784) before the artifact pass — so the `Array.isArray` guard below is the + * rule's own type guard, kept so it never depends on its caller's ordering, and + * says nothing because no caller can reach it. An entry that is not an object, + * or whose `object` is not a string, carries no object reference for this rule + * to resolve and is simply not this rule's finding. */ function collectSeedDataObjectErrors( config: ObjectStackDefinition, @@ -2466,10 +2474,7 @@ function collectSeedDataObjectErrors( const errors: string[] = []; const datasets: unknown = (config as { data?: unknown }).data; if (datasets === undefined || datasets === null) return errors; - if (!Array.isArray(datasets)) { - warnMalformedCollectionKey('data'); - return errors; - } + if (!Array.isArray(datasets)) return errors; for (const dataset of datasets) { if (!dataset || typeof dataset !== 'object') continue; const objectName: unknown = (dataset as { object?: unknown }).object; @@ -2497,9 +2502,10 @@ function collectSeedDataObjectErrors( * carrying CRUD on the RBAC link tables, ADR-0090 D12) — skip them here. * * `resolvable` carries the same two readings as its seed-data sibling above, - * and so does its shape guard: a non-array `permissions` is announced through - * {@link warnMalformedCollectionKey} and skipped, and a `permissions` entry - * that is not an object is skipped, because this rule reads + * and so does its shape guard: a non-array `permissions` is refused before + * either caller reaches this rule (the strict parse; {@link composeStacks}'s + * step 3, #19784), so its guard is a silent type guard like the sibling's, and + * a `permissions` entry that is not an object is skipped, because this rule reads * `permissions[].objects` and an unparsed input may carry neither. Same reason * as the sibling — a malformed collection must not become a bare `TypeError` * in the pass whose refusals are ADR-0112 envelopes. @@ -2511,10 +2517,7 @@ function collectPermissionGrantObjectErrors( const errors: string[] = []; const permissions: unknown = (config as { permissions?: unknown }).permissions; if (permissions === undefined || permissions === null) return errors; - if (!Array.isArray(permissions)) { - warnMalformedCollectionKey('permissions'); - return errors; - } + if (!Array.isArray(permissions)) return errors; for (const perm of permissions) { if (!perm || typeof perm !== 'object') continue; const grants = (perm as { objects?: Record }).objects; @@ -3674,38 +3677,56 @@ function composeFunctions( return { declared: true, value: merged }; } -const warnedMalformedCollectionKeys = new Set(); +/** + * The shared half of every non-array collection refusal in {@link composeStacks} + * (#18239 for `objects`, #19784 for the concatenated collections): what the + * value IS, in words the message can name, and the zod issue the strict parse + * would raise for it, re-rooted at the key's path — so a refusal raised here + * carries the same `issues` shape {@link StackSchemaInvalidError} carries from + * `defineStack`'s own door. + * @internal + */ +function describeNonArrayCollection( + key: string, + declared: unknown, +): { kind: string; issues: z.core.$ZodIssue[] } { + const parsed = z.array(z.unknown()).safeParse(declared); + const issues = parsed.success + ? [] + : parsed.error.issues.map((issue) => ({ ...issue, path: [key, ...issue.path] })); + const kind = + declared === null + ? 'null' + : typeof declared !== 'object' + ? `a ${typeof declared}` + : Object.getPrototypeOf(declared) === Object.prototype + ? 'an object' + : `a ${(declared as object).constructor?.name ?? 'non-plain'} object`; + return { kind, issues: issues as z.core.$ZodIssue[] }; +} + +const warnedMalformedCollectionEntries = new Set(); /** - * Report a collection key that carried a non-array value (#5005). - * - * The concat rule can only concatenate arrays, so such a value is skipped — - * and skipping it silently is the same defect in miniature. Only reachable - * with an unparsed stack (`strict: false`, hand-built object); the strict - * `defineStack` path rejects the shape outright. + * Report a collection whose array carries an entry that is not an object + * (#18239 — `mergeObjects`). + * + * The collection itself is well-formed, so composition goes on; the entry + * carries nothing to merge and is skipped, and skipping it silently would be + * the #5005 defect in miniature. Deduplicated per key. A non-array VALUE is no + * longer this helper's case at all: {@link composeStacks} refuses it (steps 2 + * and 3), because skipping a whole collection composes an artifact that + * silently lacks a stack's content (#19784). * @internal */ -function warnMalformedCollectionKey(key: string, shape: 'value' | 'entry' = 'value'): void { - // [#18239] `'entry'`: the collection IS an array but one of its entries is not - // an object (`mergeObjects`, where a non-array `objects` is refused instead). - // Deduplicated apart from the `'value'` notice, so neither silences the other. - const dedupKey = shape === 'value' ? key : `${key}[]`; - if (warnedMalformedCollectionKeys.has(dedupKey)) return; - warnedMalformedCollectionKeys.add(dedupKey); - if (shape === 'entry') { - console.warn( - `composeStacks: top-level key '${key}' is a collection but at least one stack carries an ` + - `entry in it that is not an object — that entry cannot be composed and was skipped. Author ` + - `every entry as an object, or run the stack through strict \`defineStack\` to have the ` + - `shape rejected where it is written.`, - ); - return; - } +function warnMalformedCollectionEntry(key: string): void { + if (warnedMalformedCollectionEntries.has(key)) return; + warnedMalformedCollectionEntries.add(key); console.warn( - `composeStacks: top-level key '${key}' is a collection (concatenated across stacks) but at ` + - `least one stack carries a non-array value for it — that value cannot be composed and was ` + - `skipped. Author it as an array, or run the stack through strict \`defineStack\` to have ` + - `the shape rejected where it is written.`, + `composeStacks: top-level key '${key}' is a collection but at least one stack carries an ` + + `entry in it that is not an object — that entry cannot be composed and was skipped. Author ` + + `every entry as an object, or run the stack through strict \`defineStack\` to have the ` + + `shape rejected where it is written.`, ); } @@ -4023,32 +4044,21 @@ function mergeObjects( const declared: unknown = (stack as { objects?: unknown }).objects; if (declared === undefined) continue; if (!Array.isArray(declared)) { - const parsed = z.array(z.unknown()).safeParse(declared); - const issues = parsed.success - ? [] - : parsed.error.issues.map((issue) => ({ ...issue, path: ['objects', ...issue.path] })); - const kind = - declared === null - ? 'null' - : typeof declared !== 'object' - ? `a ${typeof declared}` - : Object.getPrototypeOf(declared) === Object.prototype - ? 'an object' - : `a ${(declared as object).constructor?.name ?? 'non-plain'} object`; + const { kind, issues } = describeNonArrayCollection('objects', declared); throw new StackSchemaInvalidError( `composeStacks validation failed: ${stackLabel(stack, i)} declares 'objects' as ${kind}, ` + `not an array. Its objects cannot be composed, and skipping them would compose an artifact ` + `that silently lacks them. Author 'objects' as an array (\`defineStack\` normalizes the map ` + `form into one), or run the stack through strict \`defineStack\` to have the shape rejected ` + `where it is written.`, - issues as z.core.$ZodIssue[], + issues, ); } for (const obj of declared as Obj[]) { // A non-object ENTRY carries no object to merge — step 3's shape: skip it // and say so once, never dereference it into a bare `TypeError`. if (!isRecord(obj)) { - warnMalformedCollectionKey('objects', 'entry'); + warnMalformedCollectionEntry('objects'); continue; } const existing = map.get(obj.name); @@ -4508,28 +4518,52 @@ export function composeStacks( } // 3. Array collections — simple concatenation, in stack order. + // + // [ADR-0112 · #19784] A collection key holding something that is not an + // array cannot be concatenated, and it is REFUSED, never skipped — step 2's + // rule for `objects`, applied to every concatenated key: skipping the value + // composes an artifact that silently lacks that stack's grants, seed rows, + // views, … (measured per key for every `CONCAT_ARRAY_FIELDS` entry: the + // skipped value's content was absent from the composed top level, and under + // `manifest: 'preserve'` survived only inside that stack's package body, so + // the artifact disagreed with itself). A composed artifact is complete or it + // is refused. `defineStack` rejects the shape at its own door, so this is + // reachable only via `strict: false` or a hand-built stack object; the code + // is the strict parse's own (`STACK_SCHEMA_INVALID`), as in step 2. + // `undefined` is the one non-array that is not malformed: the key is absent. + // A non-object ENTRY inside an array is concatenated as-is — the entry is + // carried, not lost, so the composed content is unchanged. for (const field of CONCAT_ARRAY_FIELDS) { - const declared = stacks - .map((s) => (s as Record)[field]) - .filter((v) => v !== undefined); - const arrays = declared.filter((v): v is unknown[] => Array.isArray(v)); + const arrays: unknown[][] = []; + for (const [i, stack] of stacks.entries()) { + const declared: unknown = (stack as Record)[field]; + if (declared === undefined) continue; + if (!Array.isArray(declared)) { + const { kind, issues } = describeNonArrayCollection(field, declared); + throw new StackSchemaInvalidError( + `composeStacks validation failed: ${stackLabel(stack, i)} declares '${field}' as ${kind}, ` + + `not an array. Its '${field}' entries cannot be concatenated, and skipping them would ` + + `compose an artifact that silently lacks them. Author '${field}' as an array` + + ((MAP_SUPPORTED_FIELDS as readonly string[]).includes(field) + ? ` (\`defineStack\` normalizes the map form into one)` + : '') + + `, or run the stack through strict \`defineStack\` to have the shape rejected where it ` + + `is written.`, + issues, + ); + } + arrays.push(declared); + } if (arrays.length > 0) { composed[field] = arrays.flat(); } - // A collection key holding something that is not an array cannot be - // concatenated. `defineStack` rejects that shape, so this is only - // reachable via `strict: false` or a hand-built stack object — but - // dropping it without a word is the exact defect #5005 closes. - if (declared.length !== arrays.length) { - warnMalformedCollectionKey(field); - } } // 3a. `manifest: 'preserve'` — fold every input's package identity into the // artifact package list (ADR-0130 D4, follow-up row 3). // // Deliberately AFTER the concat pass, which owns `packages`' declared - // disposition and its malformed-value warning. For stacks that already + // disposition and its malformed-value refusal. For stacks that already // carry `packages`, preserve emits the same concatenation the pass just // computed; what it adds is the single-`manifest` stacks the pass has // nothing to concatenate for. Left undefined when there is nothing to From 3b7de329138fa9a7e4676a0f44ccb59822259e91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:08:30 +0000 Subject: [PATCH 2/5] test(spec): pin the concat-pass refusal for every concatenated key Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 --- ...ompose-stacks-concat-shape-refusal.test.ts | 157 ++++++++++++++++++ .../spec/src/compose-stacks-key-loss.test.ts | 21 ++- .../spec/src/stack-artifact-crossref.test.ts | 44 ++--- 3 files changed, 192 insertions(+), 30 deletions(-) create mode 100644 packages/spec/src/compose-stacks-concat-shape-refusal.test.ts diff --git a/packages/spec/src/compose-stacks-concat-shape-refusal.test.ts b/packages/spec/src/compose-stacks-concat-shape-refusal.test.ts new file mode 100644 index 00000000000..b84e7cd8dc4 --- /dev/null +++ b/packages/spec/src/compose-stacks-concat-shape-refusal.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `composeStacks` step 3 (the `concat` pass) refuses a stack whose value for a + * concatenated collection key is not an array (#19784 — ruling C, the + * follow-up the #18239 ruling routed here). + * + * ## What was wrong + * + * Step 3 kept only the array values it found for each `concat` key and + * announced the rest with a one-time `console.warn`. So a stack reaching + * composition with `permissions: { … }` or `data: 42` (a hand-built stack + * object, or `defineStack(config, { strict: false })`) composed "successfully" + * into an artifact that simply lacked that stack's grants or seed rows — and + * the same for every other concatenated key. Under `manifest: 'preserve'` the + * dropped value survived only inside that stack's package body, so the + * artifact's top level and its package list disagreed. + * + * ## What is pinned + * + * The ruling: 「A composed artifact is complete or it is refused」. Every key the + * composer concatenates (derived from `COMPOSE_KEY_DISPOSITIONS`, never + * transcribed — a key added to the table is covered the day it lands) refuses a + * non-array value with step 2's envelope for the same defect on `objects`: + * `STACK_SCHEMA_INVALID`, `status: 422`, one zod issue rooted at the key. + * + * Every refusal has its CONTROL: the same composition with the key authored as + * an array is accepted with both stacks' content, so no refusal can satisfy + * the assertions for the wrong reason. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { composeStacks, defineStack, COMPOSE_KEY_DISPOSITIONS, type ObjectStackDefinition } from './stack.zod'; + +type Envelope = Error & { + code?: string; + status?: number; + issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>; +}; + +/** 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 }); + +/** A stack object nobody parsed. */ +const handBuilt = (overrides: Record) => overrides as unknown as ObjectStackDefinition; + +/** A stack through `defineStack`'s `strict: false` door, which skips the parse. */ +const unparsed = (overrides: Record) => defineStack(overrides as never, { strict: false }); + +const CONCAT_KEYS = Object.entries(COMPOSE_KEY_DISPOSITIONS) + .filter(([, rule]) => rule === 'concat') + .map(([key]) => key); + +const B1 = "'com.example.b' (stack #1)"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('#19784 — composeStacks refuses a non-array concatenated collection', () => { + it('covers every concat key the composer declares (the table is the census, not a transcription)', () => { + expect(CONCAT_KEYS).toContain('permissions'); + expect(CONCAT_KEYS).toContain('data'); + expect(CONCAT_KEYS.length).toBeGreaterThanOrEqual(30); + }); + + for (const key of CONCAT_KEYS) { + describe(`'${key}'`, () => { + const a = () => handBuilt({ manifest: mf('com.example.a'), [key]: [{ name: 'a_item' }] }); + const b = (value: unknown) => handBuilt({ manifest: mf('com.example.b'), [key]: value }); + + it('a map-shaped value is refused with STACK_SCHEMA_INVALID / 422 — never skipped', () => { + const refused = refusal(() => composeStacks([a(), b({ b_item: { name: 'b_item' } })])); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toHaveLength(1); + expect(refused?.issues?.[0]?.path).toEqual([key]); + expect(refused?.issues?.[0]?.code).toBe('invalid_type'); + expect(refused?.issues?.[0]?.expected).toBe('array'); + expect(refused?.message.startsWith(`composeStacks validation failed: ${B1} declares '${key}'`)).toBe(true); + }); + + it("is refused under `manifest: 'preserve'` too", () => { + const refused = refusal(() => + composeStacks([a(), b({ b_item: { name: 'b_item' } })], { manifest: 'preserve' }), + ); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + }); + + it('the control — the same key authored as an array composes the entries of BOTH stacks', () => { + const composed = composeStacks([a(), b([{ name: 'b_item' }])]) as Record; + expect(composed[key]).toEqual([{ name: 'a_item' }, { name: 'b_item' }]); + }); + }); + } + + /** The value shapes, on the two keys the ruling's measurement named. */ + // A Set rides a hand-built stack only: `defineStack`'s map-form normalizer + // reads any object through `Object.entries`, so a Set entering through the + // `strict: false` door arrives here already flattened to `[]` — upstream of + // composition, and not this pass's finding. + const shapes: Array<{ label: string; value: () => unknown; door: typeof handBuilt }> = [ + { label: 'a number, through `strict: false`', value: () => 42, door: unparsed }, + { label: 'a string, through `strict: false`', value: () => 'not-an-array', door: unparsed }, + { label: 'null, through `strict: false`', value: () => null, door: unparsed }, + { label: 'false, through `strict: false`', value: () => false, door: unparsed }, + { label: 'a Set of entries, on a hand-built stack', value: () => new Set([{ name: 'b_item' }]), door: handBuilt }, + ]; + for (const key of ['permissions', 'data']) { + for (const shape of shapes) { + it(`'${key}' as ${shape.label} is refused, whichever position it holds`, () => { + const good = () => handBuilt({ manifest: mf('com.example.a'), [key]: [{ name: 'a_item' }] }); + const bad = () => shape.door({ manifest: mf('com.example.b'), [key]: shape.value() }); + for (const run of [() => composeStacks([good(), bad()]), () => composeStacks([bad(), good()])]) { + const refused = refusal(run); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues?.[0]?.path).toEqual([key]); + } + }); + } + } + + it('an ABSENT key is not a malformed one — composed from the stacks that declare it, silently', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const composed = composeStacks([ + handBuilt({ manifest: mf('com.example.a'), permissions: [{ name: 'a_item' }] }), + handBuilt({ manifest: mf('com.example.b'), permissions: undefined }), + ]); + expect(composed.permissions).toEqual([{ name: 'a_item' }]); + expect(warn).not.toHaveBeenCalled(); + }); + + it('a non-object ENTRY inside an array is carried as-is — the composed content is unchanged', () => { + const composed = composeStacks([ + handBuilt({ manifest: mf('com.example.a'), permissions: [{ name: 'a_item' }] }), + handBuilt({ manifest: mf('com.example.b'), permissions: [null, { name: 'b_item' }] }), + ]); + expect(composed.permissions).toEqual([{ name: 'a_item' }, null, { name: 'b_item' }]); + }); + + it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => { + const refused = refusal(() => defineStack({ manifest: mf('com.example.b'), permissions: 42 } as never)); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + }); +}); diff --git a/packages/spec/src/compose-stacks-key-loss.test.ts b/packages/spec/src/compose-stacks-key-loss.test.ts index 4aa793e7ae1..6d82c395db7 100644 --- a/packages/spec/src/compose-stacks-key-loss.test.ts +++ b/packages/spec/src/compose-stacks-key-loss.test.ts @@ -188,17 +188,22 @@ describe('#5005 rule 3 — a key with no declared rule warns', () => { expect(warnSpy.mock.calls.map((c) => String(c[0])).some((w) => w.includes("'futureList'"))).toBe(true); }); - it('warns rather than skipping a collection key that holds a non-array value', () => { + it('refuses — never skips — a collection key that holds a non-array value (#19784)', () => { + // Skipping it (the #5005 warn-and-drop) composed an artifact that silently + // lacked stack B's views; the full per-key census lives in + // `compose-stacks-concat-shape-refusal.test.ts`. const a = raw({ manifest: manifestA, views: [{ name: 'v1' }] }); const b = raw({ manifest: manifestB, views: 'not-an-array' }); - const composed = composeStacks([a, b]); - expect(composed.views).toHaveLength(1); - expect( - warnSpy.mock.calls - .map((c) => String(c[0])) - .some((w) => w.includes('composeStacks') && w.includes("'views'") && w.includes('cannot be composed')), - ).toBe(true); + let thrown: (Error & { code?: string; status?: number }) | undefined; + try { + composeStacks([a, b]); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + expect(thrown?.code).toBe('STACK_SCHEMA_INVALID'); + expect(thrown?.status).toBe(422); + expect(thrown?.message).toContain("'views'"); }); it('does NOT warn for keys the composer has a declared rule for', () => { diff --git a/packages/spec/src/stack-artifact-crossref.test.ts b/packages/spec/src/stack-artifact-crossref.test.ts index b1322f46416..64699d38c5a 100644 --- a/packages/spec/src/stack-artifact-crossref.test.ts +++ b/packages/spec/src/stack-artifact-crossref.test.ts @@ -396,18 +396,18 @@ describe('#18202 — an input that bypassed the strict parse IS checked at compo * The shape guard the two collectors carry (#18202 rework). * * Because the artifact pass reads `permissions` / `data` off inputs the strict - * parse never saw, those keys can be a non-array, and an entry can be `null` or - * a scalar. `composeStacks`'s step-3 concat pass already refuses to drop such a - * key without a word (#5005); the two collectors must not turn the same input - * into a bare `TypeError` with no `code` and no `status`, which is exactly what - * this pass did before the guards existed. Every case below composes on - * `origin/main`, so a throw here is a regression, not a stricter contract. + * parse never saw, an entry can be `null` or a scalar; the two collectors must + * not turn such an input into a bare `TypeError` with no `code` and no + * `status`, which is exactly what this pass did before the guards existed. + * Every entry-shape case below composes, so a throw there is a regression, not + * a stricter contract. * - * ⚠️ `warnMalformedCollectionKey` deduplicates per key for the lifetime of the - * module, so each key is asserted in exactly ONE test and the count assertion - * (`toBe(1)`) is what proves the two passes do not both speak. + * A non-array VALUE for either key is a different finding: since #19784 + * `composeStacks`'s step-3 concat pass REFUSES it with the ADR-0112 envelope + * before this pass runs — skipping it composed an artifact without that + * stack's grants or seed rows — so those two cases assert the refusal. */ -describe('#18202 — a malformed collection on an unparsed input is skipped, never a bare TypeError', () => { +describe('#18202 — a malformed collection on an unparsed input is skipped or refused, never a bare TypeError', () => { /** Collect `console.warn` for one call, restoring the real one afterwards. */ function warningsDuring(run: () => unknown): { warnings: string[]; thrown: Envelope | null } { const warnings: string[] = []; @@ -428,21 +428,21 @@ describe('#18202 — a malformed collection on an unparsed input is skipped, nev const composeWith = (stack: ReturnType) => () => composeStacks([serviceStack(), stack], { manifest: 'preserve' }); - it('a non-array `permissions` composes, and the key is warned about exactly once', () => { - // Map format, which `permissions` does not support — the shape a - // hand-built stack most plausibly carries. It is NOT iterable, which is - // what makes this the case that distinguishes the guard: a string value - // would iterate its characters and never throw either way. + it('a non-array `permissions` is refused by the concat pass, never a bare TypeError (#19784)', () => { + // Map format on a hand-built stack (only `defineStack` normalizes it). It + // is NOT iterable, which is what makes this the case that distinguishes a + // guard from a bare `TypeError`: a string value would iterate its + // characters and never throw either way. const mapShaped = { sales_rep: { label: 'Sales Rep', objects: { [NOWHERE]: { allowRead: true } } } }; - const { warnings, thrown } = warningsDuring(composeWith(malformed({ permissions: mapShaped }))); - expect(thrown).toBeNull(); - expect(warnings.filter((w) => w.includes("top-level key 'permissions'"))).toHaveLength(1); + const { thrown } = warningsDuring(composeWith(malformed({ permissions: mapShaped }))); + expect(thrown?.code).toBe('STACK_SCHEMA_INVALID'); + expect(thrown?.status).toBe(422); }); - it('a non-array `data` composes, and the key is warned about exactly once', () => { - const { warnings, thrown } = warningsDuring(composeWith(malformed({ data: 42 }))); - expect(thrown).toBeNull(); - expect(warnings.filter((w) => w.includes("top-level key 'data'"))).toHaveLength(1); + it('a non-array `data` is refused by the concat pass, never a bare TypeError (#19784)', () => { + const { thrown } = warningsDuring(composeWith(malformed({ data: 42 }))); + expect(thrown?.code).toBe('STACK_SCHEMA_INVALID'); + expect(thrown?.status).toBe(422); }); it('a null entry inside `permissions` is skipped, not dereferenced', () => { From a2c97839382edde9a49cb0cfb5f4912bb3f9e3a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:09:36 +0000 Subject: [PATCH 3/5] chore(changeset): composeStacks concat-pass refusal Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 --- .changeset/19784-concat-fields-refusal.md | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/19784-concat-fields-refusal.md diff --git a/.changeset/19784-concat-fields-refusal.md b/.changeset/19784-concat-fields-refusal.md new file mode 100644 index 00000000000..678d81d4be1 --- /dev/null +++ b/.changeset/19784-concat-fields-refusal.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `composeStacks` refuses a stack whose value for a concatenated collection (`permissions`, `data`, `views`, …) is not an array, with the ADR-0112 envelope + +**BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to compose with that stack's content silently missing. + +Step 3 of `composeStacks` concatenates every collection key the composer declares `concat` (`permissions`, `data`, `apps`, `views`, `flows`, `agents`, `packages`, … — every `'concat'` row of `COMPOSE_KEY_DISPOSITIONS`). It kept only the array values and announced the rest with a one-time `console.warn`. The strict `defineStack` parse already rejects a non-array value for any of these keys, so the reachable population is an input that bypassed it — a hand-built stack object, or `defineStack(config, { strict: false })`. Measured before this change, per key, composing a well-formed stack with one whose value for the key is a map: + +| the second stack's value | before | after | +| :--- | :--- | :--- | +| a map, a number, a string, `null`, `false`, a `Set` | composed; the composed collection lacks every entry of that stack (e.g. its permission-set grants, its seed rows), one `console.warn` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | +| the same, under `manifest: 'preserve'` | composed; the top-level collection lacks the entries, while that stack's package body still carries the malformed value — the artifact disagrees with itself | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + +A composed artifact is complete or it is refused, so no non-array value is skipped. An absent key (`undefined`) is not malformed and composes as before. The refusal is the one `composeStacks` already raises for a non-array `objects`: the code the strict parse raises for the same authored mistake, the zod issue on `issues` (`path: ['']`, `expected: 'array'`), and a message naming the stack by manifest id and position and the key. For a key `defineStack` accepts in the map form, the message says so. A non-object entry inside an array is still concatenated as-is — the entry is carried, not lost. + +The one-line fix: author the key as an array, or pass the stack through strict `defineStack` (which normalizes the map form and rejects every other shape where it is written). + +No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`, and the error class stays module-local. + + + +Clause-②: no (narrowing) From 46fc856355c4a9c8ca6dadf7e738478c62abf4de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:09:48 +0000 Subject: [PATCH 4/5] chore(changeset): spell the issue path in words Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 --- .changeset/19784-concat-fields-refusal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/19784-concat-fields-refusal.md b/.changeset/19784-concat-fields-refusal.md index 678d81d4be1..aa9c765bd37 100644 --- a/.changeset/19784-concat-fields-refusal.md +++ b/.changeset/19784-concat-fields-refusal.md @@ -13,7 +13,7 @@ Step 3 of `composeStacks` concatenates every collection key the composer declare | a map, a number, a string, `null`, `false`, a `Set` | composed; the composed collection lacks every entry of that stack (e.g. its permission-set grants, its seed rows), one `console.warn` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | | the same, under `manifest: 'preserve'` | composed; the top-level collection lacks the entries, while that stack's package body still carries the malformed value — the artifact disagrees with itself | refused, `STACK_SCHEMA_INVALID`, `status: 422` | -A composed artifact is complete or it is refused, so no non-array value is skipped. An absent key (`undefined`) is not malformed and composes as before. The refusal is the one `composeStacks` already raises for a non-array `objects`: the code the strict parse raises for the same authored mistake, the zod issue on `issues` (`path: ['']`, `expected: 'array'`), and a message naming the stack by manifest id and position and the key. For a key `defineStack` accepts in the map form, the message says so. A non-object entry inside an array is still concatenated as-is — the entry is carried, not lost. +A composed artifact is complete or it is refused, so no non-array value is skipped. An absent key (`undefined`) is not malformed and composes as before. The refusal is the one `composeStacks` already raises for a non-array `objects`: the code the strict parse raises for the same authored mistake, the zod issue on `issues` (`path` rooted at the key, `expected: 'array'`), and a message naming the stack by manifest id and position and the key. For a key `defineStack` accepts in the map form, the message says so. A non-object entry inside an array is still concatenated as-is — the entry is carried, not lost. The one-line fix: author the key as an array, or pass the stack through strict `defineStack` (which normalizes the map form and rejects every other shape where it is written). From 0bf2e5564619cd3e4d5b36541c66850205ffa2a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:22:31 +0000 Subject: [PATCH 5/5] chore(spec): re-record the shrunken test-typecheck debt Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 --- packages/spec/test-typecheck-debt.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 4e295b5ee94..a83ef5a6d08 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -30,8 +30,8 @@ "TS18046: 'branch' is of type 'unknown'.": 1 }, "src/compose-stacks-key-loss.test.ts": { - "TS7006: Parameter 'c' implicitly has an 'any' type.": 4, - "TS7006: Parameter 'w' implicitly has an 'any' type.": 3 + "TS7006: Parameter 'c' implicitly has an 'any' type.": 3, + "TS7006: Parameter 'w' implicitly has an 'any' type.": 2 }, "src/compose-stacks.test.ts": { "TS2322: Type '…' is not assignable to type 'undefined'.": 2,