Skip to content

feat(spec)!: key an object extension's fieldPatches by the field map's own key grammar - #29513

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

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-as5hsf

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #29366

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/data-modeling/object-extensions.mdx ("A patch must name something real") gains the key grammar. skills/objectstack-data/SKILL.md keys its fieldPatches example by industry, so every taught line stays true and no skill line changes.

维护者速读

  • 改了什么:对象扩展的 fieldPatches 的键改用与对象 fields 完全相同的键 schema(小写 snake_case),Industry、Annual Revenue 这类不可能是字段名的键在解析时就被拒,报一个带示例的 invalid_key。
  • 为什么改:以前任何字符串都能过 schema,这种补丁在 defineStack、保存门、启动时都被接受,启动只在日志里点名、什么也不改;作者以为改了标签,界面却没变。
  • 风险与回滚:收窄(BREAKING,spec minor,ADR-0087 语义迁移条目已登记)。仓内 examples/skills/docs/模板无一处写过这种键;已存储的带坏键的扩展行同步时会整行注册失败。回滚即 revert 本 PR。
  • 先看:packages/spec/src/data/object.zod.ts(FieldNameKeySchema 与 fieldPatches 的 invalid_key 文案)、packages/spec/src/data/field-customization.test.ts 末尾新一节、packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts,预期 1 个文件全绿(93 条)。
  • 需要你做的:无,席位落地。

What changes

  • packages/spec/src/data/object.zod.ts: the key schema ObjectSchema.fields used inline (the snake_case regex and its message) is hoisted, unchanged, to one module constant FieldNameKeySchema (not exported), declared above ObjectSchemaBase. ObjectSchema.fields and ObjectExtensionSchema.fieldPatches both read it, so the grammar is written once and the two cannot drift. fieldPatches' record error map answers invalid_key with a prescription: the key is a field name the extended object declares, lowercase snake_case such as industry or annual_revenue, never a label. The issue sits at fieldPatches.KEY, as the option-key refusal sits at fieldPatches.FIELD.options.KEY. The fieldPatches describe and its doc comment name the grammar.
  • packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts: the ADR-0087 semantic entry (refused, with a prescription; no conversion, because a respelling can land on another field or on none).
  • .changeset/29366-field-patches-key-grammar.md: spec minor, BREAKING, adr-0087: registered object-extension-field-patch-key-grammar-refused, with the FROM → TO line.
  • content/docs/data-modeling/object-extensions.mdx: one sentence in "A patch must name something real".
  • content/docs/references/data/object.mdx: regenerated (the describe moved).
  • No public export is added or removed (check:api-surface unchanged). No forced test edit. ui/packages has no zod twin of fieldPatches (git grep hits only the Studio controls that write keys read from the target's own field names).

Why — the measurement that chose the declaration

The target judge (@objectstack/metadata-core fieldPatchTargetMisses) does NOT reach every door, so a non-grammar key was accepted end to end before:

  • defineStack / defineObjectExtension: pure schema, no target judge; the key parsed.
  • /api/v1/meta object_extension save door: OBJECT_EXTENSION_ITEM_SCHEMA is ObjectExtensionSchema (packages/metadata-protocol/src/protocol-object-extension-item.ts); the save door's refusal (objectExtensionItemRefusal) runs the __c suffix judge, not the target judge, so the row was stored.
  • Boot / registry fold (packages/objectql/src/registry.ts, the fieldPatchTargetMisses call in the extension fold): never refuses; the patch is not applied and named once in the log.
  • os validate: refuses only when the target is in the composed stack (packages/cli/src/utils/field-patch-targets.ts); a target outside it is left to the install door.
  • Marketplace install door: refuses (the only door that always did).

So this is Clause-②: no (narrowing) · Metadata-protocol: narrowed, BREAKING, with the semantic migration entry the delta prescribes for that branch.

Authored forms: every fieldPatches key in examples/**, skills/**, content/docs/** (references excluded), packages/**/templates, dogfood and test fixtures is snake_case (industry ×20+, name, sector, notes, region, state, anything, no_such_field, plus the deliberate region__c and __proto__ refusal fixtures), so nothing authored in this repository newly refuses.

One consequence to read: a stored object_extension row that carries such a key no longer parses, so the sync that registers stored extensions (registrableObjectExtension → INVALID_METADATA) names the row and registers none of it, its fields included. This is the uniform effect of any ObjectExtensionSchema narrowing (PR #29331 had the same), stated in the changeset and the migration entry.

No stored-shape detector, measured (round 2). The contract review asked for a REFUSED_STORED_SHAPES detector so os migrate meta --stored lists such a row before the upgrade refuses it. None can be reached: applyConversionsToStoredItem wraps a row in the stack collection its type maps to, and object_extension maps to none (it is in neither SINGULAR_TO_PLURAL nor the stored-only map in conversions/stored.ts), so the stored pass returns the row untouched. Probe on this branch: applyConversionsToStoredItem('object_extension', { extend, fieldPatches: { Industry: … }, fields: { shift_at: { type: 'time', defaultValue: '12:00Z' } } }, { onTodo, onNotice }) returns the same reference with 0 TODOs and 0 notices, while the control applyConversionsToStoredItem('object', …) rewrites the time default (1 notice). A detector walking objectExtensions would therefore be dead code, so none is added; the semantic entry and the changeset say the stored pass does not list such a row, and name the check an operator runs instead.

Tests

packages/spec/src/data/field-customization.test.ts gains fieldPatches keys — the field map's own key grammar (8 cases): Industry, Annual Revenue, annual-revenue, 2nd_phone each refused with exactly one invalid_key at fieldPatches.KEY (message names the grammar and an example, no tracker id); CONTROL a snake_case key parses and a __c key stays the __c rule's single custom issue; CONTROL the guide's authored example (object-extensions.mdx, "Relabelling a vendor field") parses whole; and an agreement test that fieldPatches refuses at the key exactly the keys ObjectSchema.fields refuses, over an 11-key sample that holds keys on both sides (guarded against vacuity).

Verification

Round 2 readings on head fb1c5bc9 (objectstack-ai/objectstack): origin/main merged with scripts/pm/os-regen-merge.sh (merge base 399df5bb), content/docs/references/data/object.mdx regenerated from the merged tree; the branch's delta against main is still the six files listed above. The ablation below ran in round 1 on 6a70094d; the tested lines did not change since.

Ablation: the new tests can fail

node scripts/ablation-replace.mjs replaced z.record(FieldNameKeySchema, FieldDisplayPatchSchema, { with z.record(z.string(), FieldDisplayPatchSchema, { in object.zod.ts (anchor 1 → 0, blob f24cd618 → c7a8c925), ran src/data/field-customization.test.ts: 5 failed | 88 passed (93), the four refusal cases and the agreement test red, every control green. Restored: blob == HEAD, git diff HEAD empty. The spec suite imports ./object.zod from source, so no build sits between the mutation and the run.

Consumer packages (downstream of the contract)
  • @objectstack/spec test: 792/792 files, 23529 passed | 1 todo. typecheck: exit 0.
  • @objectstack/metadata-core (the target judge): 23/23 files, 904 tests.
  • @objectstack/objectql (the fold that applies fieldPatches): 535/535 files, 10396 tests.
  • @objectstack/metadata-protocol (the object_extension save door): 307 passed | 4 skipped (311), 22229 passed | 26 skipped.
  • @objectstack/cli field-patch files (src/utils/field-patch-targets.test.ts, test/validate-build-gate-parity.test.ts, test/validate-field-patch-targets.test.ts): 3/3, 41 tests.
  • @objectstack/cloud-connection marketplace-install-local-field-patch-targets.test.ts: 1/1, 8 tests.
  • @objectstack/rest meta-object-extension-property-classes.test.ts: 1/1, 19 tests.
  • ui app-shell DataPillar.baseObjectExtension-28575.test.tsx + StudioCustomizeControls.objectExtension-28575.test.ts: 2/2, 19 tests.
  • Every file in the repo that names fieldPatches in a test outside packages/spec is in this list; the full suites ran for the three packages the delta names.
Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on fb1c5bc9: 105 commands, all exit 0; --ran reconciliation: 105 derived, 105 run, 0 NOT-MEASURED. check:type-check-debt first returned exit 3 (its closure build queued behind the dogfood run on the verify lock, queue-timeout), and was re-run once the lock was free: exit 0. check:generated, check:api-surface (no export change), check-adr-0087-registration (registered, new here) and check-changeset-no-major exit 0.

Dogfood (narrowing arm, final head)

pnpm --filter @objectstack/dogfood test under the lock on fb1c5bc9: 397 passed | 3 skipped (400) files; 3277 passed | 28 skipped (3305) tests; VERDICT command-exit 0. (Round 1 on 6a70094d showed 4 reds in flow-runas and declarative-endpoint-anonymous-guest that also failed with the merge-base spec; main fixed them before this merge.)

Acceptance notes

  • Not fixed here, same family: ObjectExtensionSchema.fields is still keyed by a bare z.string(). Measured at the schema: { extend: 'crm_account', fields: { 'Region Code': { type: 'text', label: 'Region' } } } PARSES, so an extension can add a field whose name the target's own fields would refuse. Reported to the seat for a card.
  • Not fixed here: ObjectSchema.fields refuses constructor / prototype by record refines, which are not part of the key schema; fieldPatches: { constructor: … } still parses at the schema (it is a valid snake_case word) and is refused by the target judge at install / os validate like any unknown field. Noted, not filed.
  • Not fixed here, found in round 2: no ADR-0087 conversion that walks objectExtensions[] replays over a stored object_extension row either, for the same reason (no stack collection for the type). Reported to the seat.

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 (CI).
  • 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 with Claude Code

https://claude.ai/code/session_01ADHkmSCC3J91hA9anBJ81g


Generated by Claude Code

…s own key grammar

`ObjectExtensionSchema.fieldPatches` was `z.record(z.string(), …)`, so a key
no field name can be (`Industry`, `Annual Revenue`) parsed and reached only
the downstream target judge. The `ObjectSchema.fields` key schema is hoisted
to one module constant, `FieldNameKeySchema`, and both records read it; a
key outside the grammar gets one `invalid_key` at `fieldPatches.KEY` with a
prescription.

Claude-Session: https://claude.ai/code/session_01ADHkmSCC3J91hA9anBJ81g
Co-authored-by: Claude <noreply@anthropic.com>
…7 semantic entry, changeset, guide)

Claude-Session: https://claude.ai/code/session_01ADHkmSCC3J91hA9anBJ81g
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling 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 9 documentable anchor(s).

27 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 399df5bba888ff7cf7531d01d8dcb61d7690438a.

What this run could not see
  • 3 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 399df5bba888ff7cf7531d01d8dcb61d7690438a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 399df5bba888ff7cf7531d01d8dcb61d7690438a

⚠️ 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 399df5bba888ff7cf7531d01d8dcb61d7690438a → 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#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract-review-claim: session_01ADHkmSduNGC2SZXszyrNut · head 6a70094 · 2026-11-02T17:19Z

The lane seat reviews this head itself at CONTRACT_REVIEW_TIER (triage ruling 6575141496 on #28741 returned contract review to the lane): a narrowing of ObjectExtensionSchema.fieldPatches (its record key takes the ObjectSchema.fields key grammar) with an ADR-0087 semantic migration entry.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a70094d28734caf6f7440f866fc79c9dbded851
Local-runs: none
Scope: packages/spec/src/data/object.zod.ts, packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts + packages/spec/src/data/field-customization.test.ts, packages/spec/src/shared/record-proto-key-guard.ts, packages/spec/src/migrations/entries/README.md, packages/spec/src/migrations/registry.ts.template, packages/spec/src/migrations/types.ts, packages/spec/src/conversions/refused-shapes.ts, packages/spec/src/conversions/stored.ts, packages/metadata-core/src/extension-field-patch-targets.ts, packages/metadata-protocol/src/protocol-object-extension-item.ts, packages/objectql/src/registry.ts, packages/cli/src/utils/field-patch-targets.ts, packages/cloud-connection/src/marketplace-install-local-plugin.ts, ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx, .changeset/29366-field-patches-key-grammar.md

Inputs: card #29366 (body, triage 6580329038, claim 6581882084, os-dev-report 6583545776), PR #29513 body and file list, the net diff of 6a70094d against its merge base with main (cafe8ee6; origin/main is 20 commits ahead, git merge-tree of the two is conflict-free), the check-runs on 6a70094d, and the live tree at 6a70094d read with git show. Check-runs at review time: 31 success, 8 skipped, 0 failure, 23 still in progress (Test Core 2–6 and 8, Test UI 1–3, 5–7 and 9–16, Type Check UI, Type Check · workspace). Green already: Build Core, Test Core 1 and 7, Type Check · consumer gates, Check Changeset, Lint · ESLint, Lint · CI release & engine gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint & Repo Gates, Type Check · source gates, Spec property liveness, Governed Surface Queue Guard, PR-body guards, Dogfood Regression Gate (vacuous on a pull_request event: both shards skip, zero attestations). The landing still waits for the pending runs to conclude green; nothing in them is contradicted by the tree.

① Derived judgments

  1. Accept-set narrowing on ObjectExtensionSchema.fieldPatches keys — right, and it is a narrowing, not a location-only move. Read at every door that parses an object extension: defineStack / defineObjectExtension is the pure schema (no target judge). The /api/v1/meta object_extension save door judges the body with OBJECT_EXTENSION_ITEM_SCHEMA = z.preprocess(withoutProtectionEnvelope, ObjectExtensionSchema) and its own refusal objectExtensionItemRefusal asks six questions (package binding, registry extend refusals, row name = target, target registered under the spelled key, closure, __c suffix via judgeExtensionFieldSuffix) — fieldPatchTargetMisses / fieldPatchTargetRefusals is not among them, so a fieldPatches: { Industry: … } row was stored. The registry fold (packages/objectql/src/registry.ts ~4650) calls fieldPatchTargetMisses twice and hands both lists to reportFieldPatchMisses: the comment says "Never drops, never refuses" and the code does exactly that — the patch is left out of the merge and logged. os validate (packages/cli/src/utils/field-patch-targets.ts) judges only targets in the composed stack ("A target outside the stack is not judged here; the install door judges it"). The marketplace install door (marketplace-install-local-plugin.ts:2145, fieldPatchTargetRefusalsOf) is the only one that always refused. The only non-test callers of the judge in the whole tree are those four (cli, cloud-connection, objectql ×2, metadata-core itself). So the dev's measurement (brief item a) holds in the cited code: three doors accepted such a key end to end, which makes this Clause-②: no (narrowing) · Metadata-protocol: narrowed, BREAKING, with the semantic entry — the branch the delta prescribes for that reading.
  2. ObjectSchema.fields' accept set is unchanged byte for byte — right (brief item b). The diff removes z.string().regex(/^[a-z_][a-z0-9_]*$/, { message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")' }) from inside z.record(…, FieldSchema) and hoists the identical regex and identical message to the module constant FieldNameKeySchema (object.zod.ts:2779), then writes z.record(FieldNameKeySchema, FieldSchema) with no record-level params. Same regex, same message, no error map on that record, and zod 4's record places a key failure as one invalid_key at [key], so the path stays fields.KEY; the constructor / prototype refines and the __proto__ raw guard around it are untouched. The derivation is one declaration read twice — the delta's "do not write a second regex" is met.
  3. Issue location fieldPatches.KEY, not bare fieldPatches — right (brief item c; dev deviation 1). The delta's words "at fieldPatches" would need a hand-rolled superRefine with a static slot path, which would (i) disagree with ObjectSchema.fields' own placement at fields.KEY, the very agreement the card asks for, (ii) disagree with the sibling option-key refusal from PR feat(spec)!: object extension fieldPatches refuse a field the extension replaces, key option patches by value, refuse __proto__, carry display renames #29331 at fieldPatches.FIELD.options.KEY, and (iii) lose the key a Studio form highlights. Zod's record key path is the right reading of the delta; the test pins ['fieldPatches', key] and the agreement test pins it equal to fields.KEY over 11 keys.
  4. No public surface change — right. FieldNameKeySchema is a non-exported const; z.infer of the record is still Record<string, FieldDisplayPatch>, so no consumer type moves (check:api-surface has nothing to see). The delta's "no new public export expected" holds; open_questions is rightly empty.
  5. Generated artifacts — right. packages/spec/json-schema/ is gitignored and built in CI (the key regex now publishes as propertyNames.pattern on ObjectExtension.fieldPatches, the same projection ObjectSchema.fields already carries — a published tightening consistent with the declared narrowing). packages/spec/authorable-surface/data.json lists data/ObjectExtension:fieldPatches as a path only, no pattern. content/docs/references/data/object.mdx is regenerated for the moved describe; content/docs/references/api/package-api-assembled.mdx prints fieldPatches?: Record<string, object> and is unaffected. The record error map is not a refinement, so dropped-refinements.baseline.json's data/ObjectExtension row gains no site. The migration registry is generated from entries/ (no index to edit — README "Touch no other file for the entry"), and STEP18_RATIONALE fragments are for retirements with a D2 conversion; a refusal-only semantic entry adds none (18.object-duplicate-action-name-refused added none either).
  6. ui/** mirror (brief item e) — right, none. git grep of ui/packages and ui/apps for fieldPatches / ObjectExtensionSchema hits only StudioCustomizeControls.tsx (reads ext.fieldPatches as a plain record keyed by the target's own field names, writes the same way), StudioDesignSurface.tsx and plugin-tree/ObjectTree.tsx (prose). No zod twin redeclares the record, so there is no objectui#7715-class drift to pin.
  7. Authored forms — right. Every authored fieldPatches block in examples/**, skills/**, content/docs/** (references excluded), templates and dogfood/test fixtures keys by a snake_case name (industry in the skill and the guide; the deliberate region__c / __proto__ refusal fixtures are refused by their own rules, not the grammar). Nothing in the repository newly refuses; the guide's example is pinned whole as a CONTROL test.

② Semver level

Changeset .changeset/29366-field-patches-key-grammar.md: '@objectstack/spec': minor, **BREAKING** banner, line-initial Clause-②: no (narrowing) and Metadata-protocol: narrowed (the same two lines head the PR body, matching the claim 6581882084), exactly one disposition marker adr-0087: registered object-extension-field-patch-key-grammar-refused whose id resolves to the new entry file, and a FROM → TO line ({ Industry: … } → { industry: … }, key by field name never label). Per AGENTS Post-Task 3: a value refused / requirement tightened is narrowed; a kind takes at least minor on a package the diff moves; (narrowing) is BREAKING and carries its migration — all met. Check Changeset is green on this head. Not skip-changeset (spec publishes), not patch (a protocol kind), not major (the no-major gate holds). Clause-②: line correct — see ①.1 for why it is no (narrowing) and not semantic: the target judge did not reach the save door, the boot fold or an out-of-stack os validate target.

③ Boundary flags

  • open_questions: [] — none owed. No new public name, no product fork.
  • Deviation 1 (fieldPatches.KEY vs the delta's "at fieldPatches") — accepted, see ①.3.
  • Deviation 2 (branch not re-merged): origin/main is now 20 commits past cafe8ee6; git merge-tree is clean. Main's drift on files this PR touches or reads is registry.ts.template (a STEP18_RATIONALE fragment for ui-view-personal-declarations-retired, different lines), content/docs/references/data/object.mdx (the sharing row, a different line) and metadata-protocol files this diff does not edit. Entry ids are unique and generated sorted, so no semantic conflict; the regen merge is the queue leg's os-regen-merge.sh as usual. Accepted.
  • Deviation 3 (assignee/reviewer unset) — the seat's, not the dev's. Deviation 4 (pnpm lint not run locally) — Lint · ESLint and the three Lint gate jobs are green on the head.
  • Out-of-scope class b (ObjectExtensionSchema.fields keyed by bare z.string(), brief item f): confirmed in the live tree — object.zod.ts:5036 reads fields: refuseRecordProtoKey(z.record(z.string(), FieldSchema), 'fields'), so { extend: 'crm_account', fields: { 'Region Code': { type: 'text', … } } } parses and an extension can add a field the target's own fields map would refuse by name. Same family, same one-line fix (FieldNameKeySchema), same declaration shape (a narrowing that reaches the save door too). Rightly kept out of this card's diff (the card names fieldPatches only, and the declaration and migration entry are per surface). Escalate: no open card exists for it (issue search finds none) — the seat files one.
  • Out-of-scope class a (dogfood: flow-runas.dogfood.test.ts ×3 at 404 RECORD_NOT_FOUND on runas_note, declarative-endpoint-anonymous-guest.dogfood.test.ts ×1, brief item f): pre-existing, confirmed from the tree, not from the check-runs — the dogfood shards carry github.event_name != 'pull_request', so this head's Dogfood Regression Gate success counts zero attestations and says nothing. The confirmation is 6af3d295 on origin/main (test(dogfood): re-point the runAs and anonymous-guest flow pins to flow start admission's record rule (#29451), Fixes main red: Dogfood Regression Gate fails on main since #29365 (dfbe34d8) — flow-runas and declarative-endpoint-anonymous-guest dogfood files #29426), which landed AFTER the merge base cafe8ee6 and edits exactly those two files: the reds the dev measured on the merge base are fix(runtime): every flow start door judges a start input against the object the flow's definition reads it from #29365's intended behaviour change, already re-pinned on main. No action for this PR; the re-merge at queue time takes the fix.
  • Noted-not-filed (fieldPatches: { constructor: … } parses — it is a snake_case word; ObjectSchema.fields refuses it by a record refine, not by the key schema): right to leave — parity with the key schema is what the card asked, the two reserved names are a separate rule, and the target judge refuses an unknown field at install / os validate.

④ Design judgment

packages/spec/src/data/object.zod.ts — judged against the card's ruling (key fieldPatches by the schema that keys ObjectSchema.fields, one invalid_key with a prescription) and its first consumers (the object_extension save door, the sync's registrableObjectExtension, the registry fold). Holds. Attempts to break it:

  • Load order (把 44 个 strictUnknownKeyError 直调点批量迁到 strictObject,棘轮降到 0(路线 1 消不掉手抄数组与 shape 的漂移) #5593 block): FieldNameKeySchema is a module const at line 2779, above ObjectSchemaBase (line ~3159) which reads it eagerly; ObjectExtensionSchema is a lazySchema and reads it later. No temporal dead zone.
  • Sharing one key-schema instance between two records: zod 4 schemas are immutable; the record-level error map lives on the fieldPatches record's own def, so it cannot leak onto ObjectSchema.fields.
  • The error map returns undefined for every code but invalid_key, so the record's own invalid_type keeps zod's sentence; String(issue.input) reads the key zod hands as the invalid_key issue's input, and the test pins the message opens with that key.
  • __proto__ matches the grammar but never reaches it: refuseRecordProtoKey still wraps the record and refuses it on the raw input first, as before. region__c matches the grammar and reaches the root __c refinement exactly once (CONTROL pins one custom issue, no double refusal). constructor — see ③.
  • Prescription text: names the rule (lowercase snake_case), two examples, "never by its label", no tracker number (test pins not.toMatch(/#\d{3,}/)). The describe and the doc comment name the grammar.
  • Tests beside it: four refusals, two CONTROLs (including the guide's authored example parsed whole) and an agreement test over 11 keys that reads admission off ObjectSchema.fields' own parse rather than restating the regex, with a vacuity guard both ways. The dev's ablation (FieldNameKeySchema → z.string() on the fieldPatches record: 5 red, controls green) shows the tests can fail.
  • No finding.

packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts — judged against ADR-0087 D3, its siblings (18.object-duplicate-action-name-refused.ts, 18.metadata-item-name-grammar-enforced.ts) and its consumers (os migrate meta's TODO list, the upgrade guide, the changeset marker). Holds. The file carries the SemanticMigration shape its siblings do (id, surface, replacement, reason, acceptanceCriteria; no conversionIds because no D2 conversion exists — right, a respelling can land on another field or none, so the author owns the choice, exactly the duplicate-action sibling's argument), the filename is 18. + id as the README derives it, the id is kebab-case and unique in entries/, the changeset's registered marker names it, and no other file is touched for it (README rule 1). The reason states the measured doors truthfully (matches ①.1) and the acceptanceCriteria gives the author a verify loop (os validate passes; a stored row is refused INVALID_METADATA until renamed).

  • should-fix (follow-up card acceptable, not this PR's FAIL): the entry's own acceptance criteria promise that a stored object_extension row carrying such a key "no longer parses, so the sync … names the row (INVALID_METADATA)" — and the save door did store such rows (①.1). The sibling for the same class, object-duplicate-action-name-refused, also registered a detector in REFUSED_STORED_SHAPES (packages/spec/src/conversions/refused-shapes.ts) so os migrate meta --stored names the row BEFORE the upgrade refuses it ("never silence"); this entry registers none, so an operator learns of the row only at boot. Either add the detector (one predicate: a fieldPatches key the grammar refuses, keyed by this entry's id, which refused-shapes.test.ts already requires to be a step-18 semantic id) or record in the entry why the stored pass cannot reach an object_extension row (applyConversionsToStoredItem maps a row by SINGULAR_TO_PLURAL / STORED_ONLY_COLLECTIONS; whether object_extension is in either was not read here). Several 18.*-refused siblings carry no detector either, so this is the family's gap, not a defect that fails the entry.

Verdict reasoning. Both hits files hold against the cited code and the scenario they are accepted against; the declaration pair is the one the measurement supports; no must-fix. The record is rendered on the tree, not on the PR body: every claim above was re-read in git show 6a70094d:….

Implemented-by: claude/project-thread-as5hsf
Reviewed-by: session_01ADHkmSduNGC2SZXszyrNut

VERDICT: PASS

…ith a non-grammar fieldPatches key

`applyConversionsToStoredItem` maps no `object_extension` row onto a stack
collection, so it returns the row untouched and no REFUSED_STORED_SHAPES
detector could reach it. The semantic entry and the changeset now say so and
name the check an operator runs instead.

Claude-Session: https://claude.ai/code/session_01ADHkmSCC3J91hA9anBJ81g
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fb1c5bc9636b4de1e297d66f4a7f167acbb60f66
Local-runs: none

This is a DELTA record for the pushes after the PASS record 6583861239 (head 6a70094d). Three commits: e089e41f (prose in the ADR-0087 entry and the changeset), c9bd6e10 (a merge of main by os-regen-merge.sh), fb1c5bc9 (the regenerated content/docs/references/data/object.mdx on the merged tree). Against the merge base the PR is still the same 6 files, +168/−15. object.zod.ts, the tests and the key schema are byte-identical to 6a70094d.

  • Should-fix (a REFUSED_STORED_SHAPES detector): answered by measurement, accepted. I read the claim in the tree. applyConversionsToStoredItem (packages/spec/src/conversions/stored.ts) wraps a row in SINGULAR_TO_PLURAL[type] ?? STORED_ONLY_COLLECTIONS[type] and returns the item untouched when neither names a collection. object_extension is in neither (STORED_ONLY_COLLECTIONS holds only translation/translations; the manifest spelling map has no extension entry). So the stored pass runs no conversion and no detector over an object_extension row, and a detector would be dead code. The dev's probe agrees: the row comes back as the same reference with 0 TODOs and 0 notices, while the control object row gets its conversion.
  • The prose now says so. The entry's comment and acceptanceCriteria, and the changeset's "Who sees it" line, say os migrate meta --stored does not list such a row and name the manual check (read each stored row's fieldPatches keys against the fields key grammar). That is true of the tree and is what an upgrader needs. Semver, Clause-②: no (narrowing) and Metadata-protocol: narrowed carry unchanged from 6583861239.
  • Out of scope, filed separately: no ADR-0087 conversion that walks objectExtensions[] replays over a stored object_extension row, for the same reason. Mapping object_extension → objectExtensions in STORED_ONLY_COLLECTIONS would close that and make a detector reachable. Its own card.

Check-runs on fb1c5bc9: ci-failure.mjs GREEN, 65 check-runs, none failed.

Implemented-by: claude/project-thread-as5hsf
Reviewed-by: session_01ADHkmSduNGC2SZXszyrNut

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Head-sha: fb1c5bc9636b4de1e297d66f4a7f167acbb60f66 · read 2026-11-02T18:34Z

维护者速读

  • 改了什么:对象扩展 fieldPatches 的键改用与对象 fields 同一个键 schema(小写 snake_case),Industry、Annual Revenue 这类键在解析时就被拒,报一个带示例的 invalid_key。
  • 为什么改:以前这种补丁在 defineStack、保存门和启动时都被接受,启动只在日志里点名、什么也不改;作者以为改了标签,界面却没变。
  • 风险与回滚:收紧(BREAKING,spec minor,ADR-0087 语义条目已登记)。仓内无此写法;已存的坏键扩展行同步时整行注册失败,os migrate meta --stored 列不出它,条目里写了手工核对办法。回滚即 revert 本 PR。
  • 先看:packages/spec/src/data/object.zod.ts 的 FieldNameKeySchema 与 fieldPatches 的报错文案。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts,预期全绿。
  • 需要你做的:无,席位落地。

席位意见:可落地。契约复审 PASS(6583861239 @ 6a70094d),delta PASS(6585110935)在现 head,CI 全绿。

席位核对 — ACCEPT · domain:spec#2 · head fb1c5bc9

  1. Shape: draft, base main, Fixes #29366. Clause-②: no (narrowing) and Metadata-protocol: narrowed sit at line start in the body and the changeset (@objectstack/spec minor, ! subject, BREAKING). 6 files, +168/−15 against the merge base, inside claim 6581882084. No governed path (check-governed-merges: not governed).
  2. Premise measured: before, a non-grammar fieldPatches key passed defineStack, the save door and the boot fold; on the head it is one invalid_key at fieldPatches.KEY, and a snake_case key and the __c rule are unchanged (the dev's ablation and controls, record 6583861239).
  3. Gates: ci-failure.mjs --sha fb1c5bc9… GREEN: 65 check-runs, none failed. Dev readings on this head: spec 792/792, metadata-core, objectql, metadata-protocol green, 105 derived gates exit 0, dogfood 0 failed.
  4. Accepted deviations: the seat's should-fix (a stored-shape detector) was answered by measurement instead: the stored pass never reaches an object_extension row, so a detector would be dead. The entry and changeset say so and name the manual check. Accepted (delta 6585110935).
  5. Out of scope: spec(object-extension fields): the record key is a bare z.string(), so fields: { 'Region Code': … } parses and is refused only downstream #29523 (ObjectExtensionSchema.fields bare key) and spec(conversions): the stored pass maps no object_extension row onto objectExtensions, so no conversion or refused-shape detector ever runs over a stored extension #29554 (the stored pass maps no object_extension row onto objectExtensions), both filed, both after this PR.
  6. Landing: not Tier H and under 3000 lines. The seat runs pr_ready and auto-merge now. After merge: a landing note on spec(object-extension fieldPatches): the record key is a bare z.string(), so fieldPatches: { Industry: {…} } parses and is refused only downstream by the target judge #29366 and pm:dispatched removed.

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

1 participant