fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(metadata): key loader-held items by the row key, not by body.name - #14340

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key
Sep 2, 2026
Merged

fix(metadata): key loader-held items by the row key, not by body.name#14340
os-musk merged 2 commits into
mainfrom
claude/issue-14205-loader-item-identity-row-key

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14205

All readings below were taken on branch HEAD f5e55ff20, with a clean working tree.

The defect

MetadataManager.readListUncached() — and its no-catch sibling listForIndex(), which builds the endpoint index — merged each loader's answer into the result set keyed by body.name, admitting an item ONLY when the stored body carried a string name.

A metadata body is not required to name itself. register(type, name, data) takes the key as its ARGUMENT, and assertMetadataRegisterContract says so in as many words: "A document with NO name of its own is fine — the argument is the key". An aggregated defineView container is exactly that shape — no own name by design, its identity being the target object, carried in the row's name COLUMN — and DatabaseLoader.rowToData() returns the stored body without folding the column into it.

So a container written by register('view', OBJECT, container) lived in the registry for the life of the process and was written to sys_metadata, then disappeared at the next restart: cold registry, only the loader answering, and list('view') refused the row. listDiagnosed() reported that short answer as complete (degraded: false) because no loader had thrown.

The shape chosen, and why

The triage ruling offered two mechanical shapes — "the loader returns (name, body) pairs orrowToData exposes the key beside the body". This PR takes the first, additively:

  • MetadataLoader gains an optionalloadManyKeyed() returning (name, body) pairs, plus an exported MetadataKeyedItem type so an out-of-package implementor can name the return type.
  • DatabaseLoader implements it from the row's name column; MemoryLoader from its storage map key. Both share one query and one cache entry with loadMany(), so a keyed list() read does not re-hit the database.
  • MetadataManager.admitLoaderItems() prefers it and falls back to the previous body.name keying when a loader does not have it.

Why optional rather than a widened loadMany():MetadataLoader is exported from this package's public entry and has implementors outside it — packages/objectql's WritableFixtureLoader and FixtureLoader (measured, see below). Changing loadMany()'s return type breaks every one of them; an optional member breaks none.

rowToData() is untouched, and the key is carried BESIDE the body rather than folded into it. Nothing synthesises a name into a body that deliberately has none, so the register contract's refusal of a disagreeing data.name keeps meaning what it says. This is pinned directly (never synthesises a name into a body that deliberately has none).

Nothing consumers see today changes shape. For any item that went through register(), a data.name that exists is required to EQUAL the key, so the keyed merge produces the identical map entry; what is new is only the entries the old gate refused.

Per-site verdicts on the three typeof itemAny.name === 'string' sites

site on mainverdictevidence
readListUncached() (was line 1101)repaired5 red cases go green; ablation returns them to red
listForIndex() (was line 1283)repairedthe nameless api row now reaches buildEndpointIndex
MetadataManager.loadMany() (was line 2616)not this defect — untoucheda DEDUPE guard, not an admission gate: a nameless item falls past the if and results.push(item) runs anyway. Pinned by CONTROL: loadMany() answered with the nameless body before the repair too, which is GREEN on the pre-repair tree and green under ablation

A measured limit on the second site, stated because it is not what one would assume: the repair makes the nameless api row REACH the index, but it does not make the route answer. ApiEndpointSchema declares name REQUIRED, so the row is still excluded — at buildEndpointIndex's own separate, declared door, and now loudly, at error level with its consequence. Before, listForIndex() dropped it upstream and the author got silence. The test asserts exactly that transition rather than a served route.

Fixture triage

Three cases in metadata-manager-degraded-list-cache.test.ts and metadata-manager-list-diagnosed.test.ts spied MemoryLoader.loadMany as a loader-walk counter. The walk moved to loadManyKeyed, so the spy counted 0 — which reads exactly like a cache hit. Triage verdict: change the spelling, to a counter that is spelling-independent (loaderWalks() sums both plural reads). That repair is direction-independent: those three cases are green in BOTH directions, measured under the ablation below.

Reverse verification (ablation)

Mutation: force the loadManyKeyed branch off in admitLoaderItems, which restores the pre-repair admission gate verbatim (the fallback IS the old code). Run with an EXIT INT TERM restore trap, absolute paths from git rev-parse --show-toplevel.

No rebuild is involved and none is owed: every test imports ./metadata-manager.jsrelatively, so vitest transforms packages/metadata/src directly — nothing here resolves through a package exports entry into dist/.

  • mutation landed on disk, proved by marker counts and hash, not by an exit code: deleted-text 1 -> 0, injected-text 0 -> 1, git hash-object042f99b9… (HEAD blob) -> c72fccba…
  • predicted direction: the 5 defect cases red, every control green. Observed exactly that — 5 failed | 29 passed (34) across four test files
  • restore leg proved by observation: git checkout HEAD -- ABSOLUTE_PATH, then restored hash 042f99b9… equals the HEAD blob and git diff HEAD is empty; deleted-text back to 1, injected-text back to 0

Gates run locally, with their own verdict lines

Re-derived on the real change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; it read the change set from git itself — "9 path(s) vs merge base 909a441"). It named 30 families where the dispatch hint carried 21; the 9 additions all come from the .changeset/ file the hint's two-path derivation could not see, and every one of them was run.

All green (exit 0): check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:durability-log-level · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher · check:dispatcher-error-vocabulary · check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-comment-mask-adoption.mjs · check-cross-package-test-inputs.mjs · check-empty-changeset.mjs · check-keyed-text-bounds.mjs · check-plugin-teardown-shape.mjs · check-shard-attestation.mjs · check-undeclared-dep-imports.mjs · docs-audit/check-affected-docs.mjs · docs-audit/check-drift-comment.mjs · pm/release-rehearsal-clone.mjs --self-test

Three exited 3 = PREREQUISITE NOT MET, which each gate's own text says is NOT a red and NOT a finding. Recorded as NOT MEASURED locally, all three being CI-owned runs:

  • check-test-completeness.mjs — "grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED"
  • check-dual-build-cjs-loads — "this gate reads built output, and some package has no dist/ … 57 packages. This is NOT a pass: nothing was measured". Its self-test passed 93/93 first. The metadata package's own dual build is green (ESM + CJS + .d.ts + .d.cts, check-dts-emitted: 8/8)
  • pm/check-half-states.mjs — needs repo-scoped REST, which this container is refused (/rate_limit answers 200 with 15000 left while /repos/… answers 403). Independent confirmation of this seat's read-channel posture. pnpm check:pm-half-states exited 0

Other verdict lines:

  • pnpm --filter @objectstack/metadata testTest Files 41 passed (41) · Tests 655 passed (655)
  • pnpm --filter @objectstack/metadata buildDTS Build success · check-dts-emitted: @objectstack/metadata - 8/8 declared declaration file(s) present
  • Downstream consumer sweep (prefix filter = DEPENDENTS): turbo run build --filter='...@objectstack/metadata' over 44 packages in scopeTasks: 68 successful, 68 total. A first attempt with a bare pnpm --filter '...@objectstack/metadata' build failed on Cannot find module '@objectstack/driver-memory' — a dependency of a selected dependent that the prefix filter does not select, not a consequence of this change; turbo, which pulls ^build, is green
  • The out-of-package MetadataLoader implementors: pnpm --filter @objectstack/objectql exec vitest run over metadata-service-roundtrip-conformance.test.ts, metadata-service-getobject-equivalence.test.ts, plugin.integration.test.tsTests 136 passed (136). None declares loadManyKeyed, so all three exercise the fallback path unchanged
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) → exit 0
  • Control-byte self-scan over every touched file (grep -naP for the C0 set plus DEL) → no hits

Type checking: this package declares no typecheck script, so turbo run typecheck never selects it. tsc --noEmit -p packages/metadata/tsconfig.json --listFiles reports 0 errors in all 8 changed files, each confirmed present in the program by --listFiles; its other 89 errors are pre-existing, in untouched test files, under a config this repo does not run. The live type gate for the published surface is the DTS build above, which is green.

Clause ② — re-declared UPWARD from the diff

The dispatch declared no. Reading the diff, it is yes, additively: the published @objectstack/metadata entry gains a named type (MetadataKeyedItem) and MetadataLoader gains an optional member (loadManyKeyed?). Both are verified present in the emitted dist/index.d.ts.

No accept/reject behaviour narrows anywhere: the member is optional, so no existing implementor of the published interface has to change, and all 44 dependents plus the two out-of-package implementors are green above.

Scope

Deliberately untouched, each held serial behind this card: packages/metadata/src/plugin.ts (that is #13912's surface, out of scope here) and database-loader.ts's rowToRecord / recordedAt / createdAt / updatedAt casts (out of scope: #14037). FilesystemLoader and RemoteLoader deliberately do not implement loadManyKeyed and keep the fallback — see the report on the card for the measured reason for each.

Same-day churn accounted for: #13913 landed as PR #14206 on 2026-09-01, and its metadata-manager-views-by-object-container.test.ts is an oracle consumer of this change. Work was done on the merged tree and that file passes here, unmodified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…#14205)
`readListUncached()` and its no-catch sibling `listForIndex()` merged each
loader's answer keyed by `body.name`, admitting an item ONLY when the stored
body carried a string `name`. A metadata body is not required to name itself:
`register(type, name, data)` takes the key as its ARGUMENT, and
`assertMetadataRegisterContract` explicitly allows a document with no `name` of
its own. An aggregated `defineView` container is that shape by design — its
identity is the target object, carried in the row's `name` COLUMN — so a
container written by `register('view', OBJECT, container)` vanished from
`list('view')` at the next restart, with `listDiagnosed()` calling the short
answer complete.
A loader-held item's identity is the key its store holds it under, so the
manager asks the loader for that key instead of guessing it from the body:
`MetadataLoader` gains an OPTIONAL `loadManyKeyed()` returning (name, body)
pairs, implemented by `DatabaseLoader` (row `name` column) and `MemoryLoader`
(storage map key). The key travels beside the body and is never folded into it,
so nothing synthesises a `name` and the register contract's `data.name` check
is untouched. Loaders without the method keep the previous keying verbatim.
`MetadataManager.loadMany()` is deliberately unchanged: its `body.name` test is
a de-duplication guard, not an admission gate, so it never had this defect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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), MetadataManager (symbol, a top-level class))
  • content/docs/releases/v17.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
  • 1 changed file(s) yielded no anchor (packages/metadata/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 12 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 72adb7fcb6b6f48b656047835ed3852f314d9de1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8a19d3276837b3de70aca58a646ab578e9b0764 — the merge of head 3cb0b0e257bf74b134756ffff96f097cfeed9c9e into base 72adb7fcb6b6f48b656047835ed3852f314d9de1, 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 e8a19d3276837b3de70aca58a646ab578e9b0764 && git checkout e8a19d3276837b3de70aca58a646ab578e9b0764
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72adb7fcb6b6f48b656047835ed3852f314d9de1 3cb0b0e257bf74b134756ffff96f097cfeed9c9e && git checkout -B drift-repro 72adb7fcb6b6f48b656047835ed3852f314d9de1 && git merge --no-ff 3cb0b0e257bf74b134756ffff96f097cfeed9c9e
node scripts/docs-audit/affected-docs.mjs --json 72adb7fcb6b6f48b656047835ed3852f314d9de1

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

…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 2, 2026 03:16
@os-musk
os-musk enabled auto-merge September 2, 2026 03:17
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on 3cb0b0e2 (issue comment 5503623325 on #14205, under the 2026-08-31 in-seat review ruling); every check run on this head completed success/skipped at 03:15Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude