Skip to content

feat(spec): a per-field display patch slot on the object extension (fieldPatches), keys derived from BASE_FIELD_DISPLAY_WHITELIST - #28650

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-28560-field-patch-slot
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-28560-field-patch-slot

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V

Fixes #28560

Clause-②: yes (widening)
Metadata-protocol: added

维护者速读

  • 改了什么:对象扩展(objectExtensions[])新增一个可选槽 fieldPatches,按字段名给厂商字段打"显示补丁":只能改 label、description、inlineHelpText、placeholder 和选项的 label;键集合直接从 BASE_FIELD_DISPLAY_WHITELIST 读出,白名单放宽槽就跟着放宽。
  • 为什么改:客户想把 HotCRM 的 "Industry" 改叫 "Sector"、补一句更好的帮助文字,却不想接管整个字段;字段的类型、取值和规则仍归厂商,所以 HotCRM 下个版本照样能升级进来。今天扩展只能新增字段或整字段替换。
  • 风险与回滚:纯新增的可选键,不写它的扩展解析结果逐字节不变;槽带 [EXPERIMENTAL — not enforced],引擎注册扩展时还不搬运它,N-23d(objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565)接上合并之前没有任何运行时读它。回滚 = revert 本 PR。
  • 先看:① packages/spec/src/data/object.zod.ts 的 FieldDisplayPatchSchema / FieldOptionDisplayPatchSchema(键从白名单派生、结构键按名拒收);② 同文件 ObjectExtensionSchema 根 superRefine 里的 refuseExtensionFieldPatchKeys(__c 拒收);③ packages/spec/src/data/field-patches-derived.test.ts(放宽白名单即放宽槽的证明)。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts src/data/field-patches-derived.test.ts,预期 2 个文件、71 个测试全绿。
  • 需要你做的:无,席位落地。

What changes

ADR-0146 D10, second rule: a base field changes only within the display whitelist, and the patch is a per-field map in the extension body. ObjectExtensionSchema gains one optional key, fieldPatches:

objectExtensions: [{
  extend: 'crm_account',
  fieldPatches: {
    industry: {
      label: 'Sector',
      inlineHelpText: 'The market the account sells into',
      options: { tech: { label: 'Technology' } },
    },
  },
}]
  • Shape. Record(fieldName, closed patch). The patch's keys are BASE_FIELD_DISPLAY_WHITELIST.field, each value schema taken by key from FieldSchema and made optional, plus options: Record(storedValue, closed option patch) whose keys are BASE_FIELD_DISPLAY_WHITELIST.option, value schemas taken from SelectOptionSchema. Nothing restates the list. Construction throws if the whitelist names a key the source shape no longer declares.
  • Refusals at parse, each with a prescription.
    • Every other FieldSchema key on a patch is refused by name (unrecognized_keys) through a guidanceSets entry whose member set is derived (FieldSchema keys minus the whitelist minus options). The prescription names the whitelist, lists its members read from the constant, and states that everything else stays the vendor's (ADR-0146 D6).
    • On an option patch, value has its own guidance: the option is addressed by its stored value, and an extension cannot rename it. Every other option key (color, default, …) gets the D6 prescription, from a derived set.
    • A field's option list (the FieldSchema array form) under options is refused as membership, with a prescription showing the map form.
    • A fieldPatches key spelled as an extension field (isExtensionFieldName, e.g. region__c) is refused at fieldPatches.region__c with "declare it in fields; it is your own field". This check is in the extension's existing root superRefine. The published JSON Schema already lists that root as a dropped-refinement site (data/ObjectExtension ''), so the dropped-refinements ledger does not change.
  • Pure shape. Whether the target has the field or the option, and who may patch, belong to the server (objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565).
  • Not enforced yet. The slot's .describe() reads [EXPERIMENTAL — not enforced] and names rollout card N-23d. objectql/src/engine.ts's extension registration (now at :8857-8869; the card measured :8820-8829 on an older tree) copies the declared keys by name, so the slot is dropped there until N-23d. The engine is not changed here. The guidance comment on ObjectExtensionSchema that said "copies EXACTLY the declared keys" now names fieldPatches as the one declared key not carried yet.
  • field-customization.ts's header now points at the slot instead of saying the shape is "not declared here".

