Repository navigation
Commit a869ea6
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
- .changeset
- packages
- metadata-protocol/src
- objectql/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 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
0 commit comments