Skip to content

fix(metadata-protocol): a package's list slot serves the stored row getMetaItem naming that package serves, in every row order (#21804) - #21815

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21804-list-slot-prefer-local
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21804-list-slot-prefer-local

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21804
Clause-②: no

What changes

A package's slot in the metadata list now serves the stored row that getMetaItem naming that package serves, whatever order the store returns the rows in.

  • One resolution, two readers. The order is one function, servedOverlayRowCandidates(address) in packages/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: one findOne per candidate, first hit served. This is the same findOne sequence as before, now spelled once.
    • mergePackageAwareOverlay reads 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 passes rowsRead: { organizationId }. The list's active-overlay merge and its previewDrafts merge both pass them.
    • No second resolver: the list iterates the same candidate list; it does not restate the order.
  • What stays. The lower layer (registry artifact) still loses to any stored row of the package or package-less. A package with no row of its own still falls back to the package-less row, stamped as the package's. The MetadataService merge, whose records are already-resolved items, keeps its latest-wins rule. The list item's lock still comes from 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.
  • Landing point, as the claim said: mergePackageAwareOverlay in readFlattenedMetaItems. StoredOverlayEntry gains type (the row's stored spelling). No packages/spec change.

H1: the two resolutions at the merge base (18c2ddc1ec)

  • findServedOverlayRow: per scope (org, then env), per spelling, findOne with package_id = the package, then package_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 from readActiveOverlayRows: 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.
  • Slot body for package A, engine double, unscoped list, env-wide rows:
    • A's row and the package-less row, order [package-less, A]: A's row. Order [A, package-less]: the package-less row, stamped A. getMetaItem naming A: A's row in both orders.
    • Only A's row: A's row. Only the package-less row (under A's artifact): the package-less row, stamped A. Both agree with getMetaItem naming A.

Census: list slot vs getMetaItem, base vs this head

Engine double, view rows. 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 with getMetaItem naming A.

list request disagreements at 18c2ddc1ec at this head
no package, no organization 39 / 120 0 / 120
organization 41 / 120 0 / 120
package A 5 / 120 5 / 120
organization + package A 20 / 120 20 / 120

The 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

  • Content dumps. Over those arrangements, orders and artifacts, with five list requests (no package, package A, package B, organization, organization + package A) and two getMetaItem requests per case: 840 dumps. Base vs head: 748 byte-identical, 92 differ.
    • All 92 are in the two unscoped lists (no package: 45, organization: 47).
    • Every one is a package slot moving to the row getMetaItem naming that package serves:
      • A: package-less → A's row, 42;
      • B: package-less → B's row, 12;
      • org package-less → org A, 26;
      • env package-less → org A, 6;
      • env A → org package-less, 6.
    • Outside package slots: 0 differences. Slot count changes: 0. getMetaItem dumps: 0 differences.
  • Lock family. Same arrangements with every lock level on two of the rows: 4032 dumps of each list item's _lock family and each getMetaItem envelope. 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-delete or full, 6 each) while the item's envelope said none, because the org's package-less row is the scope. At this head the list item says none. The envelopes are identical at base and head.
    • Across all 992 slot comparisons at this head, the list item's _lock equals getMetaItem's envelope lock (base: 18 disagree).
    • The lock is still selected by 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 getMetaItem with an organization agree in every order: 0 / 120 above, and pin 3.

Pins (protocol.list-slot-prefer-local.test.ts, 85 tests)

  1. Generated: every subset of the five rows, every row order, with and without an organization, on 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, and getMetaItem naming the package serves the same row. A package with no row in scope has no slot.
  2. A's row beside the package-less row, both env-wide, both orders, with and without A's artifact, on dashboard and view: the slot is A's row, listed once.
  3. Organization scope, every order: the org's package-less row beats an env-wide row of A; the org's row of A beats the env-wide package-less row.
  4. Control: only the package-less row, under A's artifact. The slot falls back to it, stamped A, as getMetaItem does. Lit control: the artifact alone is served as itself.
  5. The draft preview: A's draft and a package-less draft, both orders. The previewed slot is A's draft, as getMetaItem with previewDrafts serves.

Reverse verification

  • Committed first (5c7330e64b). The row-order pick was restored through scripts/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, blob c3958342651e → 5261196669fb.
  • The tests resolve ./protocol.js from src, so no dist was involved.
  • Predicted, then measured on the pin file: 36 failed / 49 passed of 85.
    • Pin 2: red when A's row comes back first, green when the package-less row does, on both types and both artifact states (4 red / 4 green).
    • Pin 3, org package-less over env A: 3 red / 3 green. Pin 3, org A over env package-less: green in both orders (the org's rows are read after the env rows, so "latest" happened to coincide).
    • Pin 4: green. Pin 5: 1 red / 1 green. Pin 1: 28 red / 37 green.
  • Restored with git checkout HEAD -- ABS_PATH (the tool, and the trap): blob after = blob at HEAD = c3958342651e; git diff HEAD empty.

Tests

At 978945aecd (after merging origin/main at 75ddcd1b41, which touches cloud-connection, metadata-core's protocol handshake and runtime; 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 --listFiles includes the new test file.
  • objectql, against the rebuilt metadata-protocol dist: the 20 test files that call getMetaItems, 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-contract is 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)".
  • The three non-zero results are roster gates that read a pull request, which a local run has none of (exit 2, not wired):
    • check-partof-closing-keyword: re-run with this body as PR_BODY;
    • check-closing-target-claim and check-single-claim-paths: NOT MEASURED locally (each needs a PR number and a token); their guard workflows run on this PR.
  • Named readings:
    • 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 at 5c7330e64b read PREREQUISITE NOT MET (exit 3) on the two build readers, and that pass is not counted.
    • The four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors): green.
  • Lint, a proven narrowing.
    • Command: eslint --no-inline-config --format json over the 2 touched .ts files, at 978945aecd: 2 files, 0 errors, 0 warnings.
    • Population, read from eslint's own calculateConfigForFile / isPathIgnored: both are linted (neither ignored).
    • Invariance: no type-aware linting (parserOptions.project and projectService read null for both), so this diff cannot move any untouched file's verdict.
    • The repo-wide pnpm lint is CI's.
  • NOT MEASURED here, CI's: Test Core shards beyond the two packages above; Temporal Conformance, Dogfood, Build Core; the workspace type-check lane.

Acceptance notes

  • A list scoped to one package never falls back to the package-less row. getMetaItems({ type, packageId: A }) (GET /meta/:type?package=A) reads only A's rows (readActiveOverlayRows), so a package-less row never reaches the merge.
    • Under A's artifact with only the package-less row stored, that list serves A's artifact while getMetaItem naming A serves the package-less row.
    • With an org: the org's package-less row against an env row of A, the scoped org list serves env A while getMetaItem serves the org's package-less row.
    • These are the 25 census rows left. 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.
  • view folds 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 on dashboard. Measured on the engine double; not changed here.
  • A served body can state a lock its envelope does not report. Engine double, at 18c2ddc1ec and at this head alike. The organization holds only package B's row; an env-wide row of A declares full. getMetaItem naming A in that organization answers lock: 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, and withOverlayLockFamily leaves the body unchanged when no overlay row binds. Reported to the seat; not changed here.
  • Spelling. The list's other-spelling fallback is per type and scope (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

claude added 3 commits October 5, 2026 03:39
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (5 routes)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6cdf4411818e095fe62e046cecce51acc882be23 — the merge of head 978945aecdf8e6077ebddde9b04a6e6df0ae7fbc into base 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21815 at head 978945aecd

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T04:58Z. The os-dev report is on #21804 (5988348230). Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.
    • The first lines are Fixes #21804 and Clause-②: no.
    • The closing-keyword scan finds #21804 only.
  • Scope: 4 files, +494/-55: metadata-protocol's protocol.ts, the new pin file, one engine-double-contract row (written by the gate's own --write), and the changeset. NOT governed, and no packages/spec/src/** path. No export moves, so no is right and no contract review is owed.
  • The diff, read: triage's direction (5987404976), as ruled.
    • servedOverlayRowCandidates(address) is the one candidate order: the organization's rows, then the env-wide rows; within each, the canonical spelling, then the other; within each, the package's own row, then the package-less row, never another package's.
    • findServedOverlayRow iterates it against the store. That is the same findOne sequence as before; I re-derived it against the base's inScope/lookup pair.
    • mergePackageAwareOverlay iterates it over the rows the list already holds (servedStoredRow). Each stored row now carries its organization and type spelling. It does so in the active merge and in the previewDrafts merge.
    • ⛔ No second resolver. The list item's lock still comes from resolveOverlayLockLayer (PR fix(metadata-protocol)!: an item's lock is the strictest lock among the stored rows in scope for its address (#21761) #21801).
  • Measured:
    • List-versus-getMetaItem disagreements in the unscoped lists fell from 39/120 to 0/120 with no organization, and from 41/120 to 0/120 with one.
    • Of 840 content dumps, 92 moved. All of them are inside a package slot of an unscoped list, and each moved to the row getMetaItem naming that package serves. Nothing changed outside package slots, and no slot count changed.
    • 18 list lock-family dumps moved, each to agree with its envelope.
  • Pins (protocol.list-slot-prefer-local.test.ts, 85 tests):
    • pin 1: a generated pin over 32 row subsets × 2 organization states, with a completeness check;
    • pin 2: both row orders on dashboard and view;
    • pin 3: organization scope (H4);
    • pin 4: the package-less fallback control;
    • pin 5: the previewDrafts merge.
  • Reverse verification: from committed 5c7330e64b, the latest-wins loop was put back. It gave 36 failed / 49 passed: pin 2 red only under the package-row-first order, and pin 4 green. The restore was proved by blob equality and an empty git diff HEAD.
  • Changeset, checked sentence by sentence:
  • Evidence:
    • metadata-protocol: 20037 tests pass in 214 files.
    • objectql against the rebuilt dist: 343 tests pass in the 20 files that call getMetaItems.
    • The typecheck is green, and --listFiles compiles the new pin.
  • Gates:
    • dispatch-gates --ran: 72 of 72 exit 0. The roster and the four symbol-anchor sweeps are green.
    • The two PR-context guards not measured locally are CI's on this PR.
  • Deviations, read:
    • Pin 1 runs on dashboard, because view's container expansion folds same-name slots after the merge. Pin 2 covers view.
    • One command was refused by the dev session's safety check (a bash -c wrapper). The dev re-ran the ablation without the wrapper. The seat redid nothing.
  • CI: read at landing.

Out-of-scope findings, carried by the seat:


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 05:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 05:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 3237b4a Oct 5, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21804-list-slot-prefer-local branch October 5, 2026 06:08
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] getMetaItems: a package's list slot can serve the package-less row's body while getMetaItem naming that package serves the package's own row

2 participants