Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/19784-concat-fields-refusal.md
Original file line number Diff line number Diff line change
@@ -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` 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).

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.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, Zod schema, export, config field or stored metadata shape is added, removed, renamed or re-spelled. The strict defineStack parse already refused every input this refuses, so an authored stack that passed its schema composes exactly as before; what narrows is the runtime behaviour of composeStacks on inputs that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->

Clause-②: no (narrowing)
157 changes: 157 additions & 0 deletions packages/spec/src/compose-stacks-concat-shape-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => overrides as unknown as ObjectStackDefinition;

/** A stack through `defineStack`'s `strict: false` door, which skips the parse. */
const unparsed = (overrides: Record<string, unknown>) => 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<string, unknown>;
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);
});
});
21 changes: 13 additions & 8 deletions packages/spec/src/compose-stacks-key-loss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
44 changes: 22 additions & 22 deletions packages/spec/src/stack-artifact-crossref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -428,21 +428,21 @@ describe('#18202 — a malformed collection on an unparsed input is skipped, nev
const composeWith = (stack: ReturnType<typeof defineStack>) => () =>
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', () => {
Expand Down
Loading
Loading