The settled name: fieldPatches

No existing spelling of this slot exists. A repo-wide search for fieldPatches, fieldOverrides, fieldOverride and field_patches finds only unrelated hits: a permission-matrix UI stat and a lint test helper. The name follows ADR-0146's own words: D10 says the extension may patch a base field's display properties and calls the result "a per-field map in the extension body". The Salesforce-style "override" is not used because in ADR-0146 an override replaces the whole item (D8/D9's declared override, ADR-0131 D6's O regime), and fields on the same extension already does "add/override", which replaces a same-named field whole (mergeObjectDefinitions). A patch keeps the vendor's field and changes named display properties of it, so "patch" is the accurate word, and fieldPatches (plural, keyed by field) matches the sibling fields map.

Measured assumptions (PM)

  • check:liveness does not walk ObjectExtensionSchema. Two legs on this branch: with the marker removed from the fieldPatches describe (an ablation, restored and checked byte-identical to HEAD), check-liveness.mts --json exits 0 with unclassified: []. The positive control, a marker-less key added to ObjectSchema, exits 1 with unclassified: ['object/probeKeyNoLedger']. The walk is rooted on the metadata-type registry plus SPEC_ONLY_SCHEMAS. ManifestSchema has no objectExtensions, and the stack schema is not a governed root. So no ledger row is added.
  • The metadata-protocol /meta/types count pins are not reached. They count top-level keys of registered metadata types (object is ObjectSchema). An extension is a stack collection entry, not a metadata type. @objectstack/metadata-protocol's whole suite is green on this head (291 files passed, 4 skipped).
  • object-strictness-batch20.test.ts and the extension fixtures parse unchanged. Batch 20 is green, as is the frozen packages/qa/downstream-contract DcObjectExtension (optional key; nothing narrowed). A test pins that an extension without the slot parses to the same output as before.
  • No ui/packages/types mirror of ObjectExtension exists (grep finds none), so there is nothing to sync.

Tests

packages/spec/src/data/field-customization.test.ts gains a fieldPatches block next to STRUCTURAL_FIELD_KEYS:

  • every whitelist member is accepted, field and option, iterated from the constant;
  • every STRUCTURAL_FIELD_KEYS key is refused by name (code unrecognized_keys, path fieldPatches.industry, keys, and the message names BASE_FIELD_DISPLAY_WHITELIST and ADR-0146 D6); options membership (the list form) is refused as invalid_type at fieldPatches.industry.options;
  • every FieldSchema key outside the whitelist is refused, and the refused set is derived from the shape;
  • an option patch takes label only: value is refused with its own guidance, every other option key with D6;
  • an __c key is refused (custom at fieldPatches.region__c, naming fields);
  • pure shape: an unknown field or option still parses;
  • the slot is optional, and output is unchanged without it;
  • the describe starts with [EXPERIMENTAL — not enforced] and names N-23d.

