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
26 changes: 26 additions & 0 deletions .changeset/19785-merge-actions-shape-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': minor
---

fix(spec): `defineStack(config, { strict: false })` refuses a non-array `objects` with the ADR-0112 envelope

**BREAKING** — `defineStack`, a public root export, now refuses under `strict: false` a class of input it used to crash on or hand on unusable: a non-array `objects`, or an `objects` array holding an entry that is not an object.

The non-strict door skips the parse and hands the normalized input to the action merge that ends every `defineStack` call. That merge read `objects` with no shape guard. Measured before this change:

| `objects` under `strict: false` | before | after |
| :--- | :--- | :--- |
| a number or a string (`5`, `'abc'`) | bare `TypeError: config.objects.map is not a function`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
| `null`, `''`, `0`, `false` | returned untouched, refused one call later by `composeStacks` | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
| an array holding `null` (`[null, obj]`) | bare `TypeError` reading `actions` off `null` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` |
| an array holding another non-object (`[obj, 7]`, `[obj, 'x']`) | returned with the entry in place, a success whose objects are not all objects | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` |

`strict: false` skips validation — cross-references and schema detail — and never promised to accept a shape the merge cannot read. The refusal carries the code the strict parse raises for the same authored mistake, with the zod issue on `issues` — `path: ['objects']`, `expected: 'array'` for the collection, `path: ['objects', index]`, `expected: 'object'` for each non-object entry — the same line `composeStacks` draws for a non-array `objects`. Every row narrows: nothing that used to be refused is accepted now. An absent `objects` (`undefined`) is not malformed and behaves as before; the map form (`{ name: { … } }`) is still normalized to an array first and accepted.

Fix: author `objects` as an array of object definitions or in the map form, or drop `strict: false` to have every schema check run.

No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`.

<!-- 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 builds exactly as before; what narrows is the runtime behaviour of the strict: false door on inputs that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->

Clause-②: no (narrowing)
11 changes: 7 additions & 4 deletions packages/spec/src/compose-stacks-objects-shape-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ const rows: Array<{ label: string; build: () => ObjectStackDefinition }> = [
{ label: 'a hand-built stack whose `objects` is a map', build: () => handBuilt({ manifest: mf('com.example.b'), objects: { b_item: obj('b_item') } }) },
{ label: 'a hand-built stack whose `objects` is a number', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 5 }) },
{ label: 'a hand-built stack whose `objects` is a Set of objects', build: () => handBuilt({ manifest: mf('com.example.b'), objects: new Set([obj('b_item')]) }) },
{ label: '`strict: false` with `objects: null`', build: () => unparsed({ manifest: mf('com.example.b'), objects: null }) },
{ label: "`strict: false` with `objects: ''`", build: () => unparsed({ manifest: mf('com.example.b'), objects: '' }) },
{ label: '`strict: false` with `objects: 0`', build: () => unparsed({ manifest: mf('com.example.b'), objects: 0 }) },
{ label: '`strict: false` with `objects: false`', build: () => unparsed({ manifest: mf('com.example.b'), objects: false }) },
// The falsy rows reach composition hand-built: since #19785 the `strict:
// false` door refuses them itself (`define-stack-non-strict-objects-shape-refusal.test.ts`),
// so it can no longer carry them this far.
{ label: 'a hand-built stack whose `objects` is null', build: () => handBuilt({ manifest: mf('com.example.b'), objects: null }) },
{ label: "a hand-built stack whose `objects` is ''", build: () => handBuilt({ manifest: mf('com.example.b'), objects: '' }) },
{ label: 'a hand-built stack whose `objects` is 0', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 0 }) },
{ label: 'a hand-built stack whose `objects` is false', build: () => handBuilt({ manifest: mf('com.example.b'), objects: false }) },
];

