Repository navigation
fix(metadata-protocol): a package's list slot serves the stored row getMetaItem naming that package serves, in every row order (#21804) - #21815
Conversation
…etMetaItem naming that package serves, in every row order The list built a package's slot from the latest of the package's row and the package-less row in row order. It now takes the served-row resolution the by-name read takes (servedOverlayRowCandidates: organization scope before env-wide, canonical spelling before the other, the package's own row before the package-less row), for the active merge and the draft-preview merge. findServedOverlayRow reads the same order from the store. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…the engine-double-contract ledger Written by `node scripts/check-engine-double-contract.mjs --write`: one new pinned findOne row for protocol.list-slot-prefer-local.test.ts, 0 lost. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 11 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 6cdf4411818e095fe62e046cecce51acc882be23 && git checkout 6cdf4411818e095fe62e046cecce51acc882be23
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a 978945aecdf8e6077ebddde9b04a6e6df0ae7fbc && git checkout -B drift-repro 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a && git merge --no-ff 978945aecdf8e6077ebddde9b04a6e6df0ae7fbc
node scripts/docs-audit/affected-docs.mjs --json 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a
|
ACCEPT (seat review) — PR #21815 at head
|
…-less row getMetaItem naming the package serves (objectstack-ai#21871) Fixes objectstack-ai#21817 Clause-②: no ## What changes A slot in a list scoped to one package (`getMetaItems({ type, packageId })`, `GET /api/v1/meta/:type?package=`, and `getMetaItemsForExecution`, which reads through the same method) now serves the row `getMetaItem` naming that package serves: the package's own row, else the package-less row (ADR-0048), the organization's rows before the env-wide rows (ADR-0005). The list's membership is unchanged. It still lists only the items the package ships. - **Landing point**, as the claim said: `readFlattenedMetaItems` in `packages/metadata-protocol/src/protocol.ts`, and the merge it calls, `mergePackageAwareOverlay`. No `packages/spec` change, no export change, no new error code. - **One candidate order (H2).** The package-less rows enter the list's merges as stand-ins (`standIn` on a record). The merge picks a slot's stored row through `servedStoredRow`, which walks `servedOverlayRowCandidates`, the one order `findServedOverlayRow` and the unscoped list already share. There is no second resolver. - **Where the package-less rows come from (H2).** - Active rows: no extra read. The scoped path already reads the package-agnostic set `readActiveOverlayRows({ type }, orgId)` for the lock (the lock-row read). That set is filtered back to the rows whose `package_id` is null (`standInRows`). - Draft preview, only with `previewDrafts` and a package: one package-agnostic draft read per scope, filtered back to the package-less rows (`standInDraftRecords`). - **Membership (H3).** A stand-in serves a slot the package seats and never seats one itself. A slot that only stand-ins reach is held back and recorded in a per-call `unseated` set. A later layer (the draft preview, the MetadataService listing, the view-container expansion) may still seat it. Whatever is still recorded after the last merge is dropped. - **The lock (H4).** Untouched. It is still selected by `resolveOverlayLockLayer` from every row in scope (PR objectstack-ai#21844). ## H1: the scoped read at the base At `18fe6815a2`. The `protocol.ts` blob there is `182c66778c`, the same blob as at `9f9510f25e`, the merge base of this head. With `packageId` set, `readFlattenedMetaItems`: - lists the registry's items of the package (`listItems(type, packageId)`); - reads its stored rows with `readActiveOverlayRows(request, orgId)`, whose `queryByOrg` puts `package_id = packageId` in the `where`. That is the package's own rows only, env-wide and the organization's; - also reads the package-agnostic set, but only for the lock (`lockRows`); - merges the package's rows over its items. A package-less row never reaches the merge, so a slot serves the package's row or its artifact; - previews drafts with `package_id = packageId` only, and keeps only the MetadataService items stamped with the package. ## Census: the scoped slot against `getMetaItem` naming the package, base vs this head Engine double. PR objectstack-ai#21815's census names "16 arrangements over five rows" but does not list them, so this census runs their superset: - every subset of the same five rows (env package-less, env A, env B, org package-less, org A); - every row order (326 orderings); - with and without A's artifact, and with and without an organization; - for packages A and B, on `view` (as PR objectstack-ai#21815) and on `dashboard`. A comparison is a case where the scoped list has a slot for the name. | request | package | disagreements at base | at this head | |---|---|---|---| | no organization | A | 49 / 587 | 0 / 587 | | no organization | B | 0 / 522 | 0 / 522 | | organization | A | 90 / 636 | 0 / 636 | | organization | B | 424 / 522 | 0 / 522 | The figures are identical on `view` and on `dashboard`. The base column is the card's defect class (25 of 240 on PR objectstack-ai#21815's subset), measured over every arrangement. ## H3: membership unchanged, nothing else moved - **Membership.** Over the same cases, the scoped list's name set (name with `_packageId`) differs base vs head in 0 of 5216 lists. Slot-count changes: 0. - **Changed lists.** 1126 scoped-list dumps differ, all in the one slot for the name. 0 differences outside it. Every changed slot now equals `getMetaItem` naming the package (1126 of 1126): - B: env-wide row of B → org-scoped package-less row, 848; - A: env-wide row of A → org-scoped package-less row, 152; - A: A's artifact → env-wide package-less row, 104; - A: A's artifact → org-scoped package-less row, 22. - **Other reads.** Unscoped list dumps: 0 of 2608 differ. `getMetaItem` dumps: 0 of 5216 differ. ## H4: the lock stays the item's Lock census on `dashboard`, with A's artifact, every subset and order, one row at a time declaring `no-overlay`, `no-delete` or `full`, with and without an organization: 7830 cases. - The scoped slot's lock family (`_lock`, `_lockReason`) differs base vs head in 0 of 7830. That includes the 993 cases where the served row moved. - `getMetaItem` envelopes: 0 differ. - At this head the slot's `_lock` equals the envelope lock in 7830 of 7830, the reason equals the envelope item's in 7830 of 7830, and the label equals the envelope item's in 7830 of 7830. At base the label matched in 6837 of 7830. ## Pins `protocol.scoped-list-fallback.test.ts`, 155 tests: 1. Generated: every subset of the five rows, every row order, with and without an organization and A's artifact. For packages A and B: where the package ships the name, the scoped slot serves the row an oracle written from the rule names, and `getMetaItem` naming the package serves the same. Where it ships nothing, the scoped list has no slot. 2. Named, both row orders: A's artifact beside the env-wide package-less row (on `dashboard` and `view`); the organization's package-less row over an env-wide row of A; the organization's row of A over the env-wide package-less row. 3. Membership: a package-less row of a name A does not ship adds no slot. The scoped list lists the same names with and without it, while the unscoped list still serves that row. 4. The MetadataService layer: a package-less row stands in for A's runtime item. A control shows no slot without the runtime item, and a lit control serves the runtime item alone. 5. The draft preview: a package-less draft stands in for A's slot. A's own draft wins over it in both orders. A package-less draft of a name A does not ship previews no slot. 6. The view-container expansion: a package-less row of a name A's stored container expands is served ahead of the expansion, stamped A. A lit control serves the expansion without it. 7. The lock: where the served row moves to the organization's package-less row, the slot's lock family equals `getMetaItem`'s envelope, for three lock levels on either row. `protocol.list-slot-prefer-local.test.ts`: its docblock's "out of this card" paragraph now points at the new pin file. No test changed. ## Reverse verification On the committed head `b5492dce3e`. Every restore is `git checkout HEAD -- PATH`, proven by the blob equal to HEAD's and an empty `git diff HEAD`. | arm | red | membership pin 3 | other | |---|---|---|---| | base `protocol.ts` restored whole (blob `182c66778c`) | 48 / 155: pin 1 32, pin 2 6, pin 4 2, pin 5 1, pin 6 1, pin 7 6 | 3 / 3 green | controls green | | leg A: the active stand-in read off (`standInRows` emptied) | 47 / 155: pin 1 32, pin 2 6, pin 4 2, pin 6 1, pin 7 6 | 3 / 3 green | pin 5 4 / 4 green | | leg B: the draft stand-in read off (`standInDraftRecords` emptied) | 1 / 155: pin 5, A's artifact and a package-less draft | 3 / 3 green | pin 5's membership case green | - Both legs went through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `c96ce0d942` → `1935e0b66f` (A) and `798b96b4a4` (B). Each was restored to `c96ce0d942`, HEAD's blob, with `git diff HEAD` empty. After the restore both pin files ran 240 of 240 green. - The pin file imports `./protocol.js`, a relative import that vitest resolves to `src/`. No `dist/` is on the path, so no rebuild was owed between the legs. ## Tests (at `b5492dce3e`) - The dependency closure (12 packages, `spec` through `metadata`) was built first. - `pnpm --filter @objectstack/metadata-protocol build`: `check-dts-emitted` 2/2 declared declaration files present. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` compiles 218 of the package's test files, both pin files among them. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: Test Files 215 passed, 3 skipped (218). Tests 27900 passed, 19 skipped (27919). ## Gates (at `b5492dce3e`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths: 5 paths against merge base `9f9510f25`, 72 commands. All 72 ran and exited 0. `--ran` printed: "✓ dispatch-gates --ran: 72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED". - The artifact-roster block printed outside that total: 54 families, 37 plus 17 that are self-test only. All exited 0 except three PR-context gates, which judged nothing without a PR (exit 2, "NOT WIRED" or "NOT MEASURED"): `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. They are re-run against this PR in its report on the card. - The four symbol-anchor sweeps exited 0: `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors`. - Families derived now but not at dispatch, because they come from the whole change set rather than `protocol.ts` alone. All exited 0: - `check-adr-0087-registration`, `check-empty-changeset` and `check-scripts-symbol-anchors`, each with its self-test; - `release-rehearsal-clone --self-test` and `release-pending-publish --self-test`; - `check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:engine-double-contract`, `check:entry-guard`, `check:objectql-double-limit`, `check:objectui-changeset` and `check:parse-guard`; - `check:pm-changeset-deadline-census`, `check:pnpm-filter-targets`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - `check:engine-double-contract`: the pin file's `findOne` double has its row in `scripts/engine-double-contract.pinned.json`. Re-running `node scripts/check-engine-double-contract.mjs --write` leaves the file byte-identical (blob `bc77050a7b`). - Lint, narrowed, because `pnpm lint` is CI's run: `pnpm exec eslint --no-inline-config --format json` over the 3 changed TypeScript files linted 3 files with 0 errors and 0 warnings. - Population: `eslint.config.mjs`'s block for every TypeScript and JavaScript file, minus the build directories. None of the three is ignored. - Invariance: the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. - Not run locally: the path-scheduled CI jobs and the type-check lanes `dispatch-gates` names as CI's own. They are left to CI. ## Changeset `.changeset/21817-scoped-list-fallback.md`: `@objectstack/metadata-protocol` `patch`, `Clause-②: no`. It says that a package-scoped list now serves a package-less customization of an item the package ships, as `getMetaItem` naming the package does, and that its membership is unchanged. ## Acceptance notes - **Membership is unchanged, by triage's ruling.** Where a package ships nothing of a name, its scoped list has no slot for it, while `getMetaItem` naming that package still answers a package-less row of the name (the by-name fallback). The census counts 63 such cases for A and 218 for B per type, identical at base and head. - **Package-less view containers in a scoped list.** The view-container expansion still expands only the package's own stored containers. A package-less stored container is a stand-in like any other row: it is held back, and dropped unless the package seats its name. At base the scoped list did not read package-less rows at all. - **Cost.** A draft preview scoped to a package makes one more `sys_metadata` read per scope (the package-agnostic drafts). The active arm makes none, because it reuses the lock-row read. - **Not measured over HTTP.** Engine double only, which is the card's own measurement basis. ## Files - `packages/metadata-protocol/src/protocol.ts`: the fix. - `packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts`: the pins (new). - `packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts`: docblock pointer only. - `scripts/engine-double-contract.pinned.json`: one ledger row for the new pin file's double. - `.changeset/21817-scoped-list-fallback.md`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21804
Clause-②: no
What changes
A package's slot in the metadata list now serves the stored row that
getMetaItemnaming that package serves, whatever order the store returns the rows in.servedOverlayRowCandidates(address)inpackages/metadata-protocol/src/protocol.ts. Its input is the address{ organizationId, packageId }. It returns the ordered candidates{ scope, organizationId, spelling, packageId }: the organization's scope, then env-wide (ADR-0005); within each, the canonical spelling, then the other (meta overlays: unnormalized type segment creates phantom rows that shadow the code-authored listing and cannot be deleted #4432); within each, the package's own row, then the package-less row, never another package's (ADR-0048). With no package, any row.findServedOverlayRow(getMetaItem, its draft-preview arm,getMetaItemLayered) reads it from the store: onefindOneper candidate, first hit served. This is the samefindOnesequence as before, now spelled once.mergePackageAwareOverlayreads it over the rows the list already holds (servedStoredRow). Each stored row travels into the merge with its place (stored: { organizationId, type }), and the caller passesrowsRead: { organizationId }. The list's active-overlay merge and itspreviewDraftsmerge both pass them.resolveOverlayLockLayer([finding] lock family, package axis: a read naming a package serves that package's row and reports its lock, while the _lock gate's overlay read selects without a package #21761); that block is untouched.mergePackageAwareOverlayinreadFlattenedMetaItems.StoredOverlayEntrygainstype(the row's stored spelling). Nopackages/specchange.H1: the two resolutions at the merge base (
18c2ddc1ec)findServedOverlayRow: per scope (org, then env), per spelling,findOnewithpackage_id= the package, thenpackage_id= null. First hit served.mergePackageAwareOverlay: per slot and per package P, the LATEST contribution (registry items first, then the stored rows in row order) whose package is P or none. The stored rows come fromreadActiveOverlayRows: env rows, then the org's, merged by (package, name) with an org row replacing an env row in place. So "latest" depended on the store's order and on that map's insertion order.getMetaItemnaming A: A's row in both orders.getMetaItemnaming A.Census: list slot vs getMetaItem, base vs this head
Engine double,
viewrows. 16 arrangements over five rows (env package-less, env A, env B, org package-less, org A, including no row, one row, both, and a third package), every row order, with and without A's artifact. Each case compares the list's slot for A withgetMetaItemnaming A.18c2ddc1ecThe 25 that remain are all in a list scoped to one package. That is the row read, not this merge (Acceptance notes, first item).
H3: everything else in the list is unchanged
getMetaItemrequests per case: 840 dumps. Base vs head: 748 byte-identical, 92 differ.getMetaItemnaming that package serves:getMetaItemdumps: 0 differences._lockfamily and eachgetMetaItemenvelope. 4014 identical, 18 differ. All 18 are the organization list over [org package-less, env package-less, env A]. There the base list item stated env A's_lock(no-overlay,no-deleteorfull, 6 each) while the item's envelope saidnone, because the org's package-less row is the scope. At this head the list item saysnone. The envelopes are identical at base and head._lockequalsgetMetaItem's envelope lock (base: 18 disagree).resolveOverlayLockLayer. What moved is the body it is laid over when no overlay row binds.H4: organization scope
The organization's rows come before the env-wide rows, then the package's own row. The org list and
getMetaItemwith an organization agree in every order: 0 / 120 above, and pin 3.Pins (
protocol.list-slot-prefer-local.test.ts, 85 tests)dashboard(per-organization overridable, no list expansion of its own). For package A and package B, the list's slot serves the row an oracle written from the rule names, andgetMetaItemnaming the package serves the same row. A package with no row in scope has no slot.dashboardandview: the slot is A's row, listed once.getMetaItemdoes. Lit control: the artifact alone is served as itself.getMetaItemwithpreviewDraftsserves.Reverse verification
5c7330e64b). The row-order pick was restored throughscripts/ablation-replace.mjs(wrap mode, trap armed): the latest-wins loop back in place of the served-row call. Anchor 1 → 0, replacement 0 → 1, blobc3958342651e→5261196669fb../protocol.jsfromsrc, so nodistwas involved.git checkout HEAD -- ABS_PATH(the tool, and the trap): blob after = blob at HEAD =c3958342651e;git diff HEADempty.Tests
At
978945aecd(after mergingorigin/mainat75ddcd1b41, which touchescloud-connection,metadata-core's protocol handshake andruntime; install refreshed and the workspace rebuilt):pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 214 files passed, 3 skipped; 20 037 tests passed, 19 skipped.pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.tsc --listFilesincludes the new test file.metadata-protocoldist: the 20 test files that callgetMetaItems, 343 tests passed.Gates
All at
978945aecd, after the final commit.node scripts/pm/dispatch-gates.mjs --commands(no paths) derived 72 commands. All 72 ran, plus the 54-row artifact-roster block (53 beyond the derived set;check:engine-double-contractis in both) and the four symbol-anchor sweeps. 129 commands, 126 exit 0.dispatch-gates --ran: "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED (a DERIVED zero — all 72 recorded an exit code and none of them is 3)".check-partof-closing-keyword: re-run with this body asPR_BODY;check-closing-target-claimandcheck-single-claim-paths: NOT MEASURED locally (each needs a PR number and a token); their guard workflows run on this PR.check:engine-double-contract: OK, after recording the new pinned row through its own--write(740c063f1f).check:durability-log-level,check:nul-bytes,check:dual-build-cjs-loads,check:lean-entry-closure,check:adr-0087-registration: green. The last three ran after the full build; the first pass at5c7330e64bread PREREQUISITE NOT MET (exit 3) on the two build readers, and that pass is not counted.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchors,check:adr-anchors): green.eslint --no-inline-config --format jsonover the 2 touched.tsfiles, at978945aecd: 2 files, 0 errors, 0 warnings.calculateConfigForFile/isPathIgnored: both are linted (neither ignored).parserOptions.projectandprojectServiceread null for both), so this diff cannot move any untouched file's verdict.pnpm lintis CI's.Acceptance notes
getMetaItems({ type, packageId: A })(GET /meta/:type?package=A) reads only A's rows (readActiveOverlayRows), so a package-less row never reaches the merge.getMetaItemnaming A serves the package-less row.getMetaItemserves the org's package-less row.mergePackageAwareOverlay's docblock scopes its promise to the unscoped list, and ADR-0048 states no package-scoped list rule, so whether a package-filtered list should show package-less stand-ins is a semantics question. Unchanged here.viewfolds two packages' same-name slots into one. The list's view-container expansion (rest/meta: getViewsByObject / GET /meta/view?object= omits a runtime-authored view CONTAINER — #7163/#7736 expansion fix does not cover this path #13407) upserts every item by bare name, after the merge. With env rows of A and B for one view name, the unscoped list holds only B's. That is why pin 1 runs ondashboard. Measured on the engine double; not changed here.18c2ddc1ecand at this head alike. The organization holds only package B's row; an env-wide row of A declaresfull.getMetaItemnaming A in that organization answerslock: none, editable: true, and the served body (env A's row) carries_lock: full. The content scope and the lock scope ([finding] lock family, package axis: a read naming a package serves that package's row and reports its lock, while the _lock gate's overlay read selects without a package #21761) differ there, andwithOverlayLockFamilyleaves the body unchanged when no overlay row binds. Reported to the seat; not changed here.readActiveOverlayRows), the by-name read's per item. The candidate order is shared; the list holds one spelling per scope, so the spelling step never splits a list slot. Unchanged.Generated by Claude Code