spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) - #19147
Conversation
…onfigSchema.assignments Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…dd tests Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…cord-key-preparse-guard
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1782a33e51cb0df868673e04b39cd41b823d82a1 && git checkout 1782a33e51cb0df868673e04b39cd41b823d82a1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin adf4b18777d507236cd24b7ed59b45a7c71bd1fd 4cdba204156b06cef828319a8c75f284b49ad0cf && git checkout -B drift-repro adf4b18777d507236cd24b7ed59b45a7c71bd1fd && git merge --no-ff 4cdba204156b06cef828319a8c75f284b49ad0cf
node scripts/docs-audit/affected-docs.mjs --json adf4b18777d507236cd24b7ed59b45a7c71bd1fd
|
|
One check went red and is green again; the cause was the dispatching seat's own instruction, not this branch's code. Seat
⭐ The cause is the seat's dispatch word (5736756482), which told the dev to put both closing keywords in the body without putting #18847 into the state that ownership implies. ⇒ Fixed at the STATE end: #18847 went through the full claim protocol (labels and assignee written first, then claim comment 5737404977 naming this branch, ⛔ What was NOT done, and will not be: the gate was not weakened, no check was skipped or quarantined, the
Generated by Claude Code |
Contract reviewServed-tier: 85/85 Reviewed against the maintainer's ruling of record, comment 5725370319 (batch #154 item 1, letter A, narrow); ruling 甲 (5713646497) is withdrawn and option 乙 ruled out. The implementing dev delivered no report, so every reading below is off the diff ① Derived judgmentsDeclared
② Semver level
③ Boundary flags⭐ The ratchet line — advice for the maintainer's hand, not settled here.
Other flags
Implemented-by: VERDICT: PASS On the contract questions: the narrowing is the ruled one at both slots, the guard refuses rather than repairs, the changeset sentence is true of what shipped, and Generated by Claude Code |
…cord-key-preparse-guard Resolves the sole conflict in packages/spec/dropped-refinements.baseline.json (hand-edited, no gen: script — see scripts/lib/dropped-refinements.ts). The `entries` map merged cleanly with no textual conflict (main's #19137 removed two entries; this PR's site renames/additions touched a disjoint set). The `measured` header conflicted and is rewritten to exactly what `pnpm --filter @objectstack/spec gen:schema` reports on the merged tree: publishedSchemasWithDroppedRefinements 200, droppedRefinementSites 560, refinementSitesThatDidProject 357, refinementSitesWithNoJsonFormToCompare 9. The dropped-refinements gate embedded in build-schemas.ts passed with no undeclared/miscounted/repaired/vanished/unreasoned entries on the first run.
Round 3 conflict resolution. The only real conflict was the measured header block in packages/spec/dropped-refinements.baseline.json (this ledger is hand-edited, carries no gen: script, and is not on the merge=os-regen list). The four measured numbers are a build output, never picked from either side or computed by arithmetic, so this commit lands them as placeholder zeros; a follow-up commit on this merged tree re-runs the repo's own measurement (pnpm --filter @objectstack/spec gen:schema) and writes back the numbers it prints. entries merged with zero textual conflict.
Discharges the os-regen deferral recorded by the prior merge commit. pnpm --filter @objectstack/spec gen:schema on the merged tree (HEAD is now the merge commit, so this reads the correct merge-base) reports: 569 refinement site(s) across 204 published schema(s) reach the RUNTIME and not the published JSON Schema; 357 refinement site(s) DID reach the file; 9 had no JSON form on either side to compare. Those four numbers replace the placeholder zeros in packages/spec/dropped-refinements.baseline.json's measured header. entries needed no changes: the gate reported zero undeclared, miscounted, repaired, vanished or unreasoned sites on this run. check:authorable-surface (same script, --check mode) independently reconfirms 204/569/357/9. content/docs/references/data/object.mdx is regenerated via gen:docs from the rebuilt json-schema/ tree (it renders from that gitignored directory, which a merge cannot bring in a text merge).
Contract reviewServed-tier: 47/47 Re-review of record for the head that moved after comment 5737516015 (PASS on ① Derived judgmentsThe delta is main arriving plus one re-measured ledger; nothing PR-authored moved. The earlier PASS survives on this head.
② Semver levelUnchanged and still correct: The ledger, taken here per entry at all three anchors —
Header equals sum on all six ledgers. The 9 new sites are the same 9 schemas at every anchor — Is the +9 the ruling's line-11 cost? Advice to the seat, in two halves. (a) Not literally, but covered in substance. Line 11 says 「the guard does not project into the published JSON Schema」. The guard is a (b) What the delta changed — material to the advice, not to the verdict. The earlier record said the only way to hold the ledger flat was a negative-lookahead regex. That is no longer true on this head. Main's ③ Boundary flags
Implemented-by: VERDICT: PASS On this head: nothing PR-authored moved between Generated by Claude Code |
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) runsif (key === "__proto__") continue;abovedef.keyType._zod.run, so no key schema — regex,.refine(),.superRefine(), or one that rejects every string — can ever see a__proto__key.ObjectSchema.fieldsused to accept a document whosefieldscarried a__proto__own key and hand back a document without it: success, silent, irreversible into whateveros buildwrites.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 viaz.preprocessand refuses a__proto__key with a named, located issue (fields.__proto__) before the record ever parses.constructorandprototype— 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;constructorandprototypeare 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:3032returnsmergeActionsIntoObjects(data), notdata. Region-disjoint from draft PR docs(spec): scope the email-template locale-floor claims to a call that names a locale #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'sinhalf is aZodTransform, which unconditionally hardcodes_zod.optin = "optional"— a preprocess accepts any input, includingundefined, regardless of what the wrapped schema does. Left alone, that madeObjectSchema.fields(which carries no.optional()) report as optional to$ZodObject's own JSON-Schema requiredness check (objectProcessor,io === 'input'), so the publisheddata/Objectschema silently droppedfieldsfrom itsrequiredarray while the runtime parse still correctly refused a missingfields.refuseRecordProtoKeynow patchesoptin/optouton the pipe's innerdef.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 withz.toJSONSchema(ObjectSchema, { io: 'input' }). See the docblock inrecord-proto-key-guard.tsfor 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 independentisCollectionwalker structurally pattern-matchesObjectSchema.shape.fields's zod type; before this changefieldswas a bareZodRecord, and wrapping it inz.preprocessnecessarily makes it aZodPipe. The walker'spipecase only recursed intodef.in(correct for a.pipe()combo, whereinis the original type) and missed the record hidden indef.out(the conventionz.preprocess(fn, schema)actually uses). Fixed to check both sides of a pipe. The production merge/refuse logic instack.zod.ts(declaresCollection/objectCollectionKeys) has the identicaldef.in-only blind spot, but it is functionally unaffected here becausefieldsis excluded from that logic by literal key name, beforedeclaresCollectionis ever consulted — confirmed with an end-to-endcomposeStacks({ objectConflict: 'merge' })probe that still shallow-mergesfieldscorrectly. That production blind spot is a real, separate, dormant defect for any future collection-typed key that gets wrapped inz.preprocess(notfields— that one is safe by name) and is reported below as an out-of-scope finding rather than fixed here, sincestack.zod.tsoutside 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— viapnpm --filter @objectstack/spec gen:docs, reflecting the new.describe()text onObjectSchema.fields(and, before theoptin/optoutfix above, briefly and incorrectly downgradedfieldsto "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 nogen:script by design;check:generated's underlyingbuild-schemas.tsprints the exact correctedsitesarrays 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 afields.out.keyType/assignments.out.valueType-shaped site: the new.refine()onObjectSchema.fields' key type, and the.outpath segment thez.preprocesswrapper's pipe structure introduces, neither of which projects into the published JSON Schema (see "Known gap" below) —measured.droppedRefinementSitesmoved 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— pinsObjectSchema.fieldsrefusing__proto__(named issue, never falls through to the key-grammar's regex message), refusingconstructor/prototypevia the key grammar (invalid_key, nested refine message), and still accepting an ordinary document.packages/spec/src/automation/builtin-node-config.test.ts— pinsAssignmentConfigSchema.assignmentsrefusing__proto__, and a preservation pin thatconstructor/prototyperemain 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, includingcheck:api-surface✓ andcheck: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'snot-required (no-migration-prescription)disposition verified against the census (zero authored use anywhere reached).node scripts/pm/dispatch-gates.mjs --commandsderivation 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 onPREREQUISITE NOT MET/exit 3, requiring a full ~80-package workspace build outside this card's local scope; not a finding).dist/, not onlysrc/(importeddist/data/index.mjsdirectly and re-probed).origin/mainmid-flight (an unrelatedspecPR landed); rebuilt, re-rancheck:generated, the full test suite and typecheck again on the merged tree — all clean.Out-of-scope findings (not filed, not fixed here)
stack.zod.ts'sdeclaresCollection(case 'pipe': return declaresCollection(def.in, ...)) only reads theinside of a pipe. Forz.preprocess(fn, schema)the real type sits inout, so a future collection-typed key onObjectSchema.shapewrapped inz.preprocesswould silently stop being refused byobjectConflict: 'merge''s collision guard (composeStacks objectConflict: 'merge' merges fields only — the later object's actions (and every other key) replace the earlier package's wholesale, silently dropping its embedded actions #14848's own shape). Harmless forfieldstoday only because it is excluded by literal key name first. Dedupe words:declaresCollection,objectCollectionKeys,z.preprocess,pipe def.in,objectConflict merge.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 identicalif (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 wordsAssignmentConfigSchema catchall,handleCatchall __proto__,top-level assignment variable.Clause-②: yes (narrowing)
Generated by Claude Code