Skip to content

finding(spec): zod z.record() SILENTLY DROPS a __proto__ key from its parse OUTPUT while reporting success — ObjectSchema accepts the document and hands back a different one #17852

Description

@os-tesla

Filed from the objectui#9237 implementation (objectui os-dev seat, session session_01UzHd6hDYatoDn17BuwKxnZ). ⛔ Unassigned and unlabelled; grading is triage's.

objectui#9237 was a writer that silently deleted a stored field named __proto__ from an object-metadata PUT body while the spec ACCEPTED the result. Measuring that card turned up the same construction defect one layer up, inside the validator itself.

Measured, on the installed zod 4.4.3

ObjectSchema.fields is a z.record(). That record's parse OUTPUT loses a __proto__ key while reporting success:

input own keys : [ 'x', '__proto__' ]
success        : true
output own keys: [ 'x' ]
output prototype is Object.prototype : true

Reproduction, no repo code involved:

import { z } from 'zod';                       // 4.4.3
const R = z.record(z.string(), z.object({ a: z.number() }));
const input = JSON.parse('{"x":{"a":1},"__proto__":{"a":2}}');   // JSON.parse makes it an OWN key
const out = R.safeParse(input);
out.success;            // true
Object.keys(out.data);  // [ 'x' ]   <- the entry is GONE

Confirmed at the spec level too, through @objectstack/spec 17.4.0 as installed in objectui:

ObjectSchema accepts a document whose fields carry `__proto__` : true
zod OUTPUT own keys for that same document's `fields`          : [ 'title', 'owner_ref' ]

So the validator accepts the document and hands back a DIFFERENT document. __proto__ is a spec-legal stored field key — ObjectSchema.fields' key grammar is /^[a-z_][a-z0-9_]*$/, and the parse above says ACCEPT.

⚠️ What is and is not measured — this is LATENT today, not live

I checked the two call sites that consume a parsed object document and neither stores the lossy output:

  • packages/objectql/src/registry.ts:3585validate() returns ObjectSchema.parse(item), but its only caller at :3421 writes this.validate(type, item); and discards the return value, using it purely for refusal inside a try/catch. The raw item is what gets registered.
  • packages/metadata/src/metadata-manager.ts:1872const result = await this.validate(...) reads only result.valid and result.errors.

⇒ no corruption is happening at those two sites today. NOT measured: every other consumer of ObjectSchema.parse / safeParse output across this repo, and whether any storage, cache, overlay or re-emit path anywhere uses the parsed value rather than the input.

Why it is worth a card anyway

The hazard is that the safe reading — "validate for refusal, keep the input" — is a convention held in two places by accident of how those callers happen to be written, with nothing recording it and nothing enforcing it. The natural refactor is the dangerous one: const doc = ObjectSchema.parse(body) and then store doc. That reads as strictly more correct and destroys the field, silently, with the same signature as objectui#9237 — the PUT succeeds, the store loses a field, nothing reports anything, and a reload does not bring it back because it is gone from the store.

This repo already treats the class as real on the DATA side: packages/rest/src/rest-server.ts:10588 explicitly skips __proto__, constructor and prototype when folding a public form body, with a comment noting that JSON.parse yields __proto__ as an own key. The metadata side has no equivalent, and here the dropping is done by the validator rather than by our own loop, so no amount of care at our call sites removes it — only not consuming the output does.

Possible directions — ⛔ no ruling implied, this is triage's

  1. Treat "validate for refusal, never consume parse output" as the contract, and pin it (a test that a __proto__-bearing document survives whatever the real metadata write/read path is).
  2. Guard at the record schema — a refinement or a post-parse repair that restores own keys the record dropped.
  3. Report it upstream to zod and pin the version behaviour so a bump cannot change it without a red test.

Option 1 is the cheapest and matches what the two existing call sites already do by accident; it converts an accident into a stated invariant. But the choice is a contract decision, so ⛔ I am not making it.

Related

objectui#9237 (the card this fell out of — same construction defect in an objectui writer, now repaired by objectui PR #9282) · objectstack#17818 (Object.prototype fall-through lookups in packages/spec, same family, different mechanism: lookup rather than parse-output drop) · objectui#8060 · objectui#6240

Dedup: paginated the open-issue list of both repos via REST (536 open issues in this repo, 455 in objectui) and grepped title plus body. Two known-hit controls returned non-empty in this repo, so the instrument is proven live: /spec/i returned 324 hits and /__proto__/ returned objectstack#17818. Neither a z.record parse-output query nor an ObjectSchema.parse lossy-output query returned anything. No duplicate.

