Skip to content

fix(objectql)!: the registry fold judges an option patch against the options the owner declares, as the install door and os validate do (#28809) - #28836

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

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Fixes #28809

Clause-②: yes (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — skills/** never names fieldPatches (git grep: 0); the rule is taught in content/docs/data-modeling/object-extensions.mdx, updated here

维护者速读

  • 改了什么:注册表合并判断 fieldPatches 的选项补丁时,改为对照对象所有者自己声明的选项,和安装口、os validate 用同一个判定、同一份目标。别的扩展(或本扩展)通过 fields 重新声明所有者字段时新增的选项,补丁不再生效,日志记一次。
  • 为什么改:fix(objectql,cloud-connection)!: a field patch reaches the owner's own fields only, at the fold and the install door; the install door refuses it with 422 (#28733) #28791 之后,三处对哪些字段可补丁已一致,但选项仍不一致:合并按已合并的字段判断,安装口和 os validate 拒绝。
  • 风险与回滚:仓库内声明 fieldPatches 的包和示例为 0。补丁指向所有者已声明的选项时行为不变。回滚即还原本 PR。
  • 先看:packages/objectql/src/registry.ts 的 foldExtendersOntoDefinition(判定移到合并前)、ownFieldsTarget、admittedFieldPatches。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/registry-extension-field-patches.test.ts,预期 16/16。
  • 需要你做的:无,席位安排契约复核后落地。

What changes

  • The fold judges first, against the owner. Per extension, before the merge, the fold asks fieldPatchTargetMisses of ownFieldsTarget(owner, ownFields, carried): the owner's own fields, each as the OWNER declares it. That is the target the install door (getObjectOwner(name).definition) and os validate read. The merge gets only the admitted patches (admittedFieldPatches drops a missed field's patch, or a missed option's entry), and reportFieldPatchMisses names exactly the misses. One judge, one verdict, applied and reported from the same answer.
  • Which fields may be patched is unchanged. carried keeps fix(objectql,cloud-connection)!: a field patch reaches the owner's own fields only, at the fold and the install door; the install door refuses it with 422 (#28733) #28791's rule: an own field the fold carries in record spelling, or one this body redeclares. The array-spelled pin stays green, and applyFieldPatches keeps its ownFields guard.
  • No door moves. No change to cloud-connection or cli source, no packages/spec/**, no new gate, error code or export. metadata-core changes one docblock sentence (no code).
  • Docs. One bullet in object-extensions.mdx: options are the owner's too, plus the residual case.

Residual (ruling 4), pinned as measured (head 301b4e3d). An earlier layer (an extension's fields redeclaration, or a tenant overlay base) leaves out an option the owner declares, and a later patch names it. All three readers admit it; applyFieldPatches finds no such option, so nothing of it applies. The fold then asks the same judge of the merged fields, which only names, so the patch is named once, as on main. No new rule.

Clause-② measured: yes (narrowing). On main the fold applied a patch of an option the owner does not declare whenever it was already on the merged field: one a redeclaring extension folded first adds (served Banking), or one a tenant overlay base adds (served Bulk). Now neither applies, and each is named once; both doors already refused it.

Tests

Round 2 (head 301b4e3d, contract review should-fixes S1/S2). 17 tests in the file (6 new). Red on main 663b7c08: 3 failed | 14 passed (17): the two redeclare cases and the tenant overlay case (wholesale served Bulk). Second-pass ablation (reportFieldPatchMisses gets misses only): 2 failed | 15 passed (both residual cases); restore ok, blob == HEAD. Two-minute check: expect 17/17. objectql typecheck in full exit 0 (ledger unchanged 39/233/64); eslint exit 0; objectql full suite 520 files, 9996 tests passed.

Red first, ablation, agreement

registry-extension-field-patches.test.ts, 5 new cases:

  • another extension adds finance by redeclaring industry (priority 100), and a patcher (200) patches it;
  • the same extension redeclares and patches;
  • control: patcher folded first;
  • control: owner options tech and retail still apply onto the redeclared field;
  • the residual.

Each asks the door's judge with getObjectOwner beside the fold.

Red first: registry.ts restored to main 663b7c08 gave Tests 3 failed | 13 passed (16). Both redeclare cases served Banking, and the residual named one line. Restore: git checkout HEAD, porcelain empty.

Ablation (ablation-replace.mjs): the judge's target set back to the merged field, which gave the same 3 red. Restore: ok restored: blob == HEAD, git diff HEAD empty, marker count 0.

Agreement cases (green on main by construction, since the doors already read the owner):

  • install-local 2e refuses finance with 422 beside an installed takeover;
  • findFieldPatchTargetRefusals refuses it in both the same and another extension.
Suites, typecheck, lint, gates, dogfood

Head 23083aa1, after a whole-repo pnpm build (exit 0), each under the verify lock:

  • objectql: 520 files, 9995 tests;
  • metadata-core: 22 files, 885 tests;
  • cloud-connection: 67 files, 813 tests;
  • cli unit: 368 files, 5195 tests.

All passed. tsc --noEmit exits 0 on all four, and eslint --no-inline-config exits 0 on the 5 touched files.

Gates: dispatch-gates.mjs --commands derived 95; all 95 ran, every exit 0. --ran: 95 derived, 95 run, 0 NOT-MEASURED.

Dogfood battery (narrowing arm): pnpm --filter @objectstack/dogfood test, VERDICT command-exit 0. Test Files 380 passed, 3 skipped; Tests 3114 passed, 28 skipped.

Acceptance notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5oTRCUNQ4EA3YV84sZ

…d options in the registry fold, as at the install door and os validate (#28809)

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/objectql, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-core/src/extension-field-patch-targets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via SchemaRegistry (symbol, a top-level class))

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

  • content/docs/releases/v17/17-4.mdx (via SchemaRegistry (symbol, a top-level class))

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
  • 1 changed file(s) yielded no anchor (packages/metadata-core/src/extension-field-patch-targets.ts) — pages documenting those are invisible to this run
  • 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 — 22 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 6ea8342ac1cf3c8742313eee38c1c9cc268bae7e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6ea8342ac1cf3c8742313eee38c1c9cc268bae7e

⚠️ 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 6ea8342ac1cf3c8742313eee38c1c9cc268bae7e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ases for the test typecheck (#28809)

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head da4a27a4e3a6009cf6190581edf7cb9616a5a566 · 2026-11-01T23:54Z

Taken by the dispatching seat. Face narrowing (Clause-②: yes (narrowing), Metadata-protocol: narrowed), at CONTRACT_REVIEW_TIER with ④. The review runs as an isolated sub-agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: da4a27a4e3a6009cf6190581edf7cb9616a5a566
Local-runs: none
Scope: packages/objectql/src/registry.ts, packages/metadata-core/src/extension-field-patch-targets.ts, packages/objectql/src/registry-extension-field-patches.test.ts, packages/cli/src/utils/field-patch-targets.test.ts, packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts + packages/cloud-connection/src/marketplace-install-local-plugin.ts (fieldPatchTargetRefusalsOf, the install door's target), packages/cli/src/utils/field-patch-targets.ts (os validate's walk), packages/spec/src/data/object.zod.ts (ObjectExtensionSchema.fieldPatches, the published spec on main), docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md (D10, second rule), content/docs/data-modeling/object-extensions.mdx on main (the declared fold behaviour this PR narrows)

Reviewed at 2026-11-01T23:59Z, read-only: card #28809 (body and both comments: the claim 6564158975 and the os-dev-report 6565322781), PR #28836 (body, 7-file list, net diff origin/main...da4a27a4 over merge-base 663b7c08), and the 50 check-runs on the head. The prose faces (.changeset/28809-field-patch-owner-declared-options.md, content/docs/data-modeling/object-extensions.mdx) are read for ② and ④, not reviewed. Head history confirmed from git: 23083aa1 to da4a27a4 is one line in registry-extension-field-patches.test.ts (the typed warn-line helper); no source moves between them.

Check-runs on da4a27a4 at review time: 50, all completed, none still running. 38 success, 12 skipped. Every gate family the diff derives is green: Lint & Repo Gates, Lint · ESLint, Lint · docs & code guards, Lint · PM dispatch-gates, Lint · repo & tooling gates (the ADR-0087 registration reader lives here), Type Check · source gates / workspace / consumer gates / debt ledger, TypeScript Type Check, Build Core, Build Docs, Test Core and its 8 shards, Test UI, Live E2E UI, Dogfood Regression Gate, Check Changeset, Check PR Size, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter. The 12 skips are the path-filtered UI matrix (ui/** untouched: Lint UI, Type Check UI, Test UI shards, Build UI Deps, Build & E2E UI, UI Published Artifacts), Dogfood Verify CLI, Temporal Conformance, the two opt-in harnesses and the schedule gate. No conclusion is failure or cancelled.

① Derived judgments

The diff implies these accept-set and public-surface changes; each named right or wrong against the published spec (ObjectExtensionSchema.fieldPatches: "Display patches of the target object's own fields … which options exist stay the vendor's … A patch naming a field or option value the target does not have is refused at install and by os validate") and ADR-0146 D10 second rule (options membership is the vendor's; an extension changes a base field only within the display whitelist).

  1. The fold's option-patch accept set narrows to the owner's declared options. foldExtendersOntoDefinition now asks fieldPatchTargetMisses BEFORE the merge, of ownFieldsTarget(owner, ownFields, carried): the own contributor's stored declaration restricted to the own fields the merge will carry. An option only an extension's fields redeclaration adds (same extension or an earlier one) misses with reason: 'option', is removed from the patch by admittedFieldPatches, and is named once by reportFieldPatchMisses. On main the judge read merged after the merge, so the same patch applied whenever the redeclarer folded first. Right: this is the spec's own refusal ("which options exist stay the vendor's"; "refused at install and by os validate") made real at the third reader, and the install door (getObjectOwner(name).definition) and os validate (the composed stack's declared object) were already judging that target; no door source moves. Pinned by the three new objectql cases (redeclarer first, same extension, patcher first), with the door's judge asked beside the fold in each.

  2. Which fields a patch reaches is unchanged. carried admits an own field in the running body's record spelling or one this extension body redeclares, which is exactly the set applyFieldPatches applied on main (ownFields.has(name) plus recordFieldOf(merged)); applyFieldPatches keeps its ownFields guard. The array-spelled by-name fold pin (foldObjectExtendersOnto over an array-spelled body: not patched, named once) holds by the same reasoning: recordFieldOf(running) is undefined on array fields, the field is not carried, the judge reports reason: 'field', and the warning text is the one main printed. Right.

  3. Field-key patches on a taken-over field still apply; owner-declared options still relabel onto the redeclared field. Control case 4 pins label: 'Sector' and tech/retail relabels applied over the takeover, finance untouched. The judge's target is the owner's field, so an owner-declared option is admitted, and patchedField finds it on the merged (redeclared) field when the redeclaration kept it. Right.

  4. Naming is now the judge's verdict, not a second walk. reportFieldPatchMisses(misses, packageId) names exactly the misses the judge returned, with the same object|package|field|option dedupe key and the same warning sentence; miss.object is String(baseDefinition.name ?? extension.name ?? ''), the string main built from target.name. Right for everything the judge misses. What the judge admits but the fold cannot apply is no longer named; that is the residual, judged in ④.

  5. No owner registered: nothing judged, nothing applied, nothing named. misses is [] when owner or ownFields is undefined, the extension passes to mergeObjectDefinitions unchanged, and applyFieldPatches returns fields by reference on !ownFields. Same as main. Right.

  6. admittedFieldPatches is a pure filter of the judge's verdict. A field miss deletes the whole patch; an option or picklist-option miss deletes that option entry (a picklist-bound field's option labels were already unreachable in patchedField, so this is naming-only parity). Returns patches by reference when nothing was missed, so the by-reference contract of applyFieldPatches ("returns fields BY REFERENCE when nothing applies") is preserved upstream. Two misses on one field (field miss then option miss) cannot co-occur from fieldPatchTargetMisses (it continues after a field miss), and the loop tolerates it anyway. Right.

  7. tenantPatchedDisplayKeys (ADR-0029 D9.2a yield set) is unchanged and still reads every contributor's fieldPatches, dropped entries included. A yield key for an option the fold now never applies is inert. Right (no-op).

  8. Public surface. No export added, removed or re-typed in any package: registry.ts adds a type-only import of FieldPatchTargetMiss (already exported by @objectstack/metadata-core); ownFieldsTarget, admittedFieldPatches and reportFieldPatchMisses are module-private or private. extension-field-patch-targets.ts moves one docblock sentence and no code (fieldPatchTargetMisses body is byte-identical). No packages/spec/**, no new error code, no new gate, no door source change in cloud-connection or cli; the two door test additions pin an agreement that was green on main by construction (both doors already read the owner). Right.

  9. The install door and os validate accept sets do not move. Read in the cited code: fieldPatchTargetRefusalsOf resolves getTarget to a delivered body or registry.getObjectOwner(name).definition; findFieldPatchTargetRefusals resolves it to the composed stack's declared objects. Neither reads the resolved (merged) object, before or after this PR. Right.

② Semver level

Changeset .changeset/28809-field-patch-owner-declared-options.md: '@objectstack/objectql': minor, with Clause-②: yes (narrowing) and Metadata-protocol: narrowed as line-initial declarations, a BREAKING banner, exactly one ADR-0087 marker (adr-0087: not-required (no-migration-prescription)) with its reasoning, a Before / Now / Who-sees-it account, and the one-line remedy ("set that option's label where the option is declared"). The PR body carries the same two declaration lines, and the title carries !.

  • Clause-②: yes (narrowing) matches the diff: an input the fold accepted on main (an option patch of an extension-added option, redeclarer folded first) is no longer applied. Nothing is widened at any reader. Measured by the dev's red-first run (3 red with registry.ts at 663b7c08), which I did not re-run; the check-runs on the head are the gate verdicts.
  • Metadata-protocol: narrowed is the conservative and correct arm (AGENTS.md §3: "a value refused or a requirement tightened is narrowed; several ⇒ narrowed"): what an author can write and have SERVED by a stack booted outside the install door (a programmatic registerObject, or a boot that never ran os validate) tightens. The kind takes at least minor on the package the diff moves: objectql minor is right. A breaking changeset with its migration (the remedy line) and its ADR-0087 disposition in writing is present; Lint · repo & tooling gates (the registration reader) is green.
  • No changeset for @objectstack/metadata-core: the only change is a docblock sentence; no emitted behaviour or type moves; Check Changeset on the head is green. Not owed. cloud-connection and cli are test-only.
  • Skills-docs: none verified: git grep fieldPatches over skills/** and .claude/skills/** at the head returns nothing.
  • One accuracy slip, should-fix S2 below: the changeset's "Before" and "Who sees it" name only an option "an extension's fields redeclaration adds". On main the judge's target was merged, which starts from overlay ?? own, so an option a tenant overlay base added to an owner field was also admitted at the fold and is also dropped now (the doors refused it already). The declared arm and level are unaffected.

③ Boundary flags

④ Design judgment

The narrowing, read against the published spec on main and the cited code at the head.

Does it take a declared capability away at the consumer? No. The spec declares fieldPatches as display patches of "the target object's own fields" whose "options … stay the vendor's", and promises that "a patch naming a field or option value the target does not have is refused at install and by os validate". ADR-0146 D10 refuses options membership on an extension outright. An option that exists only because an extension replaced the owner's field through fields is not the vendor's option; relabelling it through fieldPatches was never a declared capability, and content/docs on main already told the author to set that text "where it is declared". The fold was the one reader serving it, by priority accident (the redeclarer had to fold first). The head makes the spec's own refusal real at the third reader with the same judge and the same target the two doors use, which is exactly the card's "one answer at all three readers". The route chosen (fold reads the owner, not doors reading the merged field) is the only one the spec admits: the doors cannot see another package's extension at install or validate time, and the spec says the options are the vendor's.

Breaking attempts, read in the tree:

  • Priority inversion. Takeover at 300 after a patcher at 200 patching an owner option: the patch applies onto the owner's field, then the takeover replaces the field whole and the patch is lost. Same on main; that is fields' declared "replaced, not merged" semantics, not this PR's.
  • Owner field picklist-bound, extension redeclares it with inline options plus finance, patch of finance. Owner target is picklist-bound → picklist-option miss → dropped and named; doors say the same. Consistent.
  • Overlay base. The fold's base is overlay ?? own; the judge now reads owner, never the overlay. A tenant overlay that ADDS an option to an owner field: patch of it was applied on main, dropped and named now; both doors already refused it. Consistent and spec-true, but the changeset should name it (S2). A tenant overlay that DROPS an owner option: same residual as below, by a second route.
  • No owner, extension only. Unchanged, nothing applied or named.
  • By-name fold over an array-spelled body. Unchanged, pinned.
  • The carried closure captures running (the pre-merge merged) and extension (post audit/queue governance), and the merge is fed admitted, which spreads the same extension. The judge and the merge see one body. No drift.
  • Warning text for an extension-added option reads "names an option crm_account.industry does not have … this field's are tech, retail" — the owner's list, which is what the author must address. Accurate at the fold now, and identical to the door's sentence.

The residual (card ruling 4 / dev's open question). An earlier layer (an extension's fields redeclaration at lower priority, or a tenant overlay base) drops an owner-declared option; a later extension patches that option. All three readers admit it, because the owner declares it. applyFieldPatches finds no such option on the merged field, so nothing of the option entry applies; the patch's field keys still apply. On main the fold named it (reportFieldPatchMisses over the merged target); on the head it is silent, and the head's docs and changeset declare the silence.

Judged should-fix S1, not a must-fix, for these reasons:

  • No accept set disagrees: this is the one answer the card wanted, and the residual is admitted everywhere.
  • No misleading text reaches a screen: the option is absent from the served field entirely (the takeover's choice), so the author sees the option gone, not a stale label. The harm the naming existed for ("the author reads Sector, the screen keeps Industry") does not arise.
  • The spec's refusal clause is about "a field or option value the target does not have"; the owner target HAS it. So the silence contradicts no published spec sentence and no ADR-0146 D10 sentence; it narrows only the main docs' "an unmatched patch … is named once in the log", and the head's docs re-declare that corner explicitly rather than leaving it silent.
  • It is cheap to restore and it is not a new rule: after mergeObjectDefinitions, ask the same judge of the admitted patches against ownFieldsTarget over the merged body for NAMING only (never dropping, never refusing), as main did, and dedupe under the same key. The fold then keeps "what it names is what it skipped", the invariant main's ownFieldsTarget docblock stated. Either land that, or keep the declared silence and fix the changeset sentence (S1's minimum).

Findings:

  • S1 (should-fix). The residual silent no-op. Preferred: restore main's naming of an admitted-but-inapplicable option patch (judge the admitted patches once more over the merged target, naming only). Minimum: the changeset's "Residual, unchanged rule" bullet is wrong as written: the admission rule is unchanged, but the fold's naming of this case changed (main named it, the head does not), and that is part of what the narrowing publishes. Say so, and widen the residual's cause to "an earlier layer that leaves the option out", since a tenant overlay base reaches it too.
  • S2 (should-fix). Changeset and PR body "Before" / "Who sees it": the dropped-at-the-fold set also includes an option a tenant overlay base adds to an owner field (the main target was merged, which starts from overlay ?? own). The doors already refused it, so the arm and level stand; the account of who sees the change should name it.
  • No must-fix.

Implemented-by: claude/project-thread-jqgfyc
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

Rendered by the seat's subagent.

VERDICT: PASS


Generated by Claude Code

…er left out, by the same judge asked of the merged fields (#28809)

Co-Authored-By: Claude <noreply@anthropic.com>
…e the extension redeclaration, and the residual named once (#28809)

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head 301b4e3d8f13bae733c2cc5d3c9e4a516c0ef55a · 2026-11-02T00:36Z

Taken by the dispatching seat. Face narrowing (Clause-②: yes (narrowing), Metadata-protocol: narrowed), at CONTRACT_REVIEW_TIER with ④. Delta round on the S1/S2 fixes; the review runs as an isolated sub-agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 301b4e3d8f13bae733c2cc5d3c9e4a516c0ef55a
Local-runs: none
Scope: packages/objectql/src/registry.ts, packages/metadata-core/src/extension-field-patch-targets.ts, packages/objectql/src/registry-extension-field-patches.test.ts, packages/cli/src/utils/field-patch-targets.test.ts, packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts + packages/cloud-connection/src/marketplace-install-local-plugin.ts (the install door's getTarget: a delivered body or registry.getObjectOwner(name).definition), packages/cli/src/utils/field-patch-targets.ts (findFieldPatchTargetRefusals, the composed stack's declared objects), packages/spec/src/data/object.zod.ts (ObjectExtensionSchema.fieldPatches, the published spec on main), docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md (D10, second rule), content/docs/data-modeling/object-extensions.mdx on main (the declared fold behaviour this PR narrows)

Reviewed at 2026-11-02T00:39Z, read-only: card #28809 (body and both comments: the claim 6564158975 and the os-dev-report 6565322781), PR #28836 (body, 7-file list, net diff origin/main...301b4e3d over merge-base 663b7c08), the earlier record on da4a27a4 (PR comment 6565855643, PASS with should-fixes S1 and S2), and the 54 check-runs on the head. The prose faces (.changeset/28809-field-patch-owner-declared-options.md, content/docs/data-modeling/object-extensions.mdx) are read for ② and ④, not reviewed. The seat's own Contract-review-claim: comment is not an input.

DELTA round. The prior record on da4a27a4 governs everything the delta does not move; its judgments are carried forward below and re-stated only where the delta touches them. The delta da4a27a4..301b4e3d is two commits, f6206b53 (the fold's second, naming-only pass) and 301b4e3d (changeset and docs), over four files: registry.ts (+29 −5, two hunks: the second pass in foldExtendersOntoDefinition and three docblock sentences), registry-extension-field-patches.test.ts (the residual case re-pinned as named once, one new tenant-overlay case), the changeset and object-extensions.mdx. The door tests and extension-field-patch-targets.ts are byte-identical to da4a27a4.

Check-runs on 301b4e3d at review time: 54, all completed, none still running. 40 success, 14 skipped, no failure, no cancelled. Every gate family the diff derives is green: Lint & Repo Gates, Lint · ESLint, Lint · docs & code guards, Lint · PM dispatch-gates, Lint · repo & tooling gates (the ADR-0087 registration reader), Lint · CI, release & engine gates, the three scoped lints, Type Check · source gates / workspace / consumer gates / debt ledger, TypeScript Type Check, Build Core, Build Docs, Plan Test Core shards, Test Core and its 8 shards, Test UI, Live E2E UI, Dogfood Regression Gate, Check Changeset (twice, both events), Check PR Size, Governed Surface Queue Guard, PR-body guards (twice), Spec property liveness, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter. The 14 skips are the path-filtered UI matrix (ui/** untouched: Lint UI, Type Check UI, Test UI shards, Build UI Deps, Build & E2E UI, UI Published Artifacts), the Dogfood shard matrix and Dogfood Verify CLI, Temporal Conformance, the two opt-in harnesses, the schedule gate, and the second-event copies of Auto Label and Check PR Size. The docs-drift comment (6565324481) is advisory and lists pages that name SchemaRegistry; none restates the fieldPatches rule, which lives in object-extensions.mdx, updated here.

① Derived judgments

Judgments 1 to 9 of the record on da4a27a4 stand unchanged for the first pass (the judge over ownFieldsTarget(owner, ownFields, carried) before the merge, admittedFieldPatches, the unchanged carried field rule, the unchanged doors, no public-surface move). The delta adds one judgment and amends one.

  1. The second pass is main's naming walk, over the admitted patches, feeding the log only. After mergeObjectDefinitions, the fold asks fieldPatchTargetMisses(object, admittedPatches, ownFieldsTarget(merged, ownFields, (name) => !!recordFieldOf(merged, name))) and concatenates the result onto misses for reportFieldPatchMisses. Read against the merge-base: main's ownFieldsTarget(merged, ownFields) built { ...merged, fields } from recordFieldOf(merged, name) over ownFields; the head's call builds the same target (the carried predicate is the presence test field && already makes, so it filters nothing further, and isPlainRecord(merged) always holds for a ServiceObject). The input is admittedPatches, a subset of patches. So the second pass's misses are a subset of what main named for the same extension at the same step; it adds no name main did not print, and no rule. Right.
    • Never drops, refuses or changes what applies. residual is consumed by reportFieldPatchMisses alone, which only writes console.warn; merged is already computed when the second pass runs, and nothing reads residual afterwards. Right.
    • Names exactly what the merge skipped. applyFieldPatches on the merged body applies a patch entry when ownFields.has(name), recordFieldOf(merged.fields, name) exists, the option's stored value is in the field's options array, and the field is not picklist-bound (typeof field.picklist === 'string' && field.picklist !== ''). The judge over the merged target tests the same four things with the same isRecord/array walk (fieldOf on a record fields, optionValuesOf, the same picklist test), so a second-pass miss is an admitted entry the merge could not apply, and an applied entry never misses. The D9.2a yields (tenantPatched) skip an entry without a miss on either side, as on main. Right.
    • Dedupes under the same key. Both passes build miss.object from String(baseDefinition.name ?? extension.name ?? '') and reportFieldPatchMisses keys object|package|field|option over the concatenation. A first-pass miss removes its entry from admitted (field miss: the whole patch; option / picklist-option miss: that option entry), so the second pass cannot see it; a key can be produced by at most one pass, and the per-registry fieldPatchMissesWarned set dedupes across reads as before. Right.
    • The array-spelled by-name fold pin. recordFieldOf(running) and recordFieldOf(extension) both undefined → first pass field miss, entry dropped, admittedPatches is {} → second pass returns []. Named once with main's sentence. Right.
    • A patch whose first pass misses nothing passes extension itself as admitted, and the second pass reads extension.fieldPatches: the full patch, as main judged it. Right.

4 (amended). Naming is the union of the two verdicts. What the owner-judge misses is named (the doors' refusal, the narrowing) and what the merged-judge misses among the admitted is named (main's naming of the residual). The invariant main's ownFieldsTarget docblock stated, "what it names is what it skipped", holds again, and the head's docblocks say so. Right. The residual is no longer silent; the prior record's judgment 4 ("what the judge admits but the fold cannot apply is no longer named") is withdrawn by the delta.

Pinned in the tree: the residual case now expects exactly one fieldPatches line naming industry.options.retail from EXT_PKG; the new tenant-overlay case pins wholesale (tenant-added, owner lacks it) refused at the door and named once, retail (owner declares it, overlay left it out) admitted at the door, unapplied and named once, and the served options unchanged (Bulk not served). Read, not run: the check-runs on the head are the verdicts.

② Semver level

Unchanged from the record on da4a27a4: '@objectstack/objectql': minor with Clause-②: yes (narrowing) and Metadata-protocol: narrowed as line-initial declarations on the changeset and the PR body, the BREAKING banner, one ADR-0087 marker (adr-0087: not-required (no-migration-prescription)) with its reasoning, the ! in the title. The delta changes no emitted behaviour that widens anything: the second pass is naming-only, so the narrowing arm, the narrowed kind and the minor level stand. metadata-core is a docblock sentence only (unchanged in the delta; no changeset owed; Check Changeset green). cloud-connection and cli are test-only. Skills-docs: none re-verified at the head: git grep fieldPatches over skills/** and .claude/skills/** returns 0 files.

The prose now states the narrowing truly (S1 and S2 of the prior record):

  • S2 answered. The Clause-② line, "Before" and "Who sees it" now name both sources of an owner-undeclared option: an extension's fields redeclaration (applied on main when the redeclarer folded first) and a tenant overlay base (applied on main). Read in the cited code: on main the judge's target was merged, which starts from overlay ?? own, and tenantPatchedDisplayKeys yields an option key only when packagedOption && baseOption both exist, so a tenant-added option had no yield and the patch applied (served Bulk). The sentence is true. The remedy's second arm ("or in the tenant's own customization of the field") is the right one for a tenant-added option.
  • S1 answered, on the preferred route. The "Residual, unchanged rule" bullet now reads: admitted at all three readers, nothing to relabel, "the fold asks the same judge once more of the merged fields, which only names: the patch is named once, as on main. Nothing is dropped or refused by that second asking." That is what the tree does (judgment 10), and "unchanged rule" is now accurate: admission and naming of this case are both as on main. The cause is widened to "an earlier layer" (redeclaration or tenant overlay base), which matches the two pinned routes.
  • object-extensions.mdx: the new bullet names the tenant's customization beside the redeclaration, and ends "a patch of that option is admitted but has nothing to relabel, and the boot names it once." True of the head.
  • PR body: the Residual paragraph and the "Clause-② measured" paragraph state the same two facts; the Tests paragraph's "17 tests in the file (6 new)" matches the file at the head (10 + 6 + 1; the merge-base file holds 11). The red-first and ablation counts are the dev's measurements, not re-run; the check-runs are the gate verdicts.

③ Boundary flags

④ Design judgment

The narrowing itself, read against the published spec on main and the cited code at the head. The prior record's ④ verdict stands: ObjectExtensionSchema.fieldPatches declares "display patches of the target object's own fields" whose "type, values and rules stay the vendor's" and promises "a patch naming a field or option value the target does not have is refused at install and by os validate"; ADR-0146 D10's second rule refuses options membership on an extension; content/docs on main already sent the author to "where it is declared". An option that exists only because an extension replaced the field through fields, or because a tenant overlay added it, is not the vendor's option; relabelling it through fieldPatches was never a declared capability. The head makes the spec's own refusal real at the third reader with the same judge and the same target the two doors use, and the doors' accept sets do not move (read: getTarget resolves to delivered.get(name) ?? registry.getObjectOwner(name).definition; findFieldPatchTargetRefusals resolves to the composed stack's declaredObjects). No capability is taken away at a consumer.

The delta, tried against the tree:

  • Could the second pass change what is served? No read of residual exists other than reportFieldPatchMisses; merged is final before the second pass runs. The by-name fold (foldObjectExtendersOnto → foldExtendersOntoDefinition) and the code-layer fold take the same path.
  • Could it name an applied entry? No (judgment 10): the judge over the merged target and applyFieldPatches over the merged fields read the same field, the same options array and the same picklist test.
  • Could it miss an unapplied admitted entry? Only the D9.2a yield (tenantPatched), which is the tenant's text winning, not a miss, and was silent on main too.
  • Double naming of one key. Impossible by construction (a first-pass miss is removed from admitted before the second pass sees it) and caught by the key set regardless.
  • Priority inversion (patcher at 100 admitted and applied; takeover at 300 then replaces the field whole): nothing named, the relabel is lost. Same on main and on da4a27a4; that is fields' declared "replaced, not merged" semantics.
  • Owner field not picklist-bound, takeover binds it to a picklist, patch of an owner option. First pass admits (owner's field has the option, no picklist); patchedField skips options on the merged picklist-bound field; second pass names a picklist-option miss, whose sentence says "Remove options from this patch." The doors admit the same patch. This is exactly main's naming (same judge, same target), so it is not new, and the log prefix ("not applied while the object has no such target") describes the served state truly. Noted, no finding: the sentence is the judge's shared text in metadata-core, and changing it would move the doors' refusal wording, which this card does not own.
  • The residual's sentence ("names an option crm_account.industry does not have … this field's are tech") reads the merged field's values, as main's did. Noted, no finding, for the same reason.
  • Cost. One more O(entries × options) walk per extension per uncached fold. Negligible.
  • Prose. Changeset, docs and PR body now say what the tree does for both the narrowing (two sources) and the residual (named once, nothing dropped). No sentence found that the tree contradicts.

Findings: none. S1 and S2 of the record on da4a27a4 are answered in the tree and in the prose. No must-fix; no should-fix remains.

Implemented-by: claude/project-thread-jqgfyc
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

Rendered by the seat's subagent.

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-02T00:43Z

Head 301b4e3d, report 6565322781 (on #28809), claim 6564158975. Clause-②: yes (narrowing), Metadata-protocol: narrowed. Contract review at CONTRACT_REVIEW_TIER (narrowing face): record 6565855643 PASSED on da4a27a4 with should-fixes S1 and S2; the delta record 6566356006 PASSED on this exact head with no must-fix and no should-fix.

item reading
Shape Draft, base main, Fixes #28809. 7 files: the field-patch fold in objectql/src/registry.ts judges each extension's option patches against the owner's declaration before the merge (the target the install door and os validate read), and merges only what it admits. A naming-only second pass over the merged fields names the residual once, as main did. Plus tests in objectql, cloud-connection and cli, a metadata-core docblock sentence, one docs bullet, and an objectql minor changeset with the BREAKING banner. No door source, no packages/spec/**, no export, no gate.
Should-fix S1 (the residual named once) and S2 (the tenant overlay base named as a second source) are both in the tree and the prose at 301b4e3d, judged by the delta record.
Evidence CI on head 301b4e3d (filter=latest): 14 skipped, 40 success, 0 failed. The report and the round-2 note show red on main (3 of 17), the ablations restored clean, the objectql suite (9996) and its typecheck in full, and 95/95 derived gates on the first head.
Serial #28811 has landed since. git merge-tree of this head onto current origin/main is clean, and the merge queue tests the merged tree.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with every check green or an expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 00:44
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 00:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 4a49ad7 Nov 2, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-jqgfyc branch November 2, 2026 01:05
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 size/m tests tooling

Projects

None yet

1 participant