Skip to content
31 changes: 31 additions & 0 deletions .changeset/23416-runtime-owner-overlay-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused before any write, as a code-owned object's is (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, 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 metadata save door for one shape: an `object` save naming a package other than the one whose runtime-authored row already owns the object is now refused where it was written and then discarded by the registry. The remedy is in the request a caller sends (bind it to the owner's package, or name no package), never a rewrite of a package source or a stored row; a row of that shape stored before this change is not rewritten (the registry never held it) and leaves by an ordinary delete naming its package. 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 owning package (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). The save door enforced that against a CODE-shipped owner only. Against an object that a package's `sys_metadata` row created at runtime, a save naming a second package was stored, and the registry's single-owner rule then refused it into a warning. The save answered `200`, the data plane kept the first package's schema, and `sys_metadata` held a second-package row that nothing served.

`saveMetaItem` now refuses that save before any write, with the refusal and the prescription that the code-owner case gives.

What changes for you, where package `P`'s runtime-authored row owns object `NAME` and `Q` is any other package:

- 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 while the registry kept `P`'s schema and logged `registerObject failed … already owned by package "P"`, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing stored for `Q`: "Cannot layer object 'NAME': the overlay is bound to package 'Q', but the object is owned by package 'P'. …"
- FROM the same save with `?mode=draft` storing a `Q`-bound draft row, TO the same `422` with no draft row.
- FROM `duplicatePackage` from `P` to `Q` listing in `copied` an object whose name does not carry `P`'s namespace prefix (so the copy keeps `NAME`), with a `Q`-bound row stored that the registry never held, TO that object listed in `failed` with the refusal's text and nothing stored for it.
- The same save naming `P`, or naming no package, answers `200` as before.

