Skip to content

fix(spec): ObjectSchema.fields refuses constructor / prototype at the offending key, not at the fields slot - #21070

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-20997-banned-key-path
Oct 1, 2026
Merged

os-justin merged 1 commit into
mainfrom
claude/issue-20997-banned-key-path

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #20997
Clause-②: no (no key is added and the accept set does not move: only the issue path of an existing refusal changes, read against scripts/pm/clause2-line.mjs)

What changes

ObjectSchema.fields still refuses a field named constructor or prototype. The issue is now reported at the key (fields.constructor, fields.prototype) instead of at fields, as __proto__ and the key grammar's invalid_key already were. A document with both names gets two issues instead of one. The issue code (custom) and the message are unchanged.

How

One .refine(bannedKeys([name]), { path: [name] }) per reserved name replaces the single two-name refine. A .superRefine() with a computed path was not used: it has no readable predicate, so the published JSON Schema would lose the ban (#19346). The published fields node now carries one propertyNames clause per name in the same allOf.

bannedKeys (shared/refinement-projection.ts) is not edited. Its other caller, system/tracing.zod.ts (bannedKeys(['dialect']), abort: true), keeps its slot-level refusal on purpose, as its own comment says. record-proto-key-guard.ts changes only in its docblock.

Tests

In object.test.ts:

  • each name is refused at fields.NAME, with no slot-level issue left;
  • both names give two issues;
  • a Bad Name control;
  • the published-node and census pins are updated.

The local runs and ablations are in the os-dev-report on #20997. dispatch-gates.mjs --commands was not run locally, so CI on this head is the measurement.

The consumer half is objectui#11302 (the designer page that drops the issue detail). It is not addressed here.


Generated by Claude Code

…ffending key

One single-name bannedKeys refine per reserved name, each carrying its
name as a static path, so the refusal reads fields.constructor /
fields.prototype instead of fields. The banned-keys projection still
publishes the ban (one propertyNames clause per name); message and
custom code are unchanged.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/shared/record-proto-key-guard.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4f83db5a73cf636cff2bbc3bc1a379a88feb09d2.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/shared/record-proto-key-guard.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4f83db5a73cf636cff2bbc3bc1a379a88feb09d2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5546ca5347367a769bcf6b1df950935c0b80e984 — the merge of head 0ba0c93c56ce2c40e8dcc08a8898487c052b684b into base 4f83db5a73cf636cff2bbc3bc1a379a88feb09d2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5546ca5347367a769bcf6b1df950935c0b80e984 && git checkout 5546ca5347367a769bcf6b1df950935c0b80e984
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4f83db5a73cf636cff2bbc3bc1a379a88feb09d2 0ba0c93c56ce2c40e8dcc08a8898487c052b684b && git checkout -B drift-repro 4f83db5a73cf636cff2bbc3bc1a379a88feb09d2 && git merge --no-ff 0ba0c93c56ce2c40e8dcc08a8898487c052b684b

node scripts/docs-audit/affected-docs.mjs --json 4f83db5a73cf636cff2bbc3bc1a379a88feb09d2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4f83db5a73cf636cff2bbc3bc1a379a88feb09d2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0ba0c93c56ce2c40e8dcc08a8898487c052b684b
Local-runs: none

Reviewed: PR #21070 (card #20997), branch claude/issue-20997-banned-key-path, one commit, 4 files (+126 / -56): packages/spec/src/data/object.zod.ts, packages/spec/src/data/object.test.ts, packages/spec/src/shared/record-proto-key-guard.ts (docblock only), .changeset/20997-fields-reserved-name-issue-path.md. Merge-base 6073bb96b8; origin/main has since moved to 0c5a71b094 but not on any of the five files this review reads (the four above plus shared/refinement-projection.ts and system/tracing.zod.ts), so the net diff against main is the diff against the merge-base. Inputs: the card body and its four comments (5923842636, 5924568212, 5924840863, 5924872275), objectui#11302, card #19346 and its landing commit b3615f1a4c, the PR body and file list, git show/git grep on the fetched refs, and the check-runs on the head. Nothing was built, run or ablated.

① Derived judgments

A. Accept set — unchanged in both directions. Right.

  • Runtime: bannedKeys([name]) (src/shared/refinement-projection.ts:328) tests OWN properties; two one-name predicates on the same record node conjoin to exactly the two-name predicate. .refine() defaults to non-aborting (_refine in zod core/api.js:952 sets no abort; handleRefineResult pushes continue: !abort), so the second refine still runs after the first fails. The refuseRecordProtoKey wrapper, the snake_case key regex, FieldSchema and the .describe() text are untouched.
  • Published: the generator's conjoinPropertyNames (scripts/lib/refinement-projection.ts) keeps the record's own propertyNames and appends each DISTINCT { propertyNames: { not: { enum: [name] } } } to allOf; a propertyNames rule is universal over the names, so not enum [a, b] equals not enum [a] AND not enum [b]. Pinned by the published-node test (two clauses), by the corpus-agreement test (now carrying the both-names document) and by the census pin; CI Test Core green.
  • No generated artifact was owed in the diff: packages/spec/json-schema/** is untracked (0 files in git ls-tree), and dropped-refinements.baseline.json holds only DROPPED rows (no fields.out row since convert the nine fields.out.keyType dropped-refinement rows to the bannedKeys projection arm — expected ledger +0, and the published JSON Schema would carry the constructor/prototype refusal itself #19346; its measured.* block is informational — build-schemas.ts asserts the sites paths, not those counts). The dev's "no tracked generated artifact moves" is right for that reason.

B. Every issue the parse now emits, with path, code and message (read off zod handleRefineResult — static def.path, prefixed with fields by the parent object — the record parser's invalid_key push at path: [key], and the guard's path: ['__proto__']):

  • constructor beside valid keys: ONE issue, code custom, path ['fields','constructor'], message Field names must not be "constructor" or "prototype" (reserved JavaScript prototype property names). — the same literal as on main. Pinned by the it.each (find + exact path list).
  • prototype: the same shape at ['fields','prototype']. Pinned.
  • both names: TWO issues, constructor then prototype (the reduce order), both custom, same message each. On main: one issue at ['fields']. Pinned exactly (code and path pairs).
  • __proto__: one custom at ['fields','__proto__'] with the guard's own message. Unchanged; three existing pins.
  • an invalid key (Bad Name): one invalid_key at ['fields','Bad Name'], the regex message nested under it. Unchanged; the new CONTROL pins it exactly.
  • Not pinned, and unchanged by construction: a document carrying BOTH Bad Name and constructor gets only the invalid_key issue on main and on this head — the record's invalid_key issue carries no continue, so zod's runChecks skips the node's checks (util.aborted), and the ban refine(s) never run in either spelling.

C. Consumers of the old fields-level path, and issue counters — none that needs a move, in either tree. Right that none is touched.

  • objectstack: no code compares a zod issue path to fields for this refusal. packages/rest/src/rest-server.ts:8150 (e.path === 'fields') is the ADR-0106 object-diff mask on diff entries, not zod issues; packages/spec/src/automation/builtin-node-config.test.ts:850 filters path[0] === 'fields' on the automation node schema, and a prefix read survives a longer path anyway. protocol.invalid-metadata-422-face-inventory.test.ts:221 pins the headline count generically (${issues.length} issue). No fixture outside object.test.ts parses a constructor or prototype field through ObjectSchema (the objectql/rule-validator.test.ts hits feed raw objects to the strip helpers).
  • Derived layers that DO move with the path, stated here because the changeset leaves them to inference: (a) the /meta 422 headline (metadataIssueHeadline, protocol.ts:2906) now reads 1 issue — fields.constructor [custom] where it read 1 issue — fields [custom], and 2 issues — fields.constructor [custom]; fields.prototype [custom] for a both-names document; (b) the ADR-0114 { field, code, message } envelope (api/zod-issues-to-fields.ts): custom still maps to invalid_value, field becomes fields.constructor / fields.prototype, and a both-names document yields two entries where it yielded one. The convert the nine fields.out.keyType dropped-refinement rows to the bannedKeys projection arm — expected ledger +0, and the published JSON Schema would carry the constructor/prototype refusal itself #19346 changeset (commit b3615f1a4c) told a consumer keyed on the pre-convert the nine fields.out.keyType dropped-refinement rows to the bannedKeys projection arm — expected ledger +0, and the published JSON Schema would carry the constructor/prototype refusal itself #19346 shape to "match the issue at path fields with code custom — field: "fields""; this patch supersedes that prescription. No reader in objectstack or objectui follows it.
  • objectui: packages/plugin-designer/src/MetadataFieldsPage.tsx:908 and :942 set the error from err.message alone (the objectui#11302 defect — it never reads issues), and the app-shell surfaces render issues[] by path generically; nothing compares a path to fields. objectui pins @objectstack/spec ^17.0.0 (17.5.0 installed, per the card), so the consumer sees fields.constructor only once a @objectstack/spec release carrying this patch ships and objectui's lock moves — that is the moment objectui#11302's fix can point a form at the field.

D. The dev's claim that a superRefine would drop the published ban — right, on the tree. scripts/lib/refinement-projection.ts:73 ruleOf(check) reads _zod.def.fn, "or undefined for .superRefine() / .check()"; zod's _superRefine goes through _check, which builds a $ZodCheck({ check: 'custom' }) with the closure on _zod.check and no def.fn, while _refine stores fn. The DECLARED WeakMap is keyed on the function handed to .refine(), so a superRefine's predicate is unreachable; customChecksOf would still see the custom check, the fields.out site would read dropped, the ban would leave the published node, and build-schemas.ts would fail on an unledgered site. Independently, .refine()'s path is a static def.path, so one two-name refine can only name the slot — the per-name split is the one shape that keeps the path AND the projection.

E. The other bannedKeys caller — right to leave, and the stated reason holds. bannedKeys has exactly two call sites in src: object.zod.ts and system/tracing.zod.ts:426 (composite[].condition, bannedKeys(['dialect']), abort: true). That site's own comment (:412-422) keeps abort: true "so the refusal is answered once, at the slot, instead of being nested": a dialect key marks the whole object as an expression attempt and the prescription addresses the object, not the key. The card's direction was scoped to ObjectSchema.fields. The dev also corrected the claim's premise: the helper lives in shared/refinement-projection.ts, not in record-proto-key-guard.ts.

F. Would the new pins go red if the per-name path were dropped — yes (read, not run). The it.each find(path === 'fields.NAME') returns undefined (toBeDefined fails) and the exact path list ['fields.NAME'] would read ['fields']; the both-names toEqual would read fields twice. Reverting the split to one two-name refine additionally reddens the allOf pin (one clause, two names) and the census pin (['banned-keys','banned-keys']). A slot-level duplicate left beside the per-name refines is caught by the exact path list (the test comment says so; the dev's M4 ablation reports it). The CONTROL and __proto__ pins are independent of the change, as controls should be. The published allOf pin is path-blind (the projection never reads path), so it stays green under a path drop — correct, and it matches the dev's M1 reading of 3 red / 217 green.

G. Every sentence the diff adds, against the tree.

② Semver level

③ Boundary flags

Dev report 5924840863, deviations:

  1. label-write --assign os-justin refused by the local classifier; not re-sent — the seat set it (ruling 5924872275): the PR's assignee is os-justin now. Closed.
  2. dispatch-gates.mjs --commands refused locally; not retried; recorded NOT MEASURED — CI on the head is the measurement; the check-runs below are all green. Closed.
  3. Route: .refine per name instead of the suggested superRefine — accepted by the seat; verified on the tree in ①D. Closed.
  4. File surface as claimed; shared/refinement-projection.ts and scripts/lib untouched — verified from the PR file list. Closed.

Dev report open_questions: none.

Dev report out_of_scope_findings: the guard docblock's "zod 4.4.3" against the resolved 4.6.1 — confirmed (package.json ^4.6.1, lockfile zod@4.6.1); pre-existing in three places (guard docblock, refinement-projection.ts header, baseline description and measured.zod); the behaviour the docblock describes is pinned by the guard tests. Agree with the seat: noted, not a filing class; a one-line sweep can ride a later docblock PR. Not escalated.

Seat ruling 5924872275: the PR body was rewritten and its false dispatch-gates.mjs sentence cut — verified against the current body; needs:contract-review sits on the PR and the card — verified on both.

Claim 5924568212 premise: it placed the helper in record-proto-key-guard.ts; it lives in shared/refinement-projection.ts, and the dev measured both callers as the claim asked. Closed.

Reviewer notes for the seat, neither a blocker: (i) the derived-layer sentence in ①C/①G — the changeset could name the envelope field move and the superseded #19346 prescription in one line; (ii) the "nine rows above" row-name imprecision in the object.zod.ts comment (fields.out, not fields.out.keyType).

Check-runs on 0ba0c93c56ce2c40e8dcc08a8898487c052b684b, read last: 46 runs over 35 names; deduped by name keeping the newest started_at: 35 completed, 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) — path and opt-in skips), 0 failure, none still running. Across all 46 runs, 38 success and 8 skipped — no older run of any name failed. Newest of the gate families: Test Core 04:47:59Z success (and 1/6–6/6 success), TypeScript Type Check 04:41:57Z success, Type Check · consumer gates / debt ledger / source gates / workspace success, Check Changeset 04:40:52Z success, Lint & Repo Gates success, Build Core success, Spec property liveness success, Governed Surface Queue Guard success, Dogfood Regression Gate (and 1/3–3/3) success, Dogfood Verify CLI success, Temporal Conformance success, the four PR-automation guards (same issue, same single-writer path, card claims branch, part-of) success, Check Documentation Links success, Flag docs affected success.

Implemented-by: claude/issue-20997-banned-key-path
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T05:00Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(spec): ObjectSchema.fields refuses constructor / prototype at path fields, not at the key — a Studio form cannot point at the offending field

2 participants