Skip to content

Commit 2015c54

Browse files
fix(metadata-protocol): a by-name read naming no package wears the envelope of the package whose body it serves (#22055)
Fixes #22024 Clause-②: no ## What this changes `getMetaItem` with no `packageId` (the method behind `GET /api/v1/meta/TYPE/NAME` when no package is named) merges the registry artifact's protection envelope over the body it serves, through `mergeArtifactProtection`. The fields are `_packageId`, `_packageVersion` and `_provenance`. With no package named, that envelope was `lookupArtifactItem(type, name, undefined)`: the first composite, which is the first-registered package's. When two packages ship a name and the served body is the other package's (its stored copy of a container they both ship, its package-bound row, or its MetadataService item), the answer served that body under the first-registered package's `_packageId`. The answer's top-level `packageId` / `provenance` / `packageVersion` are read off the served item (`servedLockState` → `extractProtection`), so they carried the same wrong value. The envelope lookup is now `lookupArtifactItem(type, name, envelopePackageId(request.packageId, item))`. `envelopePackageId` is `request.packageId ?? item._packageId`, which is exactly the expression the list (`readFlattenedMetaItems`) already used for each item it serves. It is now one module-level function, and both doors call it. The list's call is a byte-equivalent refactor. Result: one served body wears one envelope on both doors. Unchanged: - Which body any read serves; ruling #22027 → A stands. - A read naming a package; the helper returns the named package first. - The lock. `resolveItemLock` / `artifactLockLayerAt` are untouched, and `getMetaItem` passes `itemLock`, so `mergeArtifactProtection`'s lock branch does not run. The `_lock*` family and `lock` / `editable` / `deletable` are as before. - A served item bound to no package. Such an item names none, so its lookup is the package-less one it had. - ⛔ No new key, no `packages/spec` edit, no governed surface. Files: - `packages/metadata-protocol/src/protocol.ts`: `envelopePackageId`, the `getMetaItem` envelope merge, and the list's call site. - `packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts`: block (i), in #21980's harness. - `packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts`: the enumeration pin. - `.changeset/22024-no-package-read-wears-served-package.md` (`@objectstack/metadata-protocol` patch). ## H1: where the body and the envelope part (confirmed) Measured in-process through the real `getMetaItem` naming no package, on both kernels and in both registry orders, at base `8caa131e52` and at the fix. The probe was not committed. Setup: `pkg_a` and `pkg_b` both ship view container `task`. | Step that answers | Body served | Package known at the merge (the served item's `_packageId`) | Envelope on base | Envelope now | |---|---|---|---|---| | draft preview / `state: 'draft'` | the draft row | n/a | none (returns before the merge) | unchanged | | 1, stored row bound to `pkg_b` | `pkg_b`'s row | `pkg_b` (row `package_id`) | first-registered (`pkg_a` when first) | `pkg_b` | | 1, package-less stored row | the row | none | first-registered | unchanged (first-registered) | | 1b, expansion of `pkg_b`'s stored copy | the copy's view | `pkg_b` (the container's package) | first-registered | `pkg_b` | | 1b, expansion of a package-less copy | the copy's view | the package of the container it overlays (`runtimeViewContainerPackage`: first-registered) | first-registered | unchanged | | 2, MetadataService item stamped `pkg_b` | the service's item | `pkg_b` | first-registered | `pkg_b` | | 3, registry composite (no row) | first composite | that composite's own package | same package | unchanged | | 3, shipped-flow arm | `lookupArtifactItem` itself | its own package | same artifact | unchanged | The card's measured case, with `pkg_a` registered first: on base the read answered `Intake (pkg_b copy)` with `_packageId: pkg_a` and `_packageVersion: pkg_a@1`, and the response's top-level `packageId` was `pkg_a`. Now all three say `pkg_b`. This holds for all 12 combinations (2 kernels × object owned by `pkg_a` / `pkg_b` / no package × 2 orders). With `pkg_b` registered first, base answered `pkg_b` too, by coincidence. ## H2: the fix's shape (confirmed) - Naming no package, the envelope lookup is scoped to the served item's package. Naming a package passes it unchanged. - The producer is where the card said: `getMetaItem`'s envelope merge. Nothing else had to move. - **Package-less served bodies.** Base grafts the first-registered package's envelope. The direction ("the package whose row or expansion was served") yields the package-less lookup, because such a body names no package. That is one answer, and it is the lookup the read already made, so nothing is invented. - For a name one package ships, it is that package's envelope: the ordinary tenant overlay. - For a name two packages ship, the list serves the same body in both packages' slots, each with its own envelope. The read's pair is one of them; the control pin in (i) holds this. - **Fields that move**, all copied by `mergeArtifactProtection` from the one artifact: `_packageId`, `_packageVersion` and `_provenance`. With them move the response's top-level `packageId` / `packageVersion` / `provenance`. - **Fields that do not move:** the `_lock`, `_lockReason`, `_lockDocsUrl` and `_lockSource` family. The item-lock resolution sets it. ## H3: the 18 `lookupArtifactItem(` lines, classified `git grep -n 'lookupArtifactItem(' -- packages/metadata-protocol/src`, tests excluded, prints 18 lines. That holds at the merge base `a7a48b784d`, where `protocol.ts` is byte-identical to `8caa131e52`; line numbers below are that file's. It still prints 18 at HEAD. | Line | Member · arguments | No package, or can be undefined? | Does its envelope reach an answer whose body came from elsewhere? | |---|---|---|---| | 8399 | `packagedArtifactBase` · `type, name` | none | **No.** It is the packaged base the i18n translators compare a served view or dashboard against; nothing of it is grafted onto the body. | | 8696 | `readCodeLayerForCarryForward` · `type, name, pkg` | can be undefined | **No.** It feeds a save's credential carry-forward, a write. The write door strips the derived provenance keys before it persists. | | 9554 | `readFlattenedMetaItems` · `request.type, itemName, itemPackageId` | can be undefined | **No.** Scoped by construction to each item's own package. This is the list's rule, now `envelopePackageId`. | | 10391 | `getMetaItem` · `request.type, request.name, request.packageId` (shipped-flow arm) | can be undefined | **No.** The served body IS this artifact, and the envelope lookup now asks that artifact's own package, which answers itself. | | 10431 | `getMetaItem` · the envelope merge | can be undefined | **Yes, the card's defect.** Fixed: `envelopePackageId(request.packageId, item)`. | | 10764 | `getMetaItemLayered` · `request.type, request.name, request.packageId` (code layer) | can be undefined | **No** by the graft test; see Acceptance note 1. The result IS the `code` layer, body and envelope one artifact. The layered response reads its provenance off that layer by its own rule, and nothing is grafted onto `effective`. | | 16356 | `isArtifactBacked` · `type, name` | none | **No.** A boolean predicate. | | 16442 | `isNestedArtifactField` · `'object', name.slice(0, sep)` | none | **No.** A boolean containment test; an object has one owner. | | 16861 | `packagedArtifactOwner` · `folded.type, folded.name` | none | **No.** It returns a shipping package id for flow classification, never an envelope on a served body. | | 17261 | the definition | n/a | n/a | | 17331 | `shippedArtifactsOf` · `type, name` | none | **No.** It is the lock's artifact layer (the item-lock resolution), deliberately unchanged. | | 17335 | `shippedArtifactsOf` · `type, name, packageId` | named | **No.** Scoped, and kept only when it is that package's own. | | 18451 | `hydrateOverlayIntoRegistry` · `type, (data as any).name, options.packageId ?? undefined` | undefined for a package-less row | **No.** Scoped to the row's own package. A package-less row names none (the H2 cell). | | 18617 | `expandRuntimeViewContainer` · `type, String(item.name), ownPackageId` | not called when undefined | **No.** Own-package filter. | | 18656 | `shippedViewContainerOf` · `type, name, packageId` | returns before the lookup when undefined | **No.** Own-package filter. | | 18708 | `runtimeViewContainerPackage` · `type, container.name` | none | **No.** It answers the package a package-less container row overlays. That package becomes the expansion's own `_packageId`, and its own artifact lends the envelope (line 18617). The by-name read now looks up at that same package, so body and envelope agree. | | 18794 | `isShippedByAnotherPackage` · `type, name` | none | **No.** A boolean predicate. | | 19658 | `viewContainerNameCollisionRefusal` · `type, name` | none | **No.** The save door's collision check: the package id names a shipper in a refusal sentence. | Totals: one yes (fixed), 16 no (one of them with an open question), the definition. ## H4: the list path (confirmed, now pinned) The env-wide list already looked each item's envelope up at that item's own package. Measured: it serves `(Intake (shipped by pkg_a), pkg_a)` and `(Intake (pkg_b copy), pkg_b)`. #21980's block (d) pins the reads NAMING a package against the list's slots, and block (h) pins only the body of the read naming none. The no-package read's envelope against the list was not pinned. Block (i)(c) pins it now, and the rule is one function both doors call. ## Pins In `protocol.org-scoped-write-refused.test.ts`, block (i), reusing #21980's harness (`harness`, `PLACEMENTS`, `MEMBERS`, `saveCopyOf`). Each case runs on both kernels. - **(a)** 14 cells: 12 copy cells (3 members, the object owned by `pkg_a` or by no package, 2 registry orders) and 2 cells of a `pkg_b`-bound row of the name. In each, the read naming no package answers `[Intake (pkg_b copy), pkg_b, package]` (title, `_packageId`, `_provenance`) and top-level `packageId: pkg_b`. - **(b)** On the 12 copy cells, the read naming each package keeps its own body, its own `_packageId` and its own top-level `packageId`. - **(c)** On the 14 cells, the env-wide list holds the same `[title, _packageId, _provenance]` for the body the no-package read serves. - **control ×2** A package-less copy that stands in for both packages: the read's triple is one of the list's, and the list serves the copy in both slots. - **(d)** `protocol.lookup-artifact-item-call-sites.test.ts` records every `lookupArtifactItem(` in `protocol.ts`, keyed by its class member and argument text, with a disposition. The idiom is the repo's `readFileSync(new URL('./protocol.ts', import.meta.url))` caller-set pins (`hydrateOverlayIntoRegistry`, `applyRegistryWriteThrough`). It goes red when a call is added, removed or re-scoped without its row. Non-vacuity: one definition, no site outside a member, and the population equal to the ledger. It also asserts that both read doors call `envelopePackageId`. ## Reverse verification (on committed HEAD, restore proven) The mutation, through `node scripts/ablation-replace.mjs`: - Anchor `request.type, request.name, envelopePackageId(request.packageId, item),` → `request.type, request.name, request.packageId,` (the base's lookup). - Anchor x1 → x0, replacement x0 → x1; blob `05c2a69909ca` → `9efd4116e951`. - Restore: blob after restore `05c2a69909ca` == the HEAD blob, and `git diff HEAD` is empty. - The subject is imported from `./protocol.js` (source), so no `dist/` is on the path and no rebuild applies. - **Run 1, on `ef02620dbd`**, where (a) and (c) still shared one case. Predicted 8 red, measured **8 red / 183 green**: the 6 copy cells and the 1 row cell with `pkg_a` first, plus the ledger. (c) was never reached behind (a), so the pins were split. - **Run 2, on `4c75f230e0`.** Predicted 15 red, measured **15 distinct red**: (a) ×7, (c) ×7, ledger ×1. - Every (a) and (c) red case shows the base's mislabel: received `["Intake (pkg_b copy)", "pkg_a", "package"]` where `pkg_b` was expected. In (c) that is the list holding `pkg_b` while the read wears `pkg_a`. - Every (b) case and both controls stay green, the predicted direction. - The run's total printed 29 failures because a scratch copy of the test file ran beside it (14 duplicates). The copy was deleted and never committed. ## Clause-② (measured: no) - **Built entry declarations.** `dist/index.d.ts` and `dist/index.d.cts` are byte-identical between a build of base `protocol.ts` (`8caa131e52`) and HEAD: sha256 `f91b87a1031d3aee39c721835976fcc846025fa30f185b216262a4d6be751916` for all four files. Positive control: `envelopePackageId` occurs 3 times in HEAD's `dist/index.js` and 0 times in base's. - **Accept set.** No validation, refusal or write path is touched. The diff changes the envelope a read wears, and nothing is accepted or refused differently. ## Tests and gates (at HEAD `96059eb740`, after merging `origin/main` `a7a48b784d`) - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: **Test Files 220 passed | 3 skipped (223); Tests 28181 passed | 19 skipped (28200)**. - `pnpm --filter @objectstack/metadata-protocol run typecheck` (`tsc --noEmit`): exit 0. `--listFiles` includes both touched test files. - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived **64** commands. All 64 exit 0. - `check:dual-build-cjs-loads` and `check:lean-entry-closure` first answered PREREQUISITE NOT MET (exit 3) on a partly built tree. After `turbo run build` over every package (71/71), both exit 0. - `--ran`: `✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3)`. - Beyond the dispatch list, the change set added `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All were run. - Artifact-roster block (53 families, outside the total): 50 exit 0. - `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` are NOT WIRED without a PR context (exit 2), so they are NOT MEASURED. The `pnpm check:` forms of the same three are self-test only (exit 0). - The four symbol-anchor sweeps all exit 0: - `check:adr-symbol-anchors`: 2167 anchors across 140 records resolve. - `check:scripts-symbol-anchors`: 3763 anchors across 282 scripts resolve. - `check:spec-docblock-symbol-anchors`: 5168 anchors across 1886 spec sources resolve. - `check:adr-anchors`: OK, 42386 citations across 5271 files resolve. ## Acceptance notes 1. **The layered read's envelope (line 10764) is classified, not changed. This is an open question for the seat.** - Naming no package, `getMetaItemLayered` takes its `code` layer from the no-package lookup (first-registered for a shared name). The response's top-level `provenance` / `packageId` / `packageVersion` are read off `code ?? overlay`. - Measured, with `pkg_b`'s copy served and `pkg_a` first: `code` is `pkg_a`'s shipped item, `effective._packageId` is `pkg_b`, and top-level `packageId` is `pkg_a`. This is unchanged by this PR. - After this PR, `getMetaItem`'s item agrees with the layered `effective` on `_packageId`, which it did not on base. The two reads' top-level `packageId` now differ for that address, where on base both said `pkg_a`. - Moving the layered envelope means moving the `code` layer itself: which shipped item is the diff baseline and the reset reference. That is a body change, outside this card's envelope-only scope. 2. **A pre-existing defect next door, not touched here. Reported to the seat for filing.** - On an unscoped kernel, a stored view-item row of a name both packages ship, bound to `pkg_b`, is hydrated by write-through into the registry's bare slot. - The read NAMING `pkg_a` then reaches step 3, and `registry.getItem(view, NAME, 'pkg_a')` answers the bare slot first. It serves `pkg_b`'s row body (`Intake (pkg_b copy)`) under `_packageId: pkg_a`, while the list scoped to `pkg_a` serves `pkg_a`'s shipped item. - The environment-scoped kernel answers correctly. Measured in-process in both registry orders, with this PR's envelope change reverted (named reads take the same path either way), and not over HTTP. - Pin (b) is therefore asserted on the copy cells only. 3. A legacy stored row whose body still carries its own `_packageId` (written before the derived-provenance strip at the write door) is served with that stamp, as before. The envelope is now looked up at it, exactly as the list already does for the same row. Not measured. 4. Docs: no hand-written docs line about a by-name read's `_packageId` becomes false. `content/docs/ui/doc-pages.mdx`'s "best-effort (first match)" is about which body a bare link resolves, and that is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5a14dd commit 2015c54

4 files changed

Lines changed: 318 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
A by-name metadata read that names no package now wears the package of the body it serves
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** `getMetaItem` with no `packageId` (behind `GET /api/v1/meta/TYPE/NAME` when no package is named) merges the registry artifact's protection envelope, `_packageId`, `_packageVersion` and `_provenance`, over the body it serves. With no package named, that envelope was the first-registered package's. When two installed packages ship one name and the body served was the other package's, the answer carried that body under the wrong package. Two cases were measured. In the first, both packages ship a view container and one of them stores a copy of it: the read served the copy's view and named the first-registered package. In the second, a stored row of the name is bound to one package. The answer's top-level `packageId` / `provenance` / `packageVersion` fields, which are read off the served item, said the same wrong thing.
10+
- **What it does now.** With no package named, the envelope is looked up at the package the served item is bound to. That is the stored row's package, the package of the container copy that expands the name, or the `_packageId` of the MetadataService or registry item. This is the rule the `GET /api/v1/meta/TYPE` list already applies to each item it serves, so the list and the by-name read now give one envelope for one served body.
11+
- **Unchanged.** Which body the read serves. A read naming a package. The lock family and the `lock` / `editable` / `deletable` envelope, which still come from the item-lock resolution over the read's own address. A served item bound to no package (a package-less stored row or copy, or a registry entry with no package) keeps the package-less lookup it had, so for a name only one package ships, a tenant's package-less overlay still wears that package's envelope. The layered read (`/layers`) is not changed.
12+
- ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22024 — every `lookupArtifactItem(` in `protocol.ts` carries a recorded
5+
* disposition, so the closing card's classification cannot go stale when a
6+
* call is added, removed or re-scoped.
7+
*
8+
* `lookupArtifactItem(type, name, currentPackageId?)` answers the registry's
9+
* artifact for a name, prefer-local to `currentPackageId` and else the first
10+
* composite: the first-registered package's. A call that passes no package,
11+
* or one that can be `undefined`, therefore answers the first-registered
12+
* package's artifact for a name two packages ship. The question each row
13+
* below answers is the card's: does that artifact's `_packageId` (its
14+
* envelope) reach an answer whose body came from a different package?
15+
*
16+
* One row did: the by-name read's envelope merge in `getMetaItem`, which
17+
* served one package's stored copy under the first-registered package's
18+
* `_packageId`. It now looks its envelope up at the served item's own package
19+
* (`envelopePackageId`, the list's rule); the behaviour is pinned in
20+
* `protocol.org-scoped-write-refused.test.ts`, block (i).
21+
*
22+
* The population is the card's own enumeration command,
23+
* `git grep -n 'lookupArtifactItem(' -- packages/metadata-protocol/src`
24+
* (tests excluded), which is one file: every match is a call or the
25+
* definition. Each is keyed by the class member it sits in and its argument
26+
* text, which survive line moves and fail on any change of scope. A new call,
27+
* a removed one or a re-scoped one turns this red until its row is recorded
28+
* here with its disposition (and the card's table in the PR that adds it).
29+
*/
30+
import { readFileSync } from 'node:fs';
31+
import { fileURLToPath } from 'node:url';
32+
import { describe, expect, it } from 'vitest';
33+
34+
const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8');
35+
const CALLEE = 'lookupArtifactItem(';
36+
37+
/** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */
38+
const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
39+
40+
interface Site { readonly member: string; readonly args: string }
41+
42+
/** Every `lookupArtifactItem(` in the source: the member it sits in and its argument text, whitespace-normalised. */
43+
function sitesOf(source: string): Site[] {
44+
const lines = source.split('\n');
45+
const sites: Site[] = [];
46+
for (let at = source.indexOf(CALLEE); at !== -1; at = source.indexOf(CALLEE, at + 1)) {
47+
let end = at + CALLEE.length;
48+
for (let depth = 1; depth > 0 && end < source.length; end++) {
49+
if (source[end] === '(') depth++;
50+
else if (source[end] === ')') depth--;
51+
}
52+
const args = source.slice(at + CALLEE.length, end - 1).replace(/\s+/g, ' ').trim().replace(/,$/, '');
53+
let member = '(no member)';
54+
for (let line = source.slice(0, at).split('\n').length - 1; line >= 0; line--) {
55+
const header = MEMBER_HEADER.exec(lines[line]);
56+
if (header) { member = header[1]; break; }
57+
}
58+
sites.push({ member, args });
59+
}
60+
return sites;
61+
}
62+
63+
type Disposition = 'definition' | 'served-package envelope' | 'no';
64+
65+
/**
66+
* The recorded disposition of every site. `noPackage`: it passes no package,
67+
* or one that can be `undefined`. `why`: for a `no`, why its artifact's
68+
* envelope never reaches an answer whose body came from another package.
69+
*/
70+
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
71+
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string', noPackage: false, disposition: 'definition',
72+
why: 'the definition itself' },
73+
{ member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item)', noPackage: true, disposition: 'served-package envelope',
74+
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule)' },
75+
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId', noPackage: true, disposition: 'served-package envelope',
76+
why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares' },
77+
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
78+
why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' },
79+
{ member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
80+
why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer' },
81+
{ member: 'packagedArtifactBase', args: 'type, name', noPackage: true, disposition: 'no',
82+
why: 'the packaged base the translators compare a served body against: nothing of it is grafted onto the body' },
83+
{ member: 'readCodeLayerForCarryForward', args: 'type, name, pkg', noPackage: true, disposition: 'no',
84+
why: 'a save\'s credential carry-forward: a write, whose door strips the derived provenance keys before it persists' },
85+
{ member: 'isArtifactBacked', args: 'type, name', noPackage: true, disposition: 'no',
86+
why: 'a boolean predicate' },
87+
{ member: 'isNestedArtifactField', args: '\'object\', name.slice(0, sep)', noPackage: true, disposition: 'no',
88+
why: 'a boolean containment test over the one owner of an object' },
89+
{ member: 'packagedArtifactOwner', args: 'folded.type, folded.name', noPackage: true, disposition: 'no',
90+
why: 'a shipped-package check by name: it returns a package id, never an envelope on a served body' },
91+
{ member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
92+
why: 'the lock\'s artifact layer (the one item-lock resolution), never an envelope on a served body' },
93+
{ member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
94+
why: 'scoped to a named package, and kept only when it is that package\'s own' },
95+
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined', noPackage: true, disposition: 'no',
96+
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package' },
97+
{ member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
98+
why: 'not called without a package, and kept only when it is the container\'s own package\'s' },
99+
{ member: 'shippedViewContainerOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
100+
why: 'returns before the lookup without a package, and keeps only that package\'s own container' },
101+
{ member: 'runtimeViewContainerPackage', args: 'type, container.name', noPackage: true, disposition: 'no',
102+
why: 'the package a package-less container row overlays: it becomes the expansion\'s own package, whose own artifact lends the envelope' },
103+
{ member: 'isShippedByAnotherPackage', args: 'type, name', noPackage: true, disposition: 'no',
104+
why: 'a boolean predicate' },
105+
{ member: 'viewContainerNameCollisionRefusal', args: 'type, name', noPackage: true, disposition: 'no',
106+
why: 'the save door\'s collision check: the package id names a shipper in a refusal, never an envelope on a served body' },
107+
];
108+
109+
const keyOf = (site: Site) => `${site.member}: ${CALLEE}${site.args})`;
110+
111+
describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded disposition', () => {
112+
const found = sitesOf(SOURCE);
113+
114+
it('the sites in the source are exactly the recorded ones', () => {
115+
const recorded = SITES.map(keyOf).sort();
116+
const inSource = found.map(keyOf).sort();
117+
expect(
118+
inSource.filter((key) => !recorded.includes(key)),
119+
'a site with no recorded disposition: classify it (does its artifact\'s envelope reach an answer whose body '
120+
+ 'came from another package?) and record it in SITES',
121+
).toEqual([]);
122+
expect(recorded.filter((key) => !inSource.includes(key)), 'a recorded site that is no longer in the source').toEqual([]);
123+
expect(inSource, 'each site once').toEqual(recorded);
124+
});
125+
126+
it('the population is the card\'s enumeration: one definition and every call, each in a member', () => {
127+
// Non-vacuity: a scan that found nothing, or lost the members, cannot pass the test above by accident.
128+
expect(found.filter((site) => site.member === 'lookupArtifactItem' && site.args.includes(': string'))).toHaveLength(1);
129+
expect(found.filter((site) => site.member === '(no member)')).toEqual([]);
130+
expect(found.length).toBe(SITES.length);
131+
});
132+
133+
it('a site passing fewer than three arguments is recorded as passing no package', () => {
134+
const topLevelArgs = (args: string) => {
135+
let depth = 0;
136+
let count = 1;
137+
for (const c of args) {
138+
if (c === '(') depth++;
139+
else if (c === ')') depth--;
140+
else if (c === ',' && depth === 0) count++;
141+
}
142+
return count;
143+
};
144+
expect(SITES.filter((site) => site.disposition !== 'definition' && topLevelArgs(site.args) < 3 && !site.noPackage).map(keyOf))
145+
.toEqual([]);
146+
});
147+
148+
it('both read doors take their envelope from one rule, the served item\'s own package when none is named', () => {
149+
expect(SOURCE).toMatch(/\nfunction envelopePackageId\(requestedPackageId: string \| undefined, served: unknown\): string \| undefined \{/);
150+
expect(SOURCE).toContain('const itemPackageId = envelopePackageId(packageId, it);');
151+
expect(SITES.filter((site) => site.disposition === 'served-package envelope').map((site) => site.member).sort())
152+
.toEqual(['getMetaItem', 'readFlattenedMetaItems']);
153+
});
154+
});

‎packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,5 +1799,116 @@ describe('each package\'s copy of a view container expands into its own package\
17991799
}
18001800
}
18011801
});
1802+
1803+
// The body (h) pins is the copy's, and the read wears the envelope of
1804+
// the package whose copy it serves: the copy's own `_packageId`, as the
1805+
// env-wide list's slot of that body wears it, never the first-registered
1806+
// package's. The read naming each package keeps its own body and its
1807+
// own envelope. A stored row of the name bound to one package is served
1808+
// the same way. A package-less copy names no package: its read wears
1809+
// the envelope of a package whose list slot serves that same body.
1810+
describe('(i) the by-name read naming no package wears the envelope of the package whose body it serves', () => {
1811+
const ORDERS = [[OTHER, COPYING], [COPYING, OTHER]] as const;
1812+
const envelopeOf = (v: any) => [titleOf(v), v?._packageId, v?._provenance];
1813+
const shippedFor = (m: Member, order: readonly string[]) => order.map((pkg): [string, Record<string, unknown>] =>
1814+
[pkg, { object: 'task', ...m.body(SHIPPED_TITLE(pkg), true, pkg === COPYING ? SLUG : OTHER_SLUG) }]);
1815+
/** The env-wide list's [title, package, provenance] for every item it serves under `name`. */
1816+
async function listedEnvelopes(protocol: any, name: string): Promise<unknown[][]> {
1817+
const envWide: any = await protocol.getMetaItems({ type: 'view' });
1818+
return (envWide.items as any[]).filter((v) => v?.name === name).map(envelopeOf);
1819+
}
1820+
1821+
/**
1822+
* One store the read naming no package serves COPYING's body from, written on a fresh harness of one
1823+
* kernel. `named`: the reads naming each package are pinned on it too (the copies of a container,
1824+
* the measured case).
1825+
*/
1826+
interface Cell {
1827+
readonly label: string;
1828+
readonly named: boolean;
1829+
readonly setUp: (environmentId: string | undefined) => Promise<{ protocol: any; name: string }>;
1830+
}
1831+
const KEYED = MEMBERS.find((m) => m.member === 'a keyed member');
1832+
if (!KEYED) throw new Error('no keyed member');
1833+
const CELLS: readonly Cell[] = [
1834+
...MEMBERS.flatMap((m) => ([OTHER, undefined] as const).flatMap((owner) => ORDERS.map((order): Cell => {
1835+
const ownership = owner ? 'another package owns the object' : 'no code package owns the object';
1836+
const placement = PLACEMENTS.find((q) => q.m === m && q.owner === owner && q.ships);
1837+
if (!placement) throw new Error(`no placement for ${ownership}, ${m.member}`);
1838+
return {
1839+
label: `${m.member}; ${ownership}; ${order[0]} registered first; ${COPYING} stores a copy of the container`,
1840+
named: true,
1841+
setUp: async (environmentId) => {
1842+
const { protocol } = harness(shippedFor(m, order), environmentId, owner);
1843+
await saveCopyOf(protocol, placement, false);
1844+
return { protocol, name: loaderNames(m.body('x', true, SLUG))[0] };
1845+
},
1846+
};
1847+
}))),
1848+
...ORDERS.map((order): Cell => ({
1849+
label: `${KEYED.member}; ${order[0]} registered first; a stored row of the name, bound to ${COPYING}`,
1850+
named: false,
1851+
setUp: async (environmentId) => {
1852+
const [name] = loaderNames(KEYED.body('x', true, SLUG));
1853+
const { protocol } = harness(shippedFor(KEYED, order), environmentId);
1854+
expect((await protocol.saveMetaItem({
1855+
type: 'view', name, packageId: COPYING,
1856+
item: { name, label: COPY_TITLE, object: 'task', viewKind: 'form', config: { title: COPY_TITLE } },
1857+
})).success).toBe(true);
1858+
return { protocol, name };
1859+
},
1860+
})),
1861+
];
1862+
1863+
for (const cell of CELLS) {
1864+
it(`(a) ${cell.label}: the read naming no package serves that body under ${COPYING}'s envelope, on both kernels`, async () => {
1865+
for (const [kernel, environmentId] of KERNELS) {
1866+
const { protocol, name } = await cell.setUp(environmentId);
1867+
const read = await protocol.getMetaItem({ type: 'view', name });
1868+
expect(envelopeOf(read.item), `${kernel}: the item`).toEqual([COPY_TITLE, COPYING, 'package']);
1869+
expect(read.packageId, `${kernel}: the envelope the read reports`).toBe(COPYING);
1870+
}
1871+
});
1872+
1873+
if (cell.named) it(`(b) ${cell.label}: the read naming each package keeps its own body and its own envelope, on both kernels`, async () => {
1874+
for (const [kernel, environmentId] of KERNELS) {
1875+
const { protocol, name } = await cell.setUp(environmentId);
1876+
for (const pkg of [OTHER, COPYING]) {
1877+
const named = await protocol.getMetaItem({ type: 'view', name, packageId: pkg });
1878+
const own = pkg === COPYING ? COPY_TITLE : SHIPPED_TITLE(pkg);
1879+
expect(envelopeOf(named.item), `${kernel}: the read naming ${pkg}`).toEqual([own, pkg, 'package']);
1880+
expect(named.packageId, `${kernel}: the envelope the read naming ${pkg} reports`).toBe(pkg);
1881+
}
1882+
}
1883+
});
1884+
1885+
it(`(c) ${cell.label}: the env-wide list wears the same envelope for the body the read naming no package serves, on both kernels`, async () => {
1886+
for (const [kernel, environmentId] of KERNELS) {
1887+
const { protocol, name } = await cell.setUp(environmentId);
1888+
const read = await protocol.getMetaItem({ type: 'view', name });
1889+
expect(await listedEnvelopes(protocol, name), `${kernel}: the list's envelope of that body`)
1890+
.toContainEqual(envelopeOf(read.item));
1891+
}
1892+
});
1893+
}
1894+
1895+
for (const order of ORDERS) {
1896+
it(`control: a package-less copy stands in for both packages; ${order[0]} registered first: the read wears a package whose list slot serves it`, async () => {
1897+
const [name] = loaderNames(KEYED.body('x', true, SLUG));
1898+
for (const [kernel, environmentId] of KERNELS) {
1899+
const { protocol } = harness(shippedFor(KEYED, order), environmentId);
1900+
expect((await protocol.saveMetaItem({
1901+
type: 'view', name: 'task', item: { name: 'task', object: 'task', ...KEYED.body('Intake (env-wide copy)', true, SLUG) },
1902+
})).success).toBe(true);
1903+
1904+
const read = await protocol.getMetaItem({ type: 'view', name });
1905+
expect(titleOf(read.item), `${kernel}: the read naming no package`).toBe('Intake (env-wide copy)');
1906+
const listed = await listedEnvelopes(protocol, name);
1907+
expect(listed.map(([, pkg]) => pkg).sort(), `${kernel}: the list serves the copy in both packages' slots`).toEqual([OTHER, COPYING]);
1908+
expect(listed, `${kernel}: the env-wide list wears the same envelope for the same body`).toContainEqual(envelopeOf(read.item));
1909+
}
1910+
});
1911+
}
1912+
});
18021913
});
18031914
});

0 commit comments

Comments
 (0)