Skip to content

Commit ebc6afe

Browse files
fix(spec): defineStack strict:false refuses a non-array objects with an ADR-0112 envelope (#19798)
Fixes #19785 Clause-②: no (narrowing) `defineStack(config, { strict: false })` now refuses a non-array `objects`, and an `objects` array holding a non-object entry, with the ADR-0112 envelope: `StackSchemaInvalidError`, `code: 'STACK_SCHEMA_INVALID'`, `status: 422`, and the zod issue at `path: ['objects']` (non-array) or one per entry at `path: ['objects', index]` with `expected: 'object'` (non-object entry). Before, a number or a string raised a bare `TypeError: config.objects.map is not a function` from `mergeActionsIntoObjects`, and a `null` entry raised one reading `actions` off it. No new error code; the error class stays module-local. ## Refuse or skip: the card's first question **Refuse.** Here is what `DefineStackOptions.strict` declares for `false`: "validation is skipped for maximum flexibility (e.g., when views reference objects provided by other plugins). Use this ONLY when you need to bypass validation for advanced use cases." It promises that validation is skipped: cross-references and schema detail. It does not promise to accept, or quietly skip, a shape the action merge cannot read. No ADR is cited there. So this is not a contract-direction question, and the #18239 ruling (`5690859601`) applies one door earlier: 「an ADR-0112 envelope error (closed `error.code`, status), ⛔ not a bare `TypeError` and ⛔ not a skip」. ## What changed `packages/spec/src/stack.zod.ts`, inside `mergeActionsIntoObjects` only: 1. **Array guard.** Any `objects` that is present and not an array is refused. `undefined` is the only non-array that is not malformed, because the key is absent. This is the line #19783 drew for `mergeObjects`. It includes the falsy values `null`, `''`, `0` and `false`, which used to be handed on untouched and then refused one call later by `composeStacks` with this same code. Refusing `5` while passing `0` would repeat the silent-skip row #19783 removed. The map form (`{ name: { ... } }`) is normalized to an array before the merge and is still accepted. 2. **Non-object entries (rework round 1).** An entry that is not an object (`null`, `7`, a string, an array) is refused with the same envelope: one zod issue per such entry at `['objects', index]`, `expected: 'object'` (read from `z.array(z.looseObject({}))`, re-rooted at `objects`, which is the strict parse's own answer for the shape). The message names each position. Round 0 handed such an entry on untouched. The contract review showed that this widens the accept set under a `(narrowing)` declaration: the card's own `[null, obj]` repro threw before and would have succeeded. It would also let the next consumer, plugin-object registration in `packages/objectql/src/engine.ts` (one loop in one warn-level catch), silently drop every object after the bad entry. Now every row narrows. Measured on the base `3f9e2eaa1c`, against the built `dist`: | `objects` under `strict: false` | before | after | | :-- | :-- | :-- | | `5`, `'abc'` | `TypeError: config.objects.map is not a function`, `code`/`status` undefined | `STACK_SCHEMA_INVALID`, 422 | | `null`, `''`, `0`, `false` | returned untouched | `STACK_SCHEMA_INVALID`, 422 | | `[null, obj]` | `TypeError: Cannot read properties of null (reading 'actions')` | `STACK_SCHEMA_INVALID`, 422, issue at `['objects', 0]` | | `[obj, 7]` | returned with the entry in place | `STACK_SCHEMA_INVALID`, 422, issue at `['objects', 1]` | | map form, array, absent | accepted | accepted (unchanged) | ## Tests - New file: `packages/spec/src/define-stack-non-strict-objects-shape-refusal.test.ts`. It has 6 non-array refusal rows asserting `code` + `status` + the issue (`path`, `invalid_type`, `expected: 'array'`). Two entry refusal rows cover `[null, obj, 7]`, with issues exactly at `['objects', 0]` and `['objects', 2]`, all `invalid_type` / `expected: 'object'`, plus `[null, obj]`. Controls: array, map form and absent `objects` are all accepted with the bound action merged, and the strict door raises the same code for the same shape. - `packages/spec/src/compose-stacks-objects-shape-refusal.test.ts` (#19783's file): its four falsy rows reached composition through `strict: false`. That door now refuses those shapes itself, so the rows now reach composition as hand-built stacks. They keep the same values and the same assertions. This is a fixture re-route, and it is outside the claim's declared file surface. I am declaring it here; the contract review accepted it. - **Ablation, round 0** (commit `d3959b7b79`): `stack.zod.ts` restored from the base, with the new file run against it: `Tests 7 failed | 4 passed (11)`. The refusal rows failed with `expected undefined to be 'STACK_SCHEMA_INVALID'` and the entry row with a bare `TypeError`. Restored, `git diff HEAD` empty. - **Ablation, round 1** (commit `c5a865c769`): `scripts/ablation-replace.mjs` replaced the entry guard's condition. The anchor went x1 to x0 and the blob `88f36c9846` became `ac5fbf58be`. Result: `Tests 2 failed | 10 passed (12)`. Both entry rows failed with `expected undefined to be 'STACK_SCHEMA_INVALID'` (a bare `TypeError` reached the assertion), and every other row stayed green. Restore: blob back to the HEAD blob `88f36c9846`, `git diff HEAD` empty. - At `c5a865c769` (merge of `origin/main` `fae870352e`): - `pnpm --filter @objectstack/spec build` + `typecheck`: exit 0. The test layer compiles, and `test-typecheck-debt.json` held. - The two targeted files: 37/37. - Full spec suite: `Test Files 552 passed (552)`, `Tests 15690 passed | 1 todo`. - eslint `--no-inline-config` over the 3 touched TS files: 3 files, 0 errors, 0 warnings. Type-aware linting is not enabled in `eslint.config.mjs`, so this diff cannot move the verdict on any untouched file. - `dispatch-gates --commands` derived 82 commands (the same list as round 0). 80 exit 0. 2 are NOT MEASURED with exit 3 (they need the whole-workspace build): `check:dual-build-cjs-loads` and `check:type-check-debt`. `--ran` reconcile: 82 accounted, 0 UNRUN. - **Round 1b, at `ce893bbf31`** (merge of `origin/main` `0b83e01627`, which includes #19794, then the helper routing): - The targeted files plus #19794's `compose-stacks-concat-shape-refusal.test.ts`: 162/162. - Spec build + typecheck: exit 0. - Full spec suite: `Test Files 553 passed (553)`, `Tests 15815 passed | 1 todo`. - Ablation of the array guard: `Tests 6 failed | 6 passed (12)` (all 6 non-array rows). Ablation of the entry guard: `Tests 2 failed | 10 passed (12)`. Both restores ended at blob == HEAD `ac2a452f57`, `git diff HEAD` empty. - eslint: 3 files, 0 errors. - Gates: 82 derived, 80 exit 0, the same 2 NOT MEASURED, 0 UNRUN. - `origin/main` has since moved one commit, `de4ed33fd5` (docs(pm), #19795). It is not merged. ## Acceptance notes - `class: a`, not fixed here, filed by the seat as #19799. The same function still dereferences the other shapes it reads under `strict: false`: a top-level `actions` of `5` or `'abc'`, or `actions: [null]`, raises a TypeError from `sortActionsByOrder`. - Review item 4 is done at `ce893bbf31`. PR #19794 landed (`0b83e01627`), so the non-array block now calls its `describeNonArrayCollection('objects', …)` helper, with no hand-copied kind/issues code left. Messages are byte-stable for every input that can reach this door. The helper's one extra branch, `'an object'` for a plain object, is unreachable here because `normalizeStackInput` turns the map form into an array first. - `strict: false` with a `Set` as `objects` still returns without throwing; the normalizer reads it as a keyless map. This was recorded on #19783 and is not part of this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6bfe011 commit ebc6afe

4 files changed

Lines changed: 207 additions & 4 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `defineStack(config, { strict: false })` refuses a non-array `objects` with the ADR-0112 envelope
6+
7+
**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.
8+
9+
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:
10+
11+
| `objects` under `strict: false` | before | after |
12+
| :--- | :--- | :--- |
13+
| 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` |
14+
| `null`, `''`, `0`, `false` | returned untouched, refused one call later by `composeStacks` | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
15+
| 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]` |
16+
| 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]` |
17+
18+
`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.
19+
20+
Fix: author `objects` as an array of object definitions or in the map form, or drop `strict: false` to have every schema check run.
21+
22+
No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`.
23+
24+
<!-- 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. -->
25+
26+
Clause-②: no (narrowing)

‎packages/spec/src/compose-stacks-objects-shape-refusal.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,13 @@ const rows: Array<{ label: string; build: () => ObjectStackDefinition }> = [
8181
{ label: 'a hand-built stack whose `objects` is a map', build: () => handBuilt({ manifest: mf('com.example.b'), objects: { b_item: obj('b_item') } }) },
8282
{ label: 'a hand-built stack whose `objects` is a number', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 5 }) },
8383
{ 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')]) }) },
84-
{ label: '`strict: false` with `objects: null`', build: () => unparsed({ manifest: mf('com.example.b'), objects: null }) },
85-
{ label: "`strict: false` with `objects: ''`", build: () => unparsed({ manifest: mf('com.example.b'), objects: '' }) },
86-
{ label: '`strict: false` with `objects: 0`', build: () => unparsed({ manifest: mf('com.example.b'), objects: 0 }) },
87-
{ label: '`strict: false` with `objects: false`', build: () => unparsed({ manifest: mf('com.example.b'), objects: false }) },
84+
// The falsy rows reach composition hand-built: since #19785 the `strict:
85+
// false` door refuses them itself (`define-stack-non-strict-objects-shape-refusal.test.ts`),
86+
// so it can no longer carry them this far.
87+
{ label: 'a hand-built stack whose `objects` is null', build: () => handBuilt({ manifest: mf('com.example.b'), objects: null }) },
88+
{ label: "a hand-built stack whose `objects` is ''", build: () => handBuilt({ manifest: mf('com.example.b'), objects: '' }) },
89+
{ label: 'a hand-built stack whose `objects` is 0', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 0 }) },
90+
{ label: 'a hand-built stack whose `objects` is false', build: () => handBuilt({ manifest: mf('com.example.b'), objects: false }) },
8891
];
8992

