Skip to content

feat(spec)!: object extension fieldPatches refuse a field the extension replaces, key option patches by value, refuse __proto__, carry display renames - #29331

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-d58n14
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-d58n14

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #28655

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/data-modeling/object-extensions.mdx gains the "Patch it or take it over, not both" bullet (the replaced-field refusal and the option-key grammar). skills/objectstack-data/SKILL.md already teaches "Relabel a field the owner declares with fieldPatches — never by redeclaring it in fields" and its example keys the option by technology, so every taught line stays true and no skill line changes.

维护者速读

  • 改了什么:对象扩展的 fieldPatches 多拒三种输入——同一扩展既在 fields 里整字段替换又在 fieldPatches 里打补丁的字段、不可能是选项值的选项补丁键(如 Tech Sector)、以及 __proto__ 键;补丁上写 helpText/title 这类别名时给出改名提示;描述里的白名单成员改为从常量生成。
  • 为什么改:fieldPatches 现在已被合并真实应用,原来这些写法"能保存、不生效":补丁落在扩展自己的替换字段上,或永远匹配不到选项,__proto__ 键在保存门被存下却在注册时丢失。
  • 风险与回滚:收窄(BREAKING,spec minor)。仓内 examples/skills/docs 无一处命中被拒写法;回滚即 revert 本 PR。HotCRM 仓不在本容器,未测。
  • 先看:packages/spec/src/data/object.zod.ts(refuseFieldPatchOfReplacedField、options 的键 schema、whitelistedAliases)、packages/spec/src/data/field-key-aliases.ts(别名表搬出的唯一副本)、packages/spec/src/data/field-customization.test.ts 新增一节。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts src/data/field-patches-derived.test.ts,预期 2 个文件全绿(91 条)。
  • 需要你做的:无,席位落地。

What changes

All in ObjectExtensionSchema (packages/spec/src/data/object.zod.ts), the four should-fixes A–D of the contract review on PR #28650:

  • A. The root superRefine refuses a field name present in both fields and fieldPatches of one extension (refuseFieldPatchOfReplacedField): custom at fieldPatches.NAME, prescribing "patch the vendor's field or declare your own, not both". A __c name in both is left to the existing __c rule, so it is refused once.
  • B. A field patch's options map is keyed by SelectOptionSchema.shape.value (read off the shape, not restated), so Tech Sector is refused with invalid_key at fieldPatches.industry.options.Tech Sector. refuseRecordProtoKey now guards fields, fieldPatches and the options map: the measurement holds at the door (below), not only at schema level.
  • C. The field patch and the option patch carry the rows of their source shape's rename table whose target the whitelist admits, by filter (whitelistedAliases). Field: title, displayName onto label; help, helpText, hint, tooltip onto inlineHelpText. Option: text, name, title onto label. Carriage follows the ui/action-target-aliases.ts precedent: the two tables moved out of field.zod.ts into a relative-import-only leaf, packages/spec/src/data/field-key-aliases.ts, which FieldSchema, SelectOptionSchema and the patches all read. No second mechanism and no new public export (check:api-surface unchanged).
  • D. The fieldPatches .describe() lists its members from BASE_FIELD_DISPLAY_WHITELIST (fieldPatchesDescription).

Generated and ledger files that ride along:

  • content/docs/references/data/object.mdx (regenerated description);
  • packages/spec/dropped-refinements.baseline.json: the five data/ObjectExtension fields.valueType… sites moved to fields.out.valueType…, because the __proto__ guard wraps the record in a preprocess. The same sites, one level deeper, and no new site;
  • content/docs/data-modeling/object-extensions.mdx gains one bullet;
  • .changeset/28655-field-patches-refuse-replaced-field.md.

Forced test edits: none. The new pins are added to field-customization.test.ts and field-patches-derived.test.ts. No existing assertion changed.

Why

The card's "reach: none yet" premise has expired: mergeObjectDefinitions (packages/objectql/src/registry.ts) applies fieldPatches on main (#28565). Each input A and B refuse parsed and then did nothing visible. A patch landed on the extension's own whole-field replacement, and an option key no value can be never matched an option.

__proto__ reach, measured at the door on main 47ee57fe before the change. This is the /api/v1/meta object_extension save door's own schema (OBJECT_EXTENSION_ITEM_SCHEMA) and its registration seam (registrableObjectExtension), over a JSON-parsed body:

  • the door schema answered success: true, with fields: {} and fieldPatches missing the key;
  • the registered body was missing it too;
  • the stored (verbatim) body kept fieldPatches.__proto__ and fields.__proto__. The stored row and the served extension disagree.

Tests

The new pins, each giving zod code, path and named subject beside a control through the same parse:

  • A: refusal at fieldPatches.industry, with a control (a patch beside a fields entry of another name parses), and the __c case refused once.
  • B: invalid_key at the option key, with a control (the key verdict equals SelectOptionSchema.shape.value.safeParse for four keys). There are three __proto__ cases (fields, fieldPatches, options) over JSON documents, plus a control.
  • C: four field renames and three option renames, with a control (a rename whose target the patch refuses carries no arrow, and colour carries none under the shipped whitelist). The derived proof is in field-patches-derived.test.ts: once the mocked whitelist admits color, the option's colour rename reaches the patch.
  • D: every whitelist member appears in the description, and the widened members appear under the mock.

Ablation, in a side worktree at 5f8d7494, through scripts/ablation-replace.mjs (each anchor 1 → 0, every leg restored to blob 67228dde3fe9, git diff HEAD empty). Spec tests import src, so no build is in the path:

leg mutation red
A drop the refuseFieldPatchOfReplacedField call 1 (the A refusal)
B key z.string() key instead of optionShape.value 2 (refusal + grammar control)
B proto drop the fieldPatches guard 1 (the fieldPatches __proto__ case)
C field drop the field patch aliases 4 (the four field renames)
C option drop the option patch aliases 3 (the three option renames)
D constant description 3 (both description pins + the existing liveness pin)

Acceptance notes

  • The card listed the field renames as title, help, helpText, hint, tooltip. The filter also yields displayName onto label, which FieldSchema's table carries. It is pinned.
  • The options map's __proto__ guard goes beyond the card's "fieldPatches and fields". It is the same defect class and the same mechanism, in the same schema, inside the same gate family. Without it B's key grammar could not see that key, because zod skips __proto__ before the key schema.
  • The claim's file surface names object.zod.ts. C also touches field.zod.ts (the two tables now read from the leaf, byte-identical rows) and adds field-key-aliases.ts. This is declared as a deviation for the seat to amend.
  • HotCRM fixtures: that repository is not reachable from this container, so its count of A/B hits is NOT MEASURED. In this repository, examples/, skills/ and content/docs/ carry zero refused forms. The only authored fieldPatches (the docs page and the skill) key options by technology.
  • refuseUnlessPlatformWritten and mergeObjectDefinitions are untouched (spec(currencyConfig.homeAmount): a home or rate field with both readonly: false and an expression is told only about readonly, so the author meets the second fault on the next parse #28205 follows in the same file).

Verification

Consumer packages (downstream of spec), at 5f8d749
  • @objectstack/spec test: Test Files 790 passed, Tests 23390 passed, 1 todo. typecheck: exit 0.
  • @objectstack/objectql test: 527 files, 10140 tests passed.
  • @objectstack/metadata-protocol test: 300 passed, 4 skipped files; 22100 tests passed.
  • @objectstack/metadata-core test: 22 files, 885 tests passed.
  • @objectstack/lint test: 189 files, 7830 tests passed.
  • @objectstack/cloud-connection test: 67 files, 813 tests passed.
  • @objectstack/rest test: 367 files, 6963 passed, 332 skipped.
  • @objectstack/cli --project unit: 370 files passed. Two files reported "cli is not built" (published-subpath-*.pin.test.ts), a prerequisite, rerun after the full build: 2 files, 29 tests passed. --project integration test/validate-field-patch-targets.test.ts: 2/2 passed.
Gates and dogfood

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 5f8d7494: 103 derived commands, each run with stdin closed. 102 exited 0 on the first pass. check-plugin-teardown-shape --self-test exited 3 (PREREQUISITE NOT MET: its pinned fixture commit is outside the shallow clone); after git fetch --depth=1 origin 621a4876… it exited 0 (48 cases). --ran reconciliation: "103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN" (a DERIVED zero). Offline before the PR: check-changeset-no-major --event on this body's two lines: LEVEL AXIS accepted (no (narrowing) + narrowed, spec minor); check:adr-0087-registration: 1 declared-breaking changeset with a disposition. eslint on the five changed .ts files: exit 0. pnpm --filter @objectstack/spec check:generated: 1 stale (check:docs), fixed with --fix and re-checked green; the other 13 were up to date.

Dogfood (title carries ! and the (narrowing) arm) at 5f8d7494, after turbo run build --filter=./packages/** (78/78): os-verify-lock: VERDICT command-exit 0 · held 1829 s · pnpm --filter @objectstack/dogfood test: Test Files 389 passed, 3 skipped (392); Tests 3215 passed, 28 skipped (3243).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…key options by value, carry display aliases

Claude-Session: https://claude.ai/code/session_01ADHkmT6ynacYWXqz9Yredm
Co-authored-by: Claude <noreply@anthropic.com>
… derived description

Claude-Session: https://claude.ai/code/session_01ADHkmT6ynacYWXqz9Yredm
Co-authored-by: Claude <noreply@anthropic.com>
… changeset and the patch-or-take-over rule

Claude-Session: https://claude.ai/code/session_01ADHkmT6ynacYWXqz9Yredm
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 38 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

77 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 738971d2eabaaa677b894ae6030cac5b887f9bc6.

⛔ 17 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: defaultValue (literal, 41 pages)
  • 12 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 168 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 738971d2eabaaa677b894ae6030cac5b887f9bc6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ec41b2be516ea3244a9947b137535dacfc87187b — the merge of head 5f8d7494c93b397f54abc4deeea5780025d4335d into base 738971d2eabaaa677b894ae6030cac5b887f9bc6, 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 ec41b2be516ea3244a9947b137535dacfc87187b && git checkout ec41b2be516ea3244a9947b137535dacfc87187b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 738971d2eabaaa677b894ae6030cac5b887f9bc6 5f8d7494c93b397f54abc4deeea5780025d4335d && git checkout -B drift-repro 738971d2eabaaa677b894ae6030cac5b887f9bc6 && git merge --no-ff 5f8d7494c93b397f54abc4deeea5780025d4335d

node scripts/docs-audit/affected-docs.mjs --json 738971d2eabaaa677b894ae6030cac5b887f9bc6

⚠️ 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 738971d2eabaaa677b894ae6030cac5b887f9bc6 → 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

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Head-sha: 5f8d7494 · read 2026-11-02T12:56Z

维护者速读

席位意见:可落地,等契约复审。

席位核对 — ACCEPT · domain:spec#1 · head 5f8d7494

  1. Shape: draft, base main, Fixes #28655, Clause-②: no (narrowing) / Metadata-protocol: narrowed at line start in the body and the changeset (BREAKING spec minor). Surface: claim 6574981251 as amended on spec(object-extension fieldPatches): refuse a field both replaced in fields and patched in fieldPatches of one extension, key option patches by the option-value grammar, carry the whitelisted aliases, and derive the describe's member list #28655 (field.zod.ts, the new leaf data/field-key-aliases.ts, one docs bullet, the options-map proto guard). Not governed.
  2. Diff, read on the head: A, the root superRefine refuses a field that is both in fields and in fieldPatches (custom issue at fieldPatches.NAME; a __c name is left to the __c rule). B, a field patch's options map is keyed by SelectOptionSchema.shape.value (invalid_key with a prescription), and refuseRecordProtoKey guards fields, fieldPatches and the options map. C, both patch surfaces carry the rename guidance the whitelist admits, from the shared leaf, which FieldSchema and SelectOptionSchema now read too (rows byte-identical; no barrel export). D, the fieldPatches describe is built from BASE_FIELD_DISPLAY_WHITELIST. dropped-refinements.baseline.json: five fields.valueType sites moved to fields.out.valueType (the guard's preprocess), no new site.
  3. Premise, re-measured by the dev: mergeObjectDefinitions applies fieldPatches on main; in-repo authored A/B hits are 0 (examples, skills, docs). The __proto__ loss was measured at the /meta save door on main. HotCRM was not measured (not reachable from the container). The seat accepts that risk for landing: every refused form fails at os validate / save with a named prescription (rename the patch, drop the duplicate, re-key the option), nothing is dropped in silence, and this repo's examples, skills and docs carry zero refused forms.
  4. Tests: +130 / +19 new; six ablation legs, each red on its own pin. No forced test edits.
  5. Readings (dev, on 5f8d7494): 103/103 derived gates, DERIVED zero; dogfood 389 files / 3215 tests green; spec 790 files; objectql, metadata-protocol, metadata-core, lint, cloud-connection, rest and cli consumer suites green. main moved to 192c15cf after the round with no overlapping path, and the PR's CI runs on the merge ref. Check-runs on 5f8d7494 at this reading: 57 success, 8 skipped, 0 failure, 0 running.
  6. Landing: one CONTRACT_REVIEW_TIER review (a narrowing on the published schema face), which the seat runs itself (claim line follows). Then the seat readies and queues. After it lands, Portal UX: let a lookup show the target's name when the viewer cannot read the target (decision) #29171 (same file) and spec(currencyConfig.homeAmount): a home or rate field with both readonly: false and an expression is told only about readonly, so the author meets the second fault on the next parse #28205 / object.titleFormat has no effect when the object authors no nameField: the registry stamps a derived nameField that outranks the template, so the record title ignores it #29007 (object.zod.ts) are next.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head 5f8d749 · 2026-11-02T12:57Z

@objectstack-fleet objectstack-fleet Bot added the needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides label Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5f8d7494c93b397f54abc4deeea5780025d4335d
Local-runs: none
Scope: packages/spec/src/data/field-key-aliases.ts, packages/spec/src/data/field.zod.ts, packages/spec/src/data/object.zod.ts + packages/objectql/src/registry.ts (mergeObjectDefinitions, applyFieldPatches, patchedField, ownFieldNamesOf), packages/metadata-protocol/src/protocol-object-extension-item.ts (OBJECT_EXTENSION_ITEM_SCHEMA, registrableObjectExtension), packages/objectql/src/engine.ts (the objectExtensions registration loop), packages/spec/src/shared/record-proto-key-guard.ts (refuseRecordProtoKey), packages/spec/src/shared/strict-object.ts (aliases semantics), packages/spec/src/shared/identifiers.zod.ts (SystemIdentifierSchema), packages/spec/src/data/field-customization.ts (BASE_FIELD_DISPLAY_WHITELIST, isExtensionFieldName), packages/spec/src/data/authoring-key-lint.ts (FIELD_KEY_GUIDANCE), packages/spec/src/shared/suggestions.zod.ts (import graph), packages/spec/src/shared/alias-integrity.test.ts (the invariants the new tables must hold), packages/spec/src/data/index.ts and packages/spec/package.json exports (public-name question), packages/spec/dropped-refinements.baseline.json, packages/spec/src/data/field-customization.test.ts, packages/spec/src/data/field-patches-derived.test.ts, .changeset/28655-field-patches-refuse-replaced-field.md, content/docs/data-modeling/object-extensions.mdx, content/docs/references/data/object.mdx, skills/objectstack-data/SKILL.md, docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md (D10), scripts/check-adr-0087-registration.mjs (the no-migration-prescription rule). Every non-prose path of the diff is inside this list (narrowing face: no remainder).

Face: schema (reviewFace hits the three packages/spec/src/data paths) and a declared narrowing (Clause-②: no (narrowing) on the PR body and the changeset). Inputs read: card #28655 body and all four comments (triage 6561051425 and 6572269927, claim 6574981251, dev report 6576784326 — the seat's claim amendment widening the surface to field.zod.ts, the new leaf, one docs bullet and the options-map proto guard is taken as accepted surface), PR #29331 body, file list and net diff against main (merge base 47ee57fe), and the check-runs on this head.

Gate verdicts on 5f8d7494, read off the check-runs: Lint & Repo Gates success · TypeScript Type Check success · Test Core success · Build Core success · Governed Surface Queue Guard success · Type Check UI success · Test UI success · Build & E2E UI success · Live E2E UI success · Lint UI skipped (path-filtered; no ui/ path in the diff) · Check Changeset success · Spec property liveness success · PR-body guards success · Dogfood Regression Gate success. No required context red or in progress.

① Derived judgments

Each judgment is read off the tree at the head, never off the PR body.

  • A — refuse a field both in fields and fieldPatches of one extension: RIGHT. refuseFieldPatchOfReplacedField runs in ObjectExtensionSchema's root superRefine, walks Object.keys(fieldPatches) and refuses every key that is an own key of fields, custom at fieldPatches.NAME, skipping a __c name (left to refuseExtensionFieldPatchKeys, so it is refused once). Accept-set change: the pair {same record key in both maps} leaves the accept set; nothing else moves. The record key is the only spelling that matters: mergeObjectDefinitions merges fields by key ({ ...base.fields, ...extension.fields }) and applyFieldPatches addresses the field by key, FieldSchema.name is optional and the engine's objectExtensions loop re-keys nothing (it hands ext.fields through), so a patch and a replacement collide exactly when their record keys are equal — which is the predicate the refinement tests. Measured against the merge: for an owner-declared industry replaced in fields, ownFields contains industry, so the patch was written onto merged.fields.industry, the extension's replacement — the defect the card names is real on main and is closed here.
  • B (key) — key the options map by SelectOptionSchema.shape.value: RIGHT. The key schema is the very instance SelectOptionSchema declares for value (SystemIdentifierSchema.describe(...): min(2), /^[a-z][a-z0-9_.]*$/), read off the shape, not restated. FieldSchema.options is z.array(SelectOptionSchema), so no option an owner can declare carries a value this key refuses; the one other option shape with a looser value (the inline-grid column's) is not a field's options and is not patchable. A picklist-bound field is skipped by patchedField anyway. Accept-set change: an option-patch key outside the value grammar (Tech Sector, 2nd, x) leaves the accept set with invalid_key at fieldPatches.F.options.K and a message naming the grammar.
  • B (proto) — refuse __proto__ on the raw input of fields, fieldPatches and each options map: RIGHT. refuseRecordProtoKey is the pre-parse z.preprocess guard ObjectSchema.fields already carries (zod 4.4.3's record parser continues on __proto__ above the key schema, so no key schema — B's included — can see it). The three record slots are the only maps ObjectExtensionSchema declares (validations and indexes are arrays), so no map is left unguarded. The /api/v1/meta save door and the sync both parse OBJECT_EXTENSION_ITEM_SCHEMA = z.preprocess(withoutProtectionEnvelope, ObjectExtensionSchema), so the refusal is the door's and the sync's, not only the schema's. constructor / prototype are deliberately outside the guard (its docblock), and this diff does not change their accept status on the extension — unchanged, not a regression.
  • C — carry the whitelisted rename rows onto both patch surfaces: RIGHT, and it widens nothing. strictObject's aliases are consulted only on the unrecognized_keys path (strict-object.ts: "an alias runs only from the unrecognized_keys path") — a hint appended to a refusal, never a rewrite — so every key C touches was refused before and is refused after; only the message changes. whitelistedAliases keeps the rows whose TARGET is in BASE_FIELD_DISPLAY_WHITELIST.field / .option: on the field patch title, displayName → label and help, helpText, hint, tooltip → inlineHelpText (FIELD_KEY_GUIDANCE carries no to: onto any admitted target — grepped — so those six rows are the whole carried set); on the option patch text, name, title → label. The alias-integrity invariants hold on both tables by construction: no carried alias key is a declared patch key (none was a declared FieldSchema / SelectOptionSchema key either), every target is a key the patch accepts, and no two keys collapse onto one probe (they did not on the source tables). displayName beyond the card's list is a correct consequence of the filter, pinned.
  • D — describe built from the constant: RIGHT. fieldPatchesDescription() reads BASE_FIELD_DISPLAY_WHITELIST.field and .option through backtickList; the sentence keeps mergeObjectDefinitions (the existing liveness pin) and gains "a field the same extension replaces in fields is refused here"; content/docs/references/data/object.mdx is the regenerated projection of exactly that string. Emission change on the published description only; no accept-set change.
  • The field.zod.ts move changes nothing FieldSchema / SelectOptionSchema accept or emit: RIGHT. Set-compared: the 61 rows removed from FieldSchema's inline aliases equal FIELD_ALIAS_ROWS, the 10 rows removed from SelectOptionSchema's inline aliases equal SELECT_OPTION_ALIASES; precedence is preserved ({ ...fieldKeyGuidanceAsStrictOptions().aliases, ...FIELD_ALIAS_ROWS } as before, own rows winning); guidance still spreads fieldKeyGuidanceAsStrictOptions().guidance; both reads stay inside lazySchema bodies as before. Aliases are hint-only (above), so even a row drift would not have moved the accept set — and there is none.
  • Public surface and import graph: RIGHT, no new public name, no cycle. field-key-aliases.ts is not exported from data/index.ts, and packages/spec/package.json exports are fixed subpaths (no wildcard), so the leaf is reachable by relative import only — the ui/action-target-aliases.ts precedent; api-surface/ is untouched and check:api-surface is green. Edges added: object.zod → field-key-aliases → authoring-key-lint → suggestions.zod; suggestions.zod at this head imports protocol-upgrade-guide, alias-probe and polarity-axes only, and field.zod → authoring-key-lint already existed, so no cycle is introduced. The leaf's module scope holds two literals and two function declarations, so eager-schema load order cannot bite it.
  • dropped-refinements.baseline.json: RIGHT. The five data/ObjectExtension sites move from fields.valueType… to fields.out.valueType… — the convention the baseline already uses for the guarded ObjectSchema.fields (manifest.objects.element.fields.out.valueType) — because the guard is a preprocess pipe; same five sites, one level deeper, no site added, no site dropped. fieldPatches and its options carry no refinement, so wrapping them adds no site; the gate is green.

② Semver level

  • .changeset/28655-field-patches-refuse-replaced-field.md: '@objectstack/spec': minor with a feat(spec)!: headline, a BREAKING accept-set-narrowing banner, Before / Now / Unchanged / Who-sees-it and a remedy. Line-initial Clause-②: no (narrowing) and Metadata-protocol: narrowed, the same two lines on the PR body. A kind takes at least minor on the package the diff moves; (narrowing) is the BREAKING arm — the level matches what the diff publishes (three refusals on ObjectExtensionSchema, nothing widened, no key added, removed or renamed, no export changed).
  • ADR-0087 marker: exactly one, not-required (no-migration-prescription). Right in substance: no authorable key is added, removed or renamed, so there is nothing for objectstack migrate meta or a conversion-layer entry to rewrite — a field both replaced and patched carries two opposite intents and no rewrite keeps both, an option key outside the value grammar never addressed an option (mapping a label to a value needs the target's options, which the schema does not have), and __proto__ has no successor key. The remedy in the body is a decision the author makes per field, not a FROM → TO rewrite; the gate that re-validates this disposition statement-against-statement (check:adr-0087-registration, inside Lint & Repo Gates) is green on this head.
  • Changelog / releases untouched; the content/docs/references/ edit is the generator's output (check:docs green). Human Minimum item 5 holds.

③ Boundary flags

The dev report names six deviations; each answered here.

  1. File surface widened (field.zod.ts, the new leaf data/field-key-aliases.ts, one bullet in content/docs/data-modeling/object-extensions.mdx): accepted — the seat's claim amendment covers exactly these, and the move is the mechanism C needs (one table, two readers; exporting it from field.zod.ts would have ridden the data barrel into the published API). Answered.
  2. Options-map __proto__ guard beyond the card's fields and fieldPatches: accepted by the same amendment, and necessary rather than extra — zod skips __proto__ above the key schema, so without it B's key grammar could never see that key. Same defect class, same wrapper, same schema. Answered.
  3. Baseline sites moved to fields.out.*: a consequence of the preprocess, matching the convention already in the file; no new site. Answered (①). Note for the landing: main has since moved the same file in a disjoint block (ui/Dashboard gains one site; droppedRefinementSites 983 → 988); the hunks do not overlap and this PR adds no site, so the text merge is the right merge — the baseline is NOT_DRIVER_MANAGED, so the seat reads that conflict-free merge rather than regenerating.
  4. main moved after the verification round; no merge before pr_create: the PR's check-runs ran on the merge ref and are green; on the three hits paths main has not moved since the merge base (git diff --stat 47ee57fe origin/main on them: nothing), only the baseline (point 3). Answered.
  5. No PR assignee / reviewer: .claude/agents/os-dev.md says pr_create carries no assignees, while AGENTS.md (which wins on conflict) says the PR's assignee mirrors the card's, set by the executor at pr_create. Not a contract finding on this diff; escalated to the seat as an instruction inconsistency on a Tier S surface (.claude/**) to reconcile on its own card. No effect on this verdict.
  6. HotCRM fixtures NOT MEASURED (repository not reachable from the dev's container): the refusals are correct on this tree whatever HotCRM carries, and in this repository examples/, skills/ and content/docs/ hold zero refused forms (re-read: the only authored fieldPatches key options by technology). Escalated to the seat: before landing a BREAKING narrowing, take the HotCRM reading (count of extensions naming one field in both maps, or keying an option patch by a non-grammar string) or record the acceptance of the unmeasured risk on the card. No effect on this verdict.

④ Design judgment

Judged on the tree, file by file, against the code each hunk cites.

packages/spec/src/data/object.zod.ts

  • Does A fire on every spelling a field can be both replaced and patched? Yes within one extension: the collision the merge produces is key-equality on the two records, and that is what the refinement tests (Object.prototype.hasOwnProperty.call(fields, name) over Object.keys(fieldPatches)). Tried to break it: a fields entry whose .name differs from its key does not collide in the merge (key-based), so there is nothing to refuse; a __c key is refused once by the sibling rule, with a prescription that already names fields; a non-object fields or fieldPatches returns early, and the shape has already refused it. One ordering fact, not a defect: the root superRefine runs only once every nested property parsed (zod 4 skips a schema's checks on an aborted payload), so an extension whose fields.industry is itself invalid sees A only after that field parses — the same ordering the existing __c rule has. Observation outside the card (not a finding on this PR): across TWO extensions, a lower-priority extension that replaces industry in fields and a higher one that patches it still land the patch on the replacement (mergeObjectDefinitions applies each contributor's patches onto the running merged.fields); that is main's documented priority semantics, not this card's, and is noted for the lane.
  • Can B's key schema refuse a value SelectOptionSchema accepts? No: it is the same schema instance (optionShape.value), and every option an owner's field declares is a SelectOptionSchema (FieldSchema.options: z.array(SelectOptionSchema)), so key grammar and value grammar cannot diverge. The error map keeps the array-shaped invalid_type prescription and adds the invalid_key one; the control pin asserts key verdict equals SelectOptionSchema.shape.value.safeParse on four spellings.
  • Does the proto guard leave any map unguarded? No — fields, fieldPatches and fieldPatches.*.options are the three records the extension declares and all three are wrapped; the guard is placed inside .optional().describe() so the clone-safe optin patch in refuseProtoOwnKey holds, and the issue paths (fields.__proto__, fieldPatches.__proto__, fieldPatches.F.options.__proto__) are pinned over JSON documents, the shape the save door receives.
  • C on this file: whitelistedAliases filters by TARGET against admitted, so a row onto a refused key (isRequired → required, key → value) is not carried — the alias-integrity invariant "every alias target is a key the schema accepts" holds. Deliberate and accepted: rows onto options (values, choices, selectOptions) are not carried either, because the patch's options is a map of patches, not the field's option list a rename would imply. The guidanceSets structural keys and the carried alias keys are disjoint (no alias key is a FieldSchema key).
  • D on this file: fieldPatchesDescription() and fieldPatchStructuralPrescription() now read the same constant; the mocked-whitelist pin proves widening reaches the description.
  • Should-fix (non-blocking, a follow-up card): fieldPatches' own key stays z.string() while a field name has a grammar (FieldSchema.name /^[a-z_][a-z0-9_]*$/, and ObjectSchema.fields keys are snake_case), so fieldPatches: { Industry: { label: 'Sector' } } still parses and is refused only downstream by the target judge (install door, os validate, boot log). Same class as B, same derivation available (read the key schema off ObjectSchema.fields), outside the card's four items. Not a FAIL.

packages/spec/src/data/field-key-aliases.ts

  • Does moving the tables into a relative-import-only leaf create a public name or an import cycle? No public name: not in the data barrel, no wildcard subpath in exports, api-surface/ unchanged. No cycle: the leaf imports only ./authoring-key-lint, whose transitive imports (suggestions.zod → protocol-upgrade-guide, alias-probe, polarity-axes) do not reach field.zod or object.zod at this head; fieldSchemaAliases() is a function for the reason its docblock gives and both consumers call it inside lazySchema bodies. The exported fieldKeyGuidanceAsStrictOptions is the former module-private function, still reached only by field.zod.ts. Row-identical to the inline tables it replaces (set-compared, 61 and 10 rows). Holds.

packages/spec/src/data/field.zod.ts

  • The two aliases become reads of the leaf; nothing else in the shapes moves; guidance still derives from the lint table. Accept and emit sets of FieldSchema and SelectOptionSchema are unchanged (①). Holds.

Cited code, read to judge: mergeObjectDefinitions / applyFieldPatches / ownFieldNamesOf (the merge the card's "reach" rests on — applies patches by key onto merged.fields, owner's names only); OBJECT_EXTENSION_ITEM_SCHEMA / registrableObjectExtension (the door and the sync parse the same schema, so the three refusals hold at the door); refuseRecordProtoKey (its docblock's zod 4.4.3 measurement is what makes the pre-parse placement the only one that works); strictObject (aliases are hint-only, so C is not a widening); SystemIdentifierSchema (the grammar B publishes). The docs bullet and the skill line (skills/objectstack-data/SKILL.md: "Relabel a field the owner declares with fieldPatches — never by redeclaring it in fields", option keyed by technology) stay true under the new refusals.

Must-fix: none. Should-fix: one, named above (fieldPatches key grammar), filed as a follow-up card, not a condition of landing. Escalations to the seat: ③-5 (instruction inconsistency on .claude/agents/os-dev.md) and ③-6 (HotCRM reading before landing a BREAKING narrowing).

Implemented-by: claude/project-thread-d58n14
Reviewed-by: session_01ADHkmPi25XXKx92Xmg4viS

VERDICT: PASS

@objectstack-fleet objectstack-fleet Bot removed the needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides label Nov 2, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 13:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 13:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 3fb74b6 Nov 2, 2026
74 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-d58n14 branch November 2, 2026 13:42
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/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant