Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-E2-32680
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-E2-32680

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #32680

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill teaches how MetadataManager's plural reads stamp a loader's bodies or what loadManyKeyed entries carry; the moved answers live in the metadata service's internal read path.

维护者速读

What changes

MetadataManager.loadMany, for an unbundled type stored per (package, name) (PACKAGE_SLOTTED_STORE_TYPES) on a loader that holds items per package (loadManySlots: DatabaseLoader, the artifact door's MemoryLoader), now hands on each keyed entry's own body, stamped (_packageId) with the package the loader holds that entry for when the body carries the entry's key as its name (keyedEntryBodies). It no longer pairs loader.loadMany's bodies to the keyed entries by their JSON.stringify (the former withLoaderEntryPackages). Bodies the keyed read cannot name (a row whose name column is empty) are still handed on after them, unstamped: they are loadMany's bodies less one per keyed entry whose body is the same value (key order aside), so the comparison decides only what is kept, never a package, and it runs only when loadMany answers more bodies than the keyed read.

MetadataManager.loadManyKeyed entries carry packageId where the loader's entry carries one (keyedEntryOf), for the slotted path and the bundled one (email_template).

Every moved answer, FROM to TO (the changeset carries the same list):

Shape (host wiring a DatabaseLoader) Read FROM TO
Name two packages hold, beside another row whose body says the name (F2) MetadataManager.loadMany one body, unstamped both holders stamped with their own row's package, the other row's body unstamped
same boot pass registry the one body in the bare slot each package's slot, the other row's body in the bare slot
same bare read after the pass that body 409 RESOURCE_CONFLICT naming both packages (for action also platform, see notes); the same as without the pass
Name one package holds, beside another row whose body says the name loadMany / boot pass unstamped, filed in the bare slot stamped, filed in the holder's slot
same read naming a package that holds no row, after the pass the holder's body nothing, as without the pass
Loader whose two reads serialise a body differently (F3) loadMany both holders unstamped both stamped
Any type stored per (package, name) MetadataManager.loadManyKeyed { name, data } { name, data, packageId } where the loader holds it for a package

Not changed: a name one package holds with no other row's body saying it (CONTROL pins), a name no package holds, and a loader without loadManySlots. The number of entries both plural reads hand on is unchanged for every shape but F2, where the other row's body is now kept beside both holders.

Tests

New top-level describe [#32680] in packages/metadata/src/loaders/database-loader.test.ts (53 cases): F2 per boot-pass type with the other row stored first and last (bare read 409 naming both after the pass; reads naming ALPHA, BETA, GAMMA equal the cold read); F3 with a key-reordering loader, plus a row whose name column is empty kept once; F4 per slotted type and email_template; one holder beside another row's body (every read equals the cold read); CONTROL one holder per type. The #32458 pin "two holders of a name and another row's body that says the same name" moves FROM [[null, ALPHA]] TO [[ALPHA, ALPHA], [BETA, BETA], [null, ALPHA]], its title rewritten to say so.

BEFORE, on main d4fbb4db with the same fixtures: 43 of the 53 new cases red, the 10 CONTROLs green. The F2 bare read after the pass answered com.alpha.app with registry keys ['helper'] for every type.

Downstream readers (MetadataManager.loadMany / loadManyKeyed callers), consumer direction: objectql action-governance, metadata-sync-flow-two-holders, plugin-governance-scoped-metadata, webhook-stored-row-boot-hydration (59 passed); runtime action-activation-unpackaged, actions-addressed-route, mcp-package-object-discovery, action-governance-scope-divergence (218 passed).

Ablations (each through scripts/ablation-replace.mjs, mutation landed on disk and restored to the HEAD blob with git diff HEAD empty; the suite reads src/ by relative import, so no build leg):

  1. the keyed read swapped back for loader.loadMany (no stamp at all): 42 red (the CONTROLs among them, since this drops every stamp), the 12 F4 cases green;
  2. keyedEntryOf without packageId: the 12 F4 cases red, nothing else;
  3. the remainder compared by JSON.stringify instead of by value: the F3 case red (the empty-name row handed on twice);
  4. the remainder dropped: F3 and the two metadata: the boot priming pass hands a column-bound slotted row to register unstamped, so with two holders of a name the DatabaseLoader save inserts a package-less row (unbundled slotted types) #32458 pins that keep an empty-name row red.

Acceptance notes

Verification

metadata suite and typecheck on fad580c
pnpm --filter @objectstack/metadata test && pnpm --filter @objectstack/metadata typecheck
 Test Files  77 passed (77)
      Tests  2367 passed (2367)
os-verify-lock: VERDICT command-exit 0
Targeted gates run locally
EXIT=0 node scripts/check-adr-0087-registration.mjs --base origin/main
EXIT=0 node scripts/check-changeset-no-major.mjs --base origin/main
EXIT=0 node scripts/check-empty-changeset.mjs --base origin/main
EXIT=0 pnpm check:nul-bytes
EXIT=0 pnpm check:durability-log-level
EXIT=0 pnpm check:doc-authoring
EXIT=0 pnpm check:issue-citations
EXIT=0 pnpm check:slot-lookup
EXIT=0 pnpm check:test-source-alias
EXIT=0 pnpm check:cross-package-test-inputs
EXIT=0 node scripts/check-closing-keyword-parity.mjs

Generated by Claude Code

@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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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 MetadataManager (symbol, a top-level class))
  • content/docs/kernel/cluster.mdx (via MetadataManager (symbol, a top-level class))
  • content/docs/kernel/contracts/metadata-service.mdx (via loadMany (symbol, a method of class MetadataManager))
  • content/docs/plugins/adding-a-metadata-type.mdx (via MetadataManager (symbol, a top-level class))
  • content/docs/protocol/kernel/metadata-service.mdx (via MetadataManager (symbol, a top-level class), loadMany (symbol, a method of class MetadataManager))

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

  • content/docs/releases/implementation-status.mdx (via MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via MetadataManager (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 9f736f2e2469d159422fb2ce1456bd932278ffff → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9f736f2e2469d159422fb2ce1456bd932278ffff

⚠️ 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 9f736f2e2469d159422fb2ce1456bd932278ffff → 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:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
Request: review · area E2 · head fad580c

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head fad580c · 2026-11-14T16:11:23Z

review#3 (area-lease pilot #33059, area E2).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fad580c029a6fa7cdc88b22ad12f883a60ba29bd
Local-runs: none
Scope: packages/metadata/src/metadata-manager.ts, packages/metadata/src/loaders/loader-interface.ts, packages/metadata/src/loaders/database-loader.test.ts + none

① Derived judgments

  • keyedEntryBodies replaces content pairing (withLoaderEntryPackages, JSON.stringify matching) with each keyed entry's own body. A body is stamped with its entry's package only when the body carries the entry's key as name, else handed on unstamped (metadata: readListUncached() drops every loader-held item whose stored body has no top-level name — an aggregated view container is invisible to list() after a restart #14205: key and body name are two identities). The leftover comparison (sameBody, key-order-insensitive) runs only when loadMany answers more bodies than the keyed read. It decides only which unkeyed bodies are kept, never a package. Matches the stated rule; no package is ever inferred from content.
  • The path applies only to loaders with both loadManyKeyed and loadManySlots. Others keep loadMany as before (and the old helper returned items unchanged for them too).
  • F2's narrowing (bare read after the boot pass: one body → 409 naming both packages) is the red-line answer: ambiguity reported, never guessed. Declared and pinned with the other row stored first and last.
  • loadManyKeyed entries now carry packageId (keyedEntryOf), for the slotted and bundled paths, as an optional member already declared on MetadataKeyedItem. Additive for callers.
  • nit N1: the order handed on changed from loadMany's row order to keyed entries first, then unkeyed bodies. If a consumer registers package-less bodies into one bare slot with last-wins semantics, an unkeyed body (a row with an empty name column) whose body names X now always lands after the environment's own row X. Before, row order decided. Legacy-only shape; say so in the changeset or keep loadMany order for the unkeyed tail relative to package-less entries. Not blocking.

② Semver level

@objectstack/metadata patch with ! and Clause-②: yes (narrowing) (F2's bare read becomes a 409). packageId on keyed entries is additive on an already-declared optional member. Metadata-protocol: none, Skills-docs: none — REASON hold.

③ Boundary flags

④ Design judgment

Stamping by content equality was a guess that failed silently in two measured shapes (F2, F3). Reading each keyed entry's own body and package removes the guess, and the one moved user-visible answer is the refusal the epic's red line asks for. Holds against the cited code and the card.

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

VERDICT: PASS

N1 optional.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

  • Review: review#3 PASS at fad580c (record 6880439033). N1 is optional text and is not pushed here, so CI and the PASS head stay as they are; it is carried as a follow-up.
  • Governed: check-governed-merges --pr 33299 NOT governed (0 paths hit the register); +261/-81 changed lines (≤ 3000).
  • Checks: required checks still running at ACCEPT; auto-merge and the merge queue (dogfood regression gate included) land it only once they pass. The dogfood battery was not run locally; the queue's dogfood gate carries it.
  • Main: no conflict; not merged per pilot rule.
  • Landing: maintainer's instruction in the area thread (15:21Z) — PRs in this area that passed review go ready + auto-merge.

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