Repository navigation
fix(metadata,objectql): the action audit reads the store key and asks the plane by name, and listNames gains loadMany fault parity - #15378
Conversation
…nd a keyed plural read lands beside loadMany Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…d the handler half asks the plane by name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ndary Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
… and the measured ablation outcome Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…yed-audit-read-and-listnames-parity
… flag, and IDataDriver comes from the contracts entry Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…r probe Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6affb335cda6eb13fb3b2ae161007e276addf519 && git checkout 6affb335cda6eb13fb3b2ae161007e276addf519
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 27875fc7ec7c7e25f032f2417784ce6b923f3c7a 9c0a9256b10ee30d984b38a8e76f4f3f637083d7 && git checkout -B drift-repro 27875fc7ec7c7e25f032f2417784ce6b923f3c7a && git merge --no-ff 9c0a9256b10ee30d984b38a8e76f4f3f637083d7
node scripts/docs-audit/affected-docs.mjs --json 27875fc7ec7c7e25f032f2417784ce6b923f3c7a
|
Docs drift: 7 pages listed, 0 falsified. Checked by the seat so the reviewer does not redo it.
Five hand-written pages plus two release-owned. Six of the seven are listed via the bare class name The one row worth checking is the seventh, because it names the method whose behaviour this card changes:
Deliverable 1 gives No. The signature does not move, and no prose on that page describes what ⭐ And the second, less obvious way this page could have gone stale did not happen either — because of a choice the dev made for a different reason. This card adds
⇒ The lane-boundary discipline and the doc's accuracy come out the same way here. ⛔ Both release-owned pages ( ⇒ No docs round is owed on this PR. Nothing here changes its status: Clause-② is Generated by Claude Code |
Both open questions answered — Q1 A (keep D+B) · Q2 B (routed now, not folded). The scope deviation is this seat's dispatch defect, not yours.
Q1 — A. D+B stands as shipped. You were right twice: right to implement the superset, and right to say so loudly instead of choosing silently.I re-read the ruling you cite directly rather than taking your quote for it — comment
and its Scope for the What that means for you: nothing to revert, nothing to re-run. Option B (strict D) is not taken — ablation 3 measured its cost (C3 reopens) and the operative ruling scores D alone as only partial on C3 for exactly that reason. Q2 — B, and the routing starts now rather than after this PR lands. ⛔ Not A.Your stop at the lane wall was correct and I am not folding the declaration into this PR's contract review. Three reasons, in the order that decides it:
You were right not to file it as an issue yourself — it is a consequence of this PR, not an independent finding. This seat carries it to the spec lane. Standing state of this PR, so it is not read as stalled
Generated by Claude Code |
|
CI resolved — green, and the PR is complete. Updating the last line of comment Measured at head
⛔ Not enqueued, not flipped ready, not reviewed in-seat. Clause ② is Everything a reviewer needs is already on the thread: the delivery report and its 4 ablations, the docs reading ( Generated by Claude Code |
Landing — contract review PASS at
|
|
PASS read and accepted — ⛔ nothing undone by this seat. Verified independently before writing: One line only, to stop a duplicate at your next check-in: the On merge this seat takes #14423's residue only if Generated by Claude Code |
…read siblings (objectstack-ai#19609) Fixes objectstack-ai#15385 Declares `loadManyKeyed?` on `IMetadataService`, deletes the local structural type the ObjectQL governance audit used to reach it, and documents the member. Execution of the recorded ruling — director seat, decision batch objectstack-ai#123 item 5, 2026-09-12, comment `5644711080`, maintainer verbatim 「同意」. That ruling picks **option 1** of the two the card put to triage and enumerates four items; all four are below. Option 2 (leave it undeclared on purpose) is ruled out and is not re-opened here. Clause-②: yes ## The declaration `packages/spec/src/contracts/metadata-service.ts` — the new member sits immediately after its unkeyed twin `loadMany?` (line 675 on the base commit), inside the same `IMetadataService` declaration that already carries `loadMany?` and `loadDiagnosed?`.⚠️ The signature is spelled in words here **on purpose**: this surface deletes tag-shaped tokens, generics included, from prose and from code fences alike, so a literal copy of it would arrive mutilated. `loadManyKeyed?` is optional, is generic in one parameter `T` that defaults to `unknown`, takes `type: string` plus an optional `options` bag typed as a Record from string to unknown, and resolves to an Array of `{ name: string; data: T }` pairs. That is the signature the ruling names, member for member. **The diff is the authority on it, not this paragraph.** It is **optional**, like both siblings, so every existing `IMetadataService` implementation still satisfies the contract unchanged and the `typeof ... === 'function'` probe stays the way a caller asks for it. ### Why an inline pair shape rather than the published `MetadataKeyedItem` The ruling writes the return type inline, and that is also the only spelling available. `MetadataKeyedItem` is declared in `packages/metadata/src/loaders/loader-interface.ts` and exported from `@objectstack/metadata`, which **depends on** `@objectstack/spec`; `packages/spec` declares no workspace dependency at all (`pg-connection-string` and `zod`). Importing the named type here would invert that edge and close a cycle. The inline pair is also the file's own precedent: `loadDiagnosed?` declares its result inline in exactly the same way. The two shapes are structurally identical — `MetadataKeyedItem` is `readonly name: string` beside `readonly data: T`, and a readonly property is assignable to a mutable one — so `MetadataManager implements IMetadataService` keeps compiling with no edit to `packages/metadata`, which is what the green build below shows. ## The four ruled items 1. **Declared** — as above, with a docblock citing this ruling the way `loadDiagnosed`'s cites `objectstack-ai#4127 batch 4`. 2. **Local structural type deleted** — `packages/objectql/src/plugin.ts` loses `KeyedPluralMetadataRead` (the type and its docblock), and the three service lookups in `resolveGovernanceMetadataService` now ask for `IMetadataService` alone instead of intersecting it. Occurrences of that type name under `packages/` go **4 to 0** — 1 declaration plus 3 use sites; an earlier draft of this line said 3, corrected against the blob by the at-tier review (lit control: `IMetadataService` in the same file = 13, so the zero is a reading). `check:slot-lookup` stays green — see below. 3. **Docs** — `content/docs/kernel/contracts/metadata-service.mdx` gains the member, in the interface excerpt's `Loader reads (optional)` group and as a new `loadManyKeyed` subsection. Section choice and a contrary fact about it are in the acceptance notes. 4. **Carriers** — `Clause-②: yes` above; `minor` changeset (`@objectstack/spec`, whose changesets `fixed` group already carries `@objectstack/objectql`). The `needs:contract-review` carrier was **not** hung by this branch — the dev never wrote a label.⚠️ **Corrected provenance:** the owning seat hung it on 2026-09-21T16:58:44Z, after the dev's push, which the PR's own event log records; a reader checking the labels today will find it present. ⛔ The earlier "see the acceptance notes" pointer is dropped: those notes never mentioned the carrier. ## Round 2 — head `b96baa08f2` (2 files, +38 / −13) Three corrections, all prose; the PR's file list is unchanged at 5 and no new path was pulled in. 1. **The dangling citation.** `check:issue-citations` was RED at `97a639c5b3`: the new docblock cited an issue that returns **404** (LIT CONTROL: its neighbour `objectstack-ai#14424` → 200, so the 404 is a reading). ⭐ The replacement was **not guessed** — `objectstack-ai#15378` was verified four ways before being named: HTTP 200, `merged: true`, `merged_at`, base `main`, and the depth-immune one — `origin/main` **holds the implementation it added** (`git grep 'async loadManyKeyed' origin/main -- packages/metadata/src/metadata-manager.ts` = 1; nonsense control = 0).⚠️ **The gate's own remedy arm two does not work**, and this is filed as **objectstack-ai#19614**: keeping the `#` still matches `CITATION_RE`, and `NON_CITATION_HEADS` excuses only ordinal heads — there is no prose-acknowledgement mechanism in the script. So the dead card is kept as **bare digits without a leading hash**, with a sentence saying why. Greppable, and nothing dangles. 2. **The docs example taught what its own Callout rejects.** It probed nothing and null-coalesced to `[]`, turning absence into an empty set — on the one member whose reason for existing is that silent drops are dangerous. It now reads the member into a local, guards on `typeof === 'function'`, and its `else` branch says why absence is not emptiness, matching `plugin.ts`. `?? []` is gone from the page (0 hits). 3. **A name that named nothing.** "customization container" had **0** hits on `origin/main` across `packages/`, `content/` and `docs/`. The right vocabulary came from `MetadataKeyedItem`'s own docblock: an **aggregated `defineView` container** "has no own `name` BY DESIGN (its identity is the target object)". Both carriers now say that, each with an explicit disclaimer that it is **not** the ADR-0005 `sys_metadata` org customization overlay — which this same page documents separately. **Gate readings at `b96baa08f2`** —⚠️ `check:issue-citations` is recorded here and ⛔ not in the derived-families row (which is **below**, in the Local runs table — an earlier draft of this line said "above"), because its root script is **`--self-test` only** while CI runs the self-test *and* the scan; reporting the alias as a pass is what produced the red in the first place. Run as the **SCAN**: `node scripts/check-issue-citations.mjs --base origin/main` → **EXIT=0**, captured before any pipe, re-run at the final head → EXIT=0 (4 citations judged across 13 files; 2 resolves, 2 resolves-as-pull-request). `pnpm lint` whole repo EXIT=0 · spec build success · `check:generated` all 15 up to date against a fresh build · spec typecheck pass · spec test 509 files / 14901 passed · objectql typecheck EXIT=0 · `check:slot-lookup` holds · `check:nul-bytes` OK plus a hand control-character scan of both edited files. **Mechanical proof the `.ts` edit is docblock-only:** every added and removed line in `git diff 97a639c..b96baa0 -- packages/spec/src/contracts/metadata-service.ts` is a comment line — zero non-comment lines. No type or runtime surface moved, so the ablation recorded at `97a639c5b3` still stands and was not re-run.⚠️ **Two prerequisite failures, resolved rather than reported as passes**, both artefacts of a fresh worktree and neither about the diff: `check:docs-transcript-drift` exit 3 (`@objectstack/lint` unbuilt) → built, re-ran, EXIT=0; objectql typecheck first exit 2 with 42 errors, **all** `TS2307 Cannot find module` from an unbuilt dependency closure → built, re-ran, EXIT=0.⚠️ **A negative reading deliberately NOT relied on:** `git merge-base --is-ancestor` on objectstack-ai#15378's squash commit exited 1, but this checkout is shallow and the control leg was a shallow-window near-relative — so that negative is **void, not evidence**. The tree read and the API's `merged` / `merged_at` answer the question without a history walk. ## Verification Reverse verification, because this is a cross-package type change and a green typecheck against a stale `.d.ts` is indistinguishable from a real one. Run from the committed state through `scripts/ablation-replace.mjs`, with the on-disk and in-`dist` evidence the tool produces: - **Mutate** — the declared member renamed at its anchor. Anchor hits 1 to 0, blob `bd37483b1715` to `3a9e85d219ad`. - **Reached the artifact** — `scripts/ablation-dist-preflight.mjs` found the mutated marker in 2 built files (`packages/spec/dist/contracts/index.d.ts` and `.d.mts`), so the run below read the rebuilt declarations and not a cache. - **The ablation run** — `tsc --noEmit` in `packages/objectql` went red with **exactly one** error, and it is the call site: `src/plugin.ts(2593,35): error TS2339: Property 'loadManyKeyed' does not exist on type 'IMetadataService'.` - **Restore** — blob back to `bd37483b1715`, equal to HEAD, `git diff HEAD` empty, whole-tree `git status --porcelain` empty. After a rebuild the mutated marker is gone from `dist` (0 occurrences) and the real member is back (2), and `tsc --noEmit` in `packages/objectql` is green with zero output. That is the proof for ruled item 2: the call site now reads the contract, and it reads *only* the contract. Local runs.⚠️ **Provenance corrected — this table is not all from one head.** The nine readings restated in the Round 2 section were taken at the final head `b96baa08f2`; every other row here — the objectql typecheck, the changeset gates, the 14 docs gates and the 20 further derived families — was measured at `97a639c5b3`, **before** round 2 rewrote the `.mdx`. ⛔ Nothing is actually unmeasured at the final head: CI ran the whole docs family green there, including "`packages/spec/src/**` doc-block symbol anchors resolve". It is the sentence that over-claimed its own provenance, not the work. | check | result | |:--|:--| | `pnpm lint` (whole repo, `eslint . --no-inline-config`) | **0** — clean | | `pnpm --filter @objectstack/spec build` | success; 34/34 declaration files emitted | | `pnpm --filter @objectstack/spec check:generated` | **All 15 generated artifacts up to date** | | `pnpm --filter @objectstack/spec typecheck` | pass (includes `check:test-typecheck`) | | `pnpm --filter @objectstack/spec test` | 509 files, **14901 passed**, 1 todo | | `pnpm --filter @objectstack/objectql typecheck` | pass | | `pnpm --filter @objectstack/objectql test` | 303 files, **5050 passed** | | `pnpm check:slot-lookup` | ✓ holds — 106 unswept sites in 25 files, **none new**, baseline key set verified against `0e658fb`: no files added | | `pnpm check:nul-bytes` | ✓ 9156 text files scanned, no raw control bytes | | changeset gates (`check:empty-changeset`, `check:adr-0087-registration`, `check:changeset-no-major`, `check:changeset-gate-self-tests`) | pass | | docs gates (`check:doc-anchors`, `check:doc-authoring`, `check:doc-frontmatter`, `check:docs-section-name`, `check:docs-single-h1`, `check:docs-redirects`, `check:docs-spec-enumerations`, `check:docs-transcript-drift`, `check:docs-audit-scope`, `check:doc-route-spelling`, `docs-audit/check-affected-docs`, `docs-audit/check-drift-comment`, `check:section-landing-index`, `check:keyed-text-bounds`) | pass | | further derived families run (`check:type-check-coverage`, `check:test-source-alias`, `check:published-files`, `check:dts-closure`, `check:lean-entry-closure`, `check:cross-package-test-inputs`, `check:spec-docblock-symbol-anchors`, `check:comment-mask-adoption`, `check:comment-mask-corpus`, `check:undeclared-dep-imports`, `check:query-options-erasure`, `check:spec-parsed-alias`, `check:objectql-double-limit`, `check:engine-double-contract`, `check:durability-log-level`, `check:published-readme-links`, `check:pm-prior-rulings`, `check:sourcemap-no-sources-content`, `check:strictness-ledger`, `check:skill-refs`) | pass | | `check:type-check-debt`, `check:dual-build-cjs-loads` | **NOT MEASURED** — both exited 3 (`PREREQUISITE NOT MET`); each needs a whole-workspace build this branch did not run. Neither a pass nor a finding. Declared to CI. | ### What the generators actually moved: nothing Measured rather than inferred, and this was the one prediction worth testing. Six generators were run against the built tree — `gen:api-surface`, `gen:export-origins`, `gen:spec-changes`, `gen:schema`, `gen:docs`, `gen:declaration-map` — each exiting 0, after which `git status --porcelain` listed **no** generated artefact. `check:generated` independently reports all 15 up to date. So an optional member on a published interface moves none of the four artefacts that name `IMetadataService`, exactly as the claim predicted.⚠️ One reading on the way there was **not** a finding and should not be read as one: `check:api-surface` first reported stale with `PREREQUISITE NOT MET — this gate reads built output, and what is on disk predates the sources`. The `dist` had been built before a later edit to the test file, which is a build input. Rebuilding cleared it. It was never an artefact move. ## Acceptance notes ⛔ Noted, not filed, and deliberately **not** fixed here — each is outside this card's ruled four items. - **The docs page documents `loadManyKeyed` ahead of its own declared sibling `loadMany?`.** On `content/docs/kernel/contracts/metadata-service.mdx`, `loadMany` appeared **0** times before this change (lit control on the same page: `loadDiagnosed` = 5, so the zero is a reading). The page's interface excerpt is explicitly partial and says so — its line 29 points at `IMetadataService` in the source for the full member list — so this is a documentation gap rather than a contradiction, but the ordering is odd for a reader and it is being handed to the seat to file as its own card. Widening this PR to also document `loadMany` was declined on purpose. - **Section choice, and why.** The new member is documented as a `loadManyKeyed` subsection under `Core CRUD`, immediately after `load / loadDiagnosed` and before `list / listNames`. `Bulk Operations` was considered and rejected: despite the name, that section on this page documents bulk **writes** (`bulkRegister` / `bulkUnregister`), so a plural *loader read* filed there would sit in the write section. The chosen spot is the page's loader-read run, one step from the plural registry reads a reader would be comparing it against. - **The page's own `loadDiagnosed` example still teaches the shape this PR's new example refuses.** At `content/docs/kernel/contracts/metadata-service.mdx:138`, two sections above the new probe-first example, the pre-existing `loadDiagnosed` snippet spells an optional call plus `?? {}` — absence collapsing into a value, which is exactly what the new example's `else` branch says not to do ("Do NOT fall through to an empty set") and what the info Callout restates ("never as an empty set"). It is **present at this PR's merge base and untouched here** (`?? []` on this page at head: **0**, git grep exit 1 captured before any pipe; lit control `?? {}` on the same page: **1**, at `:138`, so the zero is a reading). The page is now internally inconsistent in style rather than wrong. ⛔ Recorded here rather than filed as a card, per the standing rule: the question "which PR will touch this file?" has an answer, and it is this one — so the note belongs where the next editor of the page will read it. Widening this PR to rewrite a snippet outside its four ruled items was declined on purpose. - **The `objectstack-ai#16090` serialisation caveat recorded in the ruling's item 3 is spent.** That issue is closed, and no open pull request holds the page. Nothing was serialised against and nothing waited. ## Landing ⛔ Draft on purpose, and it stays that way from this branch. No flip to ready, no enqueue, no auto-merge. Landing is the owning seat's act after an at-tier contract review. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #14423
All measurements below were taken on the branch head they name. Final head:
9c0a9256b(merged withorigin/mainat2ed6be649).1. A2.1 — the measurement that could have reshaped deliverable 2
Question:
metadata-manager.tsalready contained aloadManyKeyeddelegation. Does the ruling's "new manager methodloadManyKeyed(type)" mean a genuinely new public method, or the promotion of that existing path?Answer: a genuinely NEW public method. The existing delegation is private, keyless at its exit, and reads a different population — none of it is promotable as it stands.
Located by symbol, not by line number:
That delegation lives inside
private async admitLoaderItems(loader, type, items). Three facts, each of which alone rules out promotion:readListUncached()andlistForIndex()— i.e.list()/listDiagnosed()/ the endpoint index. There is no public keyed read anywhere on the manager.Mapand its caller returnsArray.from(items.values())— the keys never leave the method. That is exactly why metadata: readListUncached() drops every loader-held item whose stored body has no top-level name — an aggregated view container is invisible to list() after a restart #14205's repair reachedlist()and stopped there.loadManyKeyed,admitLoaderItemsfalls back toloadManykeyed bydata.name— "the pre-metadata: readListUncached() drops every loader-held item whose stored body has no top-level name — an aggregated view container is invisible to list() after a restart #14205 behaviour verbatim", in its own docblock — which drops precisely the nameless item this card exists to recover.So the new method is
MetadataManager.loadManyKeyed(type, options?), placed besideloadManyin the Legacy Loader API section, with a sibling privateadmitKeyedLoaderItems. It differs fromadmitLoaderItemson exactly one axis, and that axis is the whole card: the fallback isloader.list()+ per-nameloader.load(), notloadManykeyed bybody.name. The two are deliberately not merged —list()must keep its documented behaviour, and a shared helper with a mode flag would make one call site's semantics an argument.Bearing on Clause-②, reported and NOT re-ruled: the symbol
loadManyKeyedalready exists on the publishedMetadataLoaderinterface (optional,packages/metadata/src/loaders/loader-interface.ts), andMetadataKeyedItemis already exported from@objectstack/metadata. What is new is a public member onMetadataManagercarrying that name. So the "new exported symbol" premise holds for the member, and is weaker than it would be for a wholly new name and a wholly new return type — the return type is an already-published one. That is the reading; the tier is the PM's call.2.⚠️ Which ruling this implements — the dispatch and the card disagree, and I did not choose silently
The dispatch prompt carries the 04:33Z ruling (comment 5535694564, four deliverables). The card also carries a later maintainer ruling, comment 5537057614, 2026-09-04T07:13Z, decision batch #31, verbatim 「同意」, which states that the 04:33Z ruling "stands and is extended by B's probe, not replaced", and enumerates six scope items. Its table scores D alone as only △ on C3 — "shrinks to the failed loader's names — a handler whose declaration lives on that loader still reads 'undeclared'".
D+B is a strict superset of D. This PR implements D+B, because:
domain:engineseat;loadMany's published shape untouched, the four production consumers untouched, C4 pinned as a boundary, Clause-② yes;packages/objectql/src/action-governance.ts, already on the dispatch's declared file surface.If the PM wants strict-D instead, the addition is one argument and one probe entry —
lookupMetadataActioninrunActionGovernanceInventoryand its wiring inplugin.ts— and removing it is a small, self-contained revert. The reverse-verification section below measures exactly what that costs (C3 reopens).Not implemented, out of scope by both rulings: C4 (#15252,
Blocked-by: #14423) and #15245.3. What changed
packages/metadata/src/metadata-manager.tslistNames()gains the per-loadertry/catchthatloadManyandlist()have carried sinceDatabaseLoader把存储读故障吞成空结果 —— ADR-0110 D3 的 miss/outage 之分在复数读路径上不成立 #5108, using the same helpers rather than a third spelling for "a loader faulted":reportLoaderReadFailureon the way down,reportLoaderReadRecoveredon the way back.loadManyKeyed(type, options?)returningMetadataKeyedItempairs. Delegates to a loader's ownloadManyKeyed; falls back per loader tolist()+ per-nameload(). Loaders only — the same populationloadManyandloadDiagnosedread, deliberately notlist()'s registry-inclusive one, because the router's third rung isloadDiagnosedand that walks the loaders alone.packages/objectql/src/action-governance.tscollectEngineActionDeclarationsgains an optional keyed source. When present it replaces the unkeyed one — reading both would re-admit thebody.nameguess the card exists to remove.storeKey(new exportedActionDeclarationRow). Identity precedence is stated once, indeclarationIdentity: the body's ownname, else the store key.resolveActionHandlerKeys(action, storeKey)passes the store key as thefallbackKey, which is byte-for-byte what the router does with the route segment.dropHandlersDeclaredInRegistrybecomesdropHandlersDeclaredByName, taking an ordered list of by-name probes. One probe's failure never suppresses another's answer; a probe that throws still leaves the handler on the list.runActionGovernanceInventorygainsloadStandaloneActionsKeyedandlookupMetadataAction. Both old parameters keep working unchanged.packages/objectql/src/plugin.ts— the wiring: the keyed read when the plane offers one, and the by-name rung mirroringresolveRouteActionDeclaration's own branch (loadDiagnosedpreferred,loadas fallback, the caller unwrapping so the audit receives declaration-or-nothing exactly as for the registry rung).packages/runtime/src/action-governance-scope-divergence.test.ts— flipped from pinning the divergence to pinning the agreement, C1/C5 controls kept verbatim, C4 reframed as a boundary.Lane wall, reported not crossed
loadManyKeyedis not declared onIMetadataService(packages/spec/src/contracts/metadata-service.ts), where its siblingsloadMany?andloadDiagnosed?are.packages/specis another lane's, so the call site narrows with a local structural type (KeyedPluralMetadataReadinplugin.ts) — ⛔ notany, so the slot lookup stays typed undercheck:slot-lookup. This is the same positionloadDiagnosedwas in before #4127 batch 4 declared it, and the same remedy applies when the spec lane takes it: delete the local type and read the contract. Not filed as an issue — it is a direct consequence of this PR and belongs to whoever schedules the contract review Clause-② triggers.4. Zone 2 — every item, CONFIRMED or FALSIFIED
loadManyKeyeddelegationlistNames' loop is genuinely unguardedpackages/objectql, notpackages/runtimeA2.2 — read at
6e67b86c0, located by symbol:and its loop, verbatim, bare:
against
loadManyat:2668, which wraps the identical shape intry { … reportLoaderReadRecovered } catch (e) { reportLoaderReadFailure }. The repair matches that existing guard's shape and its helpers; it does not invent a third vocabulary. Positive control that the assertion is not vacuous: the reverse verification in §7 reds four cases when the guard is removed, and the pinPARITY — the same outage now reaches listNames and loadMany the same wayasserts the outage produces exactly onelogger.errorline, not two.A2.3 — CONFIRMED, and the ruling's prose is right about the fixture while being loose about the audit:
runActionGovernanceInventory/reconcileActionRegistrations/collectEngineActionDeclarationsall live inpackages/objectql/src/action-governance.ts;packages/runtime/src/action-execution.tscarries only thereconcileActionRegistrationsback-compat wrapper (itsdepsparameter is never read). The fixture is inpackages/runtimeexactly as the ruling says. Both were edited; the audit in objectql, the fixture in runtime.Firing positive control for the zero-hit greps. Every "this symbol is not here" claim above was taken with a control that must hit:
5.
unboundDeclarations— before and afterBEFORE = 0, structurally rather than by sampling. A row the plane holds under a key its body does not carry never reached
reconcileActionRegistrationsat all:collectEngineActionDeclarationsrequiredtypeof action.name === 'string'and dropped it. So it could not be reported as unbound however many such rows a plane held. That is pinned as an assertion rather than asserted in prose —BEFORE, structurally: through the UNKEYED read a nameless row is not a declaration at allassertscollectEngineActionDeclarations(...)returns[]for exactly that input.AFTER, on the same input: the row is a declaration keyed by the store key, and if nothing handles it, it is reported —
the ruled population change: a nameless row with NO handler now REACHES unboundDeclarationsmeasurescount: 1, actions: ['global:promote_lead'].One deliberate subtraction, in the other direction. A row with neither an own
namenor a store key used to be pushed asactionName: undefined, which renders in the warning as a parse failure rather than as a finding. It is now skipped, inreconcileActionRegistrations, with the reason in a comment. It is unreachable fromcollectEngineActionDeclarations(which already refuses identity-less rows) and only observable to a caller assembling rows by hand.Confidence gap, stated rather than papered over: these are counts on fixtures, including the shipped-shape one (real
NodeMetadataManagerover a realFilesystemLoader, C6). I did not boot a liveos devcomposition and count there; the census established the structural zero and this PR did not re-derive it that way.6. Cost — the ruling's item 6, measured
Two halves, each pinned:
DELEGATES to the loader — one query, no per-name readsasserts{ find: 1, findOne: 0 }over a five-row plane, reproducing the census's reading. Delegate first, fall back second — the reverse would be{find:1, findOne:5}on that same loader.COST of the audit's by-name rungassertsloadDiagnosedcosts exactly onefindOneper name (1, then 2, then 3 across three probes, a miss included).COST — the by-name rungs are bounded by the accusation listasserts the audit probes exactly the one handler still unaccounted for out of two registered, anda clean composition runs ZERO by-name probesasserts the probe list is empty when the enumeration already accounts for everything.⇒ On
DatabaseLoader, the added cost isN × findOnewhereNis the number of handlers still unaccounted for after set reconciliation and the registry rung — zero on a healthy composition, and on an unhealthy one it is bounded by the size of the accusation the audit is about to print.7. Reverse verification — four ablations, direction predicted before running
Each ablation ran from a committed state, mutated the file, proved the mutation landed on disk (the anchor gone, the injected marker present — an editor's exit code is not evidence), ran, restored with
git checkout HEAD -- ABSOLUTE_PATH, and proved the restore against the path's HEAD blob hash plus an emptygit diff HEAD. A trap restored both files on every exit path. No rebuild was needed and none is claimed: all three suites import the subject through relative source specifiers or a vitest source alias — the failure stack in the runtime run resolves topackages/metadata/src/metadata-manager.ts, i.e. source, notdist.listNames'try/catchremovedMetadataManager.loadManyKeyedremovedlookupMetadataActiondropped from the probe listAblation 1 and 2 red sets do not overlap, which is the property item 1 needs: it is a defect in
listNamesindependent of the audit, and it is pinned that way.Ablation 3 is one red over prediction, and the extra one is correct. The case is
POSITIVE CONTROL — with all three rungs wired, a handler nothing declares is still named: it asserts exactly which of two handlers is cleared, so it is a discriminator as well as a control and cannot survive the rung it discriminates on. The purely-negative controls —CONSERVATIVE — a probe that throws leaves the handler ON the listand the ownership test — stayed green, which is the property that actually matters: a "fix" that silenced the audit everywhere would have redded those. The prediction was wrong, not the code; the test file's docblock now records the measured numbers rather than the prediction.Ablation 3 also measures what strict-D would cost: dropping the by-name rung reopens C3 — the case named
...and the by-name rung, which the same fault is invisible to, clears itgoes red, i.e. a handler the router serves is accused again.8. Local verification
Gate families — derived on the ACTUAL surface, per family, with exit codes
Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed — the tool takes its own change set from the merge base, working tree and untracked files included). It reports 45 runnable families: 34 by path + 7 by change KIND + 6 declared whole-tree, 2 reached both ways. The convention block is included — this card adds test files (5 families) and touches a file carrying an ADR-0112-code-shaped value (1 family), neither of which any path map can name.All 45 run on head
20cd79820, and the 13 ratchet families re-run on the final head9c0a9256b. Every family exit 0; no family was skipped, and no exit 3 /MODULE_NOT_FOUND/ queue-timeout was read as a pass.Exit codes were captured before any pipe (
cmd redirected-to-file; ex=$?), never after a| tailor| head. The verdict lines quoted below are the gates' own, not a bare$?:One gate went RED on my own new code and was fixed rather than baselined.
check:objectql-double-limitreported the metadata test's driver double asUNJUDGED — probe threw, because it carried an injected failure flag: the gate's control probe stubs the hook, the double throws, and the candidate files as unjudged debt instead of being graded. The remedy is the one the runtime fixture already documents — a separate double overriding onlyfind, leavingfindOneon the base implementation. Restructured; re-run green. The reason is now in that double's docblock so the next author does not re-earn it.Also caught locally, by the package's own
typecheckrather than by CI:IDataDriveris exported from@objectstack/spec/contracts, not@objectstack/spec/data. Worth stating because the trap it avoids is real —@objectstack/metadata'stypecheckdoes compile*.test.ts(the error was raised on the test file itself, which is the proof), so "typecheck clean" here really does cover the new tests.Suites
Full package suites, each through the shared verify lock, on the merged tree:
Declared narrowing — and why the narrowing is a measurement, not a shortcut
turbo ls --affectedagainst the merge base reports 79 packages, i.e. effectively the whole workspace, because@objectstack/metadataand@objectstack/objectqlsit near the root of the dependency graph. That is a dependency closure, not a behavioural one, so I bounded the behavioural surface by symbol instead and ran the packages inside it plus their nearest neighbours:MetadataManager.listNames— the one behaviour change reachable by existing code.git grep -n "listNames(" -- 'packages/**/*.ts'finds no production call site outsidepackages/metadataitself. The other hits arepackages/lint's unrelated string helper,packages/core/src/fallbacks/memory-metadata.ts(its own separate implementation), theIMetadataServicedeclaration inpackages/spec, and threepackages/mcptest doubles that assertlistNamesis never called.MetadataManager.loadManyKeyed— brand new; its only caller isObjectQLPlugin.runGovernanceInventory.ObjectQLPlugin.runGovernanceInventory.The three changed packages plus
core,mcp,metadata-protocol(the packages whose tests so much as mentionlistNames) andrest(host of the/actionsroute) were run in full. The remaining ~72 are CI's, which runs the farm on this PR regardless.Repo-wide
pnpm lintwas not run locally, and is declared as not run rather than reported as narrowed — I did not take the three readings a narrowing owes (eslint's own population, a--format jsonfile count, and a configuration-invariance statement for untouched files).Lint & Repo Gatescovers it on the PR.9. Changeset derivation
.changeset/audit-router-keyed-identity-and-listnames-parity.md,@objectstack/metadata: minor,@objectstack/objectql: minor.skip-changesetis wrong: both packages ship, and both change their published behaviour.minor, notpatch: each package gains public surface — a new public member onMetadataManager, and new optional parameters plus a new exportedActionDeclarationRowon objectql's audit. Apatchwould understate an addition consumers can now depend on.major, and the reasoning is the reason option C was taken "additive":loadMany's published return shape is untouched, the four production consumers the census counted are untouched, every existing parameter ofrunActionGovernanceInventoryandcollectEngineActionDeclarationsstill works, andreconcileActionRegistrations' declaration rows gained only an optional field. Nothing an author or a consumer writes today stops working.check:changeset-no-major --self-testandcheck:adr-0087-registration --self-testboth exit 0; no ADR-0087 disposition marker is owed because nothing here is declared breaking.@objectstack/runtimeis deliberately absent: its only change is a test file, which publishes nothing.content/docs/releases/**edit, and none was needed; no drift check named one.10. C4, pinned as a boundary
packages/runtime/src/action-governance-scope-divergence.test.tskeeps its C4 case and inverts its meaning. The assertions now pin the cause as well as the outcome, which is what makes it a boundary rather than a fifth read asymmetry: the audit's lookup throws (Service 'metadata' is async — use await) before any read method runs, while every read this card added is provably healthy on the plane the audit cannot hold (loadManyKeyedon the router's own instance answers with the name). C1 and C5 are unchanged controls. The boundary is also stated inrunActionGovernanceInventory's docblock, with the note that no shipped composition registersmetadataasSCOPEDand that a future one is a new product card — #15252,Blocked-by: #14423.11. Out-of-scope findings
None filed. Nothing outside this card's scope was found that is not already tracked: #15252 (C4), #15245, and #15037 (
RemoteLoaderhas noloadManyKeyedand no store key to fall back on) were all filed before this dispatch. Worth noting for the reviewer that this PR's fallback path —list()+ per-nameload()— is whatRemoteLoaderwill take, so #15037's "undefined behaviour" note is narrower now than when it was written; it is not closed by this PR, becauseRemoteLoaderstill cannot answer with a store key.Also not filed, because it is a consequence of this PR rather than an independent finding:
loadManyKeyedis undeclared onIMetadataService(see §3, Lane wall).🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code