From eca860d38695a7eeec26a05501a9b7ef7508cdb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:54:04 +0000 Subject: [PATCH 01/15] fix(metadata-protocol): a save naming a package beside an object its package-less definition owns is refused (ADR-0029 D9.9) classifyObjectContribution now judges a runtime owner keyed by the 'sys_metadata' sentinel as it judges one bound to a real package: a row bound to package Q beside it is a mismatch, refused before any write with OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 and a prescription that moves the object into Q and keeps its table and records (delete the package-less definition without dropStorage, then save naming Q). Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 76 +++- ...entinel-owner-package-save-refusal.test.ts | 335 ++++++++++++++++++ 2 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 389ca76a105..089055ec4a4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19312,10 +19312,26 @@ export class ObjectStackProtocolImplementation implements * | `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. + * ## [#23514] …and against an owner keyed by the `'sys_metadata'` sentinel + * + * A runtime object first saved naming no package is owned by that + * package-less row, keyed by the sentinel. A save naming package `Q` beside + * it was classified `own` and written, and the write-through's + * `registerObject(…, Q, …, 'own')` met the registry's single-owner rule and + * landed in the best-effort warn: the save answered `200`, the metadata + * read served `Q`'s row, and the registry and the data plane kept the + * package-less definition, before and after a boot. So the sentinel owner + * takes the same `mismatch` verdict, with the sentinel as `ownerPackageId`; + * {@link overlayPackageMismatchError} gives it its own prescription, since + * "bind it to `'sys_metadata'`" names no package (ADR-0070 TL;DR 1: the + * package-less state is no authoring destination). + * + * | case | verdict | + * |:--|:--| + * | `P` empty / absent | `own`, unchanged — the package-less row's own re-save. | + * | `P == Q`, any package | **mismatch** — refused loudly, with the move-into-a-package prescription. | + * + * An object with no owner at all is left to the `own` verdict. * * 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 @@ -19341,11 +19357,11 @@ export class ObjectStackProtocolImplementation implements ? registry.getObjectOwner(name) : undefined; const runtimeOwnerPackageId: unknown = runtimeOwner?.packageId; + // [#23514] The `'sys_metadata'` sentinel owner is judged too: see the header. if ( binding !== '' && typeof runtimeOwnerPackageId === 'string' && runtimeOwnerPackageId !== '' - && runtimeOwnerPackageId !== 'sys_metadata' && binding !== runtimeOwnerPackageId ) { return { kind: 'mismatch', packageId: binding, ownerPackageId: runtimeOwnerPackageId }; @@ -19374,6 +19390,11 @@ export class ObjectStackProtocolImplementation implements private static overlayPackageMismatchError( name: string, rowPackageId: string, ownerPackageId: string, ): Error { + // [#23514] An owner keyed by the sentinel is owned by no package, so + // this prescription's "bind it to the owner" has nothing to name. + if (ownerPackageId === 'sys_metadata') { + return ObjectStackProtocolImplementation.unboundOwnerPackageRefusal(name, rowPackageId); + } const err: any = new Error( `Cannot layer object '${name}': the overlay is bound to package ` + `'${rowPackageId}', but the object is owned by package '${ownerPackageId}'. ` @@ -19389,6 +19410,46 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * [#23514, ADR-0029 D9.9, ADR-0070 TL;DR 1] The D9.9 refusal for an object + * whose owner is its package-less definition (the `'sys_metadata'` + * sentinel), so no package owns it: a save naming package `Q` would store a + * second definition the one registry entry never serves. + * + * The prescription names the measured way out, which moves the object into + * `Q` and keeps its table and records: delete the package-less definition + * WITHOUT `dropStorage` (the delete door's default, which keeps the table), + * then save naming `Q`. That save registers `Q`'s row as the owner, and a + * boot hydrates it as the owner. Walked on a real kernel and through + * `RestServer`'s `DELETE` and `PUT` doors on a SQLite store, across a boot, + * with no table dropped and every record served. ⛔ Not ADR-0070 D5's + * `POST /api/v1/packages/:id/adopt-orphans`: it rebinds an object's row in + * storage only, so the registry keeps the sentinel owner until the next + * boot and a save naming `Q` in between meets this refusal again. + * + * The code is D9.9's, not a new one: the ledger's meaning (a row bound to a + * package that does not own the object) holds, and the caller's branch is + * the same one; only the way out differs, and the message carries it. The + * prescription first: a 4xx message crosses the REST boundary bounded at + * 500 characters with its tail truncated. + */ + private static unboundOwnerPackageRefusal(name: string, rowPackageId: string): Error { + const err: any = new Error( + `Move object '${name}' into package '${rowPackageId}' 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 object is held by a definition ' + + `bound to no package, and an object has one registry entry, so a definition bound to ` + + `'${rowPackageId}' would never be served.`, + ); + err.code = 'OBJECT_OVERLAY_PACKAGE_MISMATCH'; + err.status = 422; + err.packageId = rowPackageId; + err.ownerPackageId = null; + err.docs = 'docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md'; + return err; + } + /** * Register ONE active overlay body into the engine's SchemaRegistry. * @@ -22161,6 +22222,11 @@ export class ObjectStackProtocolImplementation implements // runtime had discarded (#6995, the silent write-side divergence). // The receipt and the registry now agree because the write never // happens: this precedes `ensureOverlayIndex` and every `put`. + // + // [#23514] The same holds for an object its package-less definition + // owns (the `'sys_metadata'` sentinel): a save naming a package beside + // it is refused here, with the move-into-a-package prescription + // ({@link unboundOwnerPackageRefusal}). if (singularType === 'object') { const layer = this.classifyObjectContribution(request.name, request.packageId); if (layer.kind === 'mismatch') { diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts new file mode 100644 index 00000000000..ab37472f30a --- /dev/null +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23514, ADR-0029 D9.9 — a save naming package Q of a runtime object whose + * owner is its PACKAGE-LESS definition (the `'sys_metadata'` sentinel) is + * refused before any write, and the refusal names a way out that works. + * + * ## The defect + * + * `classifyObjectContribution` judged a runtime owner only when it was bound to + * a real package (#23416), so a row bound to Q beside a package-less owner was + * classified `own` and written. The write-through's + * `registerObject(…, Q, …, 'own')` met the registry's single-owner rule and + * landed in `applyObjectRegistryMutation`'s best-effort warn: the save answered + * `200`, the metadata read served Q's row, and the registry (so the data plane) + * kept the package-less definition, before and after a boot. Measured at + * `cb9841aa25` per the card, and again at `23e689dc7d` through + * `PUT /api/v1/meta/object/NAME?package=Q` on a SQLite store. + * + * ## The way out the refusal prescribes, and why it is that one + * + * Delete the package-less definition WITHOUT `dropStorage` (the delete door's + * default), which keeps the table and its records, then save naming Q: Q's row + * becomes the owner at once and at every later boot. ADR-0070 D5's + * `adopt-orphans` door is not it: it rebinds an object's row in storage only, + * and the registry keeps the sentinel owner until the next boot. + * + * ## Why a real kernel, and two of them + * + * The disagreement is between the `sys_metadata` rows the driver holds and the + * `SchemaRegistry` the data plane dispatches on, and the boot half is + * `loadMetaFromDb`'s. `ObjectQLPlugin` binds the real protocol to the real + * engine and registry; a second kernel over the same store is the boot. + * + * ## The pins + * + * - the card's sequence: Q's save is refused (`code` + `status`), nothing is + * stored for Q, and the registry, the data plane and the metadata read agree + * on the package-less definition, before and after a boot; + * - #23630 member 2's sequence: saves naming P and then Q are both refused, so + * the rows `[P, Q, package-less]` cannot be produced; + * - the prescription, followed: the object moves into Q with its table and + * records, and Q's row is the owner and the served definition across a boot; + * - CONTROL: a save naming no package over the package-less owner is unchanged. + * + * The D9.9 refusal for an owner bound to a real package is pinned, unchanged, + * by `protocol-runtime-owner-overlay-refusal.test.ts`. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** + * In-memory driver, copied from `protocol-runtime-owner-overlay-refusal.test.ts`; + * equality-only WHERE. `dropTable` records the tables a run drops, so a pin can + * say none was. + */ +function makeMemoryDriver(stores: Stores, drops: string[]) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + async dropTable(object: string) { drops.push(object); stores.delete(object); }, + }; + return driver; +} + +const P = 'com.qa.p'; +const Q = 'com.qa.q'; +const NAME = 'qa_probe_gadget'; +const SENTINEL = 'sys_metadata'; + +/** A runtime object body; `label` tells the writes apart. */ +const gadgetBody = (label: string) => ({ + name: NAME, + label, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + }, +}); + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + protocol: any; +} + +describe('#23514 — a save naming a package beside an object its package-less definition owns', () => { + const live: ObjectKernel[] = []; + afterEach(async () => { + while (live.length) { + const kernel = live.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } + }); + + /** One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run by the plugin's start. */ + async function boot(stores: Stores, drops: string[]): Promise { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + live.push(kernel); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores, drops), true); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + return { kernel, engine, protocol: kernel.getService('protocol') }; + } + + /** The bindings of the object's stored rows, as the driver holds them. */ + const storedBindings = (stores: Stores, table: 'sys_metadata' | 'sys_metadata_history' = 'sys_metadata') => + Array.from(stores.get(table)?.values() ?? []) + .filter((r) => r.type === 'object' && r.name === NAME) + .map((r) => r.package_id ?? null); + + const save = (k: Booted, packageId: string | undefined, label: string) => + k.protocol.saveMetaItem({ + type: 'object', name: NAME, ...(packageId ? { packageId } : {}), item: gadgetBody(label), + }); + + const refusalOf = (k: Booted, packageId: string, label: string) => + save(k, packageId, label).then(() => null, (e: any) => e); + + /** + * The three answers that must agree: the registry's owner, the definition + * the data plane dispatches on (the registry's object), and the metadata + * read's served row; plus the records the data plane serves. + */ + async function served(k: Booted) { + const read = await k.protocol.getMetaItem({ type: 'object', name: NAME }); + const records = await k.engine.find(NAME, {}, { context: { isSystem: true } } as any); + return { + owner: k.engine.registry.getObjectOwner(NAME)?.packageId, + dataPlaneLabel: (k.engine.registry.getObject(NAME) as { label?: string } | undefined)?.label, + metaReadLabel: (read?.item ?? read)?.label, + records: records.length, + }; + } + + /** The object saved naming no package, with two records in its table. */ + async function seedPackagelessOwner(stores: Stores, drops: string[]): Promise { + const k = await boot(stores, drops); + const res = await save(k, undefined, 'Gadget unbound'); + expect(res.success).toBe(true); + await k.engine.insert(NAME, { name: 'rec-1' }, { context: { isSystem: true } } as any); + await k.engine.insert(NAME, { name: 'rec-2' }, { context: { isSystem: true } } as any); + expect(storedBindings(stores)).toEqual([null]); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + expect(k.engine.registry.getPackagedObjectOwner(NAME)).toBeUndefined(); + return k; + } + + /** The refusal's envelope, and the first sentence the ruling binds: a way out, never "bind it to sys_metadata". */ + function expectRefusal(err: any): void { + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch( + new RegExp(`^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its ` + + 'package-less definition without dropStorage, which keeps its table and records '), + ); + expect(err.message).not.toContain(`'${SENTINEL}'`); + } + + it('the card\'s sequence: Q\'s save is refused before any write, and the stores agree, before and after a boot', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + const historyBefore = storedBindings(stores, 'sys_metadata_history'); + + expectRefusal(await refusalOf(k1, Q, 'Gadget Q')); + + // Nothing stored for Q: no row, and no history entry either. + expect(storedBindings(stores)).toEqual([null]); + expect(storedBindings(stores, 'sys_metadata_history')).toEqual(historyBefore); + expect(await served(k1)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + // The refusal holds across the boot. + expectRefusal(await refusalOf(k2, Q, 'Gadget Q')); + expect(storedBindings(stores)).toEqual([null]); + expect(drops).toEqual([]); + }); + + it('#23630 member 2: saves naming P and then Q are both refused, so `[P, Q, package-less]` cannot be produced', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + + const pErr = await refusalOf(k1, P, 'Gadget P'); + expect(pErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(pErr?.status).toBe(422); + expect(pErr?.packageId).toBe(P); + expectRefusal(await refusalOf(k1, Q, 'Gadget Q')); + + expect(storedBindings(stores)).toEqual([null]); + expect(await served(k1)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(storedBindings(stores)).toEqual([null]); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + }); + + it('the prescription is a way out: the object moves into Q with its table and records, and Q\'s row is the owner across a boot', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + expectRefusal(await refusalOf(k1, Q, 'Gadget Q')); + + // The prescription, as written: the delete names no package and no dropStorage… + const del = await k1.protocol.deleteMetaItem({ type: 'object', name: NAME }); + expect(del.success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect(drops).toEqual([]); + expect(stores.get(NAME)?.size).toBe(2); + // …and the same save, sent again. + const res = await save(k1, Q, 'Gadget Q'); + expect(res.success).toBe(true); + + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k1)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + // Q's own re-save after the boot goes live. + expect((await save(k2, Q, 'Gadget Q2')).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q2', metaReadLabel: 'Gadget Q2', records: 2, + }); + expect(drops).toEqual([]); + }); + + it('CONTROL: a save naming no package over the package-less owner is unchanged', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + + const res = await save(k1, undefined, 'Gadget unbound v2'); + + expect(res.success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect(await served(k1)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound v2', metaReadLabel: 'Gadget unbound v2', records: 2, + }); + }); +}); From dd3c721d25308f92b06d671827727f3086b95244 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:13:42 +0000 Subject: [PATCH 02/15] test(objectql): the sentinel-owner refusal's way out holds for an object hydrated at boot (fallback metadata and MetadataPlugin) Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- ...entinel-owner-package-save-refusal.test.ts | 76 ++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index ab37472f30a..a8751de5456 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -40,7 +40,10 @@ * - #23630 member 2's sequence: saves naming P and then Q are both refused, so * the rows `[P, Q, package-less]` cannot be produced; * - the prescription, followed: the object moves into Q with its table and - * records, and Q's row is the owner and the served definition across a boot; + * records, and Q's row is the owner and the served definition across a boot, + * for an object saved in this process and for one HYDRATED AT BOOT, on the + * kernel's fallback metadata and with `MetadataPlugin` (the delete heal for + * a boot-hydrated object is what makes the second half hold); * - CONTROL: a save naming no package over the package-less owner is unchanged. * * The D9.9 refusal for an owner bound to a real package is pinned, unchanged, @@ -48,8 +51,12 @@ */ import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; +import { MetadataPlugin } from '@objectstack/metadata'; import { ObjectQL } from './engine.js'; import { ObjectQLPlugin } from './plugin.js'; @@ -157,21 +164,35 @@ interface Booted { describe('#23514 — a save naming a package beside an object its package-less definition owns', () => { const live: ObjectKernel[] = []; + const roots: string[] = []; afterEach(async () => { while (live.length) { const kernel = live.pop()!; if (kernel.getState() === 'running') await kernel.shutdown(); } + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); }); - /** One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run by the plugin's start. */ - async function boot(stores: Stores, drops: string[]): Promise { + /** + * One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run + * by the plugin's start. `withMetadataPlugin` adds the `metadata` service + * (`MetadataPlugin`, an empty temp root, `watch: false`), into which + * `ObjectQLPlugin.start()` copies every object it hydrated: the composition + * whose boot-hydrated delete kept the object registered until the delete + * heal for boot-hydrated objects landed. + */ + async function boot(stores: Stores, drops: string[], withMetadataPlugin = false): Promise { // `logger.level` is the real config key, and gracefulShutdown:false keeps // process signal handlers out of vitest's fork workers (#4250). const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); live.push(kernel); const engine = new ObjectQL(); engine.registerDriver(makeMemoryDriver(stores, drops), true); + if (withMetadataPlugin) { + const root = mkdtempSync(join(tmpdir(), 'os-23514-')); + roots.push(root); + await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); + } await kernel.use(new ObjectQLPlugin({ ql: engine })); await kernel.bootstrap(); return { kernel, engine, protocol: kernel.getService('protocol') }; @@ -319,6 +340,55 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(drops).toEqual([]); }); + for (const { composition, withMetadataPlugin } of [ + { composition: 'the kernel\'s fallback metadata', withMetadataPlugin: false }, + { composition: 'MetadataPlugin', withMetadataPlugin: true }, + ]) { + it(`the prescription is a way out for an object HYDRATED AT BOOT too (${composition}): gone at once after the delete, then Q's row is the owner with the table and records kept, and a boot agrees`, async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + await k1.kernel.shutdown(); + + // The object is now hydrated at boot, not saved in this process. + const k2 = await boot(stores, drops, withMetadataPlugin); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + expectRefusal(await refusalOf(k2, Q, 'Gadget Q')); + expect(storedBindings(stores)).toEqual([null]); + + // The prescription's first call: the delete names no package and no dropStorage. + const del = await k2.protocol.deleteMetaItem({ type: 'object', name: NAME }); + expect(del.success).toBe(true); + // The registry owner, the metadata read and the data plane agree it is gone at once; + // the table keeps its records. + expect(storedBindings(stores)).toEqual([]); + expect(k2.engine.registry.getObjectOwner(NAME)).toBeUndefined(); + expect(k2.engine.registry.getObject(NAME)).toBeUndefined(); + expect((await k2.protocol.getMetaItem({ type: 'object', name: NAME }))?.item).toBeUndefined(); + await expect(k2.engine.find(NAME, {}, { context: { isSystem: true } } as any)) + .rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); + expect(stores.get(NAME)?.size).toBe(2); + + // The second call: the same save, sent again. + expect((await save(k2, Q, 'Gadget Q')).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + await k2.engine.insert(NAME, { name: 'rec-3' }, { context: { isSystem: true } } as any); + + await k2.kernel.shutdown(); + const k3 = await boot(stores, drops, withMetadataPlugin); + expect(await served(k3)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 3, + }); + expect(storedBindings(stores)).toEqual([Q]); + expect(drops).toEqual([]); + }); + } + it('CONTROL: a save naming no package over the package-less owner is unchanged', async () => { const stores: Stores = new Map(); const drops: string[] = []; From d22a2f79f513470035be2abad3e3eae8d2a82798 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:36:44 +0000 Subject: [PATCH 03/15] =?UTF-8?q?chore(changeset):=20the=20sentinel-owner?= =?UTF-8?q?=20object=20save=20refusal=20(BREAKING,=20Clause-=E2=91=A1=20no?= =?UTF-8?q?=20(narrowing))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- .changeset/23514-sentinel-owner-save.md | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/23514-sentinel-owner-save.md diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/23514-sentinel-owner-save.md new file mode 100644 index 00000000000..22a981098ee --- /dev/null +++ b/.changeset/23514-sentinel-owner-save.md @@ -0,0 +1,27 @@ +--- +'@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) + + + +**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 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`. + +What changes for you, where object `NAME` is owned by its package-less definition and `Q` is any 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 that nothing served, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written: "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. …". The error carries `packageId: Q` and `ownerPackageId: null`. + +**Remedy, in one line.** `DELETE /api/v1/meta/object/NAME` (no `package`, no `dropStorage`), then send the same `PUT /api/v1/meta/object/NAME?package=Q` again: `Q`'s row becomes the owner at once and at every later boot, and the table and its records are kept. + +**Unchanged.** + +- A save naming no package over the package-less owner (its own re-save). +- 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`. +- Rows stored before this change stay as they are. Nothing is rewritten on upgrade. From 0afedaba20c93573b984823b135306cce3e79f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:06:57 +0000 Subject: [PATCH 04/15] test(objectql): type the sentinel-owner pin's system read options (check:query-options-erasure) Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- ...col-sentinel-owner-package-save-refusal.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index a8751de5456..a58915cbb07 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -57,7 +57,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { MetadataPlugin } from '@objectstack/metadata'; -import { ObjectQL } from './engine.js'; +import { ObjectQL, type EngineReadOptions } from './engine.js'; import { ObjectQLPlugin } from './plugin.js'; type Stores = Map>>; @@ -144,6 +144,8 @@ const P = 'com.qa.p'; const Q = 'com.qa.q'; const NAME = 'qa_probe_gadget'; const SENTINEL = 'sys_metadata'; +/** The data plane's reads and writes in these pins run as the system. */ +const SYSTEM: EngineReadOptions = { context: { isSystem: true } }; /** A runtime object body; `label` tells the writes apart. */ const gadgetBody = (label: string) => ({ @@ -219,7 +221,7 @@ describe('#23514 — a save naming a package beside an object its package-less d */ async function served(k: Booted) { const read = await k.protocol.getMetaItem({ type: 'object', name: NAME }); - const records = await k.engine.find(NAME, {}, { context: { isSystem: true } } as any); + const records = await k.engine.find(NAME, {}, SYSTEM); return { owner: k.engine.registry.getObjectOwner(NAME)?.packageId, dataPlaneLabel: (k.engine.registry.getObject(NAME) as { label?: string } | undefined)?.label, @@ -233,8 +235,8 @@ describe('#23514 — a save naming a package beside an object its package-less d const k = await boot(stores, drops); const res = await save(k, undefined, 'Gadget unbound'); expect(res.success).toBe(true); - await k.engine.insert(NAME, { name: 'rec-1' }, { context: { isSystem: true } } as any); - await k.engine.insert(NAME, { name: 'rec-2' }, { context: { isSystem: true } } as any); + await k.engine.insert(NAME, { name: 'rec-1' }, SYSTEM); + await k.engine.insert(NAME, { name: 'rec-2' }, SYSTEM); expect(storedBindings(stores)).toEqual([null]); expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); expect(k.engine.registry.getPackagedObjectOwner(NAME)).toBeUndefined(); @@ -367,7 +369,7 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(k2.engine.registry.getObjectOwner(NAME)).toBeUndefined(); expect(k2.engine.registry.getObject(NAME)).toBeUndefined(); expect((await k2.protocol.getMetaItem({ type: 'object', name: NAME }))?.item).toBeUndefined(); - await expect(k2.engine.find(NAME, {}, { context: { isSystem: true } } as any)) + await expect(k2.engine.find(NAME, {}, SYSTEM)) .rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); expect(stores.get(NAME)?.size).toBe(2); @@ -377,7 +379,7 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(await served(k2)).toEqual({ owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, }); - await k2.engine.insert(NAME, { name: 'rec-3' }, { context: { isSystem: true } } as any); + await k2.engine.insert(NAME, { name: 'rec-3' }, SYSTEM); await k2.kernel.shutdown(); const k3 = await boot(stores, drops, withMetadataPlugin); From 62f6c360ff8ebd8f295d886c3d668cf113434dc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:29:23 +0000 Subject: [PATCH 05/15] docs(metadata-protocol): the sentinel-owner refusal's header names adopt-orphans as a second door that sweeps every package-less row Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 10 ++++++---- ...rotocol-sentinel-owner-package-save-refusal.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index aa6383681af..7230b1991ae 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19592,10 +19592,12 @@ export class ObjectStackProtocolImplementation implements * then save naming `Q`. That save registers `Q`'s row as the owner, and a * boot hydrates it as the owner. Walked on a real kernel and through * `RestServer`'s `DELETE` and `PUT` doors on a SQLite store, across a boot, - * with no table dropped and every record served. ⛔ Not ADR-0070 D5's - * `POST /api/v1/packages/:id/adopt-orphans`: it rebinds an object's row in - * storage only, so the registry keeps the sentinel owner until the next - * boot and a save naming `Q` in between meets this refusal again. + * with no table dropped and every record served, for an object saved in + * this process and for one hydrated at boot. ADR-0070 D5's + * `POST /api/v1/packages/:id/adopt-orphans` also hands an adopted object's + * registry owner to `Q` in the same request, but it rebinds EVERY + * package-less row of the environment, not only this object, so the + * prescription does not name it: it names the narrowest way out. * * The code is D9.9's, not a new one: the ledger's meaning (a row bound to a * package that does not own the object) holds, and the caller's branch is diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index a58915cbb07..0824cc80960 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -22,8 +22,10 @@ * Delete the package-less definition WITHOUT `dropStorage` (the delete door's * default), which keeps the table and its records, then save naming Q: Q's row * becomes the owner at once and at every later boot. ADR-0070 D5's - * `adopt-orphans` door is not it: it rebinds an object's row in storage only, - * and the registry keeps the sentinel owner until the next boot. + * `adopt-orphans` door also hands an adopted object's registry owner to Q (its + * own pins, `protocol-adopt-orphans-object-registry.test.ts`, run beside these), + * but it rebinds EVERY package-less row of the environment, so the refusal + * names the narrower way out. * * ## Why a real kernel, and two of them * From e388aa09f89cd193d25fb61ef2e2bb83ef46ad4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:35:25 +0000 Subject: [PATCH 06/15] fix(metadata-protocol): the sentinel-owner refusal names the way out true for a draft save and for rows stored before it A draft save naming Q is told to move the object with a publish-mode save naming Q first. Where a row bound to Q is already stored beside the package-less one, the refusal (save door, boot, write-through) names deleting that row by naming Q first, and a save naming no package addressed to it is told so. Pinned on a real kernel across a boot. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 120 ++++++++++--- ...entinel-owner-package-save-refusal.test.ts | 169 +++++++++++++++++- 2 files changed, 263 insertions(+), 26 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 7230b1991ae..69ed0b1aec3 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2143,6 +2143,23 @@ type ViewContainerWrite = * the binding it left, `null` for the package-less row. See * {@link ViewContainerWrite}. */ +/** + * [#23514, ADR-0029 D9.9] The state a refusal of a package's object row beside + * a package-less owner (the `'sys_metadata'` sentinel) answers, which decides + * the way out its prescription names + * (`ObjectStackProtocolImplementation.unboundOwnerPackageRefusal`). + */ +interface UnboundOwnerRefusalState { + /** The save's mode; a draft save registers nothing. */ + mode?: 'draft' | 'publish'; + /** A row bound to the package is already stored beside the package-less one. */ + storedRow?: boolean; + /** The caller named no package, and the save's address resolved to the package's row. */ + namedNoPackage?: boolean; + /** Where the refusal is raised: the save door (default), or a registration of a stored row (boot, write-through). */ + at?: 'save' | 'registry'; +} + type HydrationOptions = { packageId?: string | null; organizationId: string | null; @@ -19416,6 +19433,8 @@ export class ObjectStackProtocolImplementation implements // back into the disagreement D9.9 exists to prevent. throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, + // [#23514] The row this registers is stored. + { storedRow: true, at: 'registry' }, ); } this.engine.registry.registerObject( @@ -19559,11 +19578,13 @@ export class ObjectStackProtocolImplementation implements */ private static overlayPackageMismatchError( name: string, rowPackageId: string, ownerPackageId: string, + /** [#23514] The state the refusal answers, for a `'sys_metadata'` sentinel owner only. */ + unboundOwnerState: UnboundOwnerRefusalState = {}, ): Error { // [#23514] An owner keyed by the sentinel is owned by no package, so // this prescription's "bind it to the owner" has nothing to name. if (ownerPackageId === 'sys_metadata') { - return ObjectStackProtocolImplementation.unboundOwnerPackageRefusal(name, rowPackageId); + return ObjectStackProtocolImplementation.unboundOwnerPackageRefusal(name, rowPackageId, unboundOwnerState); } const err: any = new Error( `Cannot layer object '${name}': the overlay is bound to package ` @@ -19583,21 +19604,28 @@ export class ObjectStackProtocolImplementation implements /** * [#23514, ADR-0029 D9.9, ADR-0070 TL;DR 1] The D9.9 refusal for an object * whose owner is its package-less definition (the `'sys_metadata'` - * sentinel), so no package owns it: a save naming package `Q` would store a - * second definition the one registry entry never serves. - * - * The prescription names the measured way out, which moves the object into - * `Q` and keeps its table and records: delete the package-less definition - * WITHOUT `dropStorage` (the delete door's default, which keeps the table), - * then save naming `Q`. That save registers `Q`'s row as the owner, and a - * boot hydrates it as the owner. Walked on a real kernel and through - * `RestServer`'s `DELETE` and `PUT` doors on a SQLite store, across a boot, - * with no table dropped and every record served, for an object saved in - * this process and for one hydrated at boot. ADR-0070 D5's - * `POST /api/v1/packages/:id/adopt-orphans` also hands an adopted object's - * registry owner to `Q` in the same request, but it rebinds EVERY - * package-less row of the environment, not only this object, so the - * prescription does not name it: it names the narrowest way out. + * sentinel), so no package owns it: a definition bound to package `Q` + * beside it is one the one registry entry never serves. + * + * Its prescription names the way out that is true in the state it + * answers, each measured on a real kernel across a boot, with no table + * dropped and every record kept: + * + * | state | the way out | + * |:--|:--| + * | a publish save naming `Q`, no `Q` row stored | delete the package-less definition WITHOUT `dropStorage` (the delete door's default, which keeps the table), then send the save again: `Q`'s row is the owner at once and at every boot | + * | a DRAFT save naming `Q`, no `Q` row stored | the same delete, then a PUBLISH-mode save naming `Q` (it serves the object at once), then draft as before. A draft save registers nothing, so a draft sent right after the delete leaves the object unserved until it is published | + * | a `Q` row is already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete the `Q` row by naming `Q` (the no-package delete resolves to that same row, {@link packagelessAddress}), then the package-less definition, then a publish-mode save naming `Q` | + * | the same rows, a publish save naming NO package | it is addressed to the `Q` row ({@link packagelessAddress}); delete the `Q` row, then the same save edits the package-less definition | + * + * The boot (`loadMetaFromDb`) and the write-through + * ({@link applyObjectRegistryMutation}) meet the third state: the row + * they register is stored. + * + * ADR-0070 D5's `POST /api/v1/packages/:id/adopt-orphans` also hands an + * adopted object's registry owner to `Q` in the same request, but it + * rebinds EVERY package-less row of the environment, not only this + * object, so no prescription names it: each names the narrowest way out. * * The code is D9.9's, not a new one: the ledger's meaning (a row bound to a * package that does not own the object) holds, and the caller's branch is @@ -19605,15 +19633,41 @@ export class ObjectStackProtocolImplementation implements * prescription first: a 4xx message crosses the REST boundary bounded at * 500 characters with its tail truncated. */ - private static unboundOwnerPackageRefusal(name: string, rowPackageId: string): Error { - const err: any = new Error( - `Move object '${name}' into package '${rowPackageId}' 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 object is held by a definition ' - + `bound to no package, and an object has one registry entry, so a definition bound to ` - + `'${rowPackageId}' would never be served.`, - ); + private static unboundOwnerPackageRefusal( + name: string, rowPackageId: string, state: UnboundOwnerRefusalState = {}, + ): Error { + const q = rowPackageId; + const path = `/api/v1/meta/object/${name}`; + const tail = state.at === 'registry' + ? ` The row bound to '${q}' is not registered.` + : ' Nothing was written.'; + let message: string; + if (state.storedRow && state.namedNoPackage) { + message = `A save naming no package of object '${name}' is addressed to its stored definition bound to ` + + `'${q}', which is never served: the object is served by its definition bound to no package. ` + + `Delete the '${q}' row first (REST: DELETE ${path}?package=${q}), then send this save again: it ` + + 'then saves the package-less definition.' + tail; + } else if (state.storedRow) { + message = `Object '${name}' is served by its definition bound to no package; its stored definition bound ` + + `to '${q}' is never served. To move the object into '${q}': delete the '${q}' row (REST: DELETE ` + + `${path}?package=${q}), then the package-less definition without dropStorage, which keeps its table ` + + `and records (the same DELETE with no package parameter), then save it naming '${q}' in publish ` + + 'mode.' + tail; + } else if (state.mode === 'draft') { + message = `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package parameter), ` + + `then save it naming '${q}' in publish mode, which serves it at once, and draft after that. A draft ` + + 'save registers nothing, so a draft sent first leaves the object unserved until it is published.' + + tail; + } else { + message = `Move object '${name}' into package '${q}' first: delete its package-less definition ` + + 'without dropStorage, which keeps its table and records (REST: DELETE ' + + `${path} with no package parameter; in process: deleteMetaItem without ` + + 'packageId), then send this save again. Nothing was written: the object is held by a definition ' + + `bound to no package, and an object has one registry entry, so a definition bound to ` + + `'${q}' would never be served.`; + } + const err: any = new Error(message); err.code = 'OBJECT_OVERLAY_PACKAGE_MISMATCH'; err.status = 422; err.packageId = rowPackageId; @@ -22345,6 +22399,9 @@ export class ObjectStackProtocolImplementation implements // // Several bindings is a name more than one package holds, and a save // naming none of them is refused, not guessed. + // [#23514] Whether the CALLER named a package, read before the address + // below may name one for it: the D9.9 refusal's prescription differs. + const namedNoPackage = request.packageId == null; if ( mode === 'publish' && request.packageId == null @@ -22385,8 +22442,19 @@ export class ObjectStackProtocolImplementation implements if (singularType === 'object') { const layer = this.classifyObjectContribution(request.name, request.packageId); if (layer.kind === 'mismatch') { + // [#23514] For a sentinel owner the way out depends on the + // state: the mode, and whether a row bound to the package is + // already stored beside the package-less one (a save naming + // no package that the address above resolved to a package + // found that row; one naming the package is read for it). + const unboundOwner = layer.ownerPackageId === 'sys_metadata'; + const storedRow = unboundOwner && ( + (namedNoPackage && request.packageId != null) + || (await this.packagelessAddress(singularType, request.name)).candidates.includes(layer.packageId) + ); throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, + { mode, storedRow, namedNoPackage }, ); } } @@ -30260,6 +30328,8 @@ export class ObjectStackProtocolImplementation implements if (layer.kind === 'mismatch') { throw ObjectStackProtocolImplementation.overlayPackageMismatchError( String(record.name), layer.packageId, layer.ownerPackageId, + // [#23514] The row this hydrates is stored. + { storedRow: true, at: 'registry' }, ); } this.engine.registry.registerObject( diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index 0824cc80960..61210d699ba 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -46,6 +46,15 @@ * for an object saved in this process and for one HYDRATED AT BOOT, on the * kernel's fallback metadata and with `MetadataPlugin` (the delete heal for * a boot-hydrated object is what makes the second half hold); + * - a DRAFT save naming Q (Studio's designer door) is refused too, with its + * own order: the delete, then a PUBLISH-mode save naming Q (a draft save + * registers nothing), then drafting, publishing and a boot agree; + * - the rows the save door stored before this refusal, `[package-less, Q]` + * (Q's row never served): the boot names the way out for them, a save + * naming no package (addressed to Q's row) and one naming Q are refused + * with the way out true for those rows, and that way out walks: delete Q's + * row by naming Q, then the save naming no package edits the package-less + * definition, or the object moves into Q, with its records, across a boot; * - CONTROL: a save naming no package over the package-less owner is unchanged. * * The D9.9 refusal for an owner bound to a real package is pinned, unchanged, @@ -56,7 +65,7 @@ import { randomUUID } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { MetadataPlugin } from '@objectstack/metadata'; import { ObjectQL, type EngineReadOptions } from './engine.js'; @@ -393,6 +402,164 @@ describe('#23514 — a save naming a package beside an object its package-less d }); } + it('a DRAFT save naming Q is refused too, and its way out is the delete, then a PUBLISH-mode save naming Q, then drafting', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + + const err = await k2.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + }).then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its package-less ` + + `definition without dropStorage, .*then save it naming '${Q.replace(/\./g, '\\.')}' in publish mode`, + )); + expect(storedBindings(stores)).toEqual([null]); + + // The way out, in the order the refusal names. + expect((await k2.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await save(k2, Q, 'Gadget Q')).success).toBe(true); + // The publish-mode save serves it at once… + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + // …and drafting naming Q goes on as before, live once published. + expect((await k2.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + })).success).toBe(true); + expect((await k2.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + + await k2.kernel.shutdown(); + const k3 = await boot(stores, drops); + expect(await served(k3)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + expect(drops).toEqual([]); + }); + + describe('the rows the save door stored before this refusal: `[package-less, Q]`, Q\'s row never served', () => { + /** The package-less owner with two records, plus Q's row stored beside it, as the pre-refusal save wrote it. */ + async function seedLegacyRows(stores: Stores, drops: string[]): Promise { + const k1 = await seedPackagelessOwner(stores, drops); + await k1.kernel.shutdown(); + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()).find((r) => r.type === 'object' && r.name === NAME)!; + const body = typeof unbound.metadata === 'string' + ? JSON.parse(unbound.metadata as string) + : { ...(unbound.metadata as Record) }; + body.label = 'Gadget Q legacy'; + rows.set('legacy_q_row', { + ...unbound, + id: 'legacy_q_row', + package_id: Q, + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : body, + }); + const warn = vi.spyOn(console, 'warn'); + try { + const k2 = await boot(stores, drops); + // The boot keeps the package-less owner and names the way out for these rows. + const hydrateWarn = warn.mock.calls.map((c) => String(c[0])) + .find((line) => line.includes(`Failed to hydrate object/${NAME}`)); + expect(hydrateWarn).toContain( + `Object '${NAME}' is served by its definition bound to no package; its stored definition bound to '${Q}' is never served.`, + ); + expect(hydrateWarn).toContain(`DELETE /api/v1/meta/object/${NAME}?package=${Q}`); + expect(hydrateWarn).toContain('(code=OBJECT_OVERLAY_PACKAGE_MISMATCH)'); + expect(storedBindings(stores)).toEqual([null, Q]); + expect(k2.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + expect((k2.engine.registry.getObject(NAME) as { label?: string }).label).toBe('Gadget unbound'); + return k2; + } finally { + warn.mockRestore(); + } + } + + it('a save naming no package is addressed to Q\'s row and refused with the way out for these rows; the way out walks', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops); + + const err = await save(k, undefined, 'Gadget unbound v2').then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.message).toMatch(new RegExp( + `^A save naming no package of object '${NAME}' is addressed to its stored definition bound to ` + + `'${Q.replace(/\./g, '\\.')}', which is never served`, + )); + expect(storedBindings(stores)).toEqual([null, Q]); + + // The way out: delete Q's row by naming Q; the package-less definition stays served. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + expect((await served(k)).records).toBe(2); + // …then the same save edits the package-less definition. + expect((await save(k, undefined, 'Gadget unbound v2')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound v2', metaReadLabel: 'Gadget unbound v2', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound v2', metaReadLabel: 'Gadget unbound v2', records: 2, + }); + expect(drops).toEqual([]); + }); + + it('a save naming Q is refused with the move for these rows, and the move walks: Q\'s row, then the package-less one, then the save', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops); + + const err = await refusalOf(k, Q, 'Gadget Q'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Object '${NAME}' is served by its definition bound to no package; its stored definition bound to ` + + `'${Q.replace(/\./g, '\\.')}' is never served\\. To move the object into`, + )); + expect(storedBindings(stores)).toEqual([null, Q]); + + // The move, in the order the refusal names: Q's row by naming Q… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + // …then the package-less definition, without dropStorage… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect(k.engine.registry.getObjectOwner(NAME)).toBeUndefined(); + expect(stores.get(NAME)?.size).toBe(2); + // …then the save naming Q, in publish mode. + expect((await save(k, Q, 'Gadget Q')).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + expect(drops).toEqual([]); + }); + }); + it('CONTROL: a save naming no package over the package-less owner is unchanged', async () => { const stores: Stores = new Map(); const drops: string[] = []; From 70461726fc50d843afd0448c524788f867793c9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:36:41 +0000 Subject: [PATCH 07/15] chore(changeset): the sentinel-owner refusal's states, remedies and boot reason Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- .changeset/23514-sentinel-owner-save.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/23514-sentinel-owner-save.md index 22a981098ee..53628c27da5 100644 --- a/.changeset/23514-sentinel-owner-save.md +++ b/.changeset/23514-sentinel-owner-save.md @@ -6,22 +6,27 @@ fix(metadata-protocol)!: a save naming a package of a runtime object whose owner Clause-②: no (narrowing) - + **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 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`. +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: +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`. -- 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 `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written: "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. …". The error carries `packageId: Q` and `ownerPackageId: null`. +- 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 a `Q` row is already stored beside the package-less one (the rows this defect wrote before the refusal existed), two saves change: + - FROM `PUT /api/v1/meta/object/NAME?package=Q` answering `200` over that row, TO the refusal: "Object 'NAME' is served by its definition bound to no package; its stored definition bound to 'Q' is never served. To move the object into 'Q': delete the 'Q' row (REST: DELETE /api/v1/meta/object/NAME?package=Q), then the package-less definition without dropStorage … then save it naming 'Q' in publish mode. …". + - FROM `PUT /api/v1/meta/object/NAME` naming NO package answering `200`, TO the refusal. The door addresses a save naming no package to the item's one package binding, which is `Q`'s row, so the edit was written there and never served. 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. …". +- The boot's reason for those rows changes. `loadMetaFromDb` still keeps the package-less owner and counts `Q`'s row in `errors`. Its warning, `[Protocol] Failed to hydrate object/NAME: … (code=OBJECT_OVERLAY_PACKAGE_MISMATCH)`, now carries the move above, ending "The row bound to 'Q' is not registered.", where it carried the registry's "already owned by package \"sys_metadata\"" text. -**Remedy, in one line.** `DELETE /api/v1/meta/object/NAME` (no `package`, no `dropStorage`), then send the same `PUT /api/v1/meta/object/NAME?package=Q` again: `Q`'s row becomes the owner at once and at every later boot, and the table and its records are kept. +**Remedy, in one line per state.** Do not send the request again unchanged; follow its order. With no `Q` 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 a `Q` row stored: first `DELETE /api/v1/meta/object/NAME?package=Q`. Then either send the save naming no package again, which now edits the package-less definition, or move the object as above. A `DELETE` naming no package over those rows removes the `Q` row too: it is addressed the same way. No step drops a table or a record. **Unchanged.** -- A save naming no package over the package-less owner (its own re-save). +- 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`. -- Rows stored before this change stay as they are. Nothing is rewritten on upgrade. +- Stored rows are not rewritten on upgrade. From d9bf7bb3ecd10f9c39bad7dd626086a3a87ba1e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:45:16 +0000 Subject: [PATCH 08/15] fix(metadata-protocol): the promote doors' D9.9 refusal over a package-less owner names the deletes, then the same publish Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 28 +++++++++-- ...entinel-owner-package-save-refusal.test.ts | 49 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a698f8a3e2e..fc57ee660dc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2156,8 +2156,12 @@ interface UnboundOwnerRefusalState { storedRow?: boolean; /** The caller named no package, and the save's address resolved to the package's row. */ namedNoPackage?: boolean; - /** Where the refusal is raised: the save door (default), or a registration of a stored row (boot, write-through). */ - at?: 'save' | 'registry'; + /** + * Where the refusal is raised: the save door (default), a draft promotion + * (the per-item publish and `publish-drafts`), or a registration of a + * stored row (boot, write-through). + */ + at?: 'save' | 'promote' | 'registry'; } type HydrationOptions = { @@ -19617,6 +19621,7 @@ export class ObjectStackProtocolImplementation implements * | a DRAFT save naming `Q`, no `Q` row stored | the same delete, then a PUBLISH-mode save naming `Q` (it serves the object at once), then draft as before. A draft save registers nothing, so a draft sent right after the delete leaves the object unserved until it is published | * | a `Q` row is already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete the `Q` row by naming `Q` (the no-package delete resolves to that same row, {@link packagelessAddress}), then the package-less definition, then a publish-mode save naming `Q` | * | the same rows, a publish save naming NO package | it is addressed to the `Q` row ({@link packagelessAddress}); delete the `Q` row, then the same save edits the package-less definition | + * | a promotion of `Q`'s draft (the per-item publish, `publish-drafts`), a draft stored before the package-less row or by a draft save the door addressed to `Q` | the deletes above for the state, then the same publish: the draft stays stored, and its promotion registers it | * * The boot (`loadMetaFromDb`) and the write-through * ({@link applyObjectRegistryMutation}) meet the third state: the row @@ -19642,7 +19647,18 @@ export class ObjectStackProtocolImplementation implements ? ` The row bound to '${q}' is not registered.` : ' Nothing was written.'; let message: string; - if (state.storedRow && state.namedNoPackage) { + if (state.at === 'promote') { + // The draft is stored and stays stored: after the deletes, its + // promotion is the save that moves the object (it registers). + message = (state.storedRow + ? `Object '${name}' is served by its definition bound to no package; its stored definition bound ` + + `to '${q}' is never served. To move the object into '${q}': delete the '${q}' row (REST: DELETE ` + + `${path}?package=${q}), then the package-less definition without dropStorage, which keeps its ` + + 'table and records (the same DELETE with no package parameter), then publish this draft again.' + : `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package ` + + 'parameter), then publish this draft again: it then serves the object at once.') + tail; + } else if (state.storedRow && state.namedNoPackage) { message = `A save naming no package of object '${name}' is addressed to its stored definition bound to ` + `'${q}', which is never served: the object is served by its definition bound to no package. ` + `Delete the '${q}' row first (REST: DELETE ${path}?package=${q}), then send this save again: it ` @@ -24641,8 +24657,14 @@ export class ObjectStackProtocolImplementation implements if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) { const layer = this.classifyObjectContribution(request.name, promoteInto); if (layer.kind === 'mismatch') { + // [#23514] A package-less owner (the `'sys_metadata'` sentinel): + // the way out is a promotion, and differs where an active row + // bound to the package is already stored beside it. + const storedRow = layer.ownerPackageId === 'sys_metadata' + && (await this.packagelessAddress(singularType, request.name)).candidates.includes(layer.packageId); throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, + { at: 'promote', storedRow }, ); } } diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index 61210d699ba..33aa94114ca 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -55,6 +55,9 @@ * with the way out true for those rows, and that way out walks: delete Q's * row by naming Q, then the save naming no package edits the package-less * definition, or the object moves into Q, with its records, across a boot; + * - the promote doors (ADR-0029 D9.9 there too): Q's draft stored before the + * package-less row existed is refused at publish with the order true for + * a promotion (the delete, then the same publish), and that order walks; * - CONTROL: a save naming no package over the package-less owner is unchanged. * * The D9.9 refusal for an owner bound to a real package is pinned, unchanged, @@ -560,6 +563,52 @@ describe('#23514 — a save naming a package beside an object its package-less d }); }); + it('the promote door: Q\'s draft stored before the package-less row is refused at publish, and the delete, then the same publish, moves the object', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + // Q's draft, saved while no row held the object: judged against the owner of its moment, and stored. + expect((await k.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + })).success).toBe(true); + // Then the package-less definition, which the sentinel owns, with two records. + expect((await save(k, undefined, 'Gadget unbound')).success).toBe(true); + await k.engine.insert(NAME, { name: 'rec-1' }, SYSTEM); + await k.engine.insert(NAME, { name: 'rec-2' }, SYSTEM); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + + const err = await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q }) + .then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its package-less ` + + 'definition without dropStorage, .*then publish this draft again', + )); + expect(await served(k)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + + // The order the refusal names: the delete, then the same publish. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(k.engine.registry.getObjectOwner(NAME)).toBeUndefined(); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + expect(drops).toEqual([]); + }); + it('CONTROL: a save naming no package over the package-less owner is unchanged', async () => { const stores: Stores = new Map(); const drops: string[] = []; From e0d97ad7e94efda51ed44ec6f4cb39856a952474 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:52:51 +0000 Subject: [PATCH 09/15] chore(changeset): the promote doors' arm of the sentinel-owner refusal Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- .changeset/23514-sentinel-owner-save.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/23514-sentinel-owner-save.md index 53628c27da5..def6b4a22c4 100644 --- a/.changeset/23514-sentinel-owner-save.md +++ b/.changeset/23514-sentinel-owner-save.md @@ -19,9 +19,10 @@ What changes for you, where object `NAME` is owned by its package-less definitio - Where a `Q` row is already stored beside the package-less one (the rows this defect wrote before the refusal existed), two saves change: - FROM `PUT /api/v1/meta/object/NAME?package=Q` answering `200` over that row, TO the refusal: "Object 'NAME' is served by its definition bound to no package; its stored definition bound to 'Q' is never served. To move the object into 'Q': delete the 'Q' row (REST: DELETE /api/v1/meta/object/NAME?package=Q), then the package-less definition without dropStorage … then save it naming 'Q' in publish mode. …". - FROM `PUT /api/v1/meta/object/NAME` naming NO package answering `200`, TO the refusal. The door addresses a save naming no package to the item's one package binding, which is `Q`'s row, so the edit was written there and never served. 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 publish this draft again: it then serves the object at once. …". Where a `Q` active row is also stored, it names deleting that row first, as above. The draft stays stored. - The boot's reason for those rows changes. `loadMetaFromDb` still keeps the package-less owner and counts `Q`'s row in `errors`. Its warning, `[Protocol] Failed to hydrate object/NAME: … (code=OBJECT_OVERLAY_PACKAGE_MISMATCH)`, now carries the move above, ending "The row bound to 'Q' is not registered.", where 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 `Q` 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 a `Q` row stored: first `DELETE /api/v1/meta/object/NAME?package=Q`. Then either send the save naming no package again, which now edits the package-less definition, or move the object as above. A `DELETE` naming no package over those rows removes the `Q` row too: it is addressed the same way. No step drops a table or a record. +**Remedy, in one line per state.** Do not send the request again unchanged; follow its order. With no `Q` 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 a `Q` row stored: first `DELETE /api/v1/meta/object/NAME?package=Q`. Then either send the save naming no package again, which now edits the package-less definition, or move the object as above. For a refused publish of `Q`'s draft: the same deletes for its state, then the same publish; the draft's promotion makes `Q`'s row the owner. A `DELETE` naming no package over those rows removes the `Q` row too: it is addressed the same way. No step drops a table or a record. **Unchanged.** From edfdb27ee1a52fc942156a3f4d9c908b052e81cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:52:27 +0000 Subject: [PATCH 10/15] fix(metadata-protocol): the sentinel-owner refusal names each package's stored row by its own delete A delete naming no package resolves to the one package binding present, or is refused 409 over two, so the way out names every row bound to a package (not only the named one) by ?package= before the package-less delete, at the save (publish and draft), promote, boot and write-through doors. The texts put every step in the REST message bound. UnboundOwnerRefusalState moves above HydrationOptions' doc. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 142 ++++++++------ ...entinel-owner-package-save-refusal.test.ts | 179 ++++++++++++++++-- 2 files changed, 242 insertions(+), 79 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fc57ee660dc..cc64030ccf5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2130,19 +2130,6 @@ type ViewContainerWrite = } | { readonly container: string; readonly removed: 'every-row' }; -/** - * What a hydration of one stored row is told about it: - * `ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry` and the - * expansion hydration it hands the row to. - * - * - `packageId`: the row's package binding. - * - `organizationId`: the row's scope, `null` for an environment-wide row - * (#6602). - * - `reboundFrom`: [#23198] set only when the write that is being hydrated - * moved the row from another binding (an orphan adoption). `packageId` is - * the binding it left, `null` for the package-less row. See - * {@link ViewContainerWrite}. - */ /** * [#23514, ADR-0029 D9.9] The state a refusal of a package's object row beside * a package-less owner (the `'sys_metadata'` sentinel) answers, which decides @@ -2152,8 +2139,14 @@ type ViewContainerWrite = interface UnboundOwnerRefusalState { /** The save's mode; a draft save registers nothing. */ mode?: 'draft' | 'publish'; - /** A row bound to the package is already stored beside the package-less one. */ - storedRow?: boolean; + /** + * The packages whose ACTIVE rows of the object are stored beside the + * package-less one (none, the package the request names, or others): + * each must be deleted by naming its package, because a delete naming no + * package resolves to the one binding present, or is refused 409 over two + * ({@link ObjectStackProtocolImplementation.packagelessAddress}). + */ + storedPackages?: readonly string[]; /** The caller named no package, and the save's address resolved to the package's row. */ namedNoPackage?: boolean; /** @@ -2164,6 +2157,19 @@ interface UnboundOwnerRefusalState { at?: 'save' | 'promote' | 'registry'; } +/** + * What a hydration of one stored row is told about it: + * `ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry` and the + * expansion hydration it hands the row to. + * + * - `packageId`: the row's package binding. + * - `organizationId`: the row's scope, `null` for an environment-wide row + * (#6602). + * - `reboundFrom`: [#23198] set only when the write that is being hydrated + * moved the row from another binding (an orphan adoption). `packageId` is + * the binding it left, `null` for the package-less row. See + * {@link ViewContainerWrite}. + */ type HydrationOptions = { packageId?: string | null; organizationId: string | null; @@ -19408,7 +19414,7 @@ export class ObjectStackProtocolImplementation implements * rather than reading it back from the caller. Same sentence the boot * re-hydration already writes for the same rows. */ - private applyObjectRegistryMutation(request: { type: string; name: string; item?: any; packageId?: string | null }): void { + private async applyObjectRegistryMutation(request: { type: string; name: string; item?: any; packageId?: string | null }): Promise { // [#8862] The `&& request.type !== 'objects'` limb that stood here is // GONE. It did not merely admit a plural, it registered under one — // `registerItem` below mints the entry under the spelling that arrived @@ -19437,8 +19443,15 @@ export class ObjectStackProtocolImplementation implements // back into the disagreement D9.9 exists to prevent. throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, - // [#23514] The row this registers is stored. - { storedRow: true, at: 'registry' }, + // [#23514] The row this registers is stored, and so is every + // other package's row the way out deletes (the one read this + // method awaits, on this refusal's branch only). + { + at: 'registry', + storedPackages: layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress('object', request.name)).candidates + : [], + }, ); } this.engine.registry.registerObject( @@ -19617,15 +19630,15 @@ export class ObjectStackProtocolImplementation implements * * | state | the way out | * |:--|:--| - * | a publish save naming `Q`, no `Q` row stored | delete the package-less definition WITHOUT `dropStorage` (the delete door's default, which keeps the table), then send the save again: `Q`'s row is the owner at once and at every boot | - * | a DRAFT save naming `Q`, no `Q` row stored | the same delete, then a PUBLISH-mode save naming `Q` (it serves the object at once), then draft as before. A draft save registers nothing, so a draft sent right after the delete leaves the object unserved until it is published | - * | a `Q` row is already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete the `Q` row by naming `Q` (the no-package delete resolves to that same row, {@link packagelessAddress}), then the package-less definition, then a publish-mode save naming `Q` | - * | the same rows, a publish save naming NO package | it is addressed to the `Q` row ({@link packagelessAddress}); delete the `Q` row, then the same save edits the package-less definition | + * | a publish save naming `Q`, no package's row stored | delete the package-less definition WITHOUT `dropStorage` (the delete door's default, which keeps the table), then send the save again: `Q`'s row is the owner at once and at every boot | + * | a DRAFT save naming `Q`, no package's row stored | the same delete, then a PUBLISH-mode save naming `Q` (it serves the object at once), then draft as before. A draft save registers nothing, so a draft sent right after the delete leaves the object unserved until it is published | + * | rows bound to packages (`Q` or any other) are already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete each of them by naming its package, then the package-less definition, then a publish-mode save naming `Q`. Each row is named because a delete naming no package resolves to the one binding present, or is refused `409` over two ({@link packagelessAddress}), never to the package-less definition | + * | one such row, a publish save naming NO package | it is addressed to that row ({@link packagelessAddress}); delete the row, then the same save edits the package-less definition. Over two bindings the save is refused `409` before this refusal | * | a promotion of `Q`'s draft (the per-item publish, `publish-drafts`), a draft stored before the package-less row or by a draft save the door addressed to `Q` | the deletes above for the state, then the same publish: the draft stays stored, and its promotion registers it | * * The boot (`loadMetaFromDb`) and the write-through * ({@link applyObjectRegistryMutation}) meet the third state: the row - * they register is stored. + * they register is stored, and each names every package's row. * * ADR-0070 D5's `POST /api/v1/packages/:id/adopt-orphans` also hands an * adopted object's registry owner to `Q` in the same request, but it @@ -19647,28 +19660,35 @@ export class ObjectStackProtocolImplementation implements ? ` The row bound to '${q}' is not registered.` : ' Nothing was written.'; let message: string; - if (state.at === 'promote') { - // The draft is stored and stays stored: after the deletes, its - // promotion is the save that moves the object (it registers). - message = (state.storedRow - ? `Object '${name}' is served by its definition bound to no package; its stored definition bound ` - + `to '${q}' is never served. To move the object into '${q}': delete the '${q}' row (REST: DELETE ` - + `${path}?package=${q}), then the package-less definition without dropStorage, which keeps its ` - + 'table and records (the same DELETE with no package parameter), then publish this draft again.' - : `Move object '${name}' into package '${q}' first: delete its package-less definition without ` - + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package ` - + 'parameter), then publish this draft again: it then serves the object at once.') + tail; - } else if (state.storedRow && state.namedNoPackage) { + // [#23514] Every row bound to a package stored beside the package-less + // one is named by its own `?package=` delete, before the delete naming + // no package: over such a row that request removes it (one binding) or + // is refused 409 (two), never the package-less definition. + const rows = [...new Set(state.storedPackages ?? [])].sort(); + const rowDeletes = rows.map((pkg, i) => (i === 0 ? `DELETE ${path}?package=${pkg}` : `?package=${pkg}`)) + .join(', then '); + const rowsServed = rows.length === 1 + ? `its definition bound to '${rows[0]}' is never served` + : `its definitions bound to ${rows.map((pkg) => `'${pkg}'`).join(', ')} are never served`; + if (rows.length > 0 && state.namedNoPackage) { + // A save naming no package is addressed to the one binding present. message = `A save naming no package of object '${name}' is addressed to its stored definition bound to ` + `'${q}', which is never served: the object is served by its definition bound to no package. ` + `Delete the '${q}' row first (REST: DELETE ${path}?package=${q}), then send this save again: it ` + 'then saves the package-less definition.' + tail; - } else if (state.storedRow) { - message = `Object '${name}' is served by its definition bound to no package; its stored definition bound ` - + `to '${q}' is never served. To move the object into '${q}': delete the '${q}' row (REST: DELETE ` - + `${path}?package=${q}), then the package-less definition without dropStorage, which keeps its table ` - + `and records (the same DELETE with no package parameter), then save it naming '${q}' in publish ` - + 'mode.' + tail; + } else if (rows.length > 0) { + const finish = state.at === 'promote' ? 'publish this draft again' : `save it naming '${q}' in publish mode`; + // Every step is a request, in order, so the whole way out fits the + // REST message bound with two packages' rows and long names. + message = `Object '${name}' is served by its package-less definition; ${rowsServed}. To move it into ` + + `'${q}': ${rowDeletes}, then the same DELETE with no package and no dropStorage (the table and ` + + `records stay), then ${finish}.` + tail; + } else if (state.at === 'promote') { + // The draft is stored and stays stored: after the delete, its + // promotion is the save that moves the object (it registers). + message = `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package ` + + 'parameter), then publish this draft again: it then serves the object at once.' + tail; } else if (state.mode === 'draft') { message = `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package parameter), ` @@ -20699,7 +20719,7 @@ export class ObjectStackProtocolImplementation implements // would make a runtime-created object unreachable for data CRUD // rather than merely un-listed. This branch has never carried the // kernel-scope gate below either, for the same reason. - this.applyObjectRegistryMutation(request); + await this.applyObjectRegistryMutation(request); return; } // [#23298] Whose registry this is — never `environmentId` alone. @@ -22459,18 +22479,15 @@ export class ObjectStackProtocolImplementation implements const layer = this.classifyObjectContribution(request.name, request.packageId); if (layer.kind === 'mismatch') { // [#23514] For a sentinel owner the way out depends on the - // state: the mode, and whether a row bound to the package is - // already stored beside the package-less one (a save naming - // no package that the address above resolved to a package - // found that row; one naming the package is read for it). - const unboundOwner = layer.ownerPackageId === 'sys_metadata'; - const storedRow = unboundOwner && ( - (namedNoPackage && request.packageId != null) - || (await this.packagelessAddress(singularType, request.name)).candidates.includes(layer.packageId) - ); + // state: the mode, whether the caller named a package, and + // which packages' rows are already stored beside the + // package-less one (any package, not only the one named). + const storedPackages = layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress(singularType, request.name)).candidates + : []; throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, - { mode, storedRow, namedNoPackage }, + { mode, storedPackages, namedNoPackage }, ); } } @@ -24658,13 +24675,14 @@ export class ObjectStackProtocolImplementation implements const layer = this.classifyObjectContribution(request.name, promoteInto); if (layer.kind === 'mismatch') { // [#23514] A package-less owner (the `'sys_metadata'` sentinel): - // the way out is a promotion, and differs where an active row - // bound to the package is already stored beside it. - const storedRow = layer.ownerPackageId === 'sys_metadata' - && (await this.packagelessAddress(singularType, request.name)).candidates.includes(layer.packageId); + // the way out is a promotion, after a delete of each row bound to + // a package (any package) stored beside it. + const storedPackages = layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress(singularType, request.name)).candidates + : []; throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, - { at: 'promote', storedRow }, + { at: 'promote', storedPackages }, ); } } @@ -30386,8 +30404,14 @@ export class ObjectStackProtocolImplementation implements if (layer.kind === 'mismatch') { throw ObjectStackProtocolImplementation.overlayPackageMismatchError( String(record.name), layer.packageId, layer.ownerPackageId, - // [#23514] The row this hydrates is stored. - { storedRow: true, at: 'registry' }, + // [#23514] The row this hydrates is stored, and so is + // every other package's row the way out deletes. + { + at: 'registry', + storedPackages: layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress('object', String(record.name))).candidates + : [], + }, ); } this.engine.registry.registerObject( diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index 33aa94114ca..4a0c081b3c9 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -55,6 +55,10 @@ * with the way out true for those rows, and that way out walks: delete Q's * row by naming Q, then the save naming no package edits the package-less * definition, or the object moves into Q, with its records, across a boot; + * - the same for rows bound to ANY package (`[package-less, P]`, + * `[package-less, P, Q]`): the refusal names each row's delete by its + * package, since a delete naming no package resolves to the one binding + * present or is refused 409 over two, and that walk converges; * - the promote doors (ADR-0029 D9.9 there too): Q's draft stored before the * package-less row existed is refused at publish with the order true for * a promotion (the delete, then the same publish), and that order walks; @@ -451,8 +455,8 @@ describe('#23514 — a save naming a package beside an object its package-less d }); describe('the rows the save door stored before this refusal: `[package-less, Q]`, Q\'s row never served', () => { - /** The package-less owner with two records, plus Q's row stored beside it, as the pre-refusal save wrote it. */ - async function seedLegacyRows(stores: Stores, drops: string[]): Promise { + /** The package-less owner with two records, plus each package's row stored beside it, as the pre-refusal save wrote it. */ + async function seedLegacyRows(stores: Stores, drops: string[], packages: string[] = [Q]): Promise { const k1 = await seedPackagelessOwner(stores, drops); await k1.kernel.shutdown(); const rows = stores.get('sys_metadata')!; @@ -460,25 +464,31 @@ describe('#23514 — a save naming a package beside an object its package-less d const body = typeof unbound.metadata === 'string' ? JSON.parse(unbound.metadata as string) : { ...(unbound.metadata as Record) }; - body.label = 'Gadget Q legacy'; - rows.set('legacy_q_row', { - ...unbound, - id: 'legacy_q_row', - package_id: Q, - metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : body, - }); + for (const pkg of packages) { + body.label = `Gadget ${pkg} legacy`; + rows.set(`legacy_row_${pkg}`, { + ...unbound, + id: `legacy_row_${pkg}`, + package_id: pkg, + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : { ...body }, + }); + } const warn = vi.spyOn(console, 'warn'); try { const k2 = await boot(stores, drops); - // The boot keeps the package-less owner and names the way out for these rows. - const hydrateWarn = warn.mock.calls.map((c) => String(c[0])) - .find((line) => line.includes(`Failed to hydrate object/${NAME}`)); - expect(hydrateWarn).toContain( - `Object '${NAME}' is served by its definition bound to no package; its stored definition bound to '${Q}' is never served.`, - ); - expect(hydrateWarn).toContain(`DELETE /api/v1/meta/object/${NAME}?package=${Q}`); - expect(hydrateWarn).toContain('(code=OBJECT_OVERLAY_PACKAGE_MISMATCH)'); - expect(storedBindings(stores)).toEqual([null, Q]); + // The boot keeps the package-less owner and, for each package's row, + // names the way out: every package's row deleted by name. + const hydrateWarns = warn.mock.calls.map((c) => String(c[0])) + .filter((line) => line.includes(`Failed to hydrate object/${NAME}`)); + expect(hydrateWarns).toHaveLength(packages.length); + for (const line of hydrateWarns) { + expect(line).toContain(`Object '${NAME}' is served by its package-less definition; its definition`); + expect(line).toContain(`DELETE /api/v1/meta/object/${NAME}?package=${[...packages].sort()[0]}`); + for (const pkg of packages) expect(line).toContain(`?package=${pkg}`); + expect(line).toContain('then the same DELETE with no package and no dropStorage'); + expect(line).toContain('(code=OBJECT_OVERLAY_PACKAGE_MISMATCH)'); + } + expect(storedBindings(stores)).toEqual([null, ...packages]); expect(k2.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); expect((k2.engine.registry.getObject(NAME) as { label?: string }).label).toBe('Gadget unbound'); return k2; @@ -533,8 +543,10 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(err.packageId).toBe(Q); expect(err.ownerPackageId).toBeNull(); expect(err.message).toMatch(new RegExp( - `^Object '${NAME}' is served by its definition bound to no package; its stored definition bound to ` - + `'${Q.replace(/\./g, '\\.')}' is never served\\. To move the object into`, + `^Object '${NAME}' is served by its package-less definition; its definition bound to ` + + `'${Q.replace(/\./g, '\\.')}' is never served\\. To move it into '${Q.replace(/\./g, '\\.')}': ` + + `DELETE /api/v1/meta/object/${NAME}\\?package=${Q.replace(/\./g, '\\.')}, then the same DELETE ` + + 'with no package and no dropStorage', )); expect(storedBindings(stores)).toEqual([null, Q]); @@ -561,6 +573,133 @@ describe('#23514 — a save naming a package beside an object its package-less d }); expect(drops).toEqual([]); }); + + it('another package\'s row `[package-less, P]`: a save naming Q is refused naming P\'s delete, and the walk in that order moves the object into Q', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + + const err = await refusalOf(k, Q, 'Gadget Q'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Object '${NAME}' is served by its package-less definition; its definition bound to ` + + `'${P.replace(/\./g, '\\.')}' is never served\\. To move it into '${Q.replace(/\./g, '\\.')}': ` + + `DELETE /api/v1/meta/object/${NAME}\\?package=${P.replace(/\./g, '\\.')}, then the same DELETE ` + + 'with no package and no dropStorage', + )); + expect(storedBindings(stores)).toEqual([null, P]); + + // P's row by naming P… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + // …then the package-less definition, without dropStorage… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect(stores.get(NAME)?.size).toBe(2); + // …then the save naming Q. + expect((await save(k, Q, 'Gadget Q')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + expect(storedBindings(stores)).toEqual([Q]); + expect(drops).toEqual([]); + }); + + it('another package\'s row `[package-less, P]`: a DRAFT save naming Q and a publish of Q\'s stored draft are refused naming P\'s delete, and the publish walks', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + const pRowDelete = new RegExp( + `To move it into '${Q.replace(/\./g, '\\.')}': DELETE /api/v1/meta/object/${NAME}\\?package=` + + `${P.replace(/\./g, '\\.')}, then the same DELETE with no package and no dropStorage`, + ); + + const draftErr = await k.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + }).then(() => null, (e: any) => e); + expect(draftErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(draftErr?.status).toBe(422); + expect(draftErr?.message).toMatch(pRowDelete); + expect(draftErr?.message).toContain(`then save it naming '${Q}' in publish mode.`); + + // Q's draft as an earlier door stored it, beside P's active row. + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()).find((r) => r.type === 'object' && r.name === NAME && r.package_id == null)!; + rows.set('legacy_q_draft', { ...unbound, id: 'legacy_q_draft', package_id: Q, state: 'draft' }); + const promoteErr = await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q }) + .then(() => null, (e: any) => e); + expect(promoteErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(promoteErr?.status).toBe(422); + expect(promoteErr?.message).toMatch(pRowDelete); + expect(promoteErr?.message).toContain('then publish this draft again.'); + + // The walk the promote text names: P's row, the package-less definition, then the same publish. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect((await served(k)).owner).toBe(Q); + expect((await served(k)).records).toBe(2); + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect((await served(k2)).owner).toBe(Q); + expect((await served(k2)).records).toBe(2); + expect(drops).toEqual([]); + }); + + it('two packages\' rows `[package-less, P, Q]`: the delete naming no package is refused 409, so the refusal names each row\'s delete, and that walk converges', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P, Q]); + + const err = await refusalOf(k, Q, 'Gadget Q'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.message).toMatch(new RegExp( + `^Object '${NAME}' is served by its package-less definition; its definitions bound to ` + + `'${P.replace(/\./g, '\\.')}', '${Q.replace(/\./g, '\\.')}' are never served\\. `, + )); + expect(err.message).toContain( + `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.`, + ); + + // Measured: the delete naming no package removes neither row here; it is refused. + const ambiguous = await k.protocol.deleteMetaItem({ type: 'object', name: NAME }).then(() => null, (e: any) => e); + expect(ambiguous?.code).toBe('RESOURCE_CONFLICT'); + expect(ambiguous?.status).toBe(409); + expect(storedBindings(stores)).toEqual([null, P, Q]); + + // The walk in the named order. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect((await save(k, Q, 'Gadget Q')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + expect(drops).toEqual([]); + }); }); it('the promote door: Q\'s draft stored before the package-less row is refused at publish, and the delete, then the same publish, moves the object', async () => { From 8c84afd9e9c6590c21a32b2e1213b972535de75a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:52:56 +0000 Subject: [PATCH 11/15] chore(changeset): the sentinel-owner refusal's remedy names a row bound to any package Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- .changeset/23514-sentinel-owner-save.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/23514-sentinel-owner-save.md index def6b4a22c4..bbfa99718f3 100644 --- a/.changeset/23514-sentinel-owner-save.md +++ b/.changeset/23514-sentinel-owner-save.md @@ -14,15 +14,15 @@ An object has exactly one registry entry, so it has exactly one owner (ADR-0029 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`. -- 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. …". +- 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 a `Q` row is already stored beside the package-less one (the rows this defect wrote before the refusal existed), two saves change: - - FROM `PUT /api/v1/meta/object/NAME?package=Q` answering `200` over that row, TO the refusal: "Object 'NAME' is served by its definition bound to no package; its stored definition bound to 'Q' is never served. To move the object into 'Q': delete the 'Q' row (REST: DELETE /api/v1/meta/object/NAME?package=Q), then the package-less definition without dropStorage … then save it naming 'Q' in publish mode. …". - - FROM `PUT /api/v1/meta/object/NAME` naming NO package answering `200`, TO the refusal. The door addresses a save naming no package to the item's one package binding, which is `Q`'s row, so the edit was written there and never served. 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 publish this draft again: it then serves the object at once. …". Where a `Q` active row is also stored, it names deleting that row first, as above. The draft stays stored. -- The boot's reason for those rows changes. `loadMetaFromDb` still keeps the package-less owner and counts `Q`'s row in `errors`. Its warning, `[Protocol] Failed to hydrate object/NAME: … (code=OBJECT_OVERLAY_PACKAGE_MISMATCH)`, now carries the move above, ending "The row bound to 'Q' is not registered.", where it carried the registry's "already owned by package \"sys_metadata\"" text. +- 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 publish this draft again: it 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 publish. The draft stays stored. +- 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 `Q` 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 a `Q` row stored: first `DELETE /api/v1/meta/object/NAME?package=Q`. Then either send the save naming no package again, which now edits the package-less definition, or move the object as above. For a refused publish of `Q`'s draft: the same deletes for its state, then the same publish; the draft's promotion makes `Q`'s row the owner. A `DELETE` naming no package over those rows removes the `Q` row too: it is addressed the same way. No step drops a table or a record. +**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: the same deletes for its state, then the same publish; the draft's promotion makes `Q`'s row the owner. 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.** From 9268045df363d1c98453741aadcf2e0579a16112 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:35:24 +0000 Subject: [PATCH 12/15] fix(metadata-protocol): a publish naming no package of a package-less draft adopted into the one binding names where its steps land Measured: once that package's row is deleted, the publish lands the draft in the package-less definition (a publish naming the package answers NO_DRAFT), so the text says so and names the move into the package as the next step. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 25 +++++++- ...entinel-owner-package-save-refusal.test.ts | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 76064e732f6..7c6c2c078cc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2150,6 +2150,14 @@ interface UnboundOwnerRefusalState { storedPackages?: readonly string[]; /** The caller named no package, and the save's address resolved to the package's row. */ namedNoPackage?: boolean; + /** + * A publish naming no package of a draft bound to NO package, which the + * draft doors' address adopts into the one package binding stored + * (`packagelessDraftAddress`: `key: null`, `into: P`). No publish moves that + * draft into `P`: with `P`'s row gone it promotes into the package-less + * definition, and one naming `P` finds no `P` draft (`NO_DRAFT`). + */ + adoptedDraft?: boolean; /** * Where the refusal is raised: the save door (default), a draft promotion * (the per-item publish and `publish-drafts`), or a registration of a @@ -19638,6 +19646,7 @@ export class ObjectStackProtocolImplementation implements * | rows bound to packages (`Q` or any other) are already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete each of them by naming its package, then the package-less definition, then a publish-mode save naming `Q`. Each row is named because a delete naming no package resolves to the one binding present, or is refused `409` over two ({@link packagelessAddress}), never to the package-less definition | * | one such row, a publish save naming NO package | it is addressed to that row ({@link packagelessAddress}); delete the row, then the same save edits the package-less definition. Over two bindings the save is refused `409` before this refusal | * | a promotion of `Q`'s draft (the per-item publish, `publish-drafts`), a draft stored before the package-less row or by a draft save the door addressed to `Q` | the deletes above for the state, then the same publish: the draft stays stored, and its promotion registers it | + * | a publish naming no package of a draft bound to NO package, which the address adopts into the one stored binding `P` | no publish moves that draft into `P` (measured: with `P`'s row gone it promotes into the package-less definition; one naming `P` answers `NO_DRAFT`). Delete `P`'s row, publish again (it updates the package-less definition), then the package-less DELETE and a publish-mode save naming `P` move the object | * * The boot (`loadMetaFromDb`) and the write-through * ({@link applyObjectRegistryMutation}) meet the third state: the row @@ -19673,7 +19682,14 @@ export class ObjectStackProtocolImplementation implements const rowsServed = rows.length === 1 ? `its definition bound to '${rows[0]}' is never served` : `its definitions bound to ${rows.map((pkg) => `'${pkg}'`).join(', ')} are never served`; - if (rows.length > 0 && state.namedNoPackage) { + if (rows.length > 0 && state.at === 'promote' && state.adoptedDraft) { + // Measured: the publish then lands the draft package-less, so the + // text says so, and names the move into the package as the next step. + message = `This draft of object '${name}' is bound to no package; a publish naming no package is ` + + `addressed into '${q}', whose row is never served. DELETE ${path}?package=${q}, then publish again: ` + + `the draft lands in the package-less definition. To move it into '${q}' then: the same DELETE with no ` + + `package and no dropStorage (records stay), then save it naming '${q}' in publish mode.` + tail; + } else if (rows.length > 0 && state.namedNoPackage) { // A save naming no package is addressed to the one binding present. message = `A save naming no package of object '${name}' is addressed to its stored definition bound to ` + `'${q}', which is never served: the object is served by its definition bound to no package. ` @@ -24685,7 +24701,12 @@ export class ObjectStackProtocolImplementation implements : []; throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, - { at: 'promote', storedPackages }, + { + at: 'promote', + storedPackages, + // The package-unbound draft the address adopted into the one binding. + adoptedDraft: !('packageId' in request) && draftKey === null && promoteInto !== null, + }, ); } } diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index 4a0c081b3c9..ca9b3b84ce5 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -59,6 +59,10 @@ * `[package-less, P, Q]`): the refusal names each row's delete by its * package, since a delete naming no package resolves to the one binding * present or is refused 409 over two, and that walk converges; + * - a draft bound to NO package over `[package-less, P]`, published naming no + * package (the address adopts it into P): the text says the publish lands + * package-less after P's row is deleted, and names the move into P after + * it, and that walk ends where the text says; * - the promote doors (ADR-0029 D9.9 there too): Q's draft stored before the * package-less row existed is refused at publish with the order true for * a promotion (the delete, then the same publish), and that order walks; @@ -657,6 +661,66 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(drops).toEqual([]); }); + it('another package\'s row `[package-less, P]` and a draft bound to NO package, published naming no package: the text says the publish lands package-less, and names the move into P after it', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + // The package-less draft, as a draft save naming no package stored it before P's row existed. + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()) + .find((r) => r.type === 'object' && r.name === NAME && r.package_id == null && r.state === 'active')!; + const draftBody = typeof unbound.metadata === 'string' + ? JSON.parse(unbound.metadata as string) + : { ...(unbound.metadata as Record) }; + draftBody.label = 'Gadget unbound draft'; + rows.set('packageless_draft', { + ...unbound, + id: 'packageless_draft', + state: 'draft', + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(draftBody) : draftBody, + }); + + // The address adopts the unbound draft into P, the one binding; D9.9 refuses it. + const err = await k.protocol.publishMetaItem({ type: 'object', name: NAME }).then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(P); + expect(err.message).toMatch(new RegExp( + `^This draft of object '${NAME}' is bound to no package; a publish naming no package is addressed ` + + `into '${P.replace(/\./g, '\\.')}', whose row is never served\\.`, + )); + expect(err.message).toContain( + `DELETE /api/v1/meta/object/${NAME}?package=${P}, then publish again: the draft lands in the ` + + 'package-less definition.', + ); + expect(err.message).toContain(`then save it naming '${P}' in publish mode.`); + expect(storedBindings(stores)).toEqual([null, P, null]); + + // The walk, in the order the text names: P's row, then the same publish… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME })).success).toBe(true); + // …which lands package-less, as the text says. + expect(await served(k)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound draft', metaReadLabel: 'Gadget unbound draft', records: 2, + }); + expect(storedBindings(stores)).toEqual([null]); + // Then the move into P: the package-less DELETE, then a publish-mode save naming P. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await save(k, P, 'Gadget P')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P', metaReadLabel: 'Gadget P', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P', metaReadLabel: 'Gadget P', records: 2, + }); + expect(storedBindings(stores)).toEqual([P]); + expect(drops).toEqual([]); + }); + it('two packages\' rows `[package-less, P, Q]`: the delete naming no package is refused 409, so the refusal names each row\'s delete, and that walk converges', async () => { const stores: Stores = new Map(); const drops: string[] = []; From c3ca79dafeeef3458f497ed8a8a609561c0769e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:35:28 +0000 Subject: [PATCH 13/15] chore(changeset): the adopted package-less draft's arm of the sentinel-owner refusal Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- .changeset/23514-sentinel-owner-save.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/23514-sentinel-owner-save.md index bbfa99718f3..6d082970e62 100644 --- a/.changeset/23514-sentinel-owner-save.md +++ b/.changeset/23514-sentinel-owner-save.md @@ -20,9 +20,10 @@ What changes for you, where object `NAME` is owned by its package-less definitio - 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 publish this draft again: it 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 publish. 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: the same deletes for its state, then the same publish; the draft's promotion makes `Q`'s row the owner. 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. +**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: the same deletes for its state, then the same publish; 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.** From bbf5a12e2f7b74473e52711134e17a4de49c4616 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:34:57 +0000 Subject: [PATCH 14/15] fix(metadata-protocol): every promote-door text whose draft is bound to a package names it in its publish step A publish naming no package resolves by the rows the deletes leave, and can then address another draft (packagelessDraftAddress). The step is now the request itself, POST .../publish?package=PKG, measured over every combination of stored rows, drafts and naming at the promote door. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 9 ++- ...entinel-owner-package-save-refusal.test.ts | 58 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9f1dadc7db8..b0219f45671 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19696,7 +19696,12 @@ export class ObjectStackProtocolImplementation implements + `Delete the '${q}' row first (REST: DELETE ${path}?package=${q}), then send this save again: it ` + 'then saves the package-less definition.' + tail; } else if (rows.length > 0) { - const finish = state.at === 'promote' ? 'publish this draft again' : `save it naming '${q}' in publish mode`; + // [#23514] A promotion's last step NAMES the draft's package: a + // publish naming none resolves by the rows the deletes leave, and + // can then address another draft (`packagelessDraftAddress`). + const finish = state.at === 'promote' + ? `POST ${path}/publish?package=${q}` + : `save it naming '${q}' in publish mode`; // Every step is a request, in order, so the whole way out fits the // REST message bound with two packages' rows and long names. message = `Object '${name}' is served by its package-less definition; ${rowsServed}. To move it into ` @@ -19707,7 +19712,7 @@ export class ObjectStackProtocolImplementation implements // promotion is the save that moves the object (it registers). message = `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package ` - + 'parameter), then publish this draft again: it then serves the object at once.' + tail; + + `parameter), then POST ${path}/publish?package=${q}: the draft then serves the object at once.` + tail; } else if (state.mode === 'draft') { message = `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package parameter), ` diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts index ca9b3b84ce5..a069a176e3c 100644 --- a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -646,9 +646,11 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(promoteErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); expect(promoteErr?.status).toBe(422); expect(promoteErr?.message).toMatch(pRowDelete); - expect(promoteErr?.message).toContain('then publish this draft again.'); + expect(promoteErr?.message).toContain( + `then POST /api/v1/meta/object/${NAME}/publish?package=${Q}.`, + ); - // The walk the promote text names: P's row, the package-less definition, then the same publish. + // The walk the promote text names: P's row, the package-less definition, then the publish naming Q. expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); @@ -661,6 +663,53 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(drops).toEqual([]); }); + it('another package\'s row `[package-less, P]` with BOTH P\'s draft and a draft bound to no package, published naming no package: the text names P in its publish step, and that walk promotes P\'s draft', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()) + .find((r) => r.type === 'object' && r.name === NAME && r.package_id == null && r.state === 'active')!; + const asDraft = (id: string, pkg: string | null, label: string) => { + const body = typeof unbound.metadata === 'string' + ? JSON.parse(unbound.metadata as string) + : { ...(unbound.metadata as Record) }; + body.label = label; + rows.set(id, { + ...unbound, id, package_id: pkg, state: 'draft', + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : body, + }); + }; + asDraft('unbound_draft', null, 'Gadget unbound draft'); + asDraft('p_draft', P, 'Gadget P draft'); + + // The address is P's draft (one active binding, and P's draft stored); D9.9 refuses it. + const err = await k.protocol.publishMetaItem({ type: 'object', name: NAME }).then(() => null, (e: any) => e); + expect(err?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err?.status).toBe(422); + expect(err?.packageId).toBe(P); + expect(err?.message).toMatch(new RegExp(`^Object '${NAME}' is served by its package-less definition; `)); + expect(err?.message).toContain( + `then POST /api/v1/meta/object/${NAME}/publish?package=${P}.`, + ); + + // The walk, in the named order: P's row, the package-less definition, then the publish NAMING P. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + // P's draft is live in P; the unbound draft is left stored, unpublished. + expect(await served(k)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P draft', metaReadLabel: 'Gadget P draft', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P draft', metaReadLabel: 'Gadget P draft', records: 2, + }); + expect(drops).toEqual([]); + }); + it('another package\'s row `[package-less, P]` and a draft bound to NO package, published naming no package: the text says the publish lands package-less, and names the move into P after it', async () => { const stores: Stores = new Map(); const drops: string[] = []; @@ -789,13 +838,14 @@ describe('#23514 — a save naming a package beside an object its package-less d expect(err.ownerPackageId).toBeNull(); expect(err.message).toMatch(new RegExp( `^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its package-less ` - + 'definition without dropStorage, .*then publish this draft again', + + `definition without dropStorage, .*then POST /api/v1/meta/object/${NAME}/publish\\?package=` + + `${Q.replace(/\./g, '\\.')}: the draft then serves the object at once`, )); expect(await served(k)).toEqual({ owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, }); - // The order the refusal names: the delete, then the same publish. + // The order the refusal names: the delete, then the publish naming Q. expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); expect(k.engine.registry.getObjectOwner(NAME)).toBeUndefined(); expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); From 9a259bd7d21e122543a6c178d8f4fbccd45aedb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:35:01 +0000 Subject: [PATCH 15/15] chore(changeset): the promote remedy names the draft's package in its publish step Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude --- .changeset/23514-sentinel-owner-save.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/23514-sentinel-owner-save.md index 6d082970e62..38cb0ce987a 100644 --- a/.changeset/23514-sentinel-owner-save.md +++ b/.changeset/23514-sentinel-owner-save.md @@ -19,11 +19,11 @@ What changes for you, where object `NAME` is owned by its package-less definitio - 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 publish this draft again: it 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 publish. The draft stays stored. +- 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: the same deletes for its state, then the same publish; 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. +**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.**