**Remedy, in one line.** Save the object with `?package=P` (or with no package, which addresses `P`'s row), or give `Q`'s object its own name. A `Q`-bound row stored before this change is removed with `DELETE /api/v1/meta/object/NAME?package=Q`.

**Also changed, in a log line only.** At boot, `loadMetaFromDb` still counts a stored second-package row in its per-record `errors` and leaves `P` registered. Its `Failed to hydrate object/NAME` warning now carries the D9.9 refusal's text and `code=OBJECT_OVERLAY_PACKAGE_MISMATCH`, in place of the registry's ownership message and `code=OBJECT_OWNERSHIP_CONFLICT`.

**Unchanged.**

- A code-shipped owner. The package door still answers first (`403 NOT_OVERRIDABLE`).
- Every type except `object`. Two packages may still hold one name.
39 changes: 39 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19219,6 +19219,31 @@ export class ObjectStackProtocolImplementation implements
* | `P` empty / absent (the `'sys_metadata'` sentinel) | **accepted** — a package-less env-wide overlay is ADR-0005's platform-global shape. Before D9 this THREW `already owned by package "O"`, an artefact of the borrowed `own` slot rather than a decision (#6995, measured as P2). |
* | `P == Q`, some other package | **mismatch** — refused loudly. |
*
* ## [#23416] …and against a RUNTIME-authored owner's id `R`
*
* With no packaged owner, the object may still have an owner: the `own`
* contributor a package's `sys_metadata` row registered (the write-through
* or the boot hydration). That is the contributor `registerObject`'s
* single-owner rule compares an incoming `own` claim against, so a row
* bound to a different package was written and then refused by the
* registry into {@link applyObjectRegistryMutation}'s best-effort warn: the
* save answered `200` while `sys_metadata` held a row the registry could
* not hold, which is the disagreement {@link overlayPackageMismatchError}
* exists to prevent. So the same mismatch arm answers it, judged on the
* registry's own fact (`getObjectOwner`, keyed on the object name exactly as
* `registerObject` keys its contributors), never on a guess:
*
* | case | verdict |
* |:--|:--|
* | `P == R` | `own` — the owner's re-save, an ordinary re-registration. |
* | `P` empty / absent | `own`, unchanged — a direct save naming no package is addressed to `R`'s row before this runs ({@link packagelessAddress}). |
* | `P == Q`, some other package | **mismatch** — refused loudly, the code owner's refusal and prescription. |
*
* Only an owner bound to a real package is judged here. An owner keyed by
* the `'sys_metadata'` sentinel (a package-less runtime object) or by no
* package at all is left to today's `own` verdict: it names no package the
* prescription could direct a save to.
*
* Falls back to `'own'` — today's behaviour — for a registry that does not
* offer the discriminator, so a duck-typed engine double is never made to
* fail by a question it cannot answer.
Expand All @@ -19238,6 +19263,20 @@ export class ObjectStackProtocolImplementation implements
: undefined;
const ownerPackageId: unknown = owner?.packageId;
if (typeof ownerPackageId !== 'string' || ownerPackageId === '') {
// [#23416] No packaged owner: the runtime-authored one, if any.
const runtimeOwner = typeof registry?.getObjectOwner === 'function'
? registry.getObjectOwner(name)
: undefined;
const runtimeOwnerPackageId: unknown = runtimeOwner?.packageId;
if (
binding !== ''
&& typeof runtimeOwnerPackageId === 'string'
&& runtimeOwnerPackageId !== ''
&& runtimeOwnerPackageId !== 'sys_metadata'
&& binding !== runtimeOwnerPackageId
) {
return { kind: 'mismatch', packageId: binding, ownerPackageId: runtimeOwnerPackageId };
}
return { kind: 'own', packageId: binding || 'sys_metadata' };
}
if (binding !== '' && binding !== ownerPackageId) {
Expand Down
43 changes: 31 additions & 12 deletions packages/objectql/src/protocol-delete-object-registry-heal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { hashSpec } from '@objectstack/metadata-core';
import { SchemaRegistry } from './registry.js';
// [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so the
// engine double below cannot accept a call `ObjectQL.delete` / `ObjectQL.update`
Expand Down Expand Up @@ -432,32 +433,50 @@ describe('#6808 — deleteMetaItem stops BOTH registry exits serving a deleted o
* that row is what is served next, so it is registered again (the boot
* hydration's order) and nothing is retired.
*
* Both save orders: the registry holds ONE owner per object name, so the
* second package's write-through is refused at save time, and which package
* the registry holds before the delete depends on the order.
* Both orders: the registry holds ONE owner per object name, so which package
* the registry holds before the delete depends on which row it registered
* first.
*/
describe('[#23297] a delete naming one package, of an object two packages hold, keeps the other package\'s row registered', () => {
const P = 'app.pkg_p';
const Q = 'app.pkg_q';
const bodyOf = (name: string, label: string) => ({ ...invoiceBody(name), label });

/**
* [#23416, ADR-0029 D9.9] Only the FIRST package's row is saved through the
* door: a save into a second package of an object a runtime-authored row owns
* is refused before any write, so the door can no longer produce this state.
* A store can still hold it (rows written before that refusal existed), and
* the heal is pinned for that store. So the second row is seeded directly: the
* first row's own shape, rebound and re-labelled, carrying the checksum the
* repository computes, and the registry holds the first package's owner,
* exactly as the refused write-through left it.
*/
async function twoPackageObject(name: string, order: readonly string[], controlPlane: boolean) {
const harness = makeHarness({ controlPlane });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
for (const packageId of order) {
await harness.protocol.saveMetaItem({ type: 'object', name, packageId, item: bodyOf(name, `Invoice ${packageId}`) });
}
} finally {
warn.mockRestore();
}
const [first, second] = order;
await harness.protocol.saveMetaItem({ type: 'object', name, packageId: first, item: bodyOf(name, `Invoice ${first}`) });
const [firstRow] = storedRows(harness.rows, name);
// The seeded row is one the repository could have written: its checksum
// is the repository's own, proved on the row the door just wrote.
expect(firstRow.checksum).toBe(hashSpec(JSON.parse(firstRow.metadata), 'object'));
const secondBody = bodyOf(name, `Invoice ${second}`);
const secondRow = {
...firstRow,
id: `r_seeded_${second}`,
package_id: second,
metadata: JSON.stringify(secondBody),
checksum: hashSpec(secondBody, 'object'),
};
harness.rows.set(rowKey(secondRow), secondRow);
expect(storedRows(harness.rows, name).map((r) => r.package_id).sort()).toEqual([P, Q].sort());
expect(harness.registry.getObjectOwner(name)?.packageId).toBe(first);
return harness;
}

for (const controlPlane of [false, true]) {
for (const order of [[P, Q], [Q, P]] as const) {
it(`${controlPlane ? 'control-plane' : 'tenant'} kernel, saved ${order.join(' then ')}: getObject, getItem and data CRUD serve Q's row after DELETE ?package=P`, async () => {
it(`${controlPlane ? 'control-plane' : 'tenant'} kernel, stored ${order.join(' then ')}: getObject, getItem and data CRUD serve Q's row after DELETE ?package=P`, async () => {
const name = 'shared_invoice';
const { protocol, registry, rows, dataRows } = await twoPackageObject(name, order, controlPlane);

Expand Down
Loading
Loading