Skip to content

fix(metadata-protocol): a package-scoped list slot serves the package-less row getMetaItem naming the package serves - #21871

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21817-scoped-list-fallback
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21817-scoped-list-fallback

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name (#21803) #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 #21815's census names "16 arrangements over five rows" but does not list them, so this census runs their superset:

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 #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

claude added 5 commits October 5, 2026 10:08
…-less row getMetaItem naming the package serves

The package-less rows in scope enter each merge of a package-scoped list as
stand-ins: they serve a slot the package seats, by the shared candidate order,
and never seat one, so the list's membership is unchanged.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…tem naming the package, and its membership

Seven pins: the generated table over every subset and order of five rows,
with and without an organization and the package's artifact; named row
orders; membership; the MetadataService layer; the draft preview; the view
container expansion; the lock family. Plus the patch changeset.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…e engine-double ledger

Written by `node scripts/check-engine-double-contract.mjs --write` (1 row added, 0 lost).

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 9f9510f25e6aa65aa61ce3effb42706fabcab92e → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21871 at head b5492dce3e

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T12:04Z. The os-dev report is on #21817 (5994000158). Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main.

    • The first lines are Fixes #21817 and Clause-②: no.
    • The closing-keyword scan finds #21817 only.
    • Assignee: os-project-manager.
  • Scope: 5 files, +581/-25:

    • packages/metadata-protocol's protocol.ts (+111/-21);
    • the new pin file protocol.scoped-list-fallback.test.ts;
    • a docblock pointer in protocol.list-slot-prefer-local.test.ts;
    • one findOne row in scripts/engine-double-contract.pinned.json for the new pin file's double;
    • the changeset.

    NOT governed, no packages/spec/src/** path. No export, parameter, key, status or error code moves (mergePackageAwareOverlay is module-private; its new unseated argument is internal). So Clause-②: no is right, and no contract review is owed.

  • The diff, read: triage's direction (5988911029), as ruled.

    • A list scoped to a package passes the package-less rows in its scope into mergePackageAwareOverlay as stand-ins. A stand-in serves a slot the package seats through the one candidate order (servedOverlayRowCandidates / servedStoredRow). ⛔ No second resolver (H2).
    • The active stand-ins come from the package-agnostic lockRows read the scoped path already made, filtered back to package_id null: no extra read. Drafts take one package-agnostic draft read per scope, only under previewDrafts.
    • A stand-in never seats a slot. A slot only stand-ins reach is held in the unseated WeakSet, a later layer (draft preview, MetadataService listing) may still seat it, and what is still held is dropped after the last merge. So the scoped list's membership is unchanged (H3), and the lock path is untouched (H4).
  • Census (engine double, every subset of five rows × every row order × artifact/no artifact × organization/no organization × packages A and B, on view and dashboard; base blob 182c66778c):

    • Slot vs getMetaItem naming the package, disagreements base → head: A no-org 49/587 → 0; B no-org 0/522 → 0; A org 90/636 → 0; B org 424/522 → 0.
    • Membership: 0 of 5216 scoped name sets differ; 1126 scoped dumps differ, all in the name's slot, and all 1126 now equal getMetaItem. The unscoped list (0/2608) and getMetaItem (0/5216) do not move.
    • Lock: 0 of 7830 slot lock families differ, including the 993 where the served row moved.
  • Pins: protocol.scoped-list-fallback.test.ts, 155 tests in seven pins: the generated table (pin 1), named arrangements in both row orders (pin 2), membership (pin 3), the MetadataService layer (pin 4), the draft preview (pin 5), a stored view container's expansion (pin 6), and the lock (pin 7). Each pin asserts the scoped slot against getMetaItem naming the package in the same arrangement.

  • Reverse verification, from committed b5492dce3e, each restore proved by blob equality and an empty git diff HEAD:

    • (i) base protocol.ts whole: 48 of 155 red; membership pin 3 green (it pins what must NOT move).
    • (ii) the active stand-in read off: 47 red; pin 5 green.
    • (iii) the draft stand-in read off: 1 red (pin 5, A's artifact with a package-less draft).
  • Changeset, checked sentence by sentence: patch for @objectstack/metadata-protocol, with Clause-②: no. The three entry points named (getMetaItems({ type, packageId }), GET /api/v1/meta/:type?package=, getMetaItemsForExecution), the unchanged membership and the unchanged lock each match the diff and the pins.

  • Evidence: packages/metadata-protocol passes 27900 tests in 215 files (3 skipped). The typecheck is green, and --listFiles compiles both pin files. Narrowed eslint over the 3 changed TS files: 0 errors, 0 warnings.

  • Gates:

    • dispatch-gates --ran: 72 of 72 exit 0.
    • The roster and the four symbol-anchor sweeps pass.
    • The 3 PR-context guards exit 0 against this PR.
    • check-engine-double-contract --write leaves the ledger byte-identical.
  • CI: read at landing.

Recorded, not filed (the PR's Acceptance notes, within triage's ruling):

  • Where a package ships nothing of a name, its scoped list has no slot for that name, while getMetaItem naming the package still answers the package-less row. Triage ruled the scoped list's membership does not grow.
  • A package-less stored view container in a scoped list is a stand-in like any row, and its expansions are not served there. That is unchanged from base, where the scoped list did not read package-less rows at all.

Generated by Claude Code

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

2 participants