Skip to content

fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name (#21803) - #21844

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21803-artifact-lock-package-axis
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21803-artifact-lock-package-axis

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21803
Clause-②: no (narrowing)

What changes

Two installed code packages may ship one (type, name) (ADR-0048 §3.4). The ADR-0010 _lock gate looked the packaged artifact up with no package, so it bound the first package registered. getMetaItem, getMetaItemLayered, the list and the getMetaDiagnostics tile looked it up with the request's package. So one item had two lock answers, and the door's answer depended on registration order. Measured on the real SchemaRegistry (H1 below), the defect runs in both directions:

  • B ships _lock: 'full', A ships no lock, B registered first: a read naming A says none while the door refuses. This is the card's case: it fails closed.
  • Same arrangement, A registered first: a read naming B says full while the door admits. This one fails open, because B's packaged lock is not enforced at all.

Now:

  • One selection of the artifact layer. resolveArtifactLockLayer(address, artifactsOf) in packages/metadata-protocol/src/item-lock.ts returns the artifact of every installed package that ships the name, with the address's own package first and then the others by package id. The protocol's reader is shippedArtifactsOf, wrapped per address as artifactLockLayerAt. The gate's artifact limb, both reads, the list items and the diagnostics tile all take the layer there. No caller picks an artifact for the lock.
  • The lock is the strictest per-package answer. resolveItemLock reads the layers once per shipping package: that package's artifact over the stored rows in scope, first binding layer wins, exactly the rule as before. The item's lock is the strictest of those answers. With one package shipping the name, or none, the answer is unchanged.
  • Content stays prefer-local. A read naming A is still served A's artifact under A's provenance; only its lock family follows the binding package.
  • The served body states the resolution's answer (withItemLockFamily, which replaces withOverlayLockFamily). That means the binding layer's lock family, and no _lock* key when nothing binds. This also settles the position folded in from 5988387087 (H6 below).
  • The overlay rows are read lazily as before: only when some shipping package's artifact declares no lock, or no package ships the name.

Rulings honoured, and the one mechanism hypothesis falsified

  • Triage's direction (5987395126, restated in 5987813232): strictest among the installed packages that ship the name, never a widening, one resolution for the gate and both reads, no prefer-local artifact lock at the door. All honoured.
  • H3, taken literally, widens, so the route changed. The hypothesis was to make the artifact layer the strictest artifact, then keep "first binding layer wins". The BASE census shows a cell where that admits a save the door refused. A ships no lock, B ships no-delete, and the env-wide row declares no-overlay. With A registered first, the door at BASE refuses the save, because A's artifact does not bind and the row's no-overlay does. A strictest artifact layer would bind B's no-delete and admit it. So the resolution takes the strictest per-package answer instead. That is the ruling's own wording read per package, and its "never a widening" holds by construction: the artifact the gate bound before is always one of the shipping packages. That cell now refuses both verbs under both orders (pin 9).

H1: the artifact lookups at BASE c4d57131b5, on the real SchemaRegistry

B ships _lock: 'full', A ships no lock. Each call is listed with which package's artifact it bound.

order request gate (getEffectiveLock) save / delete getMetaItem = getMetaItemLayered list slots tile locked
B first no package B: full refused / refused B: full B full, A none 1 of 2
B first naming A B: full refused / refused A: none A none 0 of 1
B first naming B B: full refused / refused B: full B full 1 of 1
A first no package A: none admitted / admitted A: none A none, B full 1 of 2
A first naming A A: none admitted / admitted A: none A none 0 of 1
A first naming B A: none admitted / admitted B: full B full 1 of 1

The call sites:

  • The gate's artifact limb (lookupArtifactItem(canonicalType, name)) binds the first package registered, whatever the request names.
  • getMetaItem and getMetaItemLayered (lookupArtifactItem(type, name, packageId)) bind the named package's artifact, or the first registered when no package is named.
  • The list items and the diagnostics tile (packageId ?? item._packageId) bind each slot's own package.
  • mergeArtifactProtection put whatever its caller had looked up.

With B disabled, the gate bound B only when B was registered first, while a read naming A said none under both orders. After the change, every row above reads full with B's prose, is refused on both verbs, and keeps prefer-local content.

H2: what the registry enumerates

SchemaRegistry.getArtifactItem(type, name, packageId?) answers one entry: the asked package's own, else the first composite registered. The registry has no method that returns them all. The reader composes existing public methods only, so no new public surface:

  • listItems(type), indexed by name, gives the packages whose entries ship each name. It hides a disabled package's entries.
  • getAllPackages() plus isPackageDisabled(id) add the disabled packages, probed for every name.
  • getArtifactItem(type, name, P) per candidate package, kept only when it is P's own (_packageId === P). Line 17607's view-expansion code already uses this idiom.
  • The package-less getArtifactItem(type, name) is always in the set. It is the old gate's answer, and the only way to reach a plain-key artifact.
  • An object has one owner (ADR-0029 D3), so its single owner-layer lookup is the whole set.
  • A registry whose listing throws contributes no package ids, so the set falls back to the package-less lookup. That is never looser than the old gate (pinned by protocol.runtime-authoring-gate.test.ts).

H4: census, before and after, on the real SchemaRegistry

There are 2000 door verdicts, from A's lock (5 values) × B's lock (5) × the env-wide row (5) × registration order (2) × request shape (save naming none, A or B; delete naming none) × B enabled or disabled. Every verdict comes from the real gate (getEffectiveLock).

transition cells
refused → admitted (widenings) 0
admitted → refused (narrowings) 416
unchanged 1584

Verdicts that depend on registration order: 416 of 1000 cell pairs before, 0 after. The narrowings split evenly, 26 per (enabled or disabled × order × shape × verb) group.

H5: the family's enumeration pin

protocol.lock-one-resolution.test.ts pin 1 is now the family's enumeration:

  • Positions. Each position is a layer of ITEM_LOCK_LAYERS × a family axis (topology, organization, package), with the table axes that open it and the card that measured it.
  • Completeness check, failing by name, when:
    • a layer × family axis has no position;
    • a position is opened by no slice;
    • an axis of the table belongs to no position;
    • an ITEM_ADDRESS_FIELDS field has no axis.
  • Rows. The union of three slices, each a full product: 24 000 rows in all, each run under both stored-row orders and both registration orders.
    • The family product: PR fix(metadata-protocol)!: an item's lock is the strictest lock among the stored rows in scope for its address (#21761) #21801's 16 320 rows. It is checked against a frozen copy of their own titles, so no row is lost.
    • The artifact layer × package (7 200 rows): another installed package ships the name at each lock level, crossed with the package's own artifact, the env-wide row's lock, every request shape (no package, the package, the other package), both organization scopes, both topologies and both spellings.
    • The stored rows × organization × package (480 rows): the organization holds only another package's row.
  • Every row asserts that both reads report the oracle's lock, that the door's verdict matches, that the door agrees with the envelope (bar the one declared spelling difference), and that a served body states the envelope's lock.
  • Named pins:
    • pin 8, the card's case under both orders, for reads naming A, B or no package, plus the list and the tile;
    • pin 9, the never-widening join;
    • pin 10, a disabled package;
    • pin 11, the folded position.
  • The same case runs on the real SchemaRegistry in packages/objectql/src/protocol-lock-artifact-package-axis.test.ts, because @objectstack/metadata-protocol cannot import objectql.

H6: the folded position (5988387087)

This is fixed without a ruling. Only the served body disagreed: the envelope and the door already agreed on none. withItemLockFamily writes the resolution's answer onto the body and removes any _lock* key the answer does not carry. Pin 11 covers it; the layered read still reports the stored layer as stored. Slice 3 of the pin carries the same position.

Reverse verification (one-shot, committed state)

  • Gate limb. At 323ab0b07a, through scripts/ablation-replace.mjs in wrap mode, the gate's artifact limb was put back to the package-agnostic first artifact ([this.lookupArtifactItem(canonicalType, name)]). The anchor went 1 → 0 and the blob 182c6677 → c3840b80.
    • The pin file went 2506 red / 21538 green.
    • Pin 8 is red under "package A registered first" for all three request shapes, and green under "B registered first".
    • Also red: pin 9 (6), pin 10 (1), and 2496 rows of the artifact × package slice. Nothing red outside rows where another package ships the name.
    • Restored with git checkout HEAD -- PATH: the blob equals HEAD (182c6677) and git diff HEAD is empty.
  • H6 body clearing. Ablated the same way: 14 red (the 12 slice-3 cells of the folded arrangement, plus pin 11 × 2). Restored, with git diff HEAD empty.

Tests

  • pnpm --filter @objectstack/metadata-protocol test at 323ab0b07a: 214 files passed (3 skipped), 27 745 tests passed (19 skipped).
  • objectql, the 30 test files that touch locks or read envelopes (the new one included), at 323ab0b07a: 940 passed. The real-registry file again at d1551fde71: 14 of 14 (the last commit only made its find double hold limit).
  • typecheck for both packages at 323ab0b07a: clean. objectql's test layer (check:test-typecheck) compiles the new file, with no new debt.

Gates, at d1551fde71

  • node scripts/pm/dispatch-gates.mjs --commands derived 74 commands, and the artifact-roster block printed 54 more outside that total. With the four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors) that makes 131 commands: 128 exit 0.
  • 3 are NOT MEASURED locally (NOT WIRED: they need a pull request context): check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths. Their guard workflows run them on this PR.
  • --ran reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded.
  • check:engine-double-contract asked for one pin row for the new objectql double. It was recorded through its own --write.

Lint (a proven narrowing; the repo-wide pnpm lint is CI's run)

Run: eslint --no-inline-config --format json over the four touched TS files: 4 files, 0 errors, 0 warnings.

  • Population. Read from eslint's own config: isPathIgnored is false for all four.
  • Count. Read from the JSON output.
  • Invariance. eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules), and its plugins are local per-file AST rules with no import resolution. The one removed export (withOverlayLockFamily) has zero remaining references. So this diff cannot move any untouched file's verdict.

Landing points

  • packages/metadata-protocol/src/item-lock.ts and protocol.ts, as the claim's file surface said.
  • The pin (protocol.lock-one-resolution.test.ts) and the changeset.
  • Outside that surface:
    • packages/objectql/src/protocol-lock-artifact-package-axis.test.ts: the real-registry pin, since the protocol package cannot import objectql.
    • scripts/engine-double-contract.pinned.json: the gate's own --write.
  • No packages/spec/src/**, no governed surface, no new public export: item-lock.ts is not re-exported from the package entry.

Acceptance notes

  • packagedArtifactOwner (the ADR-0126 flow-owner classification) and isArtifactBacked still take the package-less first artifact. They classify owner and existence, not a lock: existence does not depend on order, but the reported owner of a flow name two packages ship is the first registered. This is not a lock-family position; noted, not filed. Carrier: none.
  • A read can report a lock with no body served: content never serves another package's row, while the lock's scope includes it. The envelope and the door agree there, so the pin asserts the body only when one is served.
  • A served body's lock family is now normalized through extractProtection. An explicit _lock: 'none' is no longer kept on a body, and a _lockSource outside the spec's enum is not echoed. The full metadata-protocol suite and the objectql envelope tests stay green.
  • Cost: the list and the tile read the registry's listing once per type and probe only the packages that ship each name (commit f38762577c). The gate reads the listing once per write.

Generated by Claude Code

claude added 10 commits October 5, 2026 06:36
…alled packages that ship the name

The _lock gate looked the packaged artifact up with no package (the first
package registered) while both reads, the list and the diagnostics tile
looked it up with the request's package. Every caller now takes the artifact
layer from one selection (resolveArtifactLockLayer over shippedArtifactsOf),
and the resolution reads the layers once per shipping package and binds the
strictest answer, so the door and the reads agree under every registration
order and the door never answers looser than it did under any.

The served body carries the resolution's lock family (withItemLockFamily),
and no _lock key when nothing binds.

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

The artifact layer's package ids are best-effort context on a metadata-only
host whose partial registry cannot list; the package-less lookup still
answers, never looser than the gate before this change.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
… artifact layer's package axis

The generated pin becomes the family's enumeration: named positions (layer x
topology / organization / package), slices that open them, and a completeness
check that fails by name. PR #21801's 16 320 rows are kept, checked under
their own titles. Every row also asserts the served body states the envelope's
lock. Named pins: the card's case, the never-widening join, a disabled
package, and the folded content-scope position.

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

The card's case, a disabled package and the never-widening join, under both
registration orders, on the registry whose getArtifactItem / listItems /
getAllPackages answers the metadata protocol's artifact layer is built from.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…ip a name

Clause-② no (narrowing), minor, with its ADR-0087 disposition.

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

Written by `check-engine-double-contract.mjs --write`.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…em's name

The artifact layer's reader probed every package listed for the type and
every installed package for every item, and a probe that misses scans the
registry collection, so the list and the diagnostics tile paid items x
packages x collection size. The listing is now indexed by name; a package is
probed for every name only when the listing cannot attribute it (a disabled
package, an entry without a name). Same set, same answers: the H4 census is
cell-for-cell unchanged.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…aller's bound

check:objectql-double-limit: the bound is applied after the filter, by
presence.

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

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

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/api/index.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/automation/flows.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), getMetaItemLayered (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/data-modeling/drivers.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/data-modeling/objects.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/deployment/cli.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/deployment/environment-variables.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/deployment/validating-metadata.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/kernel/cluster.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/kernel/contracts/metadata-service.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf), getPublished (sdk, the bare tail of client method meta.getPublished, bound to GET /api/v1/meta/:type/:name/published; the bare tail of client method meta.getPublished, bound to GET /meta/:type/:name/published))
  • content/docs/kernel/services-checklist.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/permissions/authorization.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/permissions/permission-sets.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/plugins/packages.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/releases/v17/17-1.mdx (via getMetaDiagnostics (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/releases/v17/17-3.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf), /:type/:name/published (route, bridged from symbol getMetaItemLayered — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))
  • content/docs/releases/v17/17-6.mdx (via sys_metadata (literal, a string literal in shippingPackagesOf))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 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 27991556788f1936c2ccae9aaed54996bac168b7 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 27991556788f1936c2ccae9aaed54996bac168b7

⚠️ 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 27991556788f1936c2ccae9aaed54996bac168b7 → 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 #21844 at head d1551fde71

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T08:31Z. The os-dev report is on #21803 (5990899051). Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.
    • The first lines are Fixes #21803 and Clause-②: no (narrowing).
    • The closing-keyword scan finds #21803 only.
  • Scope: 6 files, +1151/-208: metadata-protocol's item-lock.ts and protocol.ts, the family pin, a real-registry twin in objectql, one engine-double-contract row (written by the gate's own --write), and the changeset.
    • NOT governed, and no packages/spec/src/** path.
    • item-lock.ts is not reached from the package entry: index.ts does not re-export it, and exports maps "." only. So renaming withOverlayLockFamily to withItemLockFamily moves no public surface.
    • Clause-②: no (narrowing) is right, and no contract review is owed.
  • The diff, read: triage's direction (5987395126, restated by 5987813232).
    • shippedArtifactsOf enumerates every installed package that ships the name, a disabled one included, from existing registry methods. resolveArtifactLockLayer orders them: the request's package first, then by package id.
    • resolveItemLock takes, per shipping package, that package's artifact over the stored rows in scope: the first binding layer, as before. The item's lock is the strictest of those answers.
    • The artifact the door bound before is always one of those packages. So the result is never more permissive than the old door, by construction.
    • resolveItemLockLazily reads the stored rows only when some package's artifact does not bind.
    • Every lock caller takes it from artifactLockLayerAt: the _lock gate's artifact limb, getMetaItem, getMetaItemLayered, the list items and getMetaDiagnostics. ⛔ No prefer-local artifact lock at the door. Content stays prefer-local.
  • H3 falsified, route switched within the ruling. Taken literally (the strictest artifact layer, then the first binding layer), H3 admits one cell the base door refused: A ships no lock, B ships no-delete, and the row declares no-overlay. The per-package rule closes it. "Never a widening" is the ruling's governing word, so this is the ruling applied.
  • Measured on the real SchemaRegistry:
    • Of 2000 door cells, 0 widenings and 416 narrowings.
    • Order-dependent cell pairs fell from 416 of 1000 to 0 of 1000.
    • At the base, one arrangement failed open: A registered first, a save naming B was admitted while the read said full. That is closed now.
  • The folded position (5988387087), fixed without a ruling. The envelope and the door already agreed. withItemLockFamily now writes the resolution's answer onto a served body and removes a _lock family nothing binds. The changeset states the visible effect: an explicit _lock: 'none' is no longer echoed on a body.
  • Pins:
  • Reverse verification:
    • Restoring the gate's package-agnostic first artifact gave 2506 failed / 21538 passed. Pin 8 went red only under "package A registered first", and nothing went red outside rows where another package ships the name.
    • The H6 ablation turned exactly its 14 cells red.
    • Both restores were proved by blob equality and an empty git diff HEAD.
  • Changeset, checked sentence by sentence:
    • minor, ! title, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription).
    • The three "what moves" bullets match H4's narrowing classes.
    • "No write the door refused before is admitted now" matches 0 of 2000.
    • The served-body paragraph matches pin 11.
  • Evidence:
    • metadata-protocol: 27745 tests pass in 214 files.
    • The objectql envelope files pass: 940 tests in 30 files.
    • Both typechecks are green.
  • Gates:
    • dispatch-gates --ran: 74 of 74 exit 0.
    • The roster and the four symbol-anchor sweeps pass.
    • check:objectql-double-limit was red on the first pass and green at d1551fde71, with the union re-run there.
  • CI: read at landing.

Recorded, not filed: packagedArtifactOwner / isArtifactBacked still read the first registered artifact for a flow two packages ship. That is classification, and the door and the reads agree on it. It is in the PR's Acceptance notes.

Cross-lane: domain:services' #21789 holds its metadata-protocol half until this PR lands (5989615497, 5990896826).


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…nce, so org-owned sets, clones and runtime-package sets edit again (objectstack-ai#21857)

Fixes objectstack-ai#21789
Clause-②: no

## What this changes

The packaged-permission-set lock in `plugin-security` answers one
question for both write doors: is this set shipped by a code (artifact)
package? It answered it as "does any engine-registry item of this name
carry a package id?". The registry holds stored rows as well as
artifacts, and the metadata list read (`GET /api/v1/meta/permission`,
which every Studio page load issues) stamps a stored row's `package_id`
column onto its body as `_packageId`. So a set saved into a writable
runtime package looked code-shipped after the first list read.

The read the console renders from had the matching defect. The security
plugin keeps a marked copy of every overlay-backed definition in the
metadata manager for the evaluator (the "projection echo"), and the
protocol's layered read serves that copy as the item's `code` layer. The
echo carried no provenance, so an org's own set, a clone and a
runtime-package set all reported a `code` layer with no `provenance`.
objectui's permission-matrix editor reads exactly that as "a code
package ships this" and rendered them locked, while every write door
accepted the save.

Two edits, both in `packages/plugins/plugin-security/src`:

1. `packaged-permission-set-lock.ts`, `declaredPackageIdOf`: a
tenant-authored item (ADR-0010 `_provenance: 'org'`, the stamp the
hydrator writes on every stored row) is never a shipped artifact. It is
read through `isTenantAuthored` from `@objectstack/metadata-core`, the
exclusion `isCodeArtifactBody` and `SchemaRegistry.getArtifactItem`
already apply. No second provenance evaluator. A stored row of a name a
code package ships is hydrated wearing the artifact's envelope
(`_provenance: 'package'`), and the artifact itself is in the same list,
so a code-shipped set stays locked.
2. `permission-set-projection.ts`: the projection echo carries
`_provenance: 'org'` exactly when `classifyPackagedPermissionSet` (the
classifier both write doors ask, fed the same layered probe) answers
`org` for the name. A `packaged` or `unknown` verdict leaves the echo
unstamped, as before. The reported state and the enforced state are one
judgment.

Not touched: `metadata-protocol` (H4 was not needed), `packages/spec`,
any error code, any export, any parameter of an exported function (the
new parameter is on the module-private `syncEvaluatorRegistry`). The
lock-resolution semantics from objectstack-ai#21801 are unchanged.

## Measurements, before and after

Driven through the real showcase over HTTP, base `088428fb` (before) and
this branch (after):

| shape | before: door (`PUT /meta` after the list read, `PATCH /data`)
| before: layered read | after: door | after: layered read |
|---|---|---|---|---|
| set in a writable runtime package | 403 `NOT_OVERRIDABLE` / 403
`NOT_OVERRIDABLE` | `code` = echo, no `provenance`, `editable: true` |
200 / 200 | `provenance: 'org'`, `editable: true` |
| org-owned set (data door) | 200 / 200 | `code` = echo, no
`provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`,
`editable: true` |
| clone ("Clone to customize") | 200 / 200 | `code` = echo, no
`provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`,
`editable: true` |
| control: `showcase_contributor` (shipped by `com.example.showcase`) |
403 `NOT_OVERRIDABLE` / 403 `NOT_OVERRIDABLE` | `code._packageId` = the
package, `provenance: 'package'`, `editable: false` | unchanged |
unchanged |

The runtime-package set's registry row after the list read was `{
_packageId: 'com.dogfood.lock21789', _provenance: 'org' }`: the
provenance that tells it apart was on the body all along.

## Mechanism hypotheses, measured

- **H1, holds.** The lock read any non-sentinel `_packageId` as
code-shipped. The three shapes carry, in the registry: runtime-package
set `{ _packageId: PKG, _provenance: 'org' }` (the package id appears
only after a list read; neither the write-through nor the boot hydration
stamps it), org-owned set and clone `{ _provenance: 'org' }`, no package
id. The clone's record has `created_by` and `organization_id` null, but
so do the org-owned set's and the runtime-package set's records: it is
not specific to the clone.
- **H2, holds.** The platform's one answer is `isCodeArtifactBody` /
`isTenantAuthored` in `@objectstack/metadata-core` (already a dependency
of `plugin-security`). The lock reuses `isTenantAuthored`; it keeps its
two documented extensions (the echo-marker skip and the spec `packageId`
fallback).
- **H3, holds, with a refinement.** The org-owned set and the clone were
never refused by the server (both doors 200 before the fix); their
"lock" was report-only. The runtime-package set was refused by both
doors while the server's own `editable` said `true`. So the reported
state and the enforced state were split in both directions, and the fix
pins both.
- **H4, not needed.** No `metadata-protocol` edit: the layered read
already reads `provenance` off the `code` layer, and the echo now states
it.
- **H5, the lock's judgment (the smaller one).** Stamping the clone's
`created_by` / organization would not change anything the lock or the
console reads: the server lock already answered `org` for the clone, and
the console's lock came from the echo's missing provenance.
- **H6, holds.** The code-shipped set is still refused at both doors
with `403 NOT_OVERRIDABLE` (the data door's refusal is the lock's own
sentence naming the clone path), and its layered read still reports
`provenance: 'package'`, its package id and `editable: false`, before
and after a cold boot.

## Pins

- `packaged-permission-set-lock.test.ts`, block `[objectstack-ai#21789]`: the
classifier over the bodies the hydrator registers (runtime-package row,
org-owned row, clone; a shipped artifact, alone and beside a legacy
overlay wearing its envelope, in both orders), the layered probe with no
registry, the data door (hatch-open double, so only the lock can
refuse), and the metadata-door gate, with the refusal asserted on `code`
and `status`.
- `permission-set-projection.test.ts`: the echo of a set no code package
ships carries `_provenance: 'org'`; the control shows the echo of a
legacy overlay of a shipped set does not.
-
`packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts`
(new): the showcase, the three shapes made through their real doors
(`POST /packages` then `PUT /meta/permission/NAME?package=PKG`; `POST
/data/sys_permission_set`; the shipped `clone_permission_set` action's
own payload), the list read, a precondition that the list read stamped
the package id, then both doors and the layered read for each shape, the
code-shipped control at both doors and on the read, and a cold boot on
the same file that reads the three shapes again (the echo minted by the
boot's reconciliation) and re-checks the control.

## Ablations (each committed first, mutated through
`scripts/ablation-replace.mjs`, rebuilt, dist proven, restored to
`HEAD`)

Both ablations were run at `e9dff47f` (the fix and its pins committed,
pre-merge), each through `node scripts/ablation-replace.mjs` (anchor
hits went from 1 to 0, blob changed), then `pnpm turbo run build
--filter=@objectstack/plugin-security`, then `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER
--absent` (exit 0: marker absent from every built file), because the
dogfood suite resolves `plugin-security` from `dist/`. Each restore was
proven by blob equality with `HEAD` and an empty `git diff HEAD`, then a
rebuild and the preflight without `--absent` (exit 0, marker back in
`dist/index.js`, tree clean).

| ablation | what was put back | unit result | dogfood result |
|---|---|---|---|
| 1 | the lock reads "has a package id" again: `if
(isTenantAuthored(item)) return null;` replaced by a no-op | 5 failed /
87 passed: the four classifier/door pins for the runtime-package shape,
and the echo pin | 2 failed / 12 passed: runtime-package set, metadata
door and data door |
| 2 | the echo states no provenance again: the `_provenance: 'org'`
spread replaced by an empty one | 1 failed / 91 passed: the echo pin | 4
failed / 10 passed: the layered-read pin for each of the three shapes,
and the cold-boot read |

The controls (a code-shipped set refused, and its read reporting
`provenance: 'package'`) stayed green in both directions, as they must.
A first attempt at ablation 1 used a replacement that left the
`isTenantAuthored` import unused, so the DTS step of the build failed
(the JS bundle still carried the ablation and the same pins went red);
it was redone with the import kept in use, and the numbers above are
from the clean run.

## Tests and gates

All on `9e3e32ed` (this branch after merging `origin/main` `8832655a`,
which carries objectstack-ai#21812 and touches `plugin-security`), after rebuilding
the dogfood dependency closure:

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 167 files passed, 3600 tests passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0
(including `check:test-typecheck`: 0 errors).
- `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/permission-set-lock-row-provenance.dogfood.test.ts`: 14 passed.
`pnpm --filter @objectstack/dogfood typecheck`: exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 71 gate commands; all 71 run, every
exit 0, `--ran` verdict: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN.
`check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT
MET: eight packages outside the dogfood closure had no `dist/`); those
eight were built and the gate re-run, exit 0.
- Lint, narrowed and proven: `pnpm exec eslint --no-inline-config
--format json` over the five touched TypeScript files (the changeset is
not linted): 5 files in the JSON output, 0 errors, 0 warnings. The
population is the files this diff touches; `eslint.config.mjs` never
enables type-aware linting (no `parserOptions.project`, no typed rules,
stated in its own header), so this diff cannot move the verdict on any
untouched file. The repo-wide `pnpm lint` is CI's.

## Acceptance notes

- **Not in this PR: `metadata-protocol`.** Measured, the layered read
did not need to change for the read and the lock to agree: it reads
`provenance` off its `code` layer, which is the plugin's projection
echo, and all three shapes read `provenance: 'org'` with `protocol.ts`
byte-identical to `main`. No shape is left unfixed without a protocol
edit. This PR's file list is disjoint from objectstack-ai#21844's (`item-lock.ts`,
`protocol.ts`, two protocol/objectql tests, the engine-double ledger,
its changeset).
- **Observation, not filed (carrier: the `domain:engine` seat, objectstack-ai#21844
holds the region).** For a set no artifact ships, the layered read's
`code` layer is still a non-null body (the echo, read through
`readItemFromMetadataService` in `getMetaItemLayered`), while
`GetMetaItemLayeredResponseSchema.code` says `null` when no artifact
ships the item. The registry fallback right below it already drops a
tenant-authored item (`runtimeOnly`, `isTenantAuthored`); the
MetadataService read does not. After this PR the echo is tenant-stamped,
so a provenance-only filter there would answer `code: null` for these
sets; no client reads a wrong answer today, which is why it is noted
here rather than built.
- **Finding, reported for the seat to file (same family: a package id
read as "shipped by code").** The Discard Overlay action's eligibility
(`permission-set-overlay-discard.ts`, `discardPermissionSetOverlay`)
reads `_packageId ?? packageId` on the registry item, as the lock did.
Measured on `088428fb` and again on this branch: after a list read,
`POST /api/v1/security/permission-sets/ID/discard-overlay` on a set
saved into a writable runtime package answered 200 and deleted the set's
only `sys_metadata` row. The action declares, and
`content/docs/permissions/permission-sets.mdx` repeats, that it refuses
any set that is not currently package-declared.
`permission-set-drift.ts`'s declared filter carries the same reading.
Not fixed here: outside the claimed file surface.
- **Finding, reported for the seat to file.** A data-door edit (`PATCH
/api/v1/data/sys_permission_set/ID`) of a set saved into a writable
runtime package writes a second, package-less active `sys_metadata` row
carrying the edit and leaves the package-bound row unchanged (the
write-through's update leg calls `saveMetaItem` without the row's
package). Measured on this branch: two active rows after one PATCH; the
projected record reads `managed_by: 'admin'`, `package_id: null`. It is
reachable on `main` before any list read, and through a package-less
`PUT /meta`; this PR lets the data door accept the edit after a list
read too.
- **H5.** The clone's record has `created_by` and `organization_id`
null, and so do the other two shapes' records: the projection writes
them in system context. It is not what locked the clone, and is not
changed here.
- **Docs.** No `content/docs` sentence is made false by this change;
`content/docs/concepts/metadata-lifecycle.mdx` (runtime-created sets,
package-bound rows included, keep working) becomes true.
`content/docs/permissions/permission-sets.mdx` still says an edit of a
packaged set through Setup becomes an environment overlay, which the
lock has refused since the clone-to-customize ruling; that is older
drift, not touched here.
- **Report state.** `Clause-②: no` is copied from the claim: the fix
restores the lock's declared population (code-shipped sets) and widens
no accepted input; a code-shipped set is refused exactly as before.

---
_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

2 participants