Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
eca860d
fix(metadata-protocol): a save naming a package beside an object its …
claude Oct 15, 2026
7edd2b6
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
dd3c721
test(objectql): the sentinel-owner refusal's way out holds for an obj…
claude Oct 15, 2026
d22a2f7
chore(changeset): the sentinel-owner object save refusal (BREAKING, C…
claude Oct 15, 2026
0afedab
test(objectql): type the sentinel-owner pin's system read options (ch…
claude Oct 15, 2026
35e530f
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
62f6c36
docs(metadata-protocol): the sentinel-owner refusal's header names ad…
claude Oct 15, 2026
e388aa0
fix(metadata-protocol): the sentinel-owner refusal names the way out …
claude Oct 15, 2026
7046172
chore(changeset): the sentinel-owner refusal's states, remedies and b…
claude Oct 15, 2026
5d74ea9
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
d9bf7bb
fix(metadata-protocol): the promote doors' D9.9 refusal over a packag…
claude Oct 15, 2026
e0d97ad
chore(changeset): the promote doors' arm of the sentinel-owner refusal
claude Oct 15, 2026
135cb46
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
edfdb27
fix(metadata-protocol): the sentinel-owner refusal names each package…
claude Oct 15, 2026
8c84afd
chore(changeset): the sentinel-owner refusal's remedy names a row bou…
claude Oct 15, 2026
fd249ae
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
0375a53
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
9268045
fix(metadata-protocol): a publish naming no package of a package-less…
claude Oct 15, 2026
c3ca79d
chore(changeset): the adopted package-less draft's arm of the sentine…
claude Oct 15, 2026
814ee78
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
2e4ade0
Merge origin/main into claude/issue-23514-sentinel-owner-save
claude Oct 15, 2026
bbf5a12
fix(metadata-protocol): every promote-door text whose draft is bound …
claude Oct 15, 2026
9a259bd
chore(changeset): the promote remedy names the draft's package in its…
claude Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/23514-sentinel-owner-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/metadata-protocol': minor
---

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)

Clause-②: no (narrowing)

<!-- 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). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

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.

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`.

- 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. …".
- 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. …".
- 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:
- 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).
- 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. …".
- 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.
- 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. …".
- 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.

**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.

**Unchanged.**

- A save naming no package where only the package-less row is stored (its own re-save), in either mode.
- The D9.9 refusal for an owner bound to a real package: same code, status and prescription.
- An object with no owner, and an object a code package ships.
- Every type except `object`.
- Stored rows are not rewritten on upgrade.
Loading
Loading