Repository navigation
Commit b1d3945
Fixes #17852
Fixes #18847
## What
Implements maintainer ruling **A, narrow** (comment 5725370319, batch
#154 item 1) verbatim.
`$ZodRecord`'s open-key branch (zod v4 core) runs `if (key ===
"__proto__") continue;` **above** `def.keyType._zod.run`, so no key
schema — regex, `.refine()`, `.superRefine()`, or one that rejects every
string — can ever see a `__proto__` key. `ObjectSchema.fields` used to
accept a document whose `fields` carried a `__proto__` own key and hand
back a document without it: success, silent, irreversible into whatever
`os build` writes.
Two mechanisms, one per name class, at the two sites the ruling names:
- **`packages/spec/src/data/object.zod.ts:1964`
(`ObjectSchema.fields`)** — wrapped in a new pre-parse guard
(`refuseRecordProtoKey`,
`packages/spec/src/shared/record-proto-key-guard.ts`) that reads the raw
input's own keys via `z.preprocess` and refuses a `__proto__` key with a
named, located issue (`fields.__proto__`) before the record ever parses.
`constructor` and `prototype` — which **do** reach the key schema
unskipped (today's regex admits them as ordinary lowercase words) — are
refused by the key grammar itself, via a `.refine()` beside the existing
snake_case regex.
- **`packages/spec/src/automation/builtin-node-config.zod.ts:923`
(`AssignmentConfigSchema.assignments`)** — the same pre-parse guard,
`__proto__` **only**. This slot's key type (`z.string().min(1)`) carries
no grammar; `constructor` and `prototype` are legal flow-variable names
today and are left legal — no ruling narrows this slot's accept set for
those two names.
- **`packages/spec/src/stack.zod.ts:3027-3029`** — corrected the false
`// Post-parse and advisory: the stack is valid and is returned
unchanged.` comment. It was false twice over: the parse could drop a
`__proto__` key, and `:3032` returns `mergeActionsIntoObjects(data)`,
not `data`. Region-disjoint from draft PR #18482 (its hunks are old
lines 2853-2924), confirmed against the real PR file diff before
editing; nothing else in this file was touched.
## A side effect the wrapping caused, and its fix
`z.preprocess`'s `in` half is a `ZodTransform`, which unconditionally
hardcodes `_zod.optin = "optional"` — a preprocess accepts any input,
including `undefined`, regardless of what the wrapped schema does. Left
alone, that made `ObjectSchema.fields` (which carries no `.optional()`)
report as optional to `$ZodObject`'s own JSON-Schema requiredness check
(`objectProcessor`, `io === 'input'`), so the published `data/Object`
schema silently dropped `fields` from its `required` array while the
**runtime** parse still correctly refused a missing `fields`.
`refuseRecordProtoKey` now patches `optin`/`optout` on the pipe's inner
`def.in` (not the outer pipe, which every `.describe()`/`.optional()` a
caller chains afterward clones away) to mirror the wrapped schema's own
values — verified before/after with `z.toJSONSchema(ObjectSchema, { io:
'input' })`. See the docblock in `record-proto-key-guard.ts` for the
full mechanism.
## Two things flagged by the dispatching seat, answered directly
**`compose-stacks-merge-collection-refusal.test.ts`** — this is a
direct, mechanical consequence of the guard, not a defect found next
door, and it stays in this PR. The test's own independent `isCollection`
walker structurally pattern-matches `ObjectSchema.shape.fields`'s zod
type; before this change `fields` was a bare `ZodRecord`, and wrapping
it in `z.preprocess` necessarily makes it a `ZodPipe`. The walker's
`pipe` case only recursed into `def.in` (correct for a `.pipe()` combo,
where `in` is the original type) and missed the record hidden in
`def.out` (the convention `z.preprocess(fn, schema)` actually uses).
Fixed to check both sides of a pipe. The **production** merge/refuse
logic in `stack.zod.ts` (`declaresCollection`/`objectCollectionKeys`)
has the identical `def.in`-only blind spot, but it is functionally
unaffected here because `fields` is excluded from that logic **by
literal key name**, before `declaresCollection` is ever consulted —
confirmed with an end-to-end `composeStacks({ objectConflict: 'merge'
})` probe that still shallow-merges `fields` correctly. That production
blind spot is a real, separate, dormant defect for any *future*
collection-typed key that gets wrapped in `z.preprocess` (not `fields` —
that one is safe by name) and is reported below as an out-of-scope
finding rather than fixed here, since `stack.zod.ts` outside the
3027-3029 region is explicitly fenced off this card.
**Regenerated spec artifacts** — three, all produced by the repo's own
generators, none hand-edited:
-
`content/docs/references/{api/metadata,data/object,system/migration}.mdx`
— via `pnpm --filter @objectstack/spec gen:docs`, reflecting the new
`.describe()` text on `ObjectSchema.fields` (and, before the
`optin`/`optout` fix above, briefly and incorrectly downgraded `fields`
to "optional" — caught and fixed before this diff, confirmed by the
requiredness fix and a full rebuild).
- `packages/spec/dropped-refinements.baseline.json` — **hand-edited**,
not generated (it has no `gen:` script by design; `check:generated`'s
underlying `build-schemas.ts` prints the exact corrected `sites` arrays
on a mismatch, and this edit pastes those verbatim, extracted
programmatically from the build's own output rather than transcribed by
hand). Nine entries gained a `fields.out.keyType` /
`assignments.out.valueType`-shaped site: the new `.refine()` on
`ObjectSchema.fields`' key type, and the `.out` path segment the
`z.preprocess` wrapper's pipe structure introduces, neither of which
projects into the published JSON Schema (see "Known gap" below) —
`measured.droppedRefinementSites` moved from 553 to 562 accordingly.
## Known gap (stated by the ruling, not closed here)
The guard does not project into the published JSON Schema
(`packages/spec/json-schema/**`) — that general gap is #18670 and this
card does not wait on it.
## Tests
- `packages/spec/src/shared/record-proto-key-guard.test.ts` (new) — pins
the guard in isolation against a minimal record: refuses `__proto__`
with a named, located issue; a **control** proves the underlying
unguarded record really would have silently dropped it; leaves ordinary
keys, non-object input, `.optional()` composition and a caller's own `{
error }` option untouched.
- `packages/spec/src/data/object.test.ts` — pins `ObjectSchema.fields`
refusing `__proto__` (named issue, never falls through to the
key-grammar's regex message), refusing `constructor`/`prototype` via the
key grammar (`invalid_key`, nested refine message), and still accepting
an ordinary document.
- `packages/spec/src/automation/builtin-node-config.test.ts` — pins
`AssignmentConfigSchema.assignments` refusing `__proto__`, and a
**preservation** pin that `constructor`/`prototype` remain accepted as
flow-variable names.
- `packages/spec/src/compose-stacks-merge-collection-refusal.test.ts` —
updated per the scope note above; all 62 cases pass.
Every pin is a behaviour pin against the pinned `zod@^4.4.3`, not a
version-string pin, per the dispatch's instruction.
## Gates run on this PR's head
- `pnpm --filter @objectstack/spec build` — clean.
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date**, including `check:api-surface` ✓ and
`check:authorable-surface` ✓ (both named by the ruling).
- `pnpm --filter @objectstack/spec test` — 498 files / 14569 tests, all
pass.
- `pnpm --filter @objectstack/spec typecheck` — clean (`tsc --noEmit`,
`check:scripts-typecheck`, `check:test-typecheck`; the pre-existing
259-error/144-signature test-typecheck debt ledger is unchanged).
- `node scripts/check-adr-0087-registration.mjs --base origin/main` —
the changeset's `not-required (no-migration-prescription)` disposition
verified against the census (zero authored use anywhere reached).
- `node scripts/pm/dispatch-gates.mjs --commands` derivation for this
diff: **102 families derived, 99 run and green, 3 correctly
NOT-MEASURED** (`check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt` — each refuses on
`PREREQUISITE NOT MET`/exit 3, requiring a full ~80-package workspace
build outside this card's local scope; not a finding).
- Confirmed the fix reaches the rebuilt `dist/`, not only `src/`
(imported `dist/data/index.mjs` directly and re-probed).
- Rebased onto `origin/main` mid-flight (an unrelated `spec` PR landed);
rebuilt, re-ran `check:generated`, the full test suite and typecheck
again on the merged tree — all clean.
## Out-of-scope findings (not filed, not fixed here)
- **To file** (class a, reproducible): `stack.zod.ts`'s
`declaresCollection` (`case 'pipe': return declaresCollection(def.in,
...)`) only reads the `in` side of a pipe. For `z.preprocess(fn,
schema)` the real type sits in `out`, so a *future* collection-typed key
on `ObjectSchema.shape` wrapped in `z.preprocess` would silently stop
being refused by `objectConflict: 'merge'`'s collision guard (#14848's
own shape). Harmless for `fields` today only because it is excluded by
literal key name first. Dedupe words: `declaresCollection`,
`objectCollectionKeys`, `z.preprocess`, `pipe def.in`, `objectConflict
merge`.
- **Noted, not filed**: the measurement lead in the dispatch (whether
`AssignmentConfigSchema`'s own `.catchall(z.unknown())` drops a
top-level `__proto__` variable the same way) was re-measured:
`$ZodObject`'s catchall branch (`handleCatchall`, zod v4 core) carries
the identical `if (key === "__proto__") continue;` skip, with its own
comment ("skip `__proto__` so it can't replace the result prototype via
the assignment setter"). So the lead **holds** — a variable literally
named `__proto__` at the top level of an assignment node config is
silently dropped by the catchall the same way. Per the dispatch's
instruction this is reported, not fixed, and not widened into this PR.
Carrier: whoever files it — dedupe words `AssignmentConfigSchema
catchall`, `handleCatchall __proto__`, `top-level assignment variable`.
Clause-②: yes (narrowing)
---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9120837 commit b1d3945
13 files changed
Lines changed: 464 additions & 40 deletions
File tree
- .changeset
- content/docs/references
- api
- data
- system
- packages/spec
- src
- automation
- data
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
945 | 945 | | |
946 | 946 | | |
947 | 947 | | |
948 | | - | |
| 948 | + | |
949 | 949 | | |
950 | 950 | | |
951 | 951 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
153 | | - | |
| 153 | + | |
154 | 154 | | |
155 | 155 | | |
156 | 156 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
326 | 326 | | |
327 | 327 | | |
328 | 328 | | |
329 | | - | |
| 329 | + | |
330 | 330 | | |
331 | 331 | | |
332 | 332 | | |
| |||
611 | 611 | | |
612 | 612 | | |
613 | 613 | | |
614 | | - | |
| 614 | + | |
615 | 615 | | |
616 | 616 | | |
617 | 617 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
70 | | - | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
71 | 72 | | |
72 | 73 | | |
73 | 74 | | |
| |||
157 | 158 | | |
158 | 159 | | |
159 | 160 | | |
160 | | - | |
161 | | - | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
162 | 164 | | |
163 | 165 | | |
164 | 166 | | |
| |||
238 | 240 | | |
239 | 241 | | |
240 | 242 | | |
241 | | - | |
242 | | - | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
243 | 246 | | |
244 | 247 | | |
245 | 248 | | |
| |||
280 | 283 | | |
281 | 284 | | |
282 | 285 | | |
283 | | - | |
284 | | - | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
285 | 289 | | |
286 | 290 | | |
287 | 291 | | |
| |||
319 | 323 | | |
320 | 324 | | |
321 | 325 | | |
322 | | - | |
323 | | - | |
324 | | - | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
325 | 330 | | |
326 | 331 | | |
327 | 332 | | |
| |||
378 | 383 | | |
379 | 384 | | |
380 | 385 | | |
381 | | - | |
| 386 | + | |
382 | 387 | | |
383 | 388 | | |
384 | 389 | | |
| |||
678 | 683 | | |
679 | 684 | | |
680 | 685 | | |
681 | | - | |
682 | | - | |
683 | | - | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
684 | 690 | | |
685 | 691 | | |
686 | 692 | | |
| |||
948 | 954 | | |
949 | 955 | | |
950 | 956 | | |
951 | | - | |
| 957 | + | |
| 958 | + | |
952 | 959 | | |
953 | 960 | | |
954 | 961 | | |
| |||
965 | 972 | | |
966 | 973 | | |
967 | 974 | | |
968 | | - | |
969 | | - | |
970 | | - | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
971 | 979 | | |
972 | 980 | | |
973 | 981 | | |
| |||
1007 | 1015 | | |
1008 | 1016 | | |
1009 | 1017 | | |
1010 | | - | |
| 1018 | + | |
| 1019 | + | |
1011 | 1020 | | |
1012 | 1021 | | |
1013 | 1022 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
610 | 610 | | |
611 | 611 | | |
612 | 612 | | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
0 commit comments