9093
describe('#18239 — composeStacks refuses a non-array `objects` with an ADR-0112 envelope', () => {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `defineStack(config, { strict: false })` refuses a non-array `objects` with
5+
* an ADR-0112 envelope (#19785).
6+
*
7+
* ## What was wrong
8+
*
9+
* The non-strict door skips the parse and hands the normalized input straight
10+
* to `mergeActionsIntoObjects`, whose only guard was
11+
* `if (!config.objects || config.objects.length === 0)`. A truthy non-array
12+
* (`5`, `'abc'`) went on to `config.objects.map(…)` and raised a bare
13+
* `TypeError` — `code` and `status` both `undefined`, outside the envelope
14+
* every other refusal in the file carries — and a `null` ENTRY raised one
15+
* reading `actions` off it. A falsy non-array (`null`, `''`, `0`, `false`) was
16+
* handed on untouched, to be refused one door later by `composeStacks`.
17+
*
18+
* ## What is pinned
19+
*
20+
* `strict: false` skips validation; it never promised to accept a shape the
21+
* merge cannot read. So every non-array `objects` except an absent one is
22+
* refused here with the strict parse's own envelope — `STACK_SCHEMA_INVALID`,
23+
* `status: 422`, the zod issue on `issues` at `path: ['objects']` — the same
24+
* line `composeStacks` step 2 draws for the same key.
25+
*
26+
* A non-object ENTRY is refused with the same envelope, one zod issue per
27+
* entry at `path: ['objects', index]` (`expected: 'object'`): handing it on
28+
* would return a success whose objects are not all objects, and the next
29+
* consumer (plugin-object registration) drops every object after it.
30+
*
31+
* Every refusal has its CONTROL: the same stack with `objects` authored as an
32+
* array, or as the map form, is accepted and its bound actions are merged.
33+
*/
34+
import { describe, it, expect } from 'vitest';
35+
import { defineStack } from './stack.zod';
36+
37+
type Envelope = Error & {
38+
code?: string;
39+
status?: number;
40+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>;
41+
};
42+
43+
/** The thrown value, or `null` when the call is accepted. */
44+
function refusal(fn: () => unknown): Envelope | null {
45+
try {
46+
fn();
47+
return null;
48+
} catch (e) {
49+
return e as Envelope;
50+
}
51+
}
52+
53+
const manifest = { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'app' as const };
54+
55+
const obj = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } });
56+
57+
const bound = { name: 'b_open', label: 'Open', type: 'url' as const, target: 'https://example.com', objectName: 'b_item' };
58+
59+
const nonStrict = (overrides: Record<string, unknown>) =>
60+
defineStack({ manifest, ...overrides } as never, { strict: false });
61+
62+
const rows: Array<{ label: string; objects: unknown }> = [
63+
{ label: 'a number', objects: 5 },
64+
{ label: 'a string', objects: 'abc' },
65+
{ label: 'null', objects: null },
66+
{ label: "''", objects: '' },
67+
{ label: '0', objects: 0 },
68+
{ label: 'false', objects: false },
69+
];
70+
71+
describe('#19785 — defineStack strict: false refuses a non-array `objects` with an ADR-0112 envelope', () => {
72+
for (const row of rows) {
73+
it(`\`objects\` as ${row.label} is refused with code STACK_SCHEMA_INVALID and status 422, the issue at ['objects']`, () => {
74+
const refused = refusal(() => nonStrict({ objects: row.objects, actions: [bound] }));
75+
expect(refused).toBeInstanceOf(Error);
76+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
77+
expect(refused?.status).toBe(422);
78+
expect(refused?.issues).toHaveLength(1);
79+
expect(refused?.issues?.[0]?.path).toEqual(['objects']);
80+
expect(refused?.issues?.[0]?.code).toBe('invalid_type');
81+
expect(refused?.issues?.[0]?.expected).toBe('array');
82+
expect(refused?.message).toContain("'objects'");
83+
});
84+
}
85+
86+
it('the control — `objects` as an array is accepted and the bound action is merged into its object', () => {
87+
const stack = nonStrict({ objects: [obj('b_item')], actions: [bound] });
88+
expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']);
89+
});
90+
91+
it('the control — the map form is normalized first and accepted the same way', () => {
92+
const stack = nonStrict({ objects: { b_item: obj('b_item') }, actions: [bound] });
93+
expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']);
94+
});
95+
96+
it('an ABSENT `objects` is not a malformed one — accepted', () => {
97+
const stack = nonStrict({ actions: [bound] });
98+
expect(stack.objects).toBeUndefined();
99+
expect(stack.actions?.map((a) => a.name)).toEqual(['b_open']);
100+
});
101+
102+
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', () => {
103+
const refused = refusal(() => nonStrict({ objects: [null, obj('b_item'), 7], actions: [bound] }));
104+
expect(refused).toBeInstanceOf(Error);
105+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
106+
expect(refused?.status).toBe(422);
107+
expect(refused?.issues?.map((issue) => issue.path)).toEqual([
108+
['objects', 0],
109+
['objects', 2],
110+
]);
111+
expect(refused?.issues?.every((issue) => issue.code === 'invalid_type' && issue.expected === 'object')).toBe(true);
112+
});
113+
114+
it('a single non-object entry beside a good one is refused too — the card\'s `[null, obj]` repro', () => {
115+
const refused = refusal(() => nonStrict({ objects: [null, obj('b_item')] }));
116+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
117+
expect(refused?.status).toBe(422);
118+
expect(refused?.issues?.map((issue) => issue.path)).toEqual([['objects', 0]]);
119+
});
120+
121+
it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => {
122+
const refused = refusal(() => defineStack({ manifest, objects: 5 } as never));
123+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
124+
expect(refused?.status).toBe(422);
125+
});
126+
});

