Commit f34dda6
Fixes #19150
Clause-②: no
`declaresCollection` (`packages/spec/src/stack.zod.ts`) read only
`def.in` on its `pipe` arm, so a `z.preprocess`-wrapped collection key
resolved to a `transform` node, fell through to `default: return false`,
and silently left the key set `objectConflict: 'merge'` refuses to
combine (#14848).
⭐ **No current behaviour is wrong and none changes here.**
`objectCollectionKeys()` skips `fields` by name, and measured over all
43 top-level keys of `ObjectSchema` the derived refusal set is identical
before and after. This is a finding fixed before it can bite, not a
regression report.
## 1. The census — what the card asked for FIRST
The card records this as NOT measured: "whether any OTHER
`packages/spec` walker carries the same `pipe` arm … there were two
copies of this arm and only one is fixed, which is a rate, not an
anecdote."
Scanned 6890 tracked TS/JS files (`node_modules/`, `dist/` excluded) on
`origin/main` at `e6a03e6491` for every site that DISPATCHES on a zod
`pipe` node — `case 'pipe'`, `type === 'pipe'`, `instanceof z.ZodPipe`.
**13 sites**, each classified by hand from its arm:
| reading | count | sites |
|:---|:---|:---|
| IN only | 4 | `spec/src/stack.zod.ts:3415` ·
`spec/src/compose-stacks-merge-collection-refusal.test.ts:222` ·
`lint/src/component-field-specs-liveness.test.ts:68` ·
`spec/src/ui/component.test.ts:2907` |
| transform-discriminated | 5 | `spec/scripts/lib/zod-graph.ts:232`
(`pipeAuthorableSide`, the canonical one) ·
`spec/scripts/liveness/check-liveness.mts:592` ·
`spec/scripts/liveness/tombstoned-row-status.test.ts:101` ·
`spec/src/kernel/metadata-authoring-lint.ts:134` ·
`spec/src/system/metadata-form-zod-reconciliation.test.ts:172` |
| both sides | 2 | `spec/src/kernel/metadata-type-schemas.test.ts:128`
(union of both) · `:558` (OUT first, then IN) |
| pin / delegating, no side read of its own | 2 |
`spec/scripts/zod-graph.test.ts:182` (the pin ON `pipeAuthorableSide`) ·
`lint/src/validate-predicate-path-refs.ts:369` counted above as
transform-discriminated |
Both known targets fire, which is the ruler check the card asked for:
`stack.zod.ts` (this card) and the test-side copy.
**Three corrections the census produces:**
1. **The test-side copy is NOT fixed on `main`.**
`compose-stacks-merge-collection-refusal.test.ts:222` still reads
`isCollection(def!.in, …)` at `e6a03e6491`. The card's "already fixed
one file over" describes PR #19147's BRANCH, which is still open and
draft. ⛔ Untouched here on purpose — that file is #19147's surface.
2. **The other two IN-only sites fail LOUD, not silent, so they are not
instances of this card's class.**
`component-field-specs-liveness.test.ts` records `"TYPE: props schema
has no resolvable object shape"` (the type name, then that sentence) as
a violation when the walk reaches no shape; `component.test.ts:2907`
reads `.shape.properties` off the result and would throw. Neither can go
quietly green on a preprocess-wrapped input. They are noted below, not
filed.
3. **The rate, stated plainly:** of 13 pipe walkers, 2 carry this arm in
a position where it fails SILENTLY — the production derivation and its
test twin, i.e. both copies of one question — and this PR fixes the
production one. The remaining 9 already read the pipe correctly, and 5
of them run the exact rule adopted here.
## 2. The fix shape — measured, then chosen
The card deliberately left three candidates open. The landed rule reads
**OUT only when IN unwraps to a transform stage**:
```
case 'pipe':
return declaresCollection(pipeAuthorableSide(def), depth + 1);
```
- **Why not `in || out`** (the shape #19147 applied test-side): for a
genuine `a.transform(fn).pipe(b)` the author writes `a`.
`z.string().transform((s) => s.split(',')).pipe(z.array(z.string()))` is
a key whose AUTHORED value is a scalar and whose parsed value is an
array; `in || out` puts it in a refusal set that then tells the author
their scalar is a collection whose entries would be dropped. Pinned as a
dark-control assertion, not argued in prose: `eitherSideWalk` answers
`true` for that shape, the landed rule answers `false`, and
`composeStacks` composes it by later-wins.
- **Why not "refuse to walk a transform"**: this walk runs inside
`composeStacks` at author time; the derivation's job is to answer a
structural question about every key, and a throw on a shape that is
legal today would convert a silent gap into an outage.
- **Why this one**: it is already the rule at four sibling sites
(`pipeAuthorableSide` in `scripts/lib/zod-graph.ts` since #5317,
`metadata-authoring-lint.ts` and
`metadata-form-zod-reconciliation.test.ts` since #5074,
`packages/lint`'s `validate-predicate-path-refs.ts`), each carrying the
#4488 citation. Adopting it makes this a fifth SITE of one rule rather
than a fifth dialect. The unwrap before the transform test is
load-bearing and is pinned: a transform one level down is still a
transform.
## 3. The measurement, per key
`ObjectSchema.shape` — 43 top-level keys, read off the built package:
- pipe-shaped top-level keys: **1** — `titleFormat`, `optional > union[
pipe(in=string, out=transform) | object ]`, an `a.transform(fn)` pipe
carrying a scalar.
- keys whose verdict differs between the old reading, the landed reading
and the declined `in || out`: **0 of 43**.
- derived refusal set, identical under all three: `indexes, fieldGroups,
requiredPermissions, validations, activityMilestones, highlightFields,
listViews, searchableFields, actions` (9 keys).
- `fields` is a plain `record` on `main` today and is excluded by NAME
either way, so its own reading cannot move the set. After #19147 wraps
it in `z.preprocess` its reading changes (IN-only `false`,
authorable-side `true`) and the set is still unmoved, because the
exclusion is by name.
That invariant is an ASSERTION, not a claim in this body:
`compose-stacks-collection-pipe-arm.test.ts`'s last block derives the
set under all three readings from the unmocked shape and fails the day
they stop agreeing — which is the day this fix starts doing observable
work.
## 4. Tests — bright / main / dark, driven through the real production
walk
`declaresCollection` is internal and today's shape has no
preprocess-wrapped collection key, so a pin written against the shape
alone cannot tell a fixed walker from an unfixed one. The new file
mounts three probe keys on `ObjectSchema.shape` through `vi.mock` — the
only input `objectCollectionKeys()` reads — and drives them through
`composeStacks` itself:
- **anti-vacuity** — the probes really are the node shapes claimed
(`pipe` with `in=transform, out=array`; and a `pipe` whose IN is itself
the `.transform()` pipe).
- **BRIGHT CONTROL** — the IN-only reading of the preprocess probe
answers "not a collection"; the authorable-side reading answers
"collection"; and the same holds when the transform sits behind a
`prefault` wrapper.
- **MAIN** — `composeStacks` refuses two differing declarations of that
key, and the refusal message ENUMERATES the derived set, so the set
change is read per key: the probe key joins, and the nine keys that were
there before are still there, in order. Identical declarations still
compose.
- **DARK CONTROL** — the `.pipe()` probe and a plain scalar both compose
by later-wins, unchanged; `actions` is still refused exactly as before;
and `in || out` is pinned as the reading that WOULD have moved the
`.pipe()` probe.
Ablation (one-shot, on the committed state,
`scripts/ablation-replace.mjs`): the arm reverted to
`declaresCollection(def.in, depth + 1)`, mutation proven on disk (anchor
`1 -> 0`, blob `bdb4aa8c12bc -> 82b7d2ba3774`, `grep -c` of the injected
text `1` and of the removed text `0`) — **2 tests fail, both of them the
MAIN leg**, with the other 12 green, which is the expected direction:
the bright and dark legs do not depend on the fix. Restored by the same
tool, verified `blob == HEAD (bdb4aa8)` and `git diff HEAD` empty.
`dist/` is not on the resolution path here — the subject is reached by a
same-package relative import from the test — so the rebuild-to-dist
preflight does not apply and no dist marker was involved.
Runs (all on `8c50307884`, this PR's head; shared box, so seconds are
contention figures):
- `pnpm --filter @objectstack/spec test` — **501 files / 14657 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec typecheck` — exit 0 (`tsc --noEmit` +
scripts + test layer).
- `pnpm --filter @objectstack/spec check:generated` — all 16 generated
artifacts up to date; nothing to regenerate.
- `pnpm lint` (repo-wide `eslint . --no-inline-config`) — exit 0, no
narrowing claimed.
- `scripts/pm/dispatch-gates.mjs --ran` — **80 derived families
accounted for: 77 run green, 3 NOT MEASURED** (`check:type-check-debt`,
`check:lean-entry-closure`, `check:dual-build-cjs-loads` — each exits 3
PREREQUISITE NOT MET without a full workspace build, which CI does
first; none is a finding).
- Dependency-closure build (①) is empty: `@objectstack/spec` declares no
workspace dependency, so `pnpm --filter '@objectstack/spec^...' build`
matches no project.
## 5. Clause-② — the push-back the dispatch asked for
> ⭐ **Seat ruling, 2026-09-20T10:59Z — arm B taken.** The `domain:spec`
seat 4 dispatch declared `Clause-②: yes`; this dev measured that
published behaviour does not move by one row (0 of 43 `ObjectSchema`
top-level key verdicts change, the derived refusal set is
byte-identical, no export added or removed) and pushed back. The seat
adopted the measurement and **re-declared `no`** — the card's claim
comment carries the correction in place (`5749346170`), and line 3 of
this body is edited to match, so the two carriers agree. ⛔
Over-declaring to stay on the safe side is the pathology #19099
documents; the reading governs.
>
> ⚠️ `check-widening-tells --declaration no` then exited **4** with **7
T2 tells** at `packages/spec/src/stack.zod.ts:3412-3418`. The dev did ⛔
not flip back to `yes` and did ⛔ not touch the matcher, which is
correct. The tells are FALSE and the mechanism is named in the card
follow-up (`5749357966`): T2's own sentence judges a new member of a
**closed set** (`z.enum`, `z.union`, `z.discriminatedUnion`, or a
`CORE_PLUGIN_TYPES`-shaped `as const` array) and this construct is none
of the four — it is a `new Set([...])` of zod **internal node-type
discriminants**, the same seven already standing as `case` labels in the
very function this diff edits. What fired is the line-level
`BARE_STRING_ELEMENT` matcher, which does not require one of the four
openers above it. That matcher repair is ⛔ out of this PR's file surface
and is reported as a finding.
⚠️ **Seat correction, 2026-09-20T14:31Z — the paragraph below describes
the SUPERSEDED declaration.** It was written while the dispatch's
`Clause-②: yes` still stood and was left in place when the 10:59Z ruling
above re-declared `no`. Both of its claims are false at this head,
measured rather than inferred: line 3 of this body reads `Clause-②: no`,
and `.changeset/19150-declares-collection-pipe-authorable-side.md`
grades `'@objectstack/spec': patch`, not `minor`. What survives from it
is the path limb alone — `SUSPECT_TIER_GLOBS` = `packages/spec/src/**`
makes this a contract-surface PR regardless of any declaration, which is
why the lane owes the at-tier contract review that is now on record
(comment `5750417684`, `Head-sha: 7d67e1e…`, **VERDICT: PASS**,
`Clause-②: no` upheld by independent re-derivation). Kept rather than
deleted, because a body that quietly loses what it once claimed is worse
than one that carries its own correction:
> ~~Declared `yes`, copied from the claim comment, and the path limb
(`SUSPECT_TIER_GLOBS` = `packages/spec/src/**`) makes this a
contract-surface PR regardless of any declaration. The changeset is
graded `minor` because `check-changeset-no-major` requires at least one
`minor`+ package from a `yes` PR.~~
⭐ **The reading the dispatch asked for, and it points the other way:**
published behaviour does not move by one row. 0 of 43 key verdicts
change, the refusal set is identical, no export is added or removed
(`check:api-surface` green), and no authored metadata changes meaning.
By the gate's own words for clause ② — "this PR puts a new key on a
published payload" — nothing here does. If the seat accepts that
reading, the downgrade is three coordinated edits (the card's claim
line, this body's line, and the changeset level) and is the PM's to
make, not a dev's unilateral carrier split.
## Acceptance notes
Out of scope, noted and NOT filed — neither is a reproducible defect, a
declared-contract violation or a metadata-authoring trap:
- `packages/lint/src/component-field-specs-liveness.test.ts:68` —
`shapeOf` reads `def.in` only. A preprocess-wrapped `ComponentPropsMap`
schema would make it record `"props schema has no resolvable object
shape"` — a LOUD red, not a silent pass. Carrier: none today; no such
schema exists.
- `packages/spec/src/ui/component.test.ts:2907` —
`def.in._zod.def.shape` on `PageComponentSchema`
(`.strict().transform(…)`). Same shape, same loud failure (a TypeError
on the next line). Carrier: none today.
- **One divergence with a named carrier:** when #19147 lands, the
sibling test's independent walk will read `in || out` while the
production walk reads the authorable side. Measured on today's shape the
two agree, and the invariance block above asserts it — but they are two
rules answering one question, which is the drift the derivation exists
to avoid. The one-line alignment belongs to whoever lands #19147, since
that file is its surface today.
Authored by Claude Code in session `session_01AmH9bKvGoLjiY86Q4Z3og2`;
attribution is repeated in prose because the platform rewrites the
footer block on some write channels.
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8fc6a5f commit f34dda6
3 files changed
Lines changed: 431 additions & 4 deletions
File tree
- .changeset
- packages/spec/src
Lines changed: 17 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
Lines changed: 322 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
0 commit comments