describe('#18239 — composeStacks refuses a non-array `objects` with an ADR-0112 envelope', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `defineStack(config, { strict: false })` refuses a non-array `objects` with
* an ADR-0112 envelope (#19785).
*
* ## What was wrong
*
* The non-strict door skips the parse and hands the normalized input straight
* to `mergeActionsIntoObjects`, whose only guard was
* `if (!config.objects || config.objects.length === 0)`. A truthy non-array
* (`5`, `'abc'`) went on to `config.objects.map(…)` and raised a bare
* `TypeError` — `code` and `status` both `undefined`, outside the envelope
* every other refusal in the file carries — and a `null` ENTRY raised one
* reading `actions` off it. A falsy non-array (`null`, `''`, `0`, `false`) was
* handed on untouched, to be refused one door later by `composeStacks`.
*
* ## What is pinned
*
* `strict: false` skips validation; it never promised to accept a shape the
* merge cannot read. So every non-array `objects` except an absent one is
* refused here with the strict parse's own envelope — `STACK_SCHEMA_INVALID`,
* `status: 422`, the zod issue on `issues` at `path: ['objects']` — the same
* line `composeStacks` step 2 draws for the same key.
*
* A non-object ENTRY is refused with the same envelope, one zod issue per
* entry at `path: ['objects', index]` (`expected: 'object'`): handing it on
* would return a success whose objects are not all objects, and the next
* consumer (plugin-object registration) drops every object after it.
*
* Every refusal has its CONTROL: the same stack with `objects` authored as an
* array, or as the map form, is accepted and its bound actions are merged.
*/
import { describe, it, expect } from 'vitest';
import { defineStack } 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 call is accepted. */
function refusal(fn: () => unknown): Envelope | null {
try {
fn();
return null;
} catch (e) {
return e as Envelope;
}
}

const manifest = { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'app' as const };

const obj = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } });

const bound = { name: 'b_open', label: 'Open', type: 'url' as const, target: 'https://example.com', objectName: 'b_item' };

const nonStrict = (overrides: Record<string, unknown>) =>
defineStack({ manifest, ...overrides } as never, { strict: false });

const rows: Array<{ label: string; objects: unknown }> = [
{ label: 'a number', objects: 5 },
{ label: 'a string', objects: 'abc' },
{ label: 'null', objects: null },
{ label: "''", objects: '' },
{ label: '0', objects: 0 },
{ label: 'false', objects: false },
];