packages/spec/src/data/field-patches-derived.test.ts widens the constant itself (a file-scoped vi.mock adding rows and an option's color) and parses through the real ObjectExtensionSchema. The widened keys are accepted, a widened key keeps its FieldSchema value type, and the refusal prose lists the widened members.

Ablation (one-time, not a permanent test). With the fix committed, scripts/ablation-replace.mjs replaced the derived field keys with the restated list ['label', 'description', 'inlineHelpText', 'placeholder']. Two tests in field-patches-derived.test.ts went red (2 failed, 69 passed). The restore was checked against HEAD: blob b44d0197cc1d, git diff HEAD empty.

Acceptance notes

  • Describe spelling vs the order. The order asked for the describe to name "N-23d (objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565)". The describe names N-23d and ADR-0146 D10 but not the tracker id, because check:doc-authoring rule 3 bans #NNNN in .describe() prose: it projects into content/docs/references/**, where a reader cannot resolve it. The id #28565 is in the code comments beside the slot.
  • No liveness row. Nothing walks the slot, as measured above. If a ledger ever walks it, the describe marker classifies it as experimental (the marker grammar has no planned spelling that also says "not enforced"). N-23d flips the marker.
  • Out of scope, noted, not filed. ObjectExtensionSchema.fields (and now fieldPatches) is a bare z.record without the refuseRecordProtoKey guard that ObjectSchema.fields carries. A __proto__ own key (as JSON.parse produces) is dropped silently from the parse output. The new slot keeps parity with its sibling and does not take that on.

Verification

All readings are on head 09f996f719 (after merging origin/main once).

Local runs
  • pnpm --filter @objectstack/spec build: exit 0.
  • pnpm --filter @objectstack/spec check:generated -- --fix: regenerated content/docs/references/** (gen:docs) and the strictness-ledger counts (gen:strictness-ledger). The build wrote authorable-surface/data.json (data/ObjectExtension:fieldPatches). The re-check was green, and check:generated was green on the final head.
  • pnpm --filter @objectstack/spec test: 783 files passed, 23173 tests passed, 1 todo. Exit 0.
  • pnpm --filter @objectstack/spec typecheck (includes check:scripts-typecheck and check:test-typecheck): exit 0. check:test-typecheck run alone: exit 0.
  • pnpm --filter @objectstack/metadata-protocol test: 291 files passed, 4 skipped. 21912 tests passed, 26 skipped. Exit 0.
Derived gates (dispatch-gates --commands, reconciled with --ran)
  • 103 commands were derived. 102 ran green (exit 0).
  • check:skill-examples and check:lean-entry-closure first exited 3 (prerequisite: no client/client-react/objectql dist). They were re-run green after building those closures.
  • check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: about 75 packages without dist/). That is NOT MEASURED locally, a declared narrowing, and CI's Lint & Repo Gates runs it.
  • --ran reconciliation: "103 derived famil(ies) accounted for — 102 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". Exit 0.

Independent review: none run (quota); the PR waits for the seat's contract-review hand-off.


Generated by Claude Code

…erived from the display whitelist

ObjectExtensionSchema gains fieldPatches: a map from a base field name to a
closed patch whose keys are read from BASE_FIELD_DISPLAY_WHITELIST (plus an
options map keyed by stored value, its entries' keys read from the option
whitelist). Every other FieldSchema or option key is refused by name with the
ADR-0146 D6 prescription, a field list under options is refused as
membership, and an __c key is refused with "declare it in fields". The slot
is marked not enforced until the registry merge carries it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json, packages/spec/src/data/field-customization.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))
  • content/docs/automation/flows.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))
  • content/docs/frontend/cli.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))
  • content/docs/releases/v17/17-6.mdx (via invalid_type (literal, a string literal in FieldDisplayPatchSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json, packages/spec/src/data/field-customization.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 f23e051232c21d104fef019191ff504faaf700f1 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json f23e051232c21d104fef019191ff504faaf700f1

⚠️ 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 f23e051232c21d104fef019191ff504faaf700f1 → 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#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

维护者速读(席位终稿 · head 8b8a7949)

  • 改了什么: 对象扩展上新增了一个可选的 fieldPatches 槽。定制包(例如 hotcrm.custom)可以借它只改 HotCRM 自带字段的显示属性:标签、说明、帮助文字、占位符和选项标签,字段本身不用整个接管。这个槽能填哪些键,直接读自显示属性白名单。写了白名单之外的键(类型、必填、默认值、选项增删等)、改选项的存储值,或者对 __c 自有字段写补丁,都会在解析时被拒收,并且附带改法说明。
  • 为什么改: 这是 ADR-0146 D10 的第二条规则。客户只想把 “Industry” 改叫 “Sector” 的时候,不必把整个字段抄过去覆盖,厂商下一版对字段的升级也能照常生效。
  • 风险与回滚: 只加了一个可选键,标为 [EXPERIMENTAL — not enforced]。引擎还不会应用这些补丁(由合并卡 N-23d objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565 负责),现有元数据不受影响。回退本 PR 即可。
  • 先看: packages/spec/src/data/object.zod.ts 里 “Per-field display patch” 一节。
  • 两分钟验证: pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts src/data/field-patches-derived.test.ts,两个文件全绿。
  • 需要你做的: 无。不是受管面,契约复审通过后由席位落地。

席位意见: ACCEPT(车道部分)。槽的键集合由白名单派生,没有另抄一份清单,以后放宽白名单时槽会跟着放宽,这一点有反向消融为证。槽名 fieldPatches 跟着 ADR-0146 D10 的原文 “patch” 取,避开了 ADR 里专指整项替换的 “override”。

席位核对 · ACCEPT (lane part) · card #28560 (epic #28236 N-23a) · 2026-11-01T11:44Z

  1. 形态: 9 files, +435 / −14 at head 8b8a7949d1, draft, Fixes #28560 (after the _common two-line attribution), changeset .changeset/28560-field-patch-slot.md (@objectstack/spec minor). check-governed-merges.mjs --pr 28650: NOT governed, 447 lines. Tier S.
  2. 前提实测: holds. The seat read the new section of object.zod.ts:
    • whitelistedPatchShape takes each admitted key's value schema BY KEY from FieldSchema.shape / SelectOptionSchema.shape and throws at construction if the whitelist names a key the source no longer declares.
    • structuralKeysOf derives the refused set as source keys − admitted − answered-elsewhere, fed to strictObject guidanceSets with the D6 prescription. That prescription is itself built from the constant.
    • options is a map keyed by stored value. An array input gets its own "map, not the option list" message, and value inside an option patch has a separate guidance.
    • The __c refusal is on the existing root superRefine, so the dropped-refinements ledger is unchanged.
    • Seat edit 8b8a7949: one comment named field-patches.test.ts, but the file is field-patches-derived.test.ts. Comment only.
  3. 门禁 check-runs: CI restarts on 8b8a7949d1 (comment-only delta over 09f996f719). The seat re-reads every check before any landing stroke. Dev at 09f996f719:
    • dispatch-gates --commands: 103 derived, 102 exit 0. 1 NOT MEASURED (check:dual-build-cjs-loads, exit 3, no dist locally → CI). --ran 0 UNRUN.
    • spec build/typecheck 0; spec suite 783 files / 23173 tests; check:test-typecheck 0; metadata-protocol suite 291 files passed / 4 skipped.
    • Ablation: a hard-coded list in place of the derivation turns field-patches-derived.test.ts red (2 tests). That file widens the constant under vi.mock and proves the slot widens.
    • os-dev-report 6557468193.
  4. 接受的偏差:
    • The .describe() names N-23d without #28565: check:doc-authoring rule 3 bans #NNNN in describe text. The id sits in the code comment beside the slot.
    • No liveness ledger row: check:liveness does not walk ObjectExtensionSchema. The dev measured both legs: marker removed → exit 0 unclassified []; an unmarked probe key on ObjectSchema → exit 1. The card's planned row status therefore has no carrier now. N-23d owns the flip, and the walk if it wants one.
    • Refusal tests assert zod issue code, path and the named subject: there is no HTTP status at the parse layer.
  5. 范围外去向:
    • __proto__ keys: ObjectExtensionSchema.fields (and now fieldPatches) is a bare z.record, without ObjectSchema.fields' refuseRecordProtoKey. safeParse(JSON.parse(...)) with a __proto__ own key passes, and the key is dropped. Measured at schema level only; reach at a public door NOT MEASURED. The new slot matches its sibling on purpose. The seat records it here and does not file a card until a door reach is measured.
    • Skills-docs: none owed: nothing applies a patch yet. The skill lands with N-23d.
  6. 落地条款: Tier S path. Owed before any landing stroke:
    • green CI on the landing head;
    • a same-shape CONTRACT_REVIEW_TIER PASS (packages/spec/src/** non-test). This seat cannot serve that tier now and hands the review over below.

Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (HTTP 429 "You've reached your Fable limit", 2026-10-30T04:26Z) · head 8b8a794
Thread-read: none

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 8b8a7949d1592b511858f7b6924b88d32ba39e34 · 2026-11-01T11:53Z

Taken under references/contract-review.md (a): the hand-off line in 6557484771 names this head. Face schema, plus the Clause-②: yes declaration, so the review runs at CONTRACT_REVIEW_TIER with ④.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8b8a7949d1592b511858f7b6924b88d32ba39e34
Local-runs: none
Scope: packages/spec/src/data/field-customization.ts, packages/spec/src/data/object.zod.ts + packages/spec/src/data/field.zod.ts (FieldSchema, SelectOptionSchema), packages/spec/src/shared/strict-object.ts, packages/spec/src/shared/suggestions.zod.ts (KeySetGuidance), packages/spec/src/shared/alias-integrity.test.ts, packages/spec/src/data/field-customization.test.ts, packages/spec/src/data/field-patches-derived.test.ts, packages/objectql/src/registry.ts (mergeObjectDefinitions), packages/objectql/src/engine.ts (the objectExtensions loop), packages/spec/scripts/liveness/check-liveness.mts, packages/spec/liveness/README.md, packages/spec/dropped-refinements.baseline.json, packages/spec/authorable-surface/data.json, docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md, packages/spec/scripts/check-strictness-ledger.mts, packages/qa/downstream-contract/src/additional-domains.fixtures.ts, scripts/check-doc-authoring.mjs, docs/adr/0146, docs/adr/0049, .changeset/28560-field-patch-slot.md

Read-only shape held. The diff was read as git diff origin/main...FETCH_HEAD (merge-base 6e11a929e4). The 8 commits on main since that base touch none of the nine files; d2c487407b adds to engine.ts outside the extension loop, and the branch merges onto origin/main without conflict.

① Derived judgments

Each line names one change the diff implies to the accept set or the public surface, and judges it.

  1. ObjectExtensionSchema gains one optional key, fieldPatches, a record keyed by field name whose value is a closed patch object. Right. The card's Wanted 1 asks for exactly one optional per-field map, and the PR settles the name fieldPatches.
  2. A patch's field keys are label, description, inlineHelpText, placeholder. They are read from BASE_FIELD_DISPLAY_WHITELIST.field by whitelistedPatchShape, and each value schema is taken by key from FieldSchema.shape and made optional (all four are z.string() on FieldSchema). Right, and derived, not restated. field-patches-derived.test.ts widens the constant under vi.mock and parses through the real schema; the dev's one-time ablation of the derivation turned it red.
  3. A patch's options key is a record keyed by an option's stored value, each entry a closed object whose only key is label, read from BASE_FIELD_DISPLAY_WHITELIST.option and typed from SelectOptionSchema.shape.label made optional. Right.
  4. Every FieldSchema key outside the whitelist and outside options is refused with unrecognized_keys at fieldPatches.NAME, through a guidanceSets entry whose member set is structuralKeysOf(FieldSchema.shape, admitted, ['options']). Right. The prescription names the whitelist, lists its members from the constant, and states the D6 rule. The alias-integrity audit's three rules hold: no member is a declared key of the patch shape, none is answered by exact guidance, and the two sets do not overlap.
  5. On an option patch, value is refused with its own guidance (the option is addressed by its stored value, which stays the vendor's), and every other SelectOptionSchema key (description, color, default, visibleWhen, …) is refused with the D6 prescription. Right. Note that option description is display text, but the whitelist admits option label only; refusing it is the whitelist's call (N-08), not this PR's.
  6. The array form of options (a field's option list) is refused as invalid_type at fieldPatches.NAME.options, with a message showing the map form and naming D6. Right: membership cannot be changed through this slot.
  7. A fieldPatches key that ends in __c (isExtensionFieldName) is refused with a custom issue at fieldPatches.NAME, saying "declare it in fields; it is your own field". Right. It sits in the extension's existing root superRefine, and dropped-refinements.baseline.json:993-1001 already lists data/ObjectExtension site '', so the published JSON Schema gains no new dropped-refinement site.
  8. An extension body without fieldPatches parses to the same output as before. Right. The test pins { extend, fields: {}, priority: 200 } with no fieldPatches key, and the frozen DcObjectExtension fixture (no patch) is unchanged.
  9. Pure shape: a patch naming a field or option value the target may not have parses. Right by the card's Wanted 3; objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565 refuses it at install and validate.
  10. Public type surface: ObjectExtension (input) and ObjectExtensionParsed gain fieldPatches?. FieldDisplayPatchSchema and FieldOptionDisplayPatchSchema are module-private, so the patch type is reachable only through ObjectExtension. Acceptable; objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565 may want them exported for the merge's typing.
  11. Generated surface: authorable-surface/data.json adds data/ObjectExtension:fieldPatches; content/docs/references/data/object.mdx adds the row and the nested shape; package-api-assembled.mdx widens the objectExtensions cell; the strictness counts move 156 to 158 for data/ and 23 to 25 for object.zod.ts. Right. The strictness gate judges per file and object.zod.ts already has its hand-written row, so no ledger prose row is owed.
  12. The engine is not changed. engine.ts's objectExtensions loop still copies fields, label, pluralLabel, description, validations, indexes by name, so fieldPatches is parsed and then dropped at registration. Right for this card; the slot's .describe() and the code comment say so.

② Semver level

.changeset/28560-field-patch-slot.md declares @objectstack/spec: minor. The diff publishes one package, @objectstack/spec, and the change is one new optional key, so the accept set widens and nothing narrows. Clause-②: yes (widening) is right. Metadata-protocol: added is right (a new key an author can write). AGENTS.md's level rule says a kind other than none takes at least minor, which matches. Both declaration lines are line-initial in the changeset body and the PR body. Check Changeset is green on the head.

One gap on the declaration set, should-fix: SKILL.md:313 says a kind other than none owes the PR body a third line, Skills-docs: …, written by the dev. The PR body has none. The seat answered it in its ACCEPT comment ("none owed: nothing applies a patch yet; the skill lands with N-23d"). The answer is right; the carrier is wrong. No guard reads the line, so this does not block landing.

③ Boundary flags

Author: the PR's user.login is objectstack-fleet[bot], so no human login is named here.

Dev deviations (os-dev-report 6557468193), each answered:

  1. The .describe() names N-23d but not #28565. Verified: scripts/check-doc-authoring.mjs Rule 3 (lines 344 to 408) bans bare tracker ids in .describe() prose because it projects into content/docs/references/**. The id is in the code comment beside the slot. Accepted.
  2. PR body order (attribution lines before Fixes). Prose face; PR-body guards is green. Not this face. Accepted.
  3. Refusal tests assert zod code, path and message, not an HTTP status. There is no HTTP status at the parse layer. Accepted.
  4. check:dual-build-cjs-loads not measured locally. Lint & Repo Gates runs it and is green on this head. Accepted.
  5. No independent review was run. This record is that review.

open_questions: empty. Nothing to escalate.

Out-of-scope finding (__proto__ on the two bare records): concur that it is parity with the sibling fields record and not a door reach measured here. It is folded into should-fix B below, for #28565 or a card, not a reason to change this verdict.

Experimental slot, ADR-0049 and the liveness ledger: ADR-0049 allows a declared property in exactly three states; state 2 is marked and documented as not enforced. The slot's .describe() starts with [EXPERIMENTAL — not enforced], says nothing applies a patch yet, and names the card that will. That is state 2, so shipping an accepted-but-not-applied key is allowed. The liveness README accepts that marker as the experimental / planned reading. No ledger row exists, and none is owed: check-liveness.mts walks the metadata-type registry plus SPEC_ONLY_SCHEMAS (lines 481 to 493), ManifestSchema has no objectExtensions key, and the stack schema is not a root, so ObjectExtensionSchema is never walked. The dev measured this in two legs and Spec property liveness is green on this head. The card's Wanted 4 says "any liveness row reads planned", which is conditional, so the absence is consistent. Flag for #28565: its Wanted 5 "flips any planned row" has nothing to flip; only the describe marker flips.

First consumer: #28565 (N-23d) reads the slot as a record of field name to patch, applies whitelisted keys onto the base field and option labels onto the option whose stored value matches the map key, after the additive merges, with D9.2a yield and priority order. The shape here carries exactly that: a per-field map, option patches keyed by stored value, no value to rename, no membership. Nothing in the shape blocks the consumer.

Check-runs on 8b8a7949d1592b511858f7b6924b88d32ba39e34, read 2026-11-01T12:04Z: 69 runs, 55 success, 12 skipped (opt-in or path-filtered jobs), 0 failure, 2 still in progress: Test Core (the aggregate) and Test UI (4/16), both in run 41303207371, this PR's own run. No red. Landing waits for those two; the triage seat re-reads them before any landing stroke.

Hand-off line: the seat's Contract-review-handoff names this head but carries the time 2026-10-30T04:26Z, which predates the PR. The head is what the rule reads, so the hand-off stands.

④ Design judgment

Judged on the two hits files only.

packages/spec/src/data/field-customization.ts. The change is the header comment: it now points at ObjectExtensionSchema.fieldPatches instead of saying the shape is not declared here, and says the merge is still not in packages/spec. Both sentences are true on this tree. The constant, the suffix and the predicate are unchanged, and the module still imports nothing, so object.zod.ts importing it creates no cycle. Holds against D10 and the card's Measured list. No finding.

packages/spec/src/data/object.zod.ts. Against ADR-0146 D10, second rule: the patch is a per-field map in the extension body, admits only the whitelist, and refuses everything else with a prescription naming the whitelist and D6. Holds. Against the card's Wanted 1 to 4: the map is optional, the keys are derived, structural keys and __c keys are refused with prescriptions, the shape is pure, and the marker is present. Holds. Against the first consumer (#28565): the merge can read ext.fieldPatches[name] and options[value] directly. Holds.

Break attempts, each with its result:

  • A whitelist key that is structural in disguise. The whitelist is N-08's and this diff restates none of it. The existing test pins that no member is in STRUCTURAL_FIELD_KEYS, and whitelistedPatchShape throws at construction if the whitelist names a key FieldSchema no longer has. Not broken.
  • Option patches that reorder or add options. The map has no order, an entry has no value, and the array form is refused. A new key with only label cannot add an option; objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565 refuses an unknown value. Not broken.
  • A patch naming a field the base does not have. Parses by design (pure shape). Not broken; the server door is objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565's.
  • Nested fields. A grid column's label or options under a field's columns is refused as structural, since the whitelist is top-level keys only. That is the whitelist's reach (N-08), not a defect here.
  • Aliases. The patch surface declares no aliases, so an author who writes helpText, hint, tooltip or title on a patch (all aliases FieldSchema itself answers) gets an unrecognized-key refusal with no rename, because the distance budget cannot reach inlineHelpText. Finding C below.
  • Interplay with fields in the same extension. One body may carry fields.industry and fieldPatches.industry together. Both parse. fields replaces the base field whole through mergeObjectDefinitions, so the patch then lands on the replacement. The two are contradictory in one body and the collision is decidable from the body alone. Finding A below.
  • Map keys. fieldPatches and options are keyed by z.string(). SelectOptionSchema.value is SystemIdentifierSchema, so an option key like 'Tech Sector' parses although no option can carry it. The same bare z.record lets a __proto__ own key be dropped silently, as the dev noted. Finding B below.
  • The .describe() restates the whitelist members in prose ("label, description, help text, placeholder, option labels"). The refusal prescription is derived; the describe is not, and the derived test does not read it. Finding D below.

Findings. None is a must-fix.

  • Should-fix A: refuse, in the root superRefine, a field name present in both fields and fieldPatches of one extension, located at fieldPatches.NAME, with a prescription saying to patch the vendor's field or declare your own, not both. Decidable at parse; the card's Wanted 2 does not list it, so it is not a must-fix.
  • Should-fix B: key the options map by SelectOptionSchema.shape.value (the same derive-not-restate discipline), and consider refuseRecordProtoKey on fieldPatches and its sibling fields. No accepted input causes harm before objectql: an object extension's display patch is carried and applied by mergeObjectDefinitions; a patch naming a missing field or option is refused at install and validate #28565 applies a patch, and that card refuses unknown values anyway.
  • Should-fix C: carry the source shape's aliases whose target is a whitelisted key onto the two patch surfaces (derived by filter: title, help, helpText, hint, tooltip for the field; text, name, title for the option). Low severity.
  • Should-fix D: build the fieldPatches .describe() member list from the constant, or drop the enumeration, so a widened whitelist cannot leave the published description stale. Low severity.

Implemented-by: claude/issue-28560-field-patch-slot
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

Landing stroke · 2026-11-01T12:10Z · head 8b8a7949d1592b511858f7b6924b88d32ba39e34.

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.

spec: a per-field display patch slot on ObjectExtensionSchema, derived from BASE_FIELD_DISPLAY_WHITELIST; structural keys refused at parse

1 participant