Skip to content

Commit 47d569e

Browse files
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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a metadata read that names no package serves the row a save that names no package writes, when a package-unbound row of the item is stored beside its one package's row (ADR-0048, ADR-0070)
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** A save naming a package (`PUT /api/v1/meta/TYPE/NAME?package=P`) of an item that already had a package-unbound row stored the package's row beside it, with the unbound row first. The read naming no package (`GET /api/v1/meta/TYPE/NAME`) then served the unbound row's body. Meanwhile the save naming no package wrote the package's row, the delete naming no package removed it, the list served it, and the read's own `version` was the package row's token. An author editing the item without `?package=` was served a body that no save changed. A save pinned to the served token overwrote the package's row with an edit of the unbound body.
10+
- **What it does now.** For an item whose stored active rows are bound to exactly one package, a read that names no package takes its stored content from what the read naming that package takes it from: the package's own row, else the package-unbound one. That is the address the package-less save, delete and version token already resolve. The doors whose served row changes:
11+
- the item read, `GET /api/v1/meta/TYPE/NAME`;
12+
- the layered read, `GET …/layers` and the deprecated `?layers=` (its `overlay` and `effective`);
13+
- the published read, `GET …/published`, on the REST server and on the dispatcher;
14+
- the draft reads, `?state=draft` and the draft preview, whose address comes from the same active rows, because the package-less draft save inherits that package.
15+
16+
An item bound to two or more packages is read as before: the first stored row, ADR-0048 §3.3's best effort. A save or delete naming none of them is still refused `409 RESOURCE_CONFLICT`. The lock reported by these reads is selected as before.
17+
- **What follows the read.** In-process readers of the item that name no package see the same row: 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`.
18+
- **The `object` save's destructive-change check (`409 DESTRUCTIVE_CHANGE`) now judges the row the save overwrites**, read at the save's own address. Before, it read naming no package and judged the first stored row, which is the package-unbound row in the order that saves produce it. In this two-row state that moves its verdict in both directions:
19+
- A package-less save that drops a field only the package's row has is now refused. Before, it was judged against the package-unbound row and accepted, and the field was dropped with no confirmation.
20+
- A package-less save that drops a field only the package-unbound row has is now accepted. Before, it was refused over a row it does not write.
21+
- A save naming a package with no row of its own is judged against the package-unbound row it overlays, never another package's row.
22+
23+
The rule and its remedy (`force`) are unchanged.
24+
- **The draft read's corner: a 200 becomes a 404.** Take an item whose active rows are bound to package P and whose only draft is bound to another package Q. Naming no package, the strict draft read (`?state=draft`) now answers `404 NO_DRAFT` instead of serving Q's draft. P's draft is what a package-less draft save would write, and there is none; the read's `version` was already `null`. Read that draft with `?package=Q`. The draft preview naming no package now serves P's active row.
25+
- **What does not change.** No stored row is rewritten or removed. No key, export, schema or error code moves. No metadata write door's own rule moves. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again.

0 commit comments

Comments
 (0)