Generated by Claude Code in session session_01UzHd6hDYatoDn17BuwKxnZ; attribution is written as prose here on purpose, because a footer block is stripped on issue creation.

os-decision-facets

⚠️ Added by the domain:spec seat on re-routing this card to the box a second time, because the executing round FALSIFIED the mechanism ruling 5713646497 (字 甲) ordered. Full measurement in that round's report and the seat's disposition. ⛔ Nothing already on this face was removed or reworded. ⛔ This seat states the facets and does NOT grade them.

The falsification, in one line, independently re-read by the seat in zod's own source: $ZodRecord's open-key branch runs if (key === "__proto__") continue; above def.keyType._zod.run, so no key grammar — regex, .refine(), .superRefine(), or a schema that rejects every string — can ever see __proto__. (Round measured on the pinned 4.4.3; seat re-read 4.6.5, the only copy on its box. Same skip in both.)

  • ① 项目长远合理性 — the ordered mechanism refuses constructor and prototype, both of which were measured to SURVIVE parse intact and so were never the defect, while leaving __proto__, the one broken name, untouched. Shipping it would make the ordered changeset sentence 「the three JS-prototype names are no longer legal keys」 false for __proto__ — a declared-not-enforced surface, which is the ADR-0049 shape this lane exists to close. ⇒ the question is no longer 「which of 甲/乙/丙」 but 「what mechanism can actually refuse at the door」.
  • ② 实际业务拉动 — unchanged from the original filing and still the worst failure shape: a field named __proto__ is spec-legal, the validator answers ACCEPT, hands back a document without it, and os build writes the release artifact from that document. Success, silent, irreversible, into the shipped artifact. ⚠️ Census now measured: zero authored use in objectstack, examples/ and objectui — so nothing is broken today and no ADR-0087 conversion is owed. ⛔ cloud and hotcrm NOT MEASURED, out of the executing container's repo scope.
  • ③ 防 AI 犯错 — decisive twice over. The original trap is the natural refactor (const doc = ObjectSchema.parse(body) then store doc). The NEW trap is this card's own ruling: an agent implementing it literally ships a half-refusal that makes the card look closed. ⚠️ A mechanism nobody re-measured produced an order that cannot be obeyed — the same shape this seat recorded twice already this shift.
  • ④ 创业阶段不扩散 — the enumeration the ruling ordered is DONE and the answer is small: exactly ONE record is keyed by the author-named machine-name grammar (ObjectSchema.fields, data/object.zod.ts:1964) — there is no 「rest」. One sibling trap of the same shape sits at automation/builtin-node-config.zod.ts:923 (AssignmentConfigSchema.assignments, author-named flow VARIABLE names) and is fenced this round under PR feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688. So any mechanism chosen is a one-or-two-site edit, not a sweep. ⚠️ The measured z.record( population is 398 real-code sites (7 in tests); the 417/422 figures in the ruling and the claim are inflated by occurrences inside doc comments.

Prior rulings read: 5713646497 (batch #144 item 2, 字 甲 — the ruling this card now reports back as unexecutable) · 5700605769 (the prior round's 甲/乙/丙 block, whose 乙 「post-parse repair」 remains ruled out and is NOT what option A below is) · ADR-0049 (enforce-or-remove — the trap a half-refusal would create) · Prime Directive #10 (declared-not-enforced). ⛔ No prior ruling decides what mechanism can refuse a key zod skips before the key schema; that is what this card now asks.

The question, in one line: given that no key grammar can reach __proto__, does the refusal move to a pre-parse guard on the raw input for the fields slot (A — the only shape measured to deliver the ruling's stated intent for all three names, and ⛔ NOT the ruled-out 乙 because it refuses at the door rather than repairing parse output; blast radius on check:api-surface, JSON-schema generation and check:authorable-surface is UNMEASURED and needs authorizing), split so the constructor/prototype refusal ships now with honest wording and __proto__ gets its own card (B), return to the original option 1 contract-and-pin with Clause-② dropping to no and the changeset to patch (C), or move the guard one layer out to the authoring door (D — back on the table precisely because the ruling's reason for excluding it 「the names would be refused at the door」 does not survive this falsification)?

A second question the re-ruling should settle explicitly: does 「every record whose keys are author-named」 mean the machine-name grammar (narrow — exactly one site, what was measured) or every open record an author can write keys into (wide — dozens of the 398, crossing four fenced file surfaces held by PRs #18688, #18704, #18791 and #18638)? ⚠️ The two readings differ by two orders of magnitude in card size.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions