From 34bf922042f8c0d15014f4764aecd1574ccae57b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 09:55:10 +0000 Subject: [PATCH 1/4] fix(spec): read a pipe's authorable side in declaresCollection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `declaresCollection` read only `def.in` on its `pipe` arm. `z.preprocess(fn, schema)` puts the transform stage in `in` and the validated schema in `out` — the opposite of `a.transform(fn)` — so a preprocess-wrapped collection key resolved to a `transform` node, fell through to `default: return false`, and silently left the refusal set `objectCollectionKeys()` derives for `objectConflict: 'merge'`. Reads OUT only when IN is a transform stage: the rule four sibling walkers already run, and not `in || out`, which would pull a key whose authored value is a scalar into the refusal set. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- packages/spec/src/stack.zod.ts | 96 ++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 11f3c28421..bdb4aa8c12 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -3378,6 +3378,92 @@ function warnUncomposedStackKey(key: string, rule: ComposeDisposition): void { ); } +/** As much of a zod node's `def` as the collection walk below reads. */ +interface CollectionWalkDef { + type?: string; + innerType?: unknown; + in?: unknown; + out?: unknown; + options?: unknown[]; + getter?: () => unknown; +} + +/** + * The `def` of a zod node, or `undefined` when the value is not one. + * + * `typeof === 'function'` is load-bearing, not padding: the canonical schemas + * on this shape arrive as the `lazySchema` proxy, which is CALLABLE, and an + * object-only guard answers "not a schema" for every one of them. + * @internal + */ +function collectionWalkDef(schema: unknown): CollectionWalkDef | undefined { + if (schema === null || (typeof schema !== 'object' && typeof schema !== 'function')) return undefined; + return (schema as { _zod?: { def?: CollectionWalkDef } })._zod?.def; +} + +/** + * The wrappers the collection walk peels, as a set — the same labels the + * `switch` in {@link declaresCollection} peels by `case`. Used to look THROUGH + * a pipe's IN side before asking whether it is a transform stage: a transform + * one level down is still a transform. + * @internal + */ +const COLLECTION_WALK_WRAPPERS: ReadonlySet = new Set([ + 'optional', + 'nullable', + 'default', + 'prefault', + 'readonly', + 'nonoptional', + 'catch', +]); + +/** + * The side of a `pipe` node an author actually writes. + * + * Two constructs compile to the same `pipe` node and their authorable sides + * are OPPOSITE: `a.transform(fn)` keeps the accepted input shape in `in` and + * puts the transform stage in `out`, while `z.preprocess(fn, schema)` puts the + * TRANSFORM in `in` and the real, validated schema in `out`. Reading `in` + * unconditionally therefore hands back a transform node for every preprocess + * node, and a transform declares no shape at all — so {@link declaresCollection} + * falls through to `false` and a preprocess-wrapped collection key silently + * leaves the refusal set {@link objectCollectionKeys} derives. Silently is the + * whole point: that derivation exists precisely so a collection key added to + * the object shape tomorrow cannot fall back to the wholesale replacement + * `objectConflict: 'merge'` refuses. + * + * ⛔ Deliberately NOT `in || out`. For a genuine `a.transform(fn).pipe(b)` the + * author writes `a`; taking either side would pull a key whose AUTHORED value + * is a scalar into a refusal set that then names it a collection — a refusal + * nobody earned, printed in the vocabulary of entries that were never written. + * Reading OUT only when IN is a transform stage is the rule four sibling + * walkers already run — `pipeAuthorableSide` in `scripts/lib/zod-graph.ts`, + * `kernel/metadata-authoring-lint.ts`, + * `system/metadata-form-zod-reconciliation.test.ts` and `packages/lint`'s + * `validate-predicate-path-refs.ts` — so this is one rule with a fifth site, + * not a fifth dialect. + * @internal + */ +function pipeAuthorableSide(def: CollectionWalkDef): unknown { + let node = def.in; + for (let hops = 0; hops < 8; hops++) { + const inner = collectionWalkDef(node); + if (!inner?.type) break; + if (inner.type === 'transform') return def.out; + if (COLLECTION_WALK_WRAPPERS.has(inner.type)) { + node = inner.innerType; + continue; + } + if (inner.type === 'lazy') { + node = inner.getter?.(); + continue; + } + break; + } + return def.in; +} + /** * Does this schema declare a COLLECTION — an array, or a record of named * members — once the optional/default/nullable wrappers are stripped, reading @@ -3390,13 +3476,15 @@ function warnUncomposedStackKey(key: string, rule: ComposeDisposition): void { * the same. A fixed-shape config object (`enable`, `access`, `protection`, …) * is not a collection — its members are declared keys, not authored entries — * and stays on the scalar rule. + * + * A `pipe` is read on the side the AUTHOR writes, never on `in` alone — see + * {@link pipeAuthorableSide} for the two opposite conventions that compile to + * that one node. * @internal */ function declaresCollection(schema: unknown, depth = 0): boolean { if (depth > 8) return false; - const def = (schema as { - _zod?: { def?: { type?: string; innerType?: unknown; in?: unknown; options?: unknown[]; getter?: () => unknown } }; - })._zod?.def; + const def = collectionWalkDef(schema); if (!def?.type) return false; switch (def.type) { case 'array': @@ -3413,7 +3501,7 @@ function declaresCollection(schema: unknown, depth = 0): boolean { case 'lazy': return declaresCollection(def.getter?.(), depth + 1); case 'pipe': - return declaresCollection(def.in, depth + 1); + return declaresCollection(pipeAuthorableSide(def), depth + 1); case 'union': return (def.options ?? []).some((option) => declaresCollection(option, depth + 1)); default: From b23433361e44e569fd0007b477958bc2c2f85206 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 09:58:12 +0000 Subject: [PATCH 2/4] test(spec): pin the pipe arm's three legs on declaresCollection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preprocess-wrapped collection key on `ObjectSchema.shape` is the shape the real production walk cannot see today, so the probe keys ride on that shape through `vi.mock` and the legs are read through `composeStacks` itself: bright control (the IN-only reading still answers "not a collection"), main (the key is now enumerated in the refusal), dark control (a genuine `.pipe()` authored as a scalar stays out — the leg that discriminates the landed rule from `in || out`), plus a today-invariance block asserting all three candidate readings derive the same set on the unmocked shape. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- ...compose-stacks-collection-pipe-arm.test.ts | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 packages/spec/src/compose-stacks-collection-pipe-arm.test.ts diff --git a/packages/spec/src/compose-stacks-collection-pipe-arm.test.ts b/packages/spec/src/compose-stacks-collection-pipe-arm.test.ts new file mode 100644 index 0000000000..28cc0469c8 --- /dev/null +++ b/packages/spec/src/compose-stacks-collection-pipe-arm.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19150] `declaresCollection` reads a `pipe` on the side the AUTHOR writes. + * + * The walker behind `objectCollectionKeys()` — the key set + * `objectConflict: 'merge'` refuses to combine (#14848) — read only `def.in` + * on its `pipe` arm. `z.preprocess(fn, schema)` puts a transform STAGE in `in` + * and the real, validated schema in `out`, the opposite of `a.transform(fn)`, + * so a preprocess-wrapped collection key resolved to a `transform` node, fell + * through to `default: return false`, and left the refusal set in silence — + * the exact failure direction the derivation exists to close ("a collection + * key added to the object schema tomorrow would fall back to the wholesale + * replacement this rule exists to refuse"). + * + * ## Why this file exists beside `compose-stacks-merge-collection-refusal.test.ts` + * + * That file pins the refusal set against `ObjectSchema`'s shape AS IT STANDS. + * Measured on this tree, exactly one of the 43 top-level keys compiles to a + * pipe (`titleFormat`, an `a.transform(fn)` pipe carrying a scalar), so the + * shape as authored today cannot tell a fixed walker from an unfixed one — + * a pin written only against it would be green either way. The probe keys + * below are the missing discrimination: a schema shaped like the one the next + * author will write, walked by the REAL production code through + * `composeStacks`. + * + * ## The three legs + * + * - BRIGHT CONTROL — the IN-only reading of the preprocess probe (the arm as + * it stood before #19150) resolves to a `transform` and answers "not a + * collection". Kept as executable text so the defect stays legible. + * - MAIN — the same key, walked by the production code, is now IN the refusal + * set: `composeStacks` refuses two differing declarations and its message + * ENUMERATES the derived set, so the set change is read per key rather than + * asserted in prose. + * - DARK CONTROL — a genuine `.pipe()` whose authored side is a scalar and + * whose OUT side is an array stays OUT of the set, and so does a plain + * scalar. This is the leg that discriminates the landed rule from the + * `in || out` candidate: `in || out` would pull that key IN, and the author + * would be told their scalar is a collection. + * + * ## Today-invariance + * + * The last block asserts, against the UNMOCKED `ObjectSchema`, that all three + * candidate readings agree on every top-level key — i.e. this change moves no + * key on today's shape, and `fields` (the one collection `'merge'` merges, and + * the key PR #19147 wraps in `z.preprocess`) is excluded by NAME either way. + * It is written to go RED the day that stops being true, which is the day the + * fix starts doing observable work; the remedy then is to re-measure and + * re-state the invariant, never to relax the assertion. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; + +const NAMES = vi.hoisted(() => ({ + /** `z.preprocess(fn, z.array(...))` — IN is the transform, OUT is the array. */ + preprocess: 'probePreprocessCollection', + /** `.transform(...).pipe(z.array(...))` — authored as a scalar, parsed to an array. */ + pipeScalar: 'probePipeScalarToArray', + /** A plain scalar: the walk must not reach a collection by any route. */ + scalar: 'probeScalar', +})); + +// The probe keys ride on `ObjectSchema.shape` because that shape is the ONLY +// input `objectCollectionKeys()` reads. Nothing else in the module graph is +// replaced: the factory spreads the real module and the real shape. +vi.mock('./data/object.zod', async (importOriginal) => { + const actual = await importOriginal(); + const { z: zod } = await import('zod'); + const patched = zod.object({ + ...(actual.ObjectSchema.shape as Record), + [NAMES.preprocess]: zod.preprocess((raw) => raw, zod.array(zod.string())).optional(), + [NAMES.pipeScalar]: zod + .string() + .transform((s) => s.split(',')) + .pipe(zod.array(zod.string())) + .optional(), + [NAMES.scalar]: zod.string().optional(), + }); + return { ...actual, ObjectSchema: patched }; +}); + +const { composeStacks, defineStack } = await import('./stack.zod'); +const { ObjectSchema } = await import('./data/object.zod'); + +// ── Independent walkers: the three candidate readings of a `pipe` ────────── +// +// Re-implemented here rather than imported — `declaresCollection` is internal, +// and a pin that imports the subject cannot state what the subject REJECTED. + +type Def = { + type?: string; + innerType?: unknown; + in?: unknown; + out?: unknown; + options?: unknown[]; + getter?: () => unknown; +}; + +/** `typeof === 'function'` included: `lazySchema` proxies are callable. */ +const defOf = (schema: unknown): Def | undefined => + schema === null || (typeof schema !== 'object' && typeof schema !== 'function') + ? undefined + : (schema as { _zod?: { def?: Def } })._zod?.def; + +const WRAPPERS = ['optional', 'nullable', 'default', 'prefault', 'readonly', 'nonoptional', 'catch']; + +type Walk = (schema: unknown, depth?: number) => boolean; + +function makeWalk(pipeArm: (def: Def, walk: Walk, depth: number) => boolean): Walk { + const walk: Walk = (schema, depth = 0) => { + if (depth > 8) return false; + const def = defOf(schema); + if (!def?.type) return false; + if (def.type === 'array' || def.type === 'record') return true; + if (WRAPPERS.includes(def.type)) return walk(def.innerType, depth + 1); + if (def.type === 'lazy') return walk(def.getter?.(), depth + 1); + if (def.type === 'pipe') return pipeArm(def, walk, depth); + if (def.type === 'union') return (def.options ?? []).some((o) => walk(o, depth + 1)); + return false; + }; + return walk; +} + +/** The arm as it stood before #19150 — the bright control. */ +const inOnlyWalk = makeWalk((def, walk, depth) => walk(def.in, depth + 1)); + +/** The candidate this change DECLINED (and the shape the sibling test walker took). */ +const eitherSideWalk = makeWalk((def, walk, depth) => walk(def.in, depth + 1) || walk(def.out, depth + 1)); + +/** The landed rule: OUT only when IN is a transform stage. */ +const authorableWalk = makeWalk((def, walk, depth) => { + let node = def.in; + for (let hops = 0; hops < 8; hops++) { + const inner = defOf(node); + if (!inner?.type) break; + if (inner.type === 'transform') return walk(def.out, depth + 1); + if (WRAPPERS.includes(inner.type)) { + node = inner.innerType; + continue; + } + if (inner.type === 'lazy') { + node = inner.getter?.(); + continue; + } + break; + } + return walk(def.in, depth + 1); +}); + +const shapeOf = (schema: unknown): Record => + (schema as { shape: Record }).shape; + +const probe = (key: string): unknown => shapeOf(ObjectSchema)[key]; + +// ── Stack fixtures, `strict: false` so a probe key survives to composition ── + +const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const }); + +const obj = (name: string, extra: Record = {}) => ({ + name, + label: name, + fields: { title: { type: 'text' as const } }, + ...extra, +}); + +const stackWith = (id: string, extra: Record) => + defineStack({ manifest: mf(id), objects: [obj('shared', extra)] }, { strict: false }); + +/** The thrown message, or `null` when the composition is accepted. */ +function refusal(fn: () => unknown): string | null { + try { + fn(); + return null; + } catch (e) { + return (e as Error).message; + } +} + +const composedShared = (left: Record, right: Record) => { + const out = composeStacks([stackWith('com.example.a', left), stackWith('com.example.b', right)], { + objectConflict: 'merge', + }); + return (out.objects ?? []).find((o) => o.name === 'shared') as Record | undefined; +}; + +const refuse = (key: string, left: unknown, right: unknown) => + refusal(() => composedShared({ [key]: left }, { [key]: right })); + +describe('#19150 — the probe keys are the shapes this pin is about (anti-vacuity)', () => { + it('the preprocess probe is a pipe whose IN is a transform and whose OUT is the array', () => { + const def = defOf(defOf(probe(NAMES.preprocess))?.innerType); + expect(def?.type).toBe('pipe'); + expect(defOf(def?.in)?.type).toBe('transform'); + expect(defOf(def?.out)?.type).toBe('array'); + }); + + it('the `.pipe()` probe is a pipe whose authored side is a scalar and whose OUT is an array', () => { + const def = defOf(defOf(probe(NAMES.pipeScalar))?.innerType); + expect(def?.type).toBe('pipe'); + // IN is itself the `.transform()` pipe — a pipe, NOT a transform stage, so + // the authorable side stays IN and resolves to the authored `string`. + expect(defOf(def?.in)?.type).toBe('pipe'); + expect(defOf(defOf(def?.in)?.in)?.type).toBe('string'); + expect(defOf(def?.out)?.type).toBe('array'); + }); +}); + +describe('#19150 BRIGHT CONTROL — the pre-fix reading of the preprocess probe', () => { + it('reading IN alone answers "not a collection" — the silent direction', () => { + expect(inOnlyWalk(probe(NAMES.preprocess))).toBe(false); + }); + + it('the authorable-side reading answers "collection"', () => { + expect(authorableWalk(probe(NAMES.preprocess))).toBe(true); + }); + + it('resolves a transform stage that sits BEHIND a wrapper on the IN side', () => { + // The shape `scripts/zod-graph.test.ts` pins for `pipeAuthorableSide`: the + // unwrap before the transform test is load-bearing, because a transform one + // level down is still a transform. + const wrapped = z + .transform((raw: unknown) => raw) + .prefault('x') + .pipe(z.array(z.string())); + expect(inOnlyWalk(wrapped)).toBe(false); + expect(authorableWalk(wrapped)).toBe(true); + }); +}); + +describe('#19150 MAIN — the preprocess-wrapped collection key is IN the refusal set', () => { + it('refuses two differing declarations, naming the object, the key and both stacks', () => { + const msg = refuse(NAMES.preprocess, ['a'], ['b']); + expect(msg).toContain( + `composeStacks conflict: object 'shared' is defined in multiple stacks and its ` + + `'${NAMES.preprocess}' is declared with different values by 'com.example.a' (stack #0) and ` + + `'com.example.b' (stack #1).`, + ); + }); + + it('the derived set the refusal ENUMERATES carries the key — the per-key reading', () => { + const msg = refuse(NAMES.preprocess, ['a'], ['b']) ?? ''; + const enumerated = /Any other object-level collection \(([^)]*)\) is not merged/.exec(msg)?.[1] ?? ''; + const derived = enumerated.split(', ').filter(Boolean); + expect(derived).toContain(NAMES.preprocess); + // …and the keys it carried before this change are all still there, in order. + expect(derived.filter((k) => k !== NAMES.preprocess)).toEqual([ + 'indexes', + 'fieldGroups', + 'requiredPermissions', + 'validations', + 'activityMilestones', + 'highlightFields', + 'listViews', + 'searchableFields', + 'actions', + ]); + }); + + it('identical declarations still compose — the refusal is about DIFFERING values only', () => { + expect(refuse(NAMES.preprocess, ['a'], ['a'])).toBeNull(); + }); +}); + +describe('#19150 DARK CONTROL — keys whose verdict must not move', () => { + it('a genuine `.pipe()` authored as a scalar composes by later-wins, and is NOT refused', () => { + expect(refuse(NAMES.pipeScalar, 'a', 'b')).toBeNull(); + expect(composedShared({ [NAMES.pipeScalar]: 'a' }, { [NAMES.pipeScalar]: 'b' })?.[NAMES.pipeScalar]).toBe('b'); + }); + + it('`in || out` WOULD have moved that key — which is why the landed rule is not `in || out`', () => { + expect(eitherSideWalk(probe(NAMES.pipeScalar))).toBe(true); + expect(authorableWalk(probe(NAMES.pipeScalar))).toBe(false); + expect(inOnlyWalk(probe(NAMES.pipeScalar))).toBe(false); + }); + + it('a plain scalar key composes by later-wins', () => { + expect(refuse(NAMES.scalar, 'a', 'b')).toBeNull(); + expect(composedShared({ [NAMES.scalar]: 'a' }, { [NAMES.scalar]: 'b' })?.[NAMES.scalar]).toBe('b'); + }); + + it('an ordinary collection key is refused exactly as before', () => { + const msg = refuse('actions', [{ name: 'approve' }], [{ name: 'archive' }]); + expect(msg).toContain("its 'actions' is declared with different values"); + }); +}); + +describe("#19150 TODAY-INVARIANCE — the fix moves no key on today's ObjectSchema", () => { + const realShape = async (): Promise> => { + const actual = await vi.importActual('./data/object.zod'); + return shapeOf(actual.ObjectSchema); + }; + + const setUnder = (shape: Record, walk: Walk) => + Object.keys(shape).filter((key) => key !== 'fields' && walk(shape[key])); + + it('all three candidate readings derive the SAME refusal set', async () => { + const shape = await realShape(); + const inOnly = setUnder(shape, inOnlyWalk); + expect(setUnder(shape, authorableWalk), 'the landed rule moved a key on the real shape').toEqual(inOnly); + expect(setUnder(shape, eitherSideWalk), '`in || out` would move a key on the real shape').toEqual(inOnly); + expect(inOnly).toEqual([ + 'indexes', + 'fieldGroups', + 'requiredPermissions', + 'validations', + 'activityMilestones', + 'highlightFields', + 'listViews', + 'searchableFields', + 'actions', + ]); + }); + + it("'fields' is excluded by NAME, so its own reading cannot move the set either way", async () => { + const shape = await realShape(); + expect(Object.keys(shape)).toContain('fields'); + expect(setUnder(shape, inOnlyWalk)).not.toContain('fields'); + expect(setUnder(shape, authorableWalk)).not.toContain('fields'); + }); +}); From 8c5030788431a10a05dbfacd5dea5f5bb05f32b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:00:36 +0000 Subject: [PATCH 3/4] chore(changeset): declaresCollection reads a pipe's authorable side Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- ...-declares-collection-pipe-authorable-side.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/19150-declares-collection-pipe-authorable-side.md diff --git a/.changeset/19150-declares-collection-pipe-authorable-side.md b/.changeset/19150-declares-collection-pipe-authorable-side.md new file mode 100644 index 0000000000..127ed41c90 --- /dev/null +++ b/.changeset/19150-declares-collection-pipe-authorable-side.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `declaresCollection` reads a `pipe` on the side the author writes, so a `z.preprocess`-wrapped collection key cannot silently leave the `objectConflict: 'merge'` refusal set (#19150) + +Clause-②: yes + +`objectCollectionKeys()` derives — never transcribes — the object-level keys `composeStacks({ objectConflict: 'merge' })` refuses to combine (#14848), and the reason it derives them is written into its own docblock: a hand-written list "would fail in the silent direction: a collection key added to the object schema tomorrow would fall back to the wholesale replacement this rule exists to refuse". The walker behind it reintroduced exactly that silent direction through the derivation itself. + +`declaresCollection`'s `pipe` arm read only `def.in`. Two constructs compile to the same `pipe` node with OPPOSITE authorable sides: `a.transform(fn)` keeps the accepted input shape in `in`, while `z.preprocess(fn, schema)` puts the transform STAGE in `in` and the real, validated schema in `out`. A preprocess-wrapped collection key therefore resolved to a `transform` node, fell through to `default: return false`, and left the refusal set with nothing anywhere reporting it — the failure shape being a wholesale replacement where a refusal was owed. + +The arm now reads `out` only when `in` unwraps to a transform stage, which is the rule four sibling walkers in this tree already run (`pipeAuthorableSide` in `scripts/lib/zod-graph.ts`, `kernel/metadata-authoring-lint.ts`, `system/metadata-form-zod-reconciliation.test.ts`, and `packages/lint`'s `validate-predicate-path-refs.ts`) rather than a fifth dialect. + +- **`in || out` was measured and declined.** For a genuine `a.transform(fn).pipe(b)` the author writes `a`; reading either side pulls a key whose authored value is a scalar into a refusal set that then names it a collection. The landed rule leaves every `.pipe()` verdict where it was, by construction rather than by fixture choice. +- **No authored metadata changes meaning and no key changes its verdict on today's shape.** Measured over all 43 top-level keys of `ObjectSchema`: exactly one compiles to a `pipe` (`titleFormat`, an `a.transform(fn)` pipe carrying a scalar), and the derived refusal set is byte-identical under the old reading, the landed one and the declined candidate. The invariant is asserted, not claimed: `compose-stacks-collection-pipe-arm.test.ts` fails the day it stops holding. +- **`fields` keeps its exclusion by name.** It is the one collection `'merge'` merges by shallow spread, so its own reading cannot move the set either way. From 7d67e1ee4136aee8f8e6ea838950c7fb71520be2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:55:32 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(changeset):=20regrade=20to=20patch,?= =?UTF-8?q?=20Clause-=E2=91=A1=20no?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat ruled arm B on the push-back: the measurement governs. Published behaviour does not move by one row — 0 of 43 ObjectSchema top-level key verdicts change, the derived refusal set is identical, and no export is added or removed — so `yes` was over-declared and the bump is a patch. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- .changeset/19150-declares-collection-pipe-authorable-side.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/19150-declares-collection-pipe-authorable-side.md b/.changeset/19150-declares-collection-pipe-authorable-side.md index 127ed41c90..e40ce4689f 100644 --- a/.changeset/19150-declares-collection-pipe-authorable-side.md +++ b/.changeset/19150-declares-collection-pipe-authorable-side.md @@ -1,10 +1,10 @@ --- -'@objectstack/spec': minor +'@objectstack/spec': patch --- fix(spec): `declaresCollection` reads a `pipe` on the side the author writes, so a `z.preprocess`-wrapped collection key cannot silently leave the `objectConflict: 'merge'` refusal set (#19150) -Clause-②: yes +Clause-②: no `objectCollectionKeys()` derives — never transcribes — the object-level keys `composeStacks({ objectConflict: 'merge' })` refuses to combine (#14848), and the reason it derives them is written into its own docblock: a hand-written list "would fail in the silent direction: a collection key added to the object schema tomorrow would fall back to the wholesale replacement this rule exists to refuse". The walker behind it reintroduced exactly that silent direction through the derivation itself.