Repository navigation
Commit 2015c54
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
- .changeset
- packages/metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1799 | 1799 | | |
1800 | 1800 | | |
1801 | 1801 | | |
| 1802 | + | |
| 1803 | + | |
| 1804 | + | |
| 1805 | + | |
| 1806 | + | |
| 1807 | + | |
| 1808 | + | |
| 1809 | + | |
| 1810 | + | |
| 1811 | + | |
| 1812 | + | |
| 1813 | + | |
| 1814 | + | |
| 1815 | + | |
| 1816 | + | |
| 1817 | + | |
| 1818 | + | |
| 1819 | + | |
| 1820 | + | |
| 1821 | + | |
| 1822 | + | |
| 1823 | + | |
| 1824 | + | |
| 1825 | + | |
| 1826 | + | |
| 1827 | + | |
| 1828 | + | |
| 1829 | + | |
| 1830 | + | |
| 1831 | + | |
| 1832 | + | |
| 1833 | + | |
| 1834 | + | |
| 1835 | + | |
| 1836 | + | |
| 1837 | + | |
| 1838 | + | |
| 1839 | + | |
| 1840 | + | |
| 1841 | + | |
| 1842 | + | |
| 1843 | + | |
| 1844 | + | |
| 1845 | + | |
| 1846 | + | |
| 1847 | + | |
| 1848 | + | |
| 1849 | + | |
| 1850 | + | |
| 1851 | + | |
| 1852 | + | |
| 1853 | + | |
| 1854 | + | |
| 1855 | + | |
| 1856 | + | |
| 1857 | + | |
| 1858 | + | |
| 1859 | + | |
| 1860 | + | |
| 1861 | + | |
| 1862 | + | |
| 1863 | + | |
| 1864 | + | |
| 1865 | + | |
| 1866 | + | |
| 1867 | + | |
| 1868 | + | |
| 1869 | + | |
| 1870 | + | |
| 1871 | + | |
| 1872 | + | |
| 1873 | + | |
| 1874 | + | |
| 1875 | + | |
| 1876 | + | |
| 1877 | + | |
| 1878 | + | |
| 1879 | + | |
| 1880 | + | |
| 1881 | + | |
| 1882 | + | |
| 1883 | + | |
| 1884 | + | |
| 1885 | + | |
| 1886 | + | |
| 1887 | + | |
| 1888 | + | |
| 1889 | + | |
| 1890 | + | |
| 1891 | + | |
| 1892 | + | |
| 1893 | + | |
| 1894 | + | |
| 1895 | + | |
| 1896 | + | |
| 1897 | + | |
| 1898 | + | |
| 1899 | + | |
| 1900 | + | |
| 1901 | + | |
| 1902 | + | |
| 1903 | + | |
| 1904 | + | |
| 1905 | + | |
| 1906 | + | |
| 1907 | + | |
| 1908 | + | |
| 1909 | + | |
| 1910 | + | |
| 1911 | + | |
| 1912 | + | |
1802 | 1913 | | |
1803 | 1914 | | |
0 commit comments