Skip to content

fix(metadata)!: the unscoped plural read counts an action name's holders per (object, name), so the boot pass keeps a package-less row on another object beside the holder's - #33422

Queued
objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/area-E2-32681b
Queued

objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/area-E2-32681b

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #32681

Clause-②: yes (narrowing)
Metadata-protocol: none

维护者速读

  • 改了什么:动作(action)的无包行在非限定的复数读取(pluralEntryKeys,loadMany / loadManyKeyed / DatabaseLoader 的非限定读取共用)里,按 (对象, 名字) 数持有者:只有当名字唯一的持有包也把该名字绑在同一个对象上时,无包行才并入它(它的定制);绑在没人绑的对象上的无包行是环境自己的条目,单独保留。
  • 为什么改:席位裁决 A(2026-11-14T16:12Z)的后一半。之前 ALPHA 把 approve 绑在 account、一条无包行绑在 contact 时,复数读取按名字把两行并成一条、只留最后保存的那行,启动预载后注册表只剩一个;点名 ALPHA 的读取在预载后答出 contact 行(ALPHA 从没声明过的绑定),而冷读答 ALPHA 自己的 account 行。只有 BETA 绑 contact、无包行在 account 时同理。
  • 风险与回滚:只影响接了 DatabaseLoader 的宿主(工作区内 0 处);库里的行不改不迁移,只改复数读取保留哪些行、预载注册哪些;同一对象上的单持有者、两个包持有同名的情形不变;回滚 = revert 本 PR。
  • 先看:packages/metadata/src/package-slotted-store.ts 的 pluralEntryKeys(风险 hunk:customizes 判定)与新 helper entryIdentitiesOf。
  • 两分钟验证:pnpm --filter @objectstack/metadata exec vitest run src/loaders/database-loader.test.ts -t "counts an action name",预期 11 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

Second half of seat ruling A on #32681 (ADR-0150 D4: an action is keyed by (package, objectName, name)). The first half (#33318) keyed DatabaseLoader's saves and slot reads per (object, name); this one does the same for the unscoped plural read, now that #33299's keyed-entry stamping is on main.

pluralEntryKeys (packages/metadata/src/package-slotted-store.ts) is the key MetadataManager.loadMany, MetadataManager.loadManyKeyed and DatabaseLoader's unscoped reads keep one entry per. For action, an entry that carries no package is now folded into the name's one holder only when that holder binds the name to the entry's own object (or both are object-less): the holders of the entry's (object, name), counted from the row's bundle_member column first, else its body's object, through a new helper entryIdentitiesOf that pluralEntryCustomizedHolders now shares. Bound to an object no package binds the name to, the row is the environment's own entry and keeps a key of its own, so the boot priming pass registers it beside the holder's item and the store's (object, name) write rule files it in the bare slot.

Every moved answer, FROM to TO (host wiring a DatabaseLoader; stored rows; approve the action name):

Shape Answer FROM TO
ALPHA on account, a package-less row on contact plural read (loadMany) one entry, the row saved last both
same registry after the boot pass contact key alone (package-less saved last) / ALPHA's account key alone (saved first) ALPHA's account key and the contact key
same, package-less row saved last read naming ALPHA after the pass the contact row ALPHA's account item, as without the pass
BETA on contact alone, a package-less row on account plural read; registry after the pass one entry; one key both; BETA's contact key and the account key
same, package-less row saved last read naming BETA after the pass the account row BETA's contact item, as without the pass
ALPHA on account + a package-less object-less row; ALPHA object-less + a package-less row on account plural read; registry; reads after the pass one entry, one key both, both keys, reads equal to the reads without the pass

The narrowing: after the boot pass, a read naming a package no longer reaches a package-less row bound to an object that package never bound the name to.

Re-measured with #33299 merged: the fold no longer loosens a read naming BETA into ALPHA's item. In the mirror shape, a read naming BETA (which holds nothing) answers the environment's contact row before and after the pass, in both version orders, and never ALPHA's.

