Skip to content

fix(metadata)!: a package-less object-bound action row is keyed per (object, name) on the stored side, so a package-less save never lands in another object's holder - #33318

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

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Part of #32681

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

维护者速读

  • 改了什么:DatabaseLoader 存一条不属于任何包、绑在某对象上的 action 行时,按 (对象, 名字) 数持有者(和内存注册表一致),不再按名字数所有包;它按槽读行时,把"名字被两个包持有、只有一个包绑了同一对象"的包外行当作那个包的定制,排在该包自己的行前面交出。
  • 为什么改:席位裁决 A(2026-11-14T16:12Z):包外行按 (对象, 名字) 归属。之前 ALPHA 绑 account、BETA 绑 contact 时,包外的 account 存档写成包外行(而注册表把它当 ALPHA 的定制);只有 BETA 绑 contact 时,包外的 account 存档被写成 BETA 的 account 绑定(BETA 从没声明过);启动预载前后点名 ALPHA 的读答案不同。
  • 风险与回滚:只影响接了 DatabaseLoader 的宿主(工作区内 0 处);库里已有的行不改不迁移,只改新写入落在哪一行、以及冷读怎么归属;回滚 = revert 本 PR。
  • 先看:packages/metadata/src/loaders/database-loader.ts 的 slottedSaveTarget(风险 hunk:按 storeRowSlotKey 分槽、按包桶命名未命名行)与 loadManySlots;packages/metadata/src/package-slotted-store.ts 的 storeRowWriteKey 与 pluralEntryCustomizedHolders。
  • 两分钟验证:pnpm --filter @objectstack/metadata exec vitest run src/loaders/database-loader.test.ts -t 32681,预期新增用例全绿。
  • 需要你做的:无,席位落地。镜像形状(ALPHA 绑 account、包外行绑 contact)的启动预载修复依赖 fix(metadata)!: MetadataManager.loadMany takes a by-package name's bodies and packages off the loader's keyed entries; loadManyKeyed entries carry their package #33299 先合入,见 Acceptance notes。

What changes

Seat ruling A on #32681: a package-less action row bound to an object is keyed per (object, name) everywhere, as ADR-0150 D4 keys an action by (package, objectName, name).

  • Save (DatabaseLoader.save through slottedSaveTarget): rows are keyed as the in-memory store keys items (storeRowSlotKey), and storeRowWriteKey counts the holders of the written body's identity (slotIdentityOf: an object-bound action's object and name), as storeWriteKey already does in memory. A package binding the name to another object is a different key, never a holder of this one. The unique-key bucket a pending object-naming acts on is still the package's (every row of the package the write's slots belong to).
  • Slot read (DatabaseLoader.loadManySlots, and loadSlots, which filters it): a package-less action row bound to an object that exactly one package binds the name to, under a name two or more packages hold, is handed on held for that package, just before that package's own row of the object (pluralEntryCustomizedHolders). That is where the boot priming pass files it: the unscoped plural read keeps it apart from both holders, and the pass registers it after them, so the store's (object, name) write rule puts it in that package's slot. Readers that keep a slot's first item (slotStoreOf, list) now answer it without the pass too. The row is not rebound: no write, its package_id stays empty.

Every moved answer, FROM to TO (host wiring a DatabaseLoader; ALPHA and BETA are two packages, approve the action name):

