Repository navigation
fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name (#21803) - #21844
Conversation
…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>
…tifact-lock-package-axis
…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>
…tifact-lock-package-axis
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
ACCEPT (seat review) — PR #21844 at head
|
…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>
…-less row getMetaItem naming the package serves (objectstack-ai#21871) Fixes objectstack-ai#21817 Clause-②: no ## What changes A slot in a list scoped to one package (`getMetaItems({ type, packageId })`, `GET /api/v1/meta/:type?package=`, and `getMetaItemsForExecution`, which reads through the same method) now serves the row `getMetaItem` naming that package serves: the package's own row, else the package-less row (ADR-0048), the organization's rows before the env-wide rows (ADR-0005). The list's membership is unchanged. It still lists only the items the package ships. - **Landing point**, as the claim said: `readFlattenedMetaItems` in `packages/metadata-protocol/src/protocol.ts`, and the merge it calls, `mergePackageAwareOverlay`. No `packages/spec` change, no export change, no new error code. - **One candidate order (H2).** The package-less rows enter the list's merges as stand-ins (`standIn` on a record). The merge picks a slot's stored row through `servedStoredRow`, which walks `servedOverlayRowCandidates`, the one order `findServedOverlayRow` and the unscoped list already share. There is no second resolver. - **Where the package-less rows come from (H2).** - Active rows: no extra read. The scoped path already reads the package-agnostic set `readActiveOverlayRows({ type }, orgId)` for the lock (the lock-row read). That set is filtered back to the rows whose `package_id` is null (`standInRows`). - Draft preview, only with `previewDrafts` and a package: one package-agnostic draft read per scope, filtered back to the package-less rows (`standInDraftRecords`). - **Membership (H3).** A stand-in serves a slot the package seats and never seats one itself. A slot that only stand-ins reach is held back and recorded in a per-call `unseated` set. A later layer (the draft preview, the MetadataService listing, the view-container expansion) may still seat it. Whatever is still recorded after the last merge is dropped. - **The lock (H4).** Untouched. It is still selected by `resolveOverlayLockLayer` from every row in scope (PR objectstack-ai#21844). ## H1: the scoped read at the base At `18fe6815a2`. The `protocol.ts` blob there is `182c66778c`, the same blob as at `9f9510f25e`, the merge base of this head. With `packageId` set, `readFlattenedMetaItems`: - lists the registry's items of the package (`listItems(type, packageId)`); - reads its stored rows with `readActiveOverlayRows(request, orgId)`, whose `queryByOrg` puts `package_id = packageId` in the `where`. That is the package's own rows only, env-wide and the organization's; - also reads the package-agnostic set, but only for the lock (`lockRows`); - merges the package's rows over its items. A package-less row never reaches the merge, so a slot serves the package's row or its artifact; - previews drafts with `package_id = packageId` only, and keeps only the MetadataService items stamped with the package. ## Census: the scoped slot against `getMetaItem` naming the package, base vs this head Engine double. PR objectstack-ai#21815's census names "16 arrangements over five rows" but does not list them, so this census runs their superset: - every subset of the same five rows (env package-less, env A, env B, org package-less, org A); - every row order (326 orderings); - with and without A's artifact, and with and without an organization; - for packages A and B, on `view` (as PR objectstack-ai#21815) and on `dashboard`. A comparison is a case where the scoped list has a slot for the name. | request | package | disagreements at base | at this head | |---|---|---|---| | no organization | A | 49 / 587 | 0 / 587 | | no organization | B | 0 / 522 | 0 / 522 | | organization | A | 90 / 636 | 0 / 636 | | organization | B | 424 / 522 | 0 / 522 | The figures are identical on `view` and on `dashboard`. The base column is the card's defect class (25 of 240 on PR objectstack-ai#21815's subset), measured over every arrangement. ## H3: membership unchanged, nothing else moved - **Membership.** Over the same cases, the scoped list's name set (name with `_packageId`) differs base vs head in 0 of 5216 lists. Slot-count changes: 0. - **Changed lists.** 1126 scoped-list dumps differ, all in the one slot for the name. 0 differences outside it. Every changed slot now equals `getMetaItem` naming the package (1126 of 1126): - B: env-wide row of B → org-scoped package-less row, 848; - A: env-wide row of A → org-scoped package-less row, 152; - A: A's artifact → env-wide package-less row, 104; - A: A's artifact → org-scoped package-less row, 22. - **Other reads.** Unscoped list dumps: 0 of 2608 differ. `getMetaItem` dumps: 0 of 5216 differ. ## H4: the lock stays the item's Lock census on `dashboard`, with A's artifact, every subset and order, one row at a time declaring `no-overlay`, `no-delete` or `full`, with and without an organization: 7830 cases. - The scoped slot's lock family (`_lock`, `_lockReason`) differs base vs head in 0 of 7830. That includes the 993 cases where the served row moved. - `getMetaItem` envelopes: 0 differ. - At this head the slot's `_lock` equals the envelope lock in 7830 of 7830, the reason equals the envelope item's in 7830 of 7830, and the label equals the envelope item's in 7830 of 7830. At base the label matched in 6837 of 7830. ## Pins `protocol.scoped-list-fallback.test.ts`, 155 tests: 1. Generated: every subset of the five rows, every row order, with and without an organization and A's artifact. For packages A and B: where the package ships the name, the scoped slot serves the row an oracle written from the rule names, and `getMetaItem` naming the package serves the same. Where it ships nothing, the scoped list has no slot. 2. Named, both row orders: A's artifact beside the env-wide package-less row (on `dashboard` and `view`); the organization's package-less row over an env-wide row of A; the organization's row of A over the env-wide package-less row. 3. Membership: a package-less row of a name A does not ship adds no slot. The scoped list lists the same names with and without it, while the unscoped list still serves that row. 4. The MetadataService layer: a package-less row stands in for A's runtime item. A control shows no slot without the runtime item, and a lit control serves the runtime item alone. 5. The draft preview: a package-less draft stands in for A's slot. A's own draft wins over it in both orders. A package-less draft of a name A does not ship previews no slot. 6. The view-container expansion: a package-less row of a name A's stored container expands is served ahead of the expansion, stamped A. A lit control serves the expansion without it. 7. The lock: where the served row moves to the organization's package-less row, the slot's lock family equals `getMetaItem`'s envelope, for three lock levels on either row. `protocol.list-slot-prefer-local.test.ts`: its docblock's "out of this card" paragraph now points at the new pin file. No test changed. ## Reverse verification On the committed head `b5492dce3e`. Every restore is `git checkout HEAD -- PATH`, proven by the blob equal to HEAD's and an empty `git diff HEAD`. | arm | red | membership pin 3 | other | |---|---|---|---| | base `protocol.ts` restored whole (blob `182c66778c`) | 48 / 155: pin 1 32, pin 2 6, pin 4 2, pin 5 1, pin 6 1, pin 7 6 | 3 / 3 green | controls green | | leg A: the active stand-in read off (`standInRows` emptied) | 47 / 155: pin 1 32, pin 2 6, pin 4 2, pin 6 1, pin 7 6 | 3 / 3 green | pin 5 4 / 4 green | | leg B: the draft stand-in read off (`standInDraftRecords` emptied) | 1 / 155: pin 5, A's artifact and a package-less draft | 3 / 3 green | pin 5's membership case green | - Both legs went through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `c96ce0d942` → `1935e0b66f` (A) and `798b96b4a4` (B). Each was restored to `c96ce0d942`, HEAD's blob, with `git diff HEAD` empty. After the restore both pin files ran 240 of 240 green. - The pin file imports `./protocol.js`, a relative import that vitest resolves to `src/`. No `dist/` is on the path, so no rebuild was owed between the legs. ## Tests (at `b5492dce3e`) - The dependency closure (12 packages, `spec` through `metadata`) was built first. - `pnpm --filter @objectstack/metadata-protocol build`: `check-dts-emitted` 2/2 declared declaration files present. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` compiles 218 of the package's test files, both pin files among them. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: Test Files 215 passed, 3 skipped (218). Tests 27900 passed, 19 skipped (27919). ## Gates (at `b5492dce3e`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths: 5 paths against merge base `9f9510f25`, 72 commands. All 72 ran and exited 0. `--ran` printed: "✓ dispatch-gates --ran: 72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED". - The artifact-roster block printed outside that total: 54 families, 37 plus 17 that are self-test only. All exited 0 except three PR-context gates, which judged nothing without a PR (exit 2, "NOT WIRED" or "NOT MEASURED"): `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. They are re-run against this PR in its report on the card. - The four symbol-anchor sweeps exited 0: `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors`. - Families derived now but not at dispatch, because they come from the whole change set rather than `protocol.ts` alone. All exited 0: - `check-adr-0087-registration`, `check-empty-changeset` and `check-scripts-symbol-anchors`, each with its self-test; - `release-rehearsal-clone --self-test` and `release-pending-publish --self-test`; - `check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:engine-double-contract`, `check:entry-guard`, `check:objectql-double-limit`, `check:objectui-changeset` and `check:parse-guard`; - `check:pm-changeset-deadline-census`, `check:pnpm-filter-targets`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - `check:engine-double-contract`: the pin file's `findOne` double has its row in `scripts/engine-double-contract.pinned.json`. Re-running `node scripts/check-engine-double-contract.mjs --write` leaves the file byte-identical (blob `bc77050a7b`). - Lint, narrowed, because `pnpm lint` is CI's run: `pnpm exec eslint --no-inline-config --format json` over the 3 changed TypeScript files linted 3 files with 0 errors and 0 warnings. - Population: `eslint.config.mjs`'s block for every TypeScript and JavaScript file, minus the build directories. None of the three is ignored. - Invariance: the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. - Not run locally: the path-scheduled CI jobs and the type-check lanes `dispatch-gates` names as CI's own. They are left to CI. ## Changeset `.changeset/21817-scoped-list-fallback.md`: `@objectstack/metadata-protocol` `patch`, `Clause-②: no`. It says that a package-scoped list now serves a package-less customization of an item the package ships, as `getMetaItem` naming the package does, and that its membership is unchanged. ## Acceptance notes - **Membership is unchanged, by triage's ruling.** Where a package ships nothing of a name, its scoped list has no slot for it, while `getMetaItem` naming that package still answers a package-less row of the name (the by-name fallback). The census counts 63 such cases for A and 218 for B per type, identical at base and head. - **Package-less view containers in a scoped list.** The view-container expansion still expands only the package's own stored containers. A package-less stored container is a stand-in like any other row: it is held back, and dropped unless the package seats its name. At base the scoped list did not read package-less rows at all. - **Cost.** A draft preview scoped to a package makes one more `sys_metadata` read per scope (the package-agnostic drafts). The active arm makes none, because it reuses the lock-row read. - **Not measured over HTTP.** Engine double only, which is the card's own measurement basis. ## Files - `packages/metadata-protocol/src/protocol.ts`: the fix. - `packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts`: the pins (new). - `packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts`: docblock pointer only. - `scripts/engine-double-contract.pinned.json`: one ledger row for the new pin file's double. - `.changeset/21817-scoped-list-fallback.md`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21803
Clause-②: no (narrowing)
What changes
Two installed code packages may ship one
(type, name)(ADR-0048 §3.4). The ADR-0010_lockgate looked the packaged artifact up with no package, so it bound the first package registered.getMetaItem,getMetaItemLayered, the list and thegetMetaDiagnosticstile 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 realSchemaRegistry(H1 below), the defect runs in both directions:_lock: 'full', A ships no lock, B registered first: a read naming A saysnonewhile the door refuses. This is the card's case: it fails closed.fullwhile the door admits. This one fails open, because B's packaged lock is not enforced at all.Now:
resolveArtifactLockLayer(address, artifactsOf)inpackages/metadata-protocol/src/item-lock.tsreturns 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 isshippedArtifactsOf, wrapped per address asartifactLockLayerAt. 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.resolveItemLockreads 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.withItemLockFamily, which replaceswithOverlayLockFamily). 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).Rulings honoured, and the one mechanism hypothesis falsified
no-delete, and the env-wide row declaresno-overlay. With A registered first, the door at BASE refuses the save, because A's artifact does not bind and the row'sno-overlaydoes. A strictest artifact layer would bind B'sno-deleteand 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 realSchemaRegistryB ships
_lock: 'full', A ships no lock. Each call is listed with which package's artifact it bound.getEffectiveLock)getMetaItem=getMetaItemLayeredThe call sites:
lookupArtifactItem(canonicalType, name)) binds the first package registered, whatever the request names.getMetaItemandgetMetaItemLayered(lookupArtifactItem(type, name, packageId)) bind the named package's artifact, or the first registered when no package is named.packageId ?? item._packageId) bind each slot's own package.mergeArtifactProtectionput 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
noneunder both orders. After the change, every row above readsfullwith 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()plusisPackageDisabled(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.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.objecthas one owner (ADR-0029 D3), so its single owner-layer lookup is the whole set.protocol.runtime-authoring-gate.test.ts).H4: census, before and after, on the real
SchemaRegistryThere 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).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.tspin 1 is now the family's enumeration:ITEM_LOCK_LAYERS× a family axis (topology, organization, package), with the table axes that open it and the card that measured it.ITEM_ADDRESS_FIELDSfield has no axis.SchemaRegistryinpackages/objectql/src/protocol-lock-artifact-package-axis.test.ts, because@objectstack/metadata-protocolcannot 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.withItemLockFamilywrites 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)
323ab0b07a, throughscripts/ablation-replace.mjsin 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 blob182c6677→c3840b80.git checkout HEAD -- PATH: the blob equals HEAD (182c6677) andgit diff HEADis empty.git diff HEADempty.Tests
pnpm --filter @objectstack/metadata-protocol testat323ab0b07a: 214 files passed (3 skipped), 27 745 tests passed (19 skipped).323ab0b07a: 940 passed. The real-registry file again atd1551fde71: 14 of 14 (the last commit only made itsfinddouble holdlimit).typecheckfor both packages at323ab0b07a: clean. objectql's test layer (check:test-typecheck) compiles the new file, with no new debt.Gates, at
d1551fde71node scripts/pm/dispatch-gates.mjs --commandsderived 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.check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-paths. Their guard workflows run them on this PR.--ranreconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded.check:engine-double-contractasked for one pin row for the new objectql double. It was recorded through its own--write.Lint (a proven narrowing; the repo-wide
pnpm lintis CI's run)Run:
eslint --no-inline-config --format jsonover the four touched TS files: 4 files, 0 errors, 0 warnings.isPathIgnoredis false for all four.eslint.config.mjsnever enables type-aware linting (noparserOptions.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.tsandprotocol.ts, as the claim's file surface said.protocol.lock-one-resolution.test.ts) and the changeset.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.packages/spec/src/**, no governed surface, no new public export:item-lock.tsis not re-exported from the package entry.Acceptance notes
packagedArtifactOwner(the ADR-0126 flow-owner classification) andisArtifactBackedstill 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.extractProtection. An explicit_lock: 'none'is no longer kept on a body, and a_lockSourceoutside the spec's enum is not echoed. The full metadata-protocol suite and the objectql envelope tests stay green.f38762577c). The gate reads the listing once per write.Generated by Claude Code