Not changed: a name one package holds on one object beside a package-less row on that object (one entry, the row saved last); a name two packages hold (the package-less row keeps its own entry and pluralEntryCustomizedHolders names the holder it customizes, as in #33318); the /actions/OBJECT/NAME route's declaration read (see Acceptance notes); types other than action; stored rows (no migration).

Tests

  • packages/metadata/src/loaders/database-loader.test.ts, describe [#32681] the plural read counts an action name's holders per (object, name)… (11 cases): four moved shapes in both version orders (plural read entries, cold reads, registry keys after the real MetadataPlugin boot pass, reads after the pass equal to the cold reads, no row updated); CONTROL one package on one object (both orders: one entry, the one saved last, its key); CONTROL two packages on one object (three entries, bare read refused naming both and the environment, before and after the pass).
  • Moved pin: [#32791] "a package's row bound to an object and an object-less package-less row saved last" — the registry now holds ALPHA's account key beside the bare slot (FROM the bare slot alone); the bare reads are unchanged.
  • packages/runtime/src/action-package-addressing.test.ts, describe [#32681] /actions/account/approve… (4 new cases): mirror shape and single-holder cell, both save orders, over the writable sys_metadata double, the real MetadataPlugin boot pass and the protocol: registry holds both keys, a read naming the holder answers its own item, and the package-less row's route judges that item and runs the environment's handler.

Acceptance notes

Verification

Local runs

At 8efae7e (after merging origin/main with #33193): @objectstack/metadata test 78/78 files, 2423 tests; @objectstack/objectql 595/595 files, 12978 tests; @objectstack/runtime 599/599 files, 10707 passed, 19 skipped; metadata and runtime typecheck exit 0; eslint on the three changed source/test files exit 0; dogfood battery 466 passed / 3 skipped files, 3944 passed / 42 skipped tests. Derived gates: 59 families, 58 exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, no full dist in the worktree; CI Build Core covers it); dispatch-gates --ran reconciles 59/59.

Ablation (the customizes line mutated back to the by-name fold through ablation-replace.mjs, metadata rebuilt, ablation-dist-preflight --absent exit 0): metadata 8 moved cases red, every CONTROL and the first half's 11 cases green; runtime 4 of 4 new cases red (registry held one key). Route answers in that state equal the fixed state's (holder's route: no declaration; package-less row's route: that item, environment's handler). Restored to the HEAD blob (git diff HEAD empty), metadata rebuilt, fix marker back in dist/.


Generated by Claude Code

…e), so a package-less row bound to an object no package binds is the environment's own

The unscoped plural read (pluralEntryKeys) folded a package-less action row
into the name's one holder whatever object it was bound to: beside ALPHA's
approve on account, a package-less approve on contact was folded into ALPHA's
entry, the unscoped read kept one of the two rows, and the boot priming pass
registered that one alone. It is the holder's customization only when the
holder binds the name to the row's own object (ADR-0150 D4).

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
… cold read against boot pass, with one-object CONTROLs

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

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 16 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 b4624440e699395d1929b1eb7d77f60b4185c481 → packageMentionDocs.

Which tree this was computed on

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

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

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

… shape and the single-holder cell, and the package-less row's route judging and running that one item

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
…olds ALPHA's account key beside the bare slot

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

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
Request: review · area E2 · head 8efae7e

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head 8efae7e · 2026-11-14T21:38:47Z

review#3.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8efae7e95f6ed203f7efbe8b4790f66370516312
Local-runs: none
Scope: packages/metadata/src/package-slotted-store.ts, .changeset/32681-plural-read-object-holders.md + packages/metadata/src/loaders/database-loader.ts (unscopedTypeRows, loadManySlots), packages/metadata/src/metadata-manager.ts (loadMany, loadManyKeyed)

① Derived judgments

  • The key now agrees with the store's write rule. For an owner-keyed type (action), pluralEntryKeys folds a package-less entry into the name's one holder only when that holder holds the entry's own (object, name) identity (entryIdentitiesOf: the row's member, column first, then the body's object, the same ownedIdentityOf / storedRowMemberOf reading that pluralEntryCustomizedHolders already used). On any other object the entry keeps [null, name]. So the unscoped read, and the boot pass that registers what it keeps, hold both rows, and a read naming the holder answers its own item with or without the pass. That is the card's business rule: "a boot must not change which body a package-named read answers".
  • Unchanged paths, which I checked. For a non-owner-keyed type, identities is undefined and customizes reduces to the old sole !== undefined. An entry carrying a package keeps packageOf[at]. A package-less row on the holder's own object still folds, so it is that holder's customization (D4). A name held by two or more packages has sole === undefined as before, and pluralEntryCustomizedHolders still answers by identity. Its only input change is the shared helper (a pure extraction, line-identical logic). The one case where its keys[at] === [null, name] guard meets a single-holder identity is still the multi-holder name, as before.
  • Callers. DatabaseLoader.unscopedTypeRows and loadManySlots pass stored rows, which carry member. MetadataManager.loadMany and loadManyKeyed pass bodies, so the identity falls back to the body's object. Both readings are the store's.
  • Declaration. Clause-②: yes (narrowing) with the widening in prose (the registry and the plural reads now hold both entries), !, BREAKING with the remedy (read with no package, or save the row as the package's), one adr-0087: not-required (no-migration-prescription) marker, and the FROM→TO list. Correct. patch and Metadata-protocol: none hold, because no authorable key moves.
  • Fixes #32681 is accepted. The card's acceptance check (ALPHA account, BETA contact, package-less account; a read naming ALPHA after the pass equals the read without it) and its business rule are met and pinned. The PR measured the holder's /actions/OBJECT/NAME "no declaration" as identical before and after, so this PR never moved it. Its cause is in metadata-protocol's execution face, which is E1's lease. It is now metadata-protocol: an action's route beside a package-less row on another object judges no declaration for the holder's own object, and serves the row under the holder's envelope (ADR-0150 D4) #33456, which is open, so closing metadata: a package-less object-bound action row is keyed per (package, name) by DatabaseLoader but per (object, name) by the registry, so the boot pass files it as one holder's customization #32681 drops nothing.
  • nit N1: the docblock on DatabaseLoader.unscopedTypeRows (not in this diff) still says "a package-less row beside one package's is that holder's customization (D4)" without the new qualifier. Add "bound to that package's object (metadata: a package-less object-bound action row is keyed per (package, name) by DatabaseLoader but per (object, name) by the registry, so the boot pass files it as one holder's customization #32681)" in a later touch, so it matches pluralEntryKeys.

② Semver level

@objectstack/metadata: patch, fix(metadata)!, Clause-②: yes (narrowing), BREAKING, one ADR-0087 marker. This is correct for a non-protocol narrowing (AGENTS.md).

③ Boundary flags

④ Design judgment

The bug was two readers of one row disagreeing on its identity: the plural read keyed (package, name), while the store keyed (object, name). Making the plural read's fold test use the store's identity rule, through one helper shared with pluralEntryCustomizedHolders, closes it at the key and leaves no special case in the boot pass. The route-face finding is correctly split to E1 rather than patched across leases. The design holds.

Implemented-by: claude/area-E2-32681b
Reviewed-by: session_016WV4d1PGptBW3QRQNmy5e6

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
ACCEPT · area E2 (area-lease pilot #33059) · Fixes #32681 · head 8efae7e

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 21:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants