Repository navigation
Commit 47d569e
fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) (#23489)
Fixes #23291
Clause-②: no
## Option chosen: (c), the read naming no package resolves the address
the save resolves
The triage (`6149653063`) asked the lane to pick one of three options
under ADR-0070 TL;DR 1 and say why. This PR takes **(c)**.
A by-name read that names no package now picks its stored content at the
address `packagelessAddress` resolves, through one helper,
`packagelessReadAddress` (`packages/metadata-protocol/src/protocol.ts`).
That is the one resolution the package-less save, the package-less
delete and the read's own `version` token already use (#23165). For an
item whose active rows are bound to exactly one package `P`, the read
serves what `?package=P` serves: `P`'s own row, else the package-unbound
row (ADR-0048 prefer-local). Otherwise it serves any row, ADR-0048
§3.3's best-effort first match, as before.
Two callers ask it:
- `getMetaItem`: the item read `GET /api/v1/meta/TYPE/NAME`, plus the
strict draft read and the draft preview. The draft reads take their
address from the active rows, because the package-less draft save
inherits `P` from them (#11087).
- `getMetaItemLayered`: `GET …/layers`, the deprecated `?layers=`, and
`GET …/published` on both hosts (RestServer, and the dispatcher's
`/published` twin and `answerMetaLayered`). So `effective` is what the
plain read returns, and `/published` serves the same document.
The change is content only. The lock layer is still selected at the
request's address, where the `_lock` gate judges a package-less save, so
the reads and the gate bind the same rows.
**Two consequences follow:**
- **In-process readers of the item naming no package follow the read:**
the data doors' object reads (import reference targets, export and
template fields), seed apply's object lookup, and the security catalog
echo of a `permission` or `position`.
- **The `object` save's destructive-change check now reads at the save's
own address** (`packageId: request.packageId`, which a package-less
publish save has already resolved to `P` above it). It used to read
naming no package, which judged the first stored row.
- In `[null, P]` that moves its `409 DESTRUCTIVE_CHANGE` in both
directions:
- a package-less save dropping a field only `P`'s row has is now
refused, where `main` accepted it and dropped the field unconfirmed;
- one dropping a field only the unbound row has is now accepted, where
`main` refused it over a row it does not write.
- A save naming a third package `Q` with no row of its own is judged
against the unbound stand-in it overlays, never against `P`'s row. This
closes the review's third-package corner.
- The rule and its `force` remedy are unchanged.
### Why (c)
- **(b) adopt was built first and measured wrong.** Commit `b776dcd1`
made a write naming a package adopt the item's package-unbound row in
`SysMetadataRepository.resolveWriteHead`, which generalizes the #11087
orphan-draft adoption. The full package suite went red in
`protocol.org-scoped-write-refused.test.ts` (2 cases, "a package-less
env-wide copy stands in for every package with no copy of its own").
Under ADR-0048 prefer-local, the package-unbound row is the **stand-in
for every package that has no row of its own**. Adopting it into one
package silently takes the customization away from every other package
that read through it. That is a stored-data change the card does not
authorize, and ADR-0070 calls this state a read-side detail, not one
package's property. Commit `5e945626` reverts it, together with the (c)
fix.
- **(a) refuse** would narrow the save door (a save naming a package
refused beside an unbound row). The author would also need a remedy:
deleting the unbound row would remove the stand-in for every other
package. (c) needs no new refusal and no remedy.
- **(c)** fits the wording of ADR-0070 TL;DR 1 for this state. For an
item bound to one package, the package-unbound row is no longer written
by a package-less save or delete: those have gone to `P` since #23165. A
package-less publish can still promote an unbound draft into it; that is
#23495's state. It stays "a read-side rehydration detail": the stand-in
for packages with no row, and the row served again once `P`'s row is
deleted. For an item with no binding at all, the package-less row is
still an authoring destination (step 1, and the save after step 5, write
`(env-wide, state=active)`). That is ADR-0070's open work, not this
card's. It also fixes a measured inconsistency inside the read itself.
On `main`, the read naming no package served the unbound **body** with
the package row's **`version` token** (HTTP reading below).
- **What (c) leaves:** the two rows stay stored (`[null, P]`). This is
the residue the dispatch named for (c). Naming no package, the item
read, the layered and published reads, the draft reads, the list, the
save, the delete and the version token now all address `P`'s row in this
state. A read naming another package that has no row of its own still
serves the unbound row, by design (the stand-in). The draft publish and
discard naming no package are not part of this diff; the seat carries
them on their own card. Whether to convert such pairs (a migration) is
in Open questions.
## Measured first (mechanism assumptions)
**1. The card's five steps reproduce on `main`, at the protocol doors
and over HTTP.**
At the protocol doors on `main` `90637274` (row-keeping engine double,
the suite's own):
1. rows `[null]`;
2. rows `[[null,'unbound'],['com.qa.lanebapp','pkg']]`;
3. read naming none → `unbound`, read naming the package → `pkg`;
4. package-less save → `Saved app 's6' (env-wide,
package=com.qa.lanebapp, state=active)`, read naming none →
**`unbound`**, list → `['third']`;
5. package-less delete → `Deleted app 's6' (package=com.qa.lanebapp),
and it still exists: its package-less row remains.`
Over HTTP on a real kernel (`pnpm dev:todo -- --fresh`, signed-in admin,
SQLite), `PUT`/`GET`/`DELETE /api/v1/meta/app/NAME` with and without
`?package=com.qa.lanebapp`. The `main` reading was taken with this PR's
one-line fix ablated and the package rebuilt, with the dist preflight
proving the ablated form was built:
| step | `main` (fix ablated) | this PR |
|---|---|---|
| 3 · GET, no package | label `unbound`, `version` = the package row's
token | label `pkg`, `_packageId` `com.qa.lanebapp`, same token as
`?package=` |
| 4 · PUT, no package, then GET, no package | receipt
`package=com.qa.lanebapp`; GET serves **`unbound`** with the package
row's token | receipt `package=com.qa.lanebapp`; GET serves `third` |
| 4 · list | `[["third","com.qa.lanebapp"]]` |
`[["third","com.qa.lanebapp"]]` |
| 5 · DELETE, no package, then GET, no package | receipt "…its
package-less row remains"; GET `unbound` | the same receipt; GET
`unbound`, now the address of the next package-less save as well |
**2. How a package-less row comes to exist beside a package's row
today.** Through today's doors, and not only from rows stored before
ADR-0070. Step 1 (a package-less save of an item with no package binding
writes the package-unbound row, #23165's control) followed by step 2 (a
save naming the package inserts that package's row beside it) produces
the pair on a fresh kernel, as the HTTP run shows. The other doors that
write a `package_id`-null row only rewrite or promote a row already
unbound: the server-stated rewrites (`source` set), the promotion of an
unbound draft, and the restore of an unbound row. A save of a
code-shipped item with no stored row writes the unbound overlay. Rows
stored before ADR-0070 are a second source of the same pair, and the
read handles both the same way.
## Pins
`packages/metadata-protocol/src/protocol.packageless-save-address.test.ts`,
a new `[#23291]` block on the suite's existing ledger-pinned engine
double (no new double):
- **the card's five steps**, each read asserted against its save's
address: after step 2 the read naming none serves `P`'s row with the
same token as `?package=P`. Step 4's save, pinned to that token, is
accepted and the read and the list serve it. Step 5's package-less
delete leaves the unbound row, which the read and the next package-less
save both address. A last package-less delete removes it with the
original "it no longer exists" receipt;
- **storage order**: the unbound row stored first and second, both
served as `P`'s;
- **the layered read**, in both storage orders: `getMetaItemLayered({
type, name })` reports `P`'s row as `overlay`, and its `effective`
equals the plain read's item. The five-step pin checks it at step 3. A
control: the layered read naming a package with no row of its own still
reports the unbound row;
- **the draft reads**: `?state=draft` and the draft preview serve `P`'s
draft over an unbound draft, and the package-less draft save pinned to
that token writes it;
- **the draft corner**: active rows bound to `P`, and the only draft
bound to another package `Q`. Naming no package, the strict draft read
answers `404 NO_DRAFT` (envelope `code` + `status`). That draft is read
with `?package=Q`, and the draft preview serves `P`'s active row;
- **the destructive-change check**, on an `object` in `[null, P]` with
the unbound row stored first:
- a package-less save dropping a field only `P`'s row has is refused
`409 DESTRUCTIVE_CHANGE`, the rows are unchanged, and the same save is
accepted with `force`;
- a package-less save dropping a field only the unbound row has is
accepted and writes `P`'s row;
- a save naming `Q`, which has no row, is accepted when it drops a field
only `P` has, and refused when it drops one the unbound stand-in has;
- **edge, another package's row too** (`[null, P, Q]`, minted through
the doors): package-less save and delete refused `409
RESOURCE_CONFLICT`, nothing changed, each named read serves its own row.
After `DELETE ?package=Q`, the read and the package-less save address
`P` again;
- **control, the stand-in**: a read naming another package with no row
still serves the unbound row (ADR-0048 prefer-local).
**Ablation** (fix committed first; `node scripts/ablation-replace.mjs`;
each leg: anchor 1 → 0, restored blob = HEAD, `git diff HEAD` empty), at
`e39d82de` for the layered leg and `b463e4be` for the plain leg:
- **layered line** (`contentPackageId = request.packageId`): 2 red, the
five steps and unbound-row-first order, both on the layered `overlay`
assertion. 37 green, including the layered stand-in control.
- **plain-read line**: 5 red: five steps, unbound-row-first order, the
draft corner, draft reads, edge. 34 green.
- **destructive-check line** (`packageId: request.packageId` removed),
at `c9e2de86`: 1 red (the third-package pin), 41 green. The two
package-less pins stay green under it because the plain read's
resolution independently reads `P`.
- With both that line and the plain-read line removed: the two
package-less pins go red. The third-package pin passes there, as `main`
does in this storage order.
- Restored byte-identical to HEAD (hash equal, `git status` clean).
- The first round's ablation (before the layered fix): 4 red, 2 controls
green. The suite imports `./protocol.js` from source, so no dist leg
applies. The HTTP `main` reading above used the same ablation with a
rebuild: `ablation-dist-preflight` showed the marker absent, then
present again after restore, and the tree clean.
## Verification (head `8e3b9491`; `origin/main` merged in at `73c7dc4a`,
no merge since)
- `pnpm --filter @objectstack/metadata-protocol test`: 235 files passed,
3 skipped; 28400 tests passed, 19 skipped. `typecheck`: exit 0.
- The full `@objectstack/rest` suite at `8e3b9491` (it drives the
`/meta` saves of `object`, the destructive 409 faces included): 287
files, 6060 passed, 328 skipped. At `e39d82de`: every file that drives
`/published`, `/layers` or `?layers=` (23 files), 862 passed.
- `@objectstack/runtime`, the dispatcher's `/published` twin and its
parity suites (`http-dispatcher`, `meta-published-runtime-publish`,
`meta-item-query-decision`, `meta-item-read-gate-parity`): 401 passed.
Nine dispatcher/meta files at `b463e4be`: 1208 passed.
- An outage pin (`protocol.metadata-store-outage.test.ts`, the layered
read's 503 keeps the driver's error as `cause`) went red on the first
layered change. The layered read's address is now resolved outside its
`catch`, and the pin is green.
- Gates: `node scripts/pm/dispatch-gates.mjs --ran` at `8e3b9491`
reports "65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED".
The one is `node scripts/check-plugin-teardown-shape.mjs --self-test`,
exit 3, which refuses on a shallow clone (its positive control is pinned
to a commit), so it is NOT MEASURED locally and CI runs it.
`check:engine-double-contract` passed, since no new double was added.
## Changeset
`.changeset/23291-packageless-read-serves-save-address.md`:
`@objectstack/metadata-protocol` **patch**, `Clause-②: no` (upheld by
the contract review).
- It names the doors whose served row changes: the item read, the
layered and `?layers=` reads, `/published` on both hosts, and the draft
reads.
- It states the draft read's corner, which moves from 200 to 404: the
strict draft read naming no package. That is the absence answer at the
draft save's address, whose token was already `null`.
- It states the destructive-change check's move: the same `409
DESTRUCTIVE_CHANGE` rule now judges the row the save overwrites, so some
saves it accepted are refused and some it refused are accepted.
- It names the in-process readers that follow the read.
- No export, key, schema or error code moves, and no stored row is
rewritten.
## Open questions
- **Convert the `[null, P]` pair?** (c) leaves both rows stored. A
migration could fold an unbound row into the package's when no other
package reads through it. That is a stored-data decision and an ADR-0070
"migration source" step, out of this card's scope. Recommendation: no
conversion unless a measured reader still reaches the wrong row.
- **The draft-save receipt** names no package when the draft inherits
one (`(env-wide, state=draft)`), unlike the direct save's receipt. This
was observed, not changed here. The seat carries it, with the draft
publish and discard addresses, on its own card.
## Acceptance notes
- `#23165` (option B, PR #23290) is untouched: the package-less save and
delete still address the one package's row, and two packages are still
refused.
- No other drafts holding `protocol.ts` were merged in. #23464 had
landed on `main`, and `main` was merged before this PR opened (clean,
delta vs `main` = the three files above).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 54af6c0 commit 47d569e
3 files changed
Lines changed: 356 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 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
0 commit comments