diff --git a/.changeset/23416-runtime-owner-overlay-refusal.md b/.changeset/23416-runtime-owner-overlay-refusal.md new file mode 100644 index 00000000000..2f46249a377 --- /dev/null +++ b/.changeset/23416-runtime-owner-overlay-refusal.md @@ -0,0 +1,31 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused before any write, as a code-owned object's is (ADR-0029 D9.9) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +An object has exactly one registry entry, so it has exactly one owning package (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). The save door enforced that against a CODE-shipped owner only. Against an object that a package's `sys_metadata` row created at runtime, a save naming a second package was stored, and the registry's single-owner rule then refused it into a warning. The save answered `200`, the data plane kept the first package's schema, and `sys_metadata` held a second-package row that nothing served. + +`saveMetaItem` now refuses that save before any write, with the refusal and the prescription that the code-owner case gives. + +What changes for you, where package `P`'s runtime-authored row owns object `NAME` and `Q` is any other package: + +- FROM `PUT /api/v1/meta/object/NAME?package=Q` (`saveMetaItem({ type: 'object', name: NAME, packageId: Q, … })`) answering `200` "Saved object … package=Q", with a `Q`-bound row stored while the registry kept `P`'s schema and logged `registerObject failed … already owned by package "P"`, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing stored for `Q`: "Cannot layer object 'NAME': the overlay is bound to package 'Q', but the object is owned by package 'P'. …" +- FROM the same save with `?mode=draft` storing a `Q`-bound draft row, TO the same `422` with no draft row. +- FROM `duplicatePackage` from `P` to `Q` listing in `copied` an object whose name does not carry `P`'s namespace prefix (so the copy keeps `NAME`), with a `Q`-bound row stored that the registry never held, TO that object listed in `failed` with the refusal's text and nothing stored for it. +- The same save naming `P`, or naming no package, answers `200` as before. + +**Remedy, in one line.** Save the object with `?package=P` (or with no package, which addresses `P`'s row), or give `Q`'s object its own name. A `Q`-bound row stored before this change is removed with `DELETE /api/v1/meta/object/NAME?package=Q`. + +**Also changed, in a log line only.** At boot, `loadMetaFromDb` still counts a stored second-package row in its per-record `errors` and leaves `P` registered. Its `Failed to hydrate object/NAME` warning now carries the D9.9 refusal's text and `code=OBJECT_OVERLAY_PACKAGE_MISMATCH`, in place of the registry's ownership message and `code=OBJECT_OWNERSHIP_CONFLICT`. + +**Unchanged.** + +- A code-shipped owner. The package door still answers first (`403 NOT_OVERRIDABLE`). +- Every type except `object`. Two packages may still hold one name. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 087fab444f1..2f9009318a6 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19219,6 +19219,31 @@ export class ObjectStackProtocolImplementation implements * | `P` empty / absent (the `'sys_metadata'` sentinel) | **accepted** — a package-less env-wide overlay is ADR-0005's platform-global shape. Before D9 this THREW `already owned by package "O"`, an artefact of the borrowed `own` slot rather than a decision (#6995, measured as P2). | * | `P == Q`, some other package | **mismatch** — refused loudly. | * + * ## [#23416] …and against a RUNTIME-authored owner's id `R` + * + * With no packaged owner, the object may still have an owner: the `own` + * contributor a package's `sys_metadata` row registered (the write-through + * or the boot hydration). That is the contributor `registerObject`'s + * single-owner rule compares an incoming `own` claim against, so a row + * bound to a different package was written and then refused by the + * registry into {@link applyObjectRegistryMutation}'s best-effort warn: the + * save answered `200` while `sys_metadata` held a row the registry could + * not hold, which is the disagreement {@link overlayPackageMismatchError} + * exists to prevent. So the same mismatch arm answers it, judged on the + * registry's own fact (`getObjectOwner`, keyed on the object name exactly as + * `registerObject` keys its contributors), never on a guess: + * + * | case | verdict | + * |:--|:--| + * | `P == R` | `own` — the owner's re-save, an ordinary re-registration. | + * | `P` empty / absent | `own`, unchanged — a direct save naming no package is addressed to `R`'s row before this runs ({@link packagelessAddress}). | + * | `P == Q`, some other package | **mismatch** — refused loudly, the code owner's refusal and prescription. | + * + * Only an owner bound to a real package is judged here. An owner keyed by + * the `'sys_metadata'` sentinel (a package-less runtime object) or by no + * package at all is left to today's `own` verdict: it names no package the + * prescription could direct a save to. + * * Falls back to `'own'` — today's behaviour — for a registry that does not * offer the discriminator, so a duck-typed engine double is never made to * fail by a question it cannot answer. @@ -19238,6 +19263,20 @@ export class ObjectStackProtocolImplementation implements : undefined; const ownerPackageId: unknown = owner?.packageId; if (typeof ownerPackageId !== 'string' || ownerPackageId === '') { + // [#23416] No packaged owner: the runtime-authored one, if any. + const runtimeOwner = typeof registry?.getObjectOwner === 'function' + ? registry.getObjectOwner(name) + : undefined; + const runtimeOwnerPackageId: unknown = runtimeOwner?.packageId; + if ( + binding !== '' + && typeof runtimeOwnerPackageId === 'string' + && runtimeOwnerPackageId !== '' + && runtimeOwnerPackageId !== 'sys_metadata' + && binding !== runtimeOwnerPackageId + ) { + return { kind: 'mismatch', packageId: binding, ownerPackageId: runtimeOwnerPackageId }; + } return { kind: 'own', packageId: binding || 'sys_metadata' }; } if (binding !== '' && binding !== ownerPackageId) { diff --git a/packages/objectql/src/protocol-delete-object-registry-heal.test.ts b/packages/objectql/src/protocol-delete-object-registry-heal.test.ts index a91912a1aef..cec1bd89203 100644 --- a/packages/objectql/src/protocol-delete-object-registry-heal.test.ts +++ b/packages/objectql/src/protocol-delete-object-registry-heal.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { hashSpec } from '@objectstack/metadata-core'; import { SchemaRegistry } from './registry.js'; // [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so the // engine double below cannot accept a call `ObjectQL.delete` / `ObjectQL.update` @@ -432,32 +433,50 @@ describe('#6808 — deleteMetaItem stops BOTH registry exits serving a deleted o * that row is what is served next, so it is registered again (the boot * hydration's order) and nothing is retired. * - * Both save orders: the registry holds ONE owner per object name, so the - * second package's write-through is refused at save time, and which package - * the registry holds before the delete depends on the order. + * Both orders: the registry holds ONE owner per object name, so which package + * the registry holds before the delete depends on which row it registered + * first. */ describe('[#23297] a delete naming one package, of an object two packages hold, keeps the other package\'s row registered', () => { const P = 'app.pkg_p'; const Q = 'app.pkg_q'; const bodyOf = (name: string, label: string) => ({ ...invoiceBody(name), label }); + /** + * [#23416, ADR-0029 D9.9] Only the FIRST package's row is saved through the + * door: a save into a second package of an object a runtime-authored row owns + * is refused before any write, so the door can no longer produce this state. + * A store can still hold it (rows written before that refusal existed), and + * the heal is pinned for that store. So the second row is seeded directly: the + * first row's own shape, rebound and re-labelled, carrying the checksum the + * repository computes, and the registry holds the first package's owner, + * exactly as the refused write-through left it. + */ async function twoPackageObject(name: string, order: readonly string[], controlPlane: boolean) { const harness = makeHarness({ controlPlane }); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - for (const packageId of order) { - await harness.protocol.saveMetaItem({ type: 'object', name, packageId, item: bodyOf(name, `Invoice ${packageId}`) }); - } - } finally { - warn.mockRestore(); - } + const [first, second] = order; + await harness.protocol.saveMetaItem({ type: 'object', name, packageId: first, item: bodyOf(name, `Invoice ${first}`) }); + const [firstRow] = storedRows(harness.rows, name); + // The seeded row is one the repository could have written: its checksum + // is the repository's own, proved on the row the door just wrote. + expect(firstRow.checksum).toBe(hashSpec(JSON.parse(firstRow.metadata), 'object')); + const secondBody = bodyOf(name, `Invoice ${second}`); + const secondRow = { + ...firstRow, + id: `r_seeded_${second}`, + package_id: second, + metadata: JSON.stringify(secondBody), + checksum: hashSpec(secondBody, 'object'), + }; + harness.rows.set(rowKey(secondRow), secondRow); expect(storedRows(harness.rows, name).map((r) => r.package_id).sort()).toEqual([P, Q].sort()); + expect(harness.registry.getObjectOwner(name)?.packageId).toBe(first); return harness; } for (const controlPlane of [false, true]) { for (const order of [[P, Q], [Q, P]] as const) { - it(`${controlPlane ? 'control-plane' : 'tenant'} kernel, saved ${order.join(' then ')}: getObject, getItem and data CRUD serve Q's row after DELETE ?package=P`, async () => { + it(`${controlPlane ? 'control-plane' : 'tenant'} kernel, stored ${order.join(' then ')}: getObject, getItem and data CRUD serve Q's row after DELETE ?package=P`, async () => { const name = 'shared_invoice'; const { protocol, registry, rows, dataRows } = await twoPackageObject(name, order, controlPlane); diff --git a/packages/objectql/src/protocol-runtime-owner-overlay-refusal.test.ts b/packages/objectql/src/protocol-runtime-owner-overlay-refusal.test.ts new file mode 100644 index 00000000000..775d2f9a2a4 --- /dev/null +++ b/packages/objectql/src/protocol-runtime-owner-overlay-refusal.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23416, ADR-0029 D9.9 — a save into package Q of an object package P's + * RUNTIME-authored row owns is refused before any write, with the refusal the + * code-owner path gives (`OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422). + * + * ## The defect + * + * `classifyObjectContribution` asked the registry one question, + * `getPackagedObjectOwner`, which answers for a CODE artifact only. An object + * a package's `sys_metadata` row created at runtime has no packaged owner, so + * a save naming a second package classified as `own` and was written. The + * write-through then called `registerObject(…, Q, …, 'own')`, the registry's + * single-owner rule refused it (`already owned by package P`), and + * `applyObjectRegistryMutation`'s best-effort `console.warn` swallowed the + * refusal: the save answered `200` while `sys_metadata` held a Q-bound row the + * registry could not hold. Measured on a booted showcase per the card + * (`PUT /api/v1/meta/object/NAME?package=Q`). + * + * ## Why a real kernel + * + * The disagreement is between two stores the kernel assembles: the + * `sys_metadata` rows the engine's driver holds and the `SchemaRegistry` the + * data plane dispatches on. `ObjectQLPlugin` binds the real protocol to the + * real engine and registry, so every pin below reads the stored rows out of + * the driver and the owner out of the registry, never a payload. + * + * ## The pins + * + * - Q's save of P's runtime object is refused (`code` + `status`), nothing is + * stored for Q (no row, no history), and the registry and `sys_metadata` + * still agree on P; + * - CONTROL: P's own re-save answers success and moves the registry; + * - CONTROL: a save naming no package addresses P's row and answers success; + * - CONTROL: the code-owner case answers exactly what it answered before + * (`NOT_OVERRIDABLE`: the package door answers ahead of D9.9 there). + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, 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-metadata-row-actor.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + 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() {}, + }; + return driver; +} + +const P = 'com.qa.p'; +const Q = 'com.qa.q'; +const NAME = 'qa_probe_gadget'; + +/** A runtime object body; `label` tells the two packages' 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' }, + }, +}); + +describe('#23416 — a save into a second package of an object a runtime-authored row owns', () => { + let kernel: ObjectKernel; + let stores: Stores; + let engine: ObjectQL; + let protocol: any; + + beforeEach(async () => { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + stores = new Map(); + engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + protocol = kernel.getService('protocol'); + }); + afterEach(async () => { + if (kernel.getState() === 'running') await kernel.shutdown(); + }); + + /** The bindings of the object's stored `sys_metadata` rows, as the driver holds them. */ + const storedBindings = (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 registryOwner = () => engine.registry.getObjectOwner(NAME)?.packageId; + const registryLabel = () => (engine.registry.getObject(NAME) as { label?: string } | undefined)?.label; + + const saveInto = (packageId: string | undefined, label: string) => + protocol.saveMetaItem({ + type: 'object', name: NAME, ...(packageId ? { packageId } : {}), item: gadgetBody(label), + }); + + /** P authors the object at runtime: the row exists and the registry holds it as P's. */ + async function seedRuntimeOwner(): Promise { + const res = await saveInto(P, 'Gadget P'); + expect(res.success).toBe(true); + expect(storedBindings()).toEqual([P]); + expect(registryOwner()).toBe(P); + expect(engine.registry.getPackagedObjectOwner(NAME)).toBeUndefined(); + } + + it('Q\'s save is refused with the D9.9 envelope, before any write', async () => { + await seedRuntimeOwner(); + const historyBefore = storedBindings('sys_metadata_history'); + + const err = await saveInto(Q, 'Gadget 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).toBe(P); + // Nothing stored for Q: no row, and no history entry either. + expect(storedBindings()).toEqual([P]); + expect(storedBindings('sys_metadata_history')).toEqual(historyBefore); + // The two stores agree, on P. + expect(registryOwner()).toBe(P); + expect(registryLabel()).toBe('Gadget P'); + }); + + it('CONTROL: P\'s own re-save answers success and the registry follows it', async () => { + await seedRuntimeOwner(); + + const res = await saveInto(P, 'Gadget P v2'); + + expect(res.success).toBe(true); + expect(storedBindings()).toEqual([P]); + expect(registryOwner()).toBe(P); + expect(registryLabel()).toBe('Gadget P v2'); + }); + + it('CONTROL: a save naming no package addresses P\'s row and answers success', async () => { + await seedRuntimeOwner(); + + const res = await saveInto(undefined, 'Gadget unbound'); + + expect(res.success).toBe(true); + expect(storedBindings()).toEqual([P]); + expect(registryOwner()).toBe(P); + expect(registryLabel()).toBe('Gadget unbound'); + }); + + it('CONTROL: a code-packaged owner answers what it answered before', async () => { + // The package's object as `registerApp` registers it: contributors only, + // with the package provenance a loader stamps. + engine.registry.registerObject( + { ...gadgetBody('Gadget code'), _packageId: P, _provenance: 'package' } as any, + P, + ); + expect(engine.registry.getPackagedObjectOwner(NAME)?.packageId).toBe(P); + + const err = await saveInto(Q, 'Gadget Q').then(() => null, (e: any) => e); + + // [ADR-0131 D6] Managed content is sealed, so the package door answers + // ahead of the D9.9 check for a code owner (measured before this + // change, unchanged by it). + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); + expect(storedBindings()).toEqual([]); + expect(registryOwner()).toBe(P); + expect(registryLabel()).toBe('Gadget code'); + }); +}); diff --git a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts index 9d8216ab3a8..59c1ba3e878 100644 --- a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts +++ b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts @@ -135,21 +135,24 @@ async function boot(environmentId?: string) { engine.insert(NAME, { title }, { context: { isSystem: true } } as any); /** - * A package-less row of the object as it exists at rest — the save door - * resolves a package-less save of an item a package holds to that - * package's row, so such a row is only ever a stored one. + * A row of the object as it exists at rest, bound to `packageId` (`null`: + * no package). The save door writes neither once another package's row + * owns the object: it resolves a package-less save to that package's row, + * and [#23416, ADR-0029 D9.9] refuses a save naming a second package. So + * such a row is only ever a stored one. */ - const seedUnboundRow = async () => { - const body = ticket('Ticket unbound'); + const seedStoredRow = async (packageId: string | null) => { + const body = ticket(`Ticket ${packageId ?? 'unbound'}`); const now = new Date().toISOString(); await engine.insert('sys_metadata', { - id: 'seed_active_unbound', type: 'object', name: NAME, organization_id: null, - package_id: null, state: 'active', metadata: JSON.stringify(body), checksum: hashSpec(body, 'object'), + id: `seed_active_${packageId ?? 'unbound'}`, type: 'object', name: NAME, organization_id: null, + package_id: packageId, state: 'active', metadata: JSON.stringify(body), checksum: hashSpec(body, 'object'), version: 1, created_at: now, updated_at: now, }, { context: { isSystem: true } } as any); }; + const seedUnboundRow = () => seedStoredRow(null); - return { save, remove, storedPackages, hasTable, records, insertRecord, seedUnboundRow }; + return { save, remove, storedPackages, hasTable, records, insertRecord, seedStoredRow, seedUnboundRow }; } const ok = (res: any) => { @@ -167,10 +170,12 @@ const refusedConflict = (res: any) => { /** Two packages' rows of the object, its table and one record — the card's setup. */ async function twoPackageObject(environmentId?: string) { const h = await boot(environmentId); - // The registry holds one owner per object name, so the second package's - // write-through is refused (and logged) at save time; the row is stored. + // [#23416, ADR-0029 D9.9] The save door refuses a save into a second + // package of an object a runtime-authored row owns, so Q's row is one + // stored before that refusal existed: seeded, not saved. The registry + // holds P, as the refused write-through left it. ok(await h.save(P)); - ok(await h.save(Q)); + await h.seedStoredRow(Q); await h.insertRecord('kept'); expect(await h.storedPackages()).toEqual([P, Q]); expect(await h.hasTable()).toBe(true);