spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) - #19147
os-elon-musk wants to merge 6 commits into
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 297437495ba05665ae7bbe21dd0c3c7c8534f54c && git checkout 297437495ba05665ae7bbe21dd0c3c7c8534f54c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eeaa882459edffd077c1cda2972af06d1f10550b fcef6de83fc48f510cc17d1a41813360e4bade09 && git checkout -B drift-repro eeaa882459edffd077c1cda2972af06d1f10550b && git merge --no-ff fcef6de83fc48f510cc17d1a41813360e4bade09
node scripts/docs-audit/affected-docs.mjs --json eeaa882459edffd077c1cda2972af06d1f10550b
|
|
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.
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