Skip to content

Commit 222293f

Browse files
fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused (ADR-0029 D9.9) (#23510)
Fixes #23416 Clause-②: no ## What this does `classifyObjectContribution` (`packages/metadata-protocol/src/protocol.ts`) asked the registry one question, `getPackagedObjectOwner`, which answers for a CODE artifact only. An object that package P's `sys_metadata` row created at runtime has no packaged owner, so a save of it naming package Q classified as `own` and was written. The write-through then called `registerObject(…, Q, …, 'own')`, the registry's single-owner rule (ADR-0029 D3) refused it, and `applyObjectRegistryMutation`'s best-effort `console.warn` swallowed the refusal. The save answered `200` while `sys_metadata` held a Q-bound row the registry could not hold: the disagreement ADR-0029 D9.9 (`overlayPackageMismatchError`) exists to prevent. The classifier now asks one more question when there is no packaged owner: the registry's own `own` contributor (`getObjectOwner(name)`, keyed on the object name exactly as `registerObject` keys its contributors). When that owner is bound to a real package and the row's binding names a different package, it answers the existing `mismatch` arm. So the save door refuses with the code-owner path's refusal and prescription (`OBJECT_OVERLAY_PACKAGE_MISMATCH` / `422`), before `ensureOverlayIndex` and every `put`. One arm answers both owners; no second refusal path, no new error code, no new message. Unchanged by construction: - A binding equal to the owner's package: `own`, an ordinary re-registration (the owner's re-save stays `200`). - A save naming no package: still `own`. A direct publish-mode save naming no package is addressed to the owner's row before this runs (`packagelessAddress`). - An owner keyed by the `sys_metadata` sentinel (a package-less runtime object) or by no package: left to today's verdict. It names no package the prescription could direct a save to. See the acceptance notes. - A code-packaged owner: the existing table, untouched. ## Premise check (the seat's ruling premise) The ruling needed a runtime owner's package that can be read deterministically at that point of `saveMetaItem`. It holds. The registry keeps exactly one `own` contributor per object name (`registerObject` enforces it), and that contributor is the very fact `registerObject` judges an incoming `own` claim against. The arm predicts the registry's own refusal and judges nothing else. The object write-through is not kernel-gated (`applyRegistryWriteThrough`'s object branch), so every saved runtime object is in the registry on every kernel topology. Measured at the HTTP door on both topologies (below). ## Measured before the fix (real kernel, base `cb9841aa25`) `ObjectKernel` + `ObjectQLPlugin` + an in-memory driver, `saveMetaItem` as `PUT /api/v1/meta/object/NAME?package=Q` calls it: | setup | Q's save | rows stored | registry owner | warn | |:--|:--|:--|:--|:--| | P saved `qa_probe_gadget` | `success: true`, "Saved object 'qa_probe_gadget' (env-wide, package=com.qa.q, state=active)" | `com.qa.p`, `com.qa.q` | `com.qa.p` | `registerObject failed … already owned by package "com.qa.p" … cannot claim ownership` | ## Measured after the fix - Real kernel pin (new file, 4 cases): Q's save answers `OBJECT_OVERLAY_PACKAGE_MISMATCH` / `422` (with `packageId` Q and `ownerPackageId` P). No row and no history entry is stored for Q. The registry and `sys_metadata` both still say P. CONTROLS: P's own re-save answers success and the registry follows it; a save naming no package addresses P's row; a code-packaged owner answers `NOT_OVERRIDABLE` / `403`, as it did before this change (measured on the base). - HTTP door, one-time reading (the real `RestServer` `PUT /api/v1/meta/object/:name` route over a real better-sqlite3 engine, control-plane and environment kernels): P `200`; Q `422` with body `code: OBJECT_OVERLAY_PACKAGE_MISMATCH` and the D9.9 sentence; P again `200`; stored bindings `[com.probe.p]`. - Other faces of the same door, one-time readings on the real kernel, before → after: - a draft save into Q: success with a Q draft row stored → `422`, no draft row; - `duplicatePackage` P → Q of an object whose name lacks P's namespace prefix (so the copy keeps the name): listed in `copied` with a Q row the registry never held → listed in `failed` with the D9.9 text, nothing stored; - boot (`loadMetaFromDb`) with a stored Q row beside P's: P registered and the Q row counted in `errors` either way. The warn line changes from `code=OBJECT_OWNERSHIP_CONFLICT` to `code=OBJECT_OVERLAY_PACKAGE_MISMATCH`. ## Fixtures re-judged (the rule's consumer radius) Two suites built the two-package runtime shape by saving the second package's row through the door. Both are RE-SEEDED, not weakened: the first package's row is still saved through the door, and the second row is written straight into the store, as a row stored before this refusal existed. - `packages/objectql/src/protocol-delete-object-registry-heal.test.ts`, `twoPackageObject` (PR #23408's pin, per pointer `6163309335`). The seeded row is the first row's own shape, rebound and re-labelled, with the repository's checksum (`hashSpec`). The fixture asserts that formula on the row the door just wrote, and that the registry still holds the first package. - `packages/rest/src/meta-delete-drop-storage-other-rows.test.ts`, `twoPackageObject` (PR #23464's pin, landed after the claim). Its existing `seedUnboundRow` is generalised to `seedStoredRow(packageId)`. This file is a **surface increment** over the claim's declared surface: it is the PM's swept consumer radius, and the PR breaks it without the re-seed. Each re-seeded fixture was ablated against the behaviour it pins, to show it still bites (mutation through `scripts/ablation-replace.mjs`, `dist/` proven by `scripts/ablation-dist-preflight.mjs`, restored blob equal to `HEAD` and the tree clean): | ablation | mutated | restored | |:--|:--|:--| | this PR's arm (`mismatch` return replaced by `own`), new pin | 1 failed / 3 passed (the refusal case: "expected null to be an instance of Error") | 4 passed | | #23408's heal (its remaining-rows branch made unreachable), heal file | 5 failed / 10 passed (the four re-seeded object cases + the dashboard case) | 15 passed | | #23464's drop refusal (its remaining-rows check made unreachable), rest file | 3 failed / 2 passed | 5 passed | ## Tests (consumer contract face) All readings below are at this PR's head `55bb8619e7`, which merges `origin/main` at `394f65e89`. Each command ran under the shared verify lock (`scripts/pm/os-verify-lock.sh`), and each verdict is that wrapper's `VERDICT command-exit` line. | command | result | |:--|:--| | `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` | exit 0 · 236 files passed, 3 skipped · 28397 tests passed, 19 skipped | | `pnpm --filter @objectstack/metadata-protocol typecheck` | exit 0 | | `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2` | exit 0 · 416 files · 8097 tests passed | | `pnpm --filter @objectstack/objectql typecheck` (tsc + `check:test-typecheck`) | exit 0 · the test layer compiles, debt unchanged | | `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2` | exit 0 · 283 files · 5852 tests passed, 326 skipped (first attempt: lock exit 99, re-run with the same slot) | | `pnpm --filter @objectstack/rest typecheck` (tsc + `check:test-typecheck`) | exit 0 | The objectql suite's `repo` project, which holds the tests that read outside the package, is CI's (`test:repo`). The red-before reading for the new pin was taken at the base: 2 failed of 4. The refusal case answered `null` (a success). The code-owner control then read `NOT_OVERRIDABLE`, and that measured value is what the control now pins. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `55bb8619e7`: 67 commands. All 67 ran at that head with exit codes recorded, and `--ran` reconciles: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN". Every command exited 0. `check:type-check-debt` first got lock exit 99 (its own PREREQUISITE NOT MET, exit 3) and passed when re-run under the lock: "1 ledger entr(ies) re-measured … none above its recorded number". On an earlier head, `check-adr-0087-registration` refused a `## FROM → TO` section label beside `no-migration-prescription`. The changeset now states the FROM/TO as request answers, and the gate passes (`not-required (no-migration-prescription)`). CI owns everything the derivation does not place: the artifact-roster families, the wide-population families, the type-check lanes, Test Core, Build Core, dogfood and the temporal conformance job. `packages/runtime` and `packages/qa/dogfood` suites were not run locally. A heuristic sweep of their tests for a second package's object save through the door found none. `origin/main` has moved past `394f65e89` by two commits (`ad5a7d7c39` spec page-action rows, `111b5821ce` a runtime test). Neither touches this diff's packages or behaviour; CI's merge ref covers them. ## Acceptance notes - **Package-less owner, same family, not fixed here.** An object a row bound to NO package owns (`sys_metadata` sentinel) behaves as this card did before the fix. On a real kernel at `cb9841aa25`, a package-less save then a `?package=Q` save answered success with rows `[null, com.qa.q]`, while the registry kept the sentinel owner and warned `already owned by package "sys_metadata"`. The arm deliberately excludes it: the D9.9 prescription would tell the author to bind to `'sys_metadata'`, which is not a package, and the right remedy (orphan adoption, or saving without a package) is a wording decision. Reported to the seat for the D9.9 family's close-out, not filed by this PR. - **`duplicatePackage` now reports what it used to fake.** A source object whose name lacks the source namespace prefix keeps its name in the copy. Before this PR that copy was a row the registry refused, over the same physical table (object name = table name). Now it lands in `failed[]`. The docblock of `duplicatePackage` already calls a same-name copy a collision. The prescription text it carries ("bind … to P, or have Q extend") is the code-owner path's and fits the save door better than the duplicate face; renaming the object is the remedy there. - **The prescription's second remedy is a code-package affordance.** "have Q extend the object instead" names `objectExtensions`, which a code package declares. No runtime metadata door authors an extension. The ruled prescription is reused unchanged; the first remedy (bind to P) is always available. - **Callers left as they are.** `applyObjectRegistryMutation` and `loadMetaFromDb` already route a `mismatch` to their warn and per-record `errors`. The heal comment in `restoreArtifactRegistryView` ("a second package's runtime-authored row never registered (its write-through was refused at save)") stays true of stored rows and sits in the delete heal that `deleteMetaItem` runs, outside this card's surface. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f21216 commit 222293f

5 files changed

Lines changed: 359 additions & 23 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
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)
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, 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). -->
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 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.
14+
15+
`saveMetaItem` now refuses that save before any write, with the refusal and the prescription that the code-owner case gives.
16+
17+
What changes for you, where package `P`'s runtime-authored row owns object `NAME` and `Q` is any other package:
18+
19+
- 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'. …"
20+
- FROM the same save with `?mode=draft` storing a `Q`-bound draft row, TO the same `422` with no draft row.
21+
- 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.
22+
- The same save naming `P`, or naming no package, answers `200` as before.
23+
24+
**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`.
25+
26+
**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`.
27+
28+
**Unchanged.**
29+
30+
- A code-shipped owner. The package door still answers first (`403 NOT_OVERRIDABLE`).
31+
- Every type except `object`. Two packages may still hold one name.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19219,6 +19219,31 @@ export class ObjectStackProtocolImplementation implements
1921919219
* | `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). |
1922019220
* | `P == Q`, some other package | **mismatch** — refused loudly. |
1922119221
*
19222+
* ## [#23416] …and against a RUNTIME-authored owner's id `R`
19223+
*
19224+
* With no packaged owner, the object may still have an owner: the `own`
19225+
* contributor a package's `sys_metadata` row registered (the write-through
19226+
* or the boot hydration). That is the contributor `registerObject`'s
19227+
* single-owner rule compares an incoming `own` claim against, so a row
19228+
* bound to a different package was written and then refused by the
19229+
* registry into {@link applyObjectRegistryMutation}'s best-effort warn: the
19230+
* save answered `200` while `sys_metadata` held a row the registry could
19231+
* not hold, which is the disagreement {@link overlayPackageMismatchError}
19232+
* exists to prevent. So the same mismatch arm answers it, judged on the
19233+
* registry's own fact (`getObjectOwner`, keyed on the object name exactly as
19234+
* `registerObject` keys its contributors), never on a guess:
19235+
*
19236+
* | case | verdict |
19237+
* |:--|:--|
19238+
* | `P == R` | `own` — the owner's re-save, an ordinary re-registration. |
19239+
* | `P` empty / absent | `own`, unchanged — a direct save naming no package is addressed to `R`'s row before this runs ({@link packagelessAddress}). |
19240+
* | `P == Q`, some other package | **mismatch** — refused loudly, the code owner's refusal and prescription. |
19241+
*
19242+
* Only an owner bound to a real package is judged here. An owner keyed by
19243+
* the `'sys_metadata'` sentinel (a package-less runtime object) or by no
19244+
* package at all is left to today's `own` verdict: it names no package the
19245+
* prescription could direct a save to.
19246+
*
1922219247
* Falls back to `'own'` — today's behaviour — for a registry that does not
1922319248
* offer the discriminator, so a duck-typed engine double is never made to
1922419249
* fail by a question it cannot answer.
@@ -19238,6 +19263,20 @@ export class ObjectStackProtocolImplementation implements
1923819263
: undefined;
1923919264
const ownerPackageId: unknown = owner?.packageId;
1924019265
if (typeof ownerPackageId !== 'string' || ownerPackageId === '') {
19266+
// [#23416] No packaged owner: the runtime-authored one, if any.
19267+
const runtimeOwner = typeof registry?.getObjectOwner === 'function'
19268+
? registry.getObjectOwner(name)
19269+
: undefined;
19270+
const runtimeOwnerPackageId: unknown = runtimeOwner?.packageId;
19271+
if (
19272+
binding !== ''
19273+
&& typeof runtimeOwnerPackageId === 'string'
19274+
&& runtimeOwnerPackageId !== ''
19275+
&& runtimeOwnerPackageId !== 'sys_metadata'
19276+
&& binding !== runtimeOwnerPackageId
19277+
) {
19278+
return { kind: 'mismatch', packageId: binding, ownerPackageId: runtimeOwnerPackageId };
19279+
}
1924119280
return { kind: 'own', packageId: binding || 'sys_metadata' };
1924219281
}
1924319282
if (binding !== '' && binding !== ownerPackageId) {

‎packages/objectql/src/protocol-delete-object-registry-heal.test.ts‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

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

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

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

0 commit comments

Comments
 (0)