describe('#19785 — defineStack strict: false refuses a non-array `objects` with an ADR-0112 envelope', () => {
for (const row of rows) {
it(`\`objects\` as ${row.label} is refused with code STACK_SCHEMA_INVALID and status 422, the issue at ['objects']`, () => {
const refused = refusal(() => nonStrict({ objects: row.objects, actions: [bound] }));
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(['objects']);
expect(refused?.issues?.[0]?.code).toBe('invalid_type');
expect(refused?.issues?.[0]?.expected).toBe('array');
expect(refused?.message).toContain("'objects'");
});
}

it('the control — `objects` as an array is accepted and the bound action is merged into its object', () => {
const stack = nonStrict({ objects: [obj('b_item')], actions: [bound] });
expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']);
});

it('the control — the map form is normalized first and accepted the same way', () => {
const stack = nonStrict({ objects: { b_item: obj('b_item') }, actions: [bound] });
expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']);
});

it('an ABSENT `objects` is not a malformed one — accepted', () => {
const stack = nonStrict({ actions: [bound] });
expect(stack.objects).toBeUndefined();
expect(stack.actions?.map((a) => a.name)).toEqual(['b_open']);
});

it('a non-object ENTRY is refused with the same envelope, one issue per entry at [objects, index] — never handed on, never a bare TypeError', () => {
const refused = refusal(() => nonStrict({ objects: [null, obj('b_item'), 7], actions: [bound] }));
expect(refused).toBeInstanceOf(Error);
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
expect(refused?.status).toBe(422);
expect(refused?.issues?.map((issue) => issue.path)).toEqual([
['objects', 0],
['objects', 2],
]);
expect(refused?.issues?.every((issue) => issue.code === 'invalid_type' && issue.expected === 'object')).toBe(true);
});

it('a single non-object entry beside a good one is refused too — the card\'s `[null, obj]` repro', () => {
const refused = refusal(() => nonStrict({ objects: [null, obj('b_item')] }));
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
expect(refused?.status).toBe(422);
expect(refused?.issues?.map((issue) => issue.path)).toEqual([['objects', 0]]);
});

it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => {
const refused = refusal(() => defineStack({ manifest, objects: 5 } as never));
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
expect(refused?.status).toBe(422);
});
});
48 changes: 48 additions & 0 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2947,6 +2947,54 @@ function sortActionsByOrder<T extends { order?: number }>(actions: T[]): T[] {
* @internal
*/
function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefinition {
// [ADR-0112 · #19785] Shape guard, because `defineStack(config, { strict:
// false })` hands this merge the normalized input with no parse in between.
// A non-array `objects` is REFUSED with the strict parse's own envelope
// (`STACK_SCHEMA_INVALID`, 422, the zod issue at `['objects']`) — never a bare
// `TypeError` from `.map`, and never a pass-through: bound actions cannot be
// merged into objects that are not a list, and a stack handed on in that
// shape is one `composeStacks` refuses with this same code anyway. `strict:
// false` skips VALIDATION (cross-references, schema detail); it never
// promised to accept a shape this merge cannot read. `undefined` is the one
// non-array that is not malformed — the key is simply absent — the same line
// `mergeObjects` draws for the same key.
const declaredObjects: unknown = (config as { objects?: unknown }).objects;
if (declaredObjects !== undefined && !Array.isArray(declaredObjects)) {
const { kind, issues } = describeNonArrayCollection('objects', declaredObjects);
throw new StackSchemaInvalidError(
`defineStack validation failed: 'objects' is ${kind}, not an array. Bound actions cannot be ` +
`merged into it, and \`strict: false\` skips validation, not this shape — \`composeStacks\` ` +
`refuses the same stack with the same code. Author 'objects' as an array or in the map form ` +
`(\`{ name: { … } }\`), or drop \`strict: false\` to have every schema check run.`,
issues,
);
}

// [#19785] A non-object ENTRY is refused with the same envelope, one zod
// issue per entry at `['objects', index]` (`expected: 'object'`) — the
// strict parse's own answer for it. Never handed on: the merge cannot read
// it, and a stack returned with it is a success whose objects are not all
// objects — the next consumer (plugin-object registration) then drops every
// object after it inside one warn-level catch, which is concealment, not
// leniency.
if (Array.isArray(declaredObjects) && declaredObjects.some((entry) => !isRecord(entry))) {
const parsed = z.array(z.looseObject({})).safeParse(declaredObjects);
const issues = parsed.success
? []
: parsed.error.issues.map((issue) => ({ ...issue, path: ['objects', ...issue.path] }));
const positions = declaredObjects
.map((entry, index) => (isRecord(entry) ? null : `#${index} (${entry === null ? 'null' : Array.isArray(entry) ? 'an array' : `a ${typeof entry}`})`))
.filter((position): position is string => position !== null);
throw new StackSchemaInvalidError(
`defineStack validation failed: 'objects' holds ${positions.length === 1 ? 'an entry' : 'entries'} ` +
`that ${positions.length === 1 ? 'is' : 'are'} not an object — ${positions.join(', ')}. Bound actions ` +
`cannot be merged into such an entry, and \`strict: false\` skips validation, not this shape. Author ` +
`every entry of 'objects' as an object definition, or drop \`strict: false\` to have every schema ` +
`check run.`,
issues as z.core.$ZodIssue[],
);
}

// Honour `order` on the preserved top-level actions regardless of objects.
const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions;
const topChanged = sortedTop !== config.actions;
Expand Down
Loading