Repository navigation
Commit 488f4f5
Fixes #19151
Clause-②: yes (narrowing)
⚠️ **This diverges from the claim comment, deliberately and on the
record.** Claim 5751323556 declares `Clause-②: no`; the criterion as I
read it says `yes (narrowing)`. What lands here is a **refusal newly
added to a published parse surface** — the same act, on the same family,
that the sibling PR #19147 declared `Clause-②: yes (narrowing)` in
`.changeset/17852-record-proto-key-preparse-guard.md`. Grading a sibling
site differently from its family is how a family stops being one, and of
the two possible errors, declaring `no` on a real narrowing is the one
that lets a contract narrowing land without contract review. The seat
owns the correction if it reads the criterion the other way; I write
this line once, here, and nowhere else.
---
## STEP ONE — the vendor line, re-read first-hand. It holds.
The card's premise arrived half second-hand, so this was the gate before
any edit.
**Which zod, and how it was resolved.** `packages/spec/package.json`
declares `"zod": "^4.4.3"`. Resolved from the package's own entry rather
than from the manifest text:
```
node -e "const {createRequire}=require('module');
const r=createRequire('.../packages/spec/src/index.ts');
const p=r.resolve('zod/package.json');
console.log(p, require(p).version);"
=> /home/user/objectstack-issue-19151/node_modules/.pnpm/zod@4.4.3/node_modules/zod/package.json 4.4.3
```
`pnpm --filter @objectstack/spec why zod` reports **`Found 1 version of
zod`** — 4.4.3 — so the resolution is not one of two. (The lockfile does
carry a second, 4.6.1, reached only through the better-auth family;
`packages/spec` never sees it.)
**The line, at the cited coordinates.**
`node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/schemas.js`,
`handleCatchall` opens at 759 and the skip is at **767-769**, exactly as
reported:
```js
function handleCatchall(proms, input, payload, ctx, def, inst) { // 759
...
for (const key in input) {
// skip __proto__ so it can't replace the result prototype via the // 767
// assignment setter on the plain {} we build into // 768
if (key === "__proto__") // 769
continue; // 770
if (keySet.has(key)) continue;
...
const r = _catchall.run({ value: input[key], issues: [] }, ctx);
```
`grep -n '__proto__' v4/core/schemas.js` returns exactly two sites in
the file: **767-769** here, and **1496** in `$ZodRecord`'s open-key
branch — the one #17852 measured and PR #19147 guarded. One function
apart, same shape, and the `continue` sits above the schema that would
judge the key in both.
⇒ **`premise_still_valid: true`.** The card's quotation was not accepted
as evidence; it was reproduced.
## The defect, reproduced end to end on this tree
At `origin/main` `0870fb5418`, through the real exported schema:
```
input (JSON.parse) own enumerable keys : [ 'total', '__proto__', 'other' ]
AssignmentConfigSchema.safeParse => success: true
parsed own keys : [ 'total', 'other' ]
```
with two lit controls in the same run: the identical config **without**
`__proto__` round-trips both keys (so the instrument can see keys at
all), and the same `__proto__` **inside** `assignments` is already
refused loudly by #19147's record guard (so the instrument can see a
refusal). `JSON.parse` is what makes `__proto__` an own enumerable key;
an object literal's `{ __proto__: … }` sets the prototype and never
reaches either loop.
This lands on data an author wrote on purpose: the schema's own docblock
says its top-level keys may be flow variables, and the descriptor
declares `additionalProperties: true`.
## The fix
`refuseCatchallProtoKey` in
`packages/spec/src/shared/record-proto-key-guard.ts` — a **sibling** of
`refuseRecordProtoKey`, both now calling one private
`refuseProtoOwnKey`. Identical mechanism, identical refused name,
identical issue shape (`custom`, `path: ['__proto__']`).
`refuseRecordProtoKey`'s message bytes and behaviour are unchanged.
Why a second wrapper rather than a second call site of the first: the
refusal **names the parser that would otherwise drop the key**, and here
that is `.catchall()`, not `z.record()`. An author told their top-level
flow variable was dropped by "z.record()" would go looking at the
`assignments` map — a different slot, one level down, with a different
guard. A pin asserts the two messages name their own parser and not the
other's.
## Why a pre-parse guard — the two alternatives, eliminated by
measurement
1. **A key/catchall schema cannot see it.** The `continue` at 769 is
above `_catchall.run`, so no catchall — not even `z.never()`, whose
`unrecognized_keys` list is built inside the loop the `continue` already
left — ever receives the key. Same structural unreachability the record
guard's docblock records.
2. **Declaring `__proto__` in the object's own shape refuses every
config.** Measured: zod reads a declared key as `input["__proto__"]` and
tests presence as `"__proto__" in input`; on an ordinary object both
answer through the **inherited accessor**, so the value is
`Object.prototype` and the key is always "present". A plain config with
no `__proto__` authored came back `success: false` with an
`invalid_type` at `['__proto__']`. (It is also unwritable as an object
literal at all — `{ __proto__: schema }` sets the shape object's
prototype rather than adding a key, measured: the shape had one key,
`assignments`.)
That leaves the raw input, ahead of the parse.
## Why `__proto__` only — re-derived, not copied
The record guard refuses `__proto__` alone on the ground that
`constructor` and `prototype` reach the key schema unskipped. That
ground had to be re-established at this position, because it is a
different loop. Measured at the catchall, top level:
| authored top-level key | parse | key in the output |
|---|---|---|
| `constructor` | success | kept |
| `prototype` | success | kept |
| `toString` | success | kept |
| `__proto__` | success | **dropped** |
⇒ the reasoning transfers exactly, and for the same reason it was true
below: only `__proto__` is structurally unrepresentable. Everything else
round-trips, so refusing it here would be a narrowing no ruling ordered.
The `assignments` slot keeps its own guard; the two are different
parsers at different depths and neither covers the other.
## The pins, and their ablation
The sharpest pin asserts **behaviour**, through one `classify()` helper
that discriminates the three outcomes an authored key can meet —
`refused` / `silently-dropped` / `silently-kept`. A bare `success ===
false` would pass for a schema that refused every config; a bare key
check would pass for one that kept the key and reported success. Both
the defect and its over-correction are named, not assumed.
Beside them: an unguarded-object CONTROL that must stay
`silently-dropped` on this exact zod; a preservation row per
reserved-looking name; the previously-accepted shapes (empty config,
bare legacy config, the CEL envelope and its malformed counterpart); the
`assignments` guard and the array-form prescription still firing at
their own paths; and an invariance pin on the JSON projection.
**Ablation** (`scripts/ablation-replace.mjs`, anchor declared and hit
exactly once, mutation verified against the disk):
```
anchor x1 -> x0 ; blob 50724ef -> 21c448025a16 (mutation landed)
result Tests 6 failed | 88 passed (94)
restore blob after restore 50724ef == blob at HEAD 50724ef, `git diff HEAD` empty
```
Direction observed: **red**, as expected. The six that turn red are
exactly the six `#19151` assertions. **The `#17852` / `#18847`
record-guard pins stay green under the same mutation** — which is the
pin that the two guards are independent, and that these six are not
riding on the other one's work. The fix was committed before the
ablation, so the restore leg points at a commit that really exists; the
subject resolves through the package's own `src` (a same-package
relative import), so no `dist` leg is involved and none is claimed.
## What else moved, and why
`packages/spec/dropped-refinements.baseline.json` — the guard wraps the
object in a `z.preprocess` pipe, so the `AssignmentValue` refinement the
JSON projection already dropped sits one segment deeper:
`assignments.out.valueType` becomes `out.assignments.out.valueType`.
**Same single site, same gap, no new one.** The build gate caught it and
printed the corrected entry verbatim; this is that entry.
Nothing else regenerated: `pnpm --filter @objectstack/spec
check:generated` reports **all 15 generated artifacts up to date**, and
the JSON projection is byte-identical to the pre-change baseline — same
`type`, same single `properties.assignments`, same `xExpression:
'value'` on the map value, same `additionalProperties`. The expression
ledger still derives `assignments.*` through
`getSchemalessNodeConfigJsonSchemas()`, because every spec walker
resolves a preprocess pipe to its OUT side (`pipeAuthorableSide`). All
four are pinned, not merely observed.
## Verification
Every exit code captured before any pipe.
| what | verdict |
|---|---|
| `pnpm --filter @objectstack/spec build && … check:generated` | exit 0
— all 15 artifacts current |
| `pnpm --filter @objectstack/spec test` | exit 0 — **504 files / 14754
tests** |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 (test layer
included) |
| service-automation reconciliation suites (8 files: form↔Zod ledger,
expression ledger, config parse/schemas/unknown-keys, assignment
envelope ×2, logic nodes) | exit 0 — 117 tests |
| `dispatch-gates --commands` then `--ran` | **81 derived, 81 run, 0
UNRUN** |
| `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 |
Three of the 81 answered **exit 3 — PREREQUISITE NOT MET, which is not a
red and not a pass**: `check-plugin-teardown-shape --self-test` (its
positive control is pinned to a commit outside this shallow clone),
`check:dual-build-cjs-loads` and `check:type-check-debt` (both read a
whole-repo `dist/` this box did not build within the foreground cap). CI
builds and runs all three.
Two families were derived from a base four commits behind `origin/main`
and are named rather than assumed: `check:merged-result` and
`check:issue-citations` were wired into `lint.yml` after this branch's
base. Both were run anyway — green, after the citation-spelling
correction described below.
Measured for the changeset's disposition: **zero** authored use of
`__proto__` as a top-level key on an `assignment` node config, across
this repo, `examples/` and the `objectui` sibling — against a **lit
control of 100 authored `assignment` node declarations in 26 files here
and 11 files there**. The census is a working-tree reading at
`1418799698`, not a history question; this clone is shallow (boundary
`ae8edd2c4f71d6f6fea5261e8284997f5546392f`) and no count here depends on
history.
## Acceptance notes — noted, not filed
1. **`scripts/check-issue-citations.mjs` cannot resolve a same-repo
citation written `objectstack#N`.** `buildBoard` builds its probe set as
`rows.filter((r) => !r.qualifier)`, so every **qualified** citation is
excluded from the probe — while `classifyCitation` treats
`objectstack#N` as naming this repo and resolves it against that same
board. The number is therefore never on the board and always reports
`allocated-but-absent`. Two-leg measurement on this diff, same six
citations, same run mode: with `objectstack#17852` → `board: probed (1
citations)`, `2 allocated-but-absent`, exit 2; with `#17852` → `board:
probed (2 citations)`, `6 resolves`, exit 0. Independent control: `GET
/repos//issues/17852` answers **HTTP 200**
(state `closed`), so the number resolves and the gate's own transport
would have found it had it asked. This diff's added citations use the
bare spelling, which is this repo's documented form for its own issues
and what the gate's failure text itself prescribes. The gate is not
otherwise touched here.
2. **`treeifyError` / `error.format()` throw on any issue path
containing `__proto__`.** Reproduced first-hand against #19147's landed
record guard on zod 4.4.3: both throw `TypeError: Cannot read properties
of undefined (reading 'push')` on the `['assignments','__proto__']`
path, while the same call on an ordinary refusal path succeeds (lit
control). This guard uses the same `path: ['__proto__']` shape as its
landed sibling, deliberately — it adds no new exposure class, and
changing the path shape for one of the two would create two dialects and
pre-empt a decision that belongs to whoever takes that question.
#19151's body already records this connection; it is not this change's
subject and not its acceptance condition.
3. **Two open PRs also hold
`packages/spec/dropped-refinements.baseline.json`** — #19373 and #19335.
That file is deliberately **not** `merge=os-regen` (recomputing a
shrink-only ratchet can widen it), so whichever lands second reads the
conflict by hand. No open PR holds either source file this change edits;
lit control on the same scan, 18:25:35Z: 11 open PRs touch
`packages/spec/` at all and 1 touches `packages/spec/src/automation/`.
Not filed here: the PM files what is worth filing, per ruling A-narrow's
own instruction that a further site is its own card **when measured**.
## Scope
One site. ⛔ No sweep over the 398 records, ⛔ no re-opening of #17852's
ruling, and #19147's `assignments` guard is untouched — it is correct
and it is not this change's. #18670, the JSON-Schema projection gap, is
not addressed here and stays open.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9e16d8 commit 488f4f5
6 files changed
Lines changed: 386 additions & 53 deletions
File tree
- .changeset
- packages/spec
- src
- automation
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
383 | 383 | | |
384 | 384 | | |
385 | 385 | | |
386 | | - | |
| 386 | + | |
387 | 387 | | |
388 | 388 | | |
389 | 389 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
665 | 665 | | |
666 | 666 | | |
667 | 667 | | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
85 | | - | |
| 85 | + | |
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
| |||
918 | 918 | | |
919 | 919 | | |
920 | 920 | | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
921 | 930 | | |
922 | | - | |
| 931 | + | |
923 | 932 | | |
924 | 933 | | |
925 | 934 | | |
| |||
938 | 947 | | |
939 | 948 | | |
940 | 949 | | |
941 | | - | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
942 | 963 | | |
943 | 964 | | |
944 | 965 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
10 | | - | |
11 | | - | |
12 | | - | |
13 | | - | |
14 | | - | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| |||
91 | 91 | | |
92 | 92 | | |
93 | 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 | + | |
0 commit comments