‎packages/spec/src/stack.zod.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2948,6 +2948,54 @@ function sortActionsByOrder<T extends { order?: number }>(actions: T[]): T[] {
29482948
* @internal
29492949
*/
29502950
function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefinition {
2951+
// [ADR-0112 · #19785] Shape guard, because `defineStack(config, { strict:
2952+
// false })` hands this merge the normalized input with no parse in between.
2953+
// A non-array `objects` is REFUSED with the strict parse's own envelope
2954+
// (`STACK_SCHEMA_INVALID`, 422, the zod issue at `['objects']`) — never a bare
2955+
// `TypeError` from `.map`, and never a pass-through: bound actions cannot be
2956+
// merged into objects that are not a list, and a stack handed on in that
2957+
// shape is one `composeStacks` refuses with this same code anyway. `strict:
2958+
// false` skips VALIDATION (cross-references, schema detail); it never
2959+
// promised to accept a shape this merge cannot read. `undefined` is the one
2960+
// non-array that is not malformed — the key is simply absent — the same line
2961+
// `mergeObjects` draws for the same key.
2962+
const declaredObjects: unknown = (config as { objects?: unknown }).objects;
2963+
if (declaredObjects !== undefined && !Array.isArray(declaredObjects)) {
2964+
const { kind, issues } = describeNonArrayCollection('objects', declaredObjects);
2965+
throw new StackSchemaInvalidError(
2966+
`defineStack validation failed: 'objects' is ${kind}, not an array. Bound actions cannot be ` +
2967+
`merged into it, and \`strict: false\` skips validation, not this shape — \`composeStacks\` ` +
2968+
`refuses the same stack with the same code. Author 'objects' as an array or in the map form ` +
2969+
`(\`{ name: { … } }\`), or drop \`strict: false\` to have every schema check run.`,
2970+
issues,
2971+
);
2972+
}
2973+
2974+
// [#19785] A non-object ENTRY is refused with the same envelope, one zod
2975+
// issue per entry at `['objects', index]` (`expected: 'object'`) — the
2976+
// strict parse's own answer for it. Never handed on: the merge cannot read
2977+
// it, and a stack returned with it is a success whose objects are not all
2978+
// objects — the next consumer (plugin-object registration) then drops every
2979+
// object after it inside one warn-level catch, which is concealment, not
2980+
// leniency.
2981+
if (Array.isArray(declaredObjects) && declaredObjects.some((entry) => !isRecord(entry))) {
2982+
const parsed = z.array(z.looseObject({})).safeParse(declaredObjects);
2983+
const issues = parsed.success
2984+
? []
2985+
: parsed.error.issues.map((issue) => ({ ...issue, path: ['objects', ...issue.path] }));
2986+
const positions = declaredObjects
2987+
.map((entry, index) => (isRecord(entry) ? null : `#${index} (${entry === null ? 'null' : Array.isArray(entry) ? 'an array' : `a ${typeof entry}`})`))
2988+
.filter((position): position is string => position !== null);
2989+
throw new StackSchemaInvalidError(
2990+
`defineStack validation failed: 'objects' holds ${positions.length === 1 ? 'an entry' : 'entries'} ` +
2991+
`that ${positions.length === 1 ? 'is' : 'are'} not an object — ${positions.join(', ')}. Bound actions ` +
2992+
`cannot be merged into such an entry, and \`strict: false\` skips validation, not this shape. Author ` +
2993+
`every entry of 'objects' as an object definition, or drop \`strict: false\` to have every schema ` +
2994+
`check run.`,
2995+
issues as z.core.$ZodIssue[],
2996+
);
2997+
}
2998+
29512999
// Honour `order` on the preserved top-level actions regardless of objects.
29523000
const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions;
29533001
const topChanged = sortedTop !== config.actions;

0 commit comments

Comments
 (0)