Shape Answer FROM TO
ALPHA approve on account, BETA's on contact; a package-less save of approve on account the row written a new package-less row bound to account ALPHA's account row (its customization, D4)
BETA's approve on contact alone; a package-less save of approve on account the row written a new row with package_id BETA bound to account (a binding BETA never declared) a new package-less row bound to account
ALPHA's approve on account alone; a package-less save of an object-less approve the row written a new row with package_id ALPHA, object-less a new package-less object-less row
BETA's approve on contact and a package-less row on account; ALPHA's first save of approve on account the row written a new ALPHA row beside the package-less one the package-less row, updated and bound to ALPHA (a package's first item takes over the bare slot, as in memory)
ALPHA's row on account, BETA's on contact, a stored package-less row on account (any version order) read naming ALPHA without the boot pass (MetadataManager.get) ALPHA's own row the package-less row, as after the pass
same list('action') without the pass ALPHA's row and the package-less row the package-less row (ALPHA's slot), as after the pass
same bare read, read naming BETA, every read after the pass 409 naming ALPHA and BETA; BETA's row; as listed unchanged
a package-less row on o1, then a package's save on o2 the row written the package's save takes the o1 row over the package's own new row; the o1 row keeps its column (moved #32540 pin)
ALPHA on account, BETA on contact, package-less save (A-B-save / B-save-A) /actions/account/approve declaration ALPHA's own body while ALPHA's slot holds the customization / refused ambiguous-in-environment (and contact: no declaration) the item ALPHA's slot holds, ALPHA's handler run, in both orders

The second row is the narrowing: an address naming BETA no longer reaches an account binding a package-less save wrote into BETA's bucket.

Not changed: types other than action (their identity is the name, and the slot read's new rule never fires for them); a name one package holds; stored rows (no migration: a stored package-less row is read as its holder's customization, never rebound; rows earlier saves wrote are listed by the ADR-0150 D10 report as before).

Tests

  • packages/metadata/src/loaders/database-loader.test.ts describe [#32681] (11 cases); the moved metadata: one package's action name bound to two objects keeps ONE DatabaseLoader row, so the later save overwrites the earlier binding (ADR-0150 D3/D4) #32540 pin in metadata-manager-match-endpoint.test.ts; packages/runtime/src/action-package-addressing.test.ts describe [#32681] (3 cases: real ObjectQL registry, protocol over a MetadataManager from the real MetadataPlugin boot pass, DatabaseLoader over a writable sys_metadata double routed through the engine dispatch asserts).
  • BEFORE (observed with the fix reverted): A-B-save judged ALPHA's own body while ALPHA's slot read the customization; B-save-A refused the account route ambiguous-in-environment and found no contact declaration; the single-holder save stored a [BETA, account] row.

Acceptance notes

Verification

Local runs

At c7e35e3: @objectstack/metadata test 77/77 files, 2325 tests; @objectstack/objectql 595/595 files, 12942 tests; @objectstack/runtime 599/599 files, 10678 passed, 19 skipped; metadata + runtime typecheck exit 0; dogfood battery 464 passed / 3 skipped files, 3927 passed / 42 skipped tests, no failures. Derived gates: 70 families, 69 exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, no dist in the worktree; CI Build Core covers it). Ablation (stored-side files reverted to the merge base, landing proven by grep): metadata [#32681] 10 failed / 1 CONTROL green, the moved o1 pin red; runtime (metadata dist rebuilt, preflight --absent exit 0) 3 of 3 red. Restored to the HEAD blobs, tree clean.

After merging origin/main (with #33299) at 123588d, clean merge: @objectstack/metadata test 78/78 files, 2412 tests; typecheck exit 0.


Generated by Claude Code

… (object, name) on the stored side

DatabaseLoader's save keys rows as the in-memory store keys items
(storeRowSlotKey), so storeRowWriteKey counts the holders of the
written action's (object, name), never every package holding the name.
Its slot read hands a package-less row bound to an object exactly one
package binds the name to (under a name two packages hold) on as that
package's customization, first in its slot, as the boot pass files it.

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

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata, touching 8 documentable anchor(s).

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

  • content/docs/protocol/kernel/metadata-service.mdx (via DatabaseLoader (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via DatabaseLoader (symbol, a top-level class))
  • content/docs/releases/v18/18-4.mdx (via DatabaseLoader (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
  • 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 e0ce1b523c9cdb625211fc866593f57aee9223ee → packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added the tests label Nov 14, 2026
…lumn as stored

Keyed per (object, name), the o1 row is another slot in a bucket the o2
save does not write; a package-less object-less save names it first.

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
…ver the stored rows, in both save orders

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
…age-less action row

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

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head df6d261 · 2026-11-14T16:56:44Z

review#3.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface and removed size/m labels Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: df6d261521d40e4dc588ee9ec1befef8d605e2d8
Local-runs: none
Scope: packages/metadata/src/loaders/database-loader.ts, packages/metadata/src/package-slotted-store.ts + packages/metadata/src/metadata-manager-match-endpoint.test.ts, packages/metadata/src/loaders/database-loader.test.ts, packages/runtime/src/action-package-addressing.test.ts, docs/adr/0150 (D4)

① Derived judgments

  • Write side. slottedSaveTarget now keys each stored row by storeRowSlotKey(type, name, package, member), and storeRowWriteKey passes slotIdentityOf(type, name, data). An action bound to an object therefore counts the holders of its (object, name), as the in-memory storeWriteKey does, and never every package holding the name. For every other type (and object-less actions) ownedIdentityOf returns the bare name, so storeRowSlotKey reduces to storeRowKey and nothing moves. The unique-key bucket for unnamed stays per package (storeRowKey(name, slotPackageOf(slot))), which matches sys_metadata's (type, name, package_id, bundle_member) key, so a write can never collide with another member's row.
  • Read side. loadManySlots hands a package-less action row on, held for the one package binding the name to that row's object, only when all of these hold: the type is owner-keyed; the unscoped plural read kept the row under its own [null, name] key; it is the row the unscoped read keeps for that key (highest version); and exactly one package binds the (object, name). The row is placed just before that package's own row of the object, so slot-first readers answer it, matching the store after the boot pass. Nothing is written back. I checked the one place this could drop a row: the holder is storedItemPackageId(data) when the row carries no package, but readTypeRows already sets packageId via storedRowPackageId, which falls back to the body. The holder row's slot key therefore matches the customization's key, and the customization is always consumed.
  • Red lines (ADR-0150). One holder of the (object, name) keeps its behaviour (CONTROL pinned). Two holders of the same (object, name) still go to a package-less row (CONTROL pinned). Resolution is not loosened: the read side only aligns the no-pass read with what the pass already answers. The one outcome that moves for a single name-holder (BETA on contact alone; a package-less account save is now the environment's row, not BETA's) is the declared narrowing, and it removes a binding BETA never declared.
  • Changed pin. metadata-manager-match-endpoint.test.ts moves from "row named o1 by the o2 save" to "left as stored". That is the right consequence of the per-(object,name) key: the o2 save no longer writes the package-less bucket. The follow-on object-less save names it first. Pinned.
  • Coverage: six loader cases (each with its BEFORE), the no-pass and after-pass read agreement in both orders, a manager-register-plus-reboot round trip, and the runtime route judging and running one item over the stored rows in both save orders.

② Semver level

@objectstack/metadata: patch with Clause-②: yes (narrowing), a BREAKING banner and the adr-0087: not-required (no-migration-prescription) marker: correct for a non-protocol narrowing (AGENTS.md). No stored row is rewritten. Metadata-protocol: none holds, since nothing an author writes is refused.

③ Boundary flags

④ Design judgment

Seat ruling A on #32681 (ADR-0150 D4: a customization is keyed per (object, name)) requires the stored side to count holders exactly as the in-memory store does. Before this change the two disagreed, so the item in memory and the row after a reboot could name different holders. Reusing slotIdentityOf / storeRowSlotKey, instead of a second rule, keeps one definition of identity across both stores, and the reboot round-trip pin measures the agreement directly. Making the no-pass read hand the customization to its holder, without rebinding the row, is the minimal way to make reads independent of whether the pass ran, and it leaves migration to the D10 report. The design holds.

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

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Review carry — PASS stands

Head: 123588d87515bcb6c762cec4d1a0357d4d6d604f · reviewer: review#3 (session_016WV4d1PGptBW3QRQNmy5e6)

From my PASS at df6d261521d40e4dc588ee9ec1befef8d605e2d8 (6881085816):

The PASS carries to this head. The ACCEPT cites this note as Regen-provenance and re-reads every check at the landing head. The mirror and single-holder boot fold stays a follow-up on #32681 (Part of).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
ACCEPT · area E2 (area-lease pilot #33059) · Part of #32681 · head 123588d

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 18:45
@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/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants