Skip to content

Commit a869ea6

Browse files
fix(metadata-protocol)!: a save naming a package of an object its package-less definition owns is refused, with a way out into that package (ADR-0029 D9.9) (#23704)
Fixes #23514 Clause-②: no (narrowing) #23630 member 2's sequence (the package-less owner, `[P, Q, package-less]`) is pinned here. PR #23683 (member 1) closed #23630 on the seat's condition that this PR pins member 2. ## What changed `classifyObjectContribution` (`packages/metadata-protocol/src/protocol.ts`) now judges a runtime owner keyed by the `sys_metadata` sentinel as it judges one bound to a real package. A save naming any package `Q` of an object whose registry owner is its package-less definition is a `mismatch`, so `saveMetaItem`'s existing ADR-0029 D9.9 call refuses it **before any write**, with the existing code `OBJECT_OVERLAY_PACKAGE_MISMATCH` / `422` and no new error code. `overlayPackageMismatchError` hands the sentinel owner to a new private `unboundOwnerPackageRefusal`, because D9.9's prescription ("bind it to the owner's package") would read "bind it to `sys_metadata`", and the package-less state is no authoring destination (ADR-0070 TL;DR 1). The refusal names the measured way out, prescription first: > Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage, which keeps its table and records (REST: DELETE /api/v1/meta/object/NAME with no package parameter; in process: deleteMetaItem without packageId), then send this save again. Nothing was written: … The error carries `packageId: Q` and `ownerPackageId: null`. D9.9's refusal for an owner bound to a real package is unchanged (same code, status, text). This resumes `eca860d386` (the earlier dev's committed, never-shipped refusal) merged with `main`. What changed since that sha: two `main` merges (no conflict: #23626's sealed-write refusal and #23654's draft addressing sit above the D9.9 call and were left as `main` has them); two new boot-hydrated pins; the pin's read options typed (`check:query-options-erasure`); the code header's note on `adopt-orphans` rewritten, since PR #23688 (merged after the first merge) makes it hand an adopted object's owner to `Q` in the same request; and the changeset. ## The ruling's premise, measured before shipping The seat's route was conditional: refuse only if the two calls (delete without `dropStorage`, then save naming `Q`) are a real way out for an object **hydrated at boot** too. Measured after #23659's delete heal, on the merged branch: **Protocol level, real `ObjectKernel` + `ObjectQLPlugin` + memory driver, a second kernel over the same store as the boot**, on the kernel's fallback metadata AND with `MetadataPlugin` (`NodeMetadataManager`, `watch: false`, the composition whose boot-hydrated delete failed before #23659): seed package-less + 2 records, reboot, save naming `Q` refused `422`, delete (no package, no `dropStorage`) → the registry owner, `getObject`, `getMetaItem` and the data plane (`OBJECT_NOT_FOUND` / `404`) all say gone at once, table keeps 2 records; save naming `Q` → owner `Q`, data plane and metadata read serve `Q`'s definition, 2 records; insert a 3rd; reboot → owner `Q`, 3 records, rows `[Q]`, nothing dropped. Pinned (two cases). **HTTP, full composition** (`pnpm dev:crm -- --database file:SCRATCH/crm.db -p 38514`, a persistent SQLite file and `OS_HOME` so a restart is a real boot; seeded admin session): 1. `PUT /api/v1/meta/object/qa_probe_gadget` (no package) → `200`; two `POST /api/v1/data/qa_probe_gadget` → `201`, `201`. 2. `PUT …?package=com.qa.q` → `422` `OBJECT_OVERLAY_PACKAGE_MISMATCH`, the message above; `GET` meta still "Gadget unbound", data `200` 2 records. 3. Restart (boot-hydrated). `GET` meta "Gadget unbound", data 2 records. `PUT …?package=com.qa.q` → `422` again. 4. `DELETE /api/v1/meta/object/qa_probe_gadget` → `200` "Deleted object 'qa_probe_gadget' — it no longer exists." Then meta `GET` → `404`, data `GET` → `404` `OBJECT_NOT_FOUND` (gone at once). 5. `PUT …?package=com.qa.q` → `200` "Saved object … package=com.qa.q". Meta "Gadget Q", data `200` **2 records** (kept). `POST` rec-3 → `201`, 3 records. 6. Restart. Boot log: `Registered object: qa_probe_gadget (own, priority=100) from com.qa.q`. Meta "Gadget Q", data 3 records. `PUT …?package=com.qa.q` (owner's re-save) → `200`, live ("Gadget Q2"). H3 (`adopt-orphans`): PR #23688 landed on `main` during this run. Its own pins (`protocol-adopt-orphans-object-registry.test.ts`, including "a save naming Q after the adoption goes live") run green beside this refusal on the merged head: after an adoption the owner is `Q`, so the save classifies `own` and is not refused. The prescription does not name `adopt-orphans`: it rebinds every package-less row of the environment, not only this object; the delete-then-save pair is the narrowest way out. ## Pins `packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts` (real kernel; 6 cases): - the card's sequence: `Q`'s save is refused (`code` + `status` + `packageId` + `ownerPackageId` + the first sentence), nothing stored for `Q` (rows and history), registry / data plane / metadata read agree on the package-less definition, before and after a boot, and the refusal holds after the boot; - #23630 member 2: saves naming `P` then `Q` are both refused, so `[P, Q, package-less]` cannot be produced, across a boot; - the way out, object saved in this process: delete, save naming `Q`, owner and served definition `Q`, records kept, across a boot; - the way out, object **hydrated at boot**, on the fallback metadata and on `MetadataPlugin` (above); - CONTROL: a save naming no package over the package-less owner is unchanged. ## Negative control (one-time ablation, not a permanent case) `objectql` reads `metadata-protocol` through `dist/` (a `KNOWN_UNALIASED_TEST_IMPORTS` pair), so each leg rebuilt and ran the preflight. Fix blob at HEAD `1d7f77e50f53`. - First attempt was a no-op and is void: an insertion anchor kept its count, `ablation-replace` refused ("the anchor count moved 1 -> 1") and restored; nothing was read from it. - Mutant: re-added the sentinel carve-out (`&& runtimeOwnerPackageId !== 'sys_metadata'`, spelled with a marker) via `scripts/ablation-replace.mjs`: anchor `1 -> 0`, blob `1d7f77e50f53 -> 3dd4bee907ce`; rebuild exit 0; `ablation-dist-preflight`: marker present in `dist/index.js` and `dist/index.cjs`. Pin file: **5 failed | 1 passed** (the CONTROL stays green by design). Expected red, observed red. - Restore: `git checkout HEAD --`, blob `1d7f77e50f53` == HEAD, `git diff HEAD` empty; rebuild; preflight `--absent`: marker absent from all 24 built files, tree clean; pin file **6 passed**. ## Local verification (final head `62f6c360f`) All runs through `scripts/pm/os-verify-lock.sh`, each `VERDICT command-exit 0`: - `@objectstack/metadata-protocol` full suite, 3 shards (`--maxWorkers=2`): 80 + 78 (2 skipped) + 78 (1 skipped) files passed; 1340 + 17832 + 1388 = 20560 tests passed, 19 skipped. - `@objectstack/objectql` full suite (`--project local`): `Test Files 426 passed (426)`, `Tests 8140 passed (8140)`. - `typecheck` for `metadata-protocol` and `objectql`: exit 0; `check:test-typecheck` OK, debt unchanged (39 files / 233 errors / 64 pinned signatures). - Neighbour pins on the merged head: `protocol-adopt-orphans-object-registry`, `plugin-runtime-object-metadata-copy`, `protocol-runtime-owner-overlay-refusal`, `protocol-object-overlay-layer`, `protocol-boot-object-package-binding`, `protocol-delete-boot-hydrated-object-heal`, `protocol-delete-object-registry-heal`, `protocol-package-lifecycle`, `protocol-writepath-object-ownership` and this pin: 10 files, 82 passed; `metadata-protocol`'s `protocol.object-registry-write-through-spelling`: 12 passed. Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `62f6c360f` derived 67 commands (3 paths vs merge base `3bc407d30`); all 67 ran on that head with exits captured before any pipe: **67 × exit 0**. `--ran`: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). Two gates first refused on the shallow clone (`check-engine-split-ratio.mjs --days 90`, `check-plugin-teardown-shape.mjs --self-test`) and passed after `git fetch --shallow-since=2026-07-10 origin main`. `check:query-options-erasure` was red at `d22a2f79` (test surface 236 → 238 from the pin's `as any` read options) and is fixed in `0afedaba2`. `check-adr-0087-registration`: `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`. ## Round 2 (contract review 6198087441): the way out in every state the refusal answers - A DRAFT save naming Q is refused with its own order: delete the package-less definition without dropStorage, then a publish-mode save naming Q (it serves the object at once), then draft. Measured: a draft right after the delete leaves the object 404 until published. - Rows stored before this refusal, [package-less, Q]: a save naming no package is addressed to Q's row and told to DELETE ?package=Q first, then send it again (it then edits the package-less definition); a save naming Q is told the move: DELETE ?package=Q, then the package-less DELETE without dropStorage, then a publish-mode save naming Q. The no-package DELETE over those rows removes Q's row (measured), so no prescription relies on it. The boot's warning for those rows carries the same move, ending: The row bound to Q is not registered. - The promote doors (#23683, now on main): a Q draft over a package-less owner is refused with: the same deletes, then `POST …/publish?package=Q` (the publish names the draft's package since Round 5). - Pins: 10 cases (4 new); ablation of the state read: 4 failed / 6 passed, restore 10 passed. HTTP walk on dev:crm across a restart for the draft door and the legacy rows. Gates at e0d97ad: 67 x exit 0, --ran derived zero. ## Round 3 (contract review 6199542688): every package's stored row is named by its own delete - The refusal's state carries every package bound to an active row stored beside the package-less one (`packagelessAddress(…).candidates`), not only the named Q. Every text (save in publish and draft mode, the promote doors, the boot and the write-through) names `DELETE …?package=PKG` for each such row, then `the same DELETE with no package and no dropStorage`, then the save naming Q in publish mode (or, at the promote doors, `POST …/publish?package=Q`). - Measured before the edit: over `[package-less, P]` the no-package DELETE removes P's row ("(package=com.qa.p), and it still exists"); over `[package-less, P, Q]` it answers `409 RESOURCE_CONFLICT` and deletes nothing. The named walk converges across a boot: owner Q, records kept, nothing dropped. - Every step fits the 500-character REST bound: at most 452 characters with two packages and long names. - Pins: 13 cases (3 new: `[package-less, P]` with a save naming Q; `[package-less, P]` with a draft save and a publish of Q's draft; `[package-less, P, Q]`, with the 409 asserted). Ablations: the state read 7 failed / 6 passed; Q-only keying put back 3 failed / 10 passed (exactly the new pins); restore 13 passed. Gates at fd249ae: 67 x exit 0, --ran derived zero. ## Round 4 (contract review 6201547904): the adopted package-less draft - A publish naming no package of a draft bound to NO package, over one package's row P, is adopted into P by the draft doors' address (`key: null`, `into: P`) and refused by D9.9. Measured: no publish moves that draft into P. After `DELETE …?package=P` the same publish lands it in the package-less definition, and a publish naming P answers `404 NO_DRAFT`. - So its text says that, and names the measured next step: `DELETE …?package=P`, then publish again (the draft lands package-less), then the package-less DELETE and a publish-mode save naming P. At most 489 characters, inside the REST bound. - Pinned across a boot (14 cases, 1 new); ablation of the arm: 1 failed / 13 passed (exactly the new pin), restore 14 passed. #23706's draft-door pins and #23716's metadata-copy pin run green beside these. Gates at 814ee78: 67 x exit 0, --ran derived zero. ## Round 5 (contract review 6203512210): the promote door's publish step names the package - Every promote-door text whose draft is bound to a package ends with the request itself, `POST /api/v1/meta/object/NAME/publish?package=PKG`. A publish naming no package resolves by the rows the deletes leave, and over `[package-less, P]` with both P's draft and an unbound draft it would promote the unbound one. The adopted-draft arm (a draft bound to no package) is unchanged. - Measured once over the whole promote door: active rows {[package-less], [package-less, P], [package-less, P, Q]} x drafts {none, unbound, P, unbound+P} x publish naming {none, P, Q}, 36 cells, across a boot. 10 are refused, and each one's walk, executed from its own text, lands where the text says (owner, served definition, 2 records, nothing dropped). The table is in the dev's round-6 report on #23514. The other 26 are not refused (published, 404 NO_DRAFT or 409). - Pins: 15 cases (1 new: the review's cell; 2 updated to the named step). Ablation of the named step: 2 failed / 13 passed (the two stored-rows promote pins), restore 15 passed. #23724's adopt-orphans draft-view pin runs green beside these. Gates at 9a259bd: 67 x exit 0, --ran derived zero. ## Acceptance notes - #23683 landed first (954bf72); this branch merges it, and its promote doors give the promote text above for a package-less owner. Its pin (package-promote-runtime-owner-refusal.integration) runs green beside these: 8 passed. - A save naming no package over an object `Q` owns resolves to `Q`'s row (`packagelessAddress`, by design) and is unchanged here. - `applyObjectRegistryMutation` is now async; its one caller (`applyRegistryWriteThrough`) awaits it, and only its mismatch branch awaits a read (the stored package bindings its text names). Its best-effort warn is unchanged; for the object save door the warn is now unreachable for a sentinel owner, as it already was for a real-package owner. - Rows stored before this change (`[package-less, Q]`, or rows bound to any package) are not rewritten. A boot still keeps the package-less owner and counts each package's row in errors, but its reason is now the move for those rows, naming every package's row by its own delete (see Round 2 and Round 3), not the registry's ownership text. _Body refreshed 2026-10-15T23:08Z by `domain:engine` seat 1 from the dev's round-6 report 6204057324 (its three listed edits), over the earlier refreshes._ --- _Generated by [Claude Code](https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 53646fc commit a869ea6

3 files changed

Lines changed: 1129 additions & 7 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: a save naming a package of a runtime object whose owner is its package-less definition is refused before any write, with a way out that moves the object into that package (ADR-0029 D9.9)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten, converted or deleted by this change, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the object save door for one shape: a save naming a package of an object whose registry owner is its package-less definition (the `sys_metadata` sentinel), or a save naming no package that the door addresses to such a package's row, is refused, where it was written and then discarded by the registry. The remedy is in the requests a caller sends (deletes and saves through the same doors), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
An object has exactly one registry entry, so it has exactly one owner (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). A runtime object first saved naming no package is owned by that package-less row. A save naming package `Q` beside it was written and answered `200`, but the registry refused `Q`'s row into a warning and kept the package-less definition. The metadata read then served `Q`'s row while the data plane served the package-less one, before and after a boot, and at boot `Q`'s row was counted in `errors`. The package-less state is no authoring destination (ADR-0070), so the refusal does not say "bind it to `sys_metadata`". It names the way into `Q` that is true in the state it answers.
14+
15+
What changes for you, where object `NAME` is owned by its package-less definition and `Q` is any package. Every refusal below is `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written. The error carries `packageId: Q` and `ownerPackageId: null`.
16+
17+
- With no package's row stored beside the package-less one: FROM `PUT /api/v1/meta/object/NAME?package=Q` (`saveMetaItem({ type: 'object', name: NAME, packageId: Q, … })`) answering `200` "Saved object … package=Q" with a `Q`-bound row stored that nothing served, TO the refusal: "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage, which keeps its table and records … then send this save again. …".
18+
- FROM the same save as a draft (`PUT /api/v1/meta/object/NAME?package=Q&mode=draft`, Studio's designer) answering `200` with a `Q` draft that could never go live, TO the refusal: "Move object 'NAME' into package 'Q' first: … then save it naming 'Q' in publish mode, which serves it at once, and draft after that. …".
19+
- Where rows bound to packages are already stored beside the package-less one (the rows this defect wrote before the refusal existed, bound to `Q` or to any other package `P`), every refusal names each such row's own `DELETE …?package=` before the package-less `DELETE`. A `DELETE` naming no package over those rows does not reach the package-less definition: over one package's row it removes that row (its receipt says "(package=P), and it still exists"), and over two it is refused `409 RESOURCE_CONFLICT` ("Name the package this delete is for …"). Two saves change:
20+
- FROM `PUT /api/v1/meta/object/NAME?package=Q` (publish or draft) answering `200`, TO the refusal: "Object 'NAME' is served by its package-less definition; its definitions bound to 'P', 'Q' are never served. To move it into 'Q': DELETE /api/v1/meta/object/NAME?package=P, then ?package=Q, then the same DELETE with no package and no dropStorage (the table and records stay), then save it naming 'Q' in publish mode. …" (with one row, its own package only).
21+
- FROM `PUT /api/v1/meta/object/NAME` naming NO package answering `200`, TO the refusal, where one package's row is stored. The door addresses a save naming no package to the item's one package binding, so the edit was written to that row and never served. Over two bindings this save is refused `409` before, as now. The refusal reads: "A save naming no package of object 'NAME' is addressed to its stored definition bound to 'Q', which is never served: … Delete the 'Q' row first (REST: DELETE /api/v1/meta/object/NAME?package=Q), then send this save again: it then saves the package-less definition. …".
22+
- FROM promoting `Q`'s draft of the object (`POST /api/v1/meta/object/NAME/publish?package=Q`, or `POST /api/v1/packages/Q/publish-drafts`) answering success with `Q`'s active row stored beside the package-less one and never served, TO the refusal. This reaches a draft stored before the package-less row existed, or one a draft save naming no package wrote over the rows above. The per-item publish answers `422`; `publish-drafts` answers `200` with `outcome: 'refused'` and the draft in `failed[]` with this code (ADR-0067 D2). The text reads "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage … then POST /api/v1/meta/object/NAME/publish?package=Q: the draft then serves the object at once. …". Where any package's active row is also stored, it names deleting each such row first, as above, then the same `POST …/publish?package=Q`. The publish step always names the draft's package: a publish naming none resolves by the rows the deletes leave, and over `[package-less, P]` with both `P`'s draft and a draft bound to no package it would then promote the other draft. The draft stays stored.
23+
- A publish naming NO package of a draft bound to no package, over one package's row `P`, is addressed into `P` (the draft doors adopt the unbound draft into the one binding), and is refused the same way. No publish moves that draft into `P`: with `P`'s row deleted it lands in the package-less definition, and a publish naming `P` answers `404 NO_DRAFT`. So its text says that: "This draft of object 'NAME' is bound to no package; a publish naming no package is addressed into 'P', whose row is never served. DELETE /api/v1/meta/object/NAME?package=P, then publish again: the draft lands in the package-less definition. To move it into 'P' then: the same DELETE with no package and no dropStorage (records stay), then save it naming 'P' in publish mode. …".
24+
- The boot's reason for those rows changes. `loadMetaFromDb` still keeps the package-less owner and counts each package's row in `errors`. Its warning, `[Protocol] Failed to hydrate object/NAME: … (code=OBJECT_OVERLAY_PACKAGE_MISMATCH)`, now carries the move above, naming every package's row, and ends "The row bound to 'Q' is not registered.". It carried the registry's "already owned by package \"sys_metadata\"" text.
25+
26+
**Remedy, in one line per state.** Do not send the request again unchanged; follow its order. With no package's row stored: `DELETE /api/v1/meta/object/NAME` (no `package`, no `dropStorage`), then `PUT /api/v1/meta/object/NAME?package=Q` in publish mode. That publish-mode save makes `Q`'s row the owner at once and at every boot; a draft save registers nothing, so draft only after it. With rows bound to packages stored (`Q` or any other): first `DELETE /api/v1/meta/object/NAME?package=PKG` for each such package, then as above. Where the one stored row is the one a save naming no package was addressed to, after its delete that save may instead be sent again, and it edits the package-less definition. For a refused publish of `Q`'s draft (whether the request named `Q` or the address resolved to it): the same deletes for its state, then `POST /api/v1/meta/object/NAME/publish?package=Q`; the draft's promotion makes `Q`'s row the owner. For a refused publish naming no package of a draft bound to no package: `DELETE …?package=P`, then the same publish (the draft lands package-less), then the package-less `DELETE` and a publish-mode save naming `P` if the object belongs in `P`. Never rely on a `DELETE` naming no package while a package's row is stored: it removes that row, or is refused over two. No step drops a table or a record.
27+
28+
**Unchanged.**
29+
30+
- A save naming no package where only the package-less row is stored (its own re-save), in either mode.
31+
- The D9.9 refusal for an owner bound to a real package: same code, status and prescription.
32+
- An object with no owner, and an object a code package ships.
33+
- Every type except `object`.
34+
- Stored rows are not rewritten on upgrade.

0 commit comments

Comments
 (0)