From 19479a3a503549d35e0f32cfea330daf5c7eaa8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 12:05:19 +0000 Subject: [PATCH 1/7] test(runtime): pin ADR-0029 D9.9 at the promote doors (red first) The measured sequence per door: a draft bound to package Q saved before package P's runtime-authored row of the object existed, then promoted by the per-item publish or POST /packages/Q/publish-drafts. Controls: a promote with no second-package owner, the owner's own draft, and the per-item publish's adoption of a package-unbound draft into the owner's package. Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- ...-runtime-owner-refusal.integration.test.ts | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts diff --git a/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts b/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts new file mode 100644 index 00000000000..58b39fe6590 --- /dev/null +++ b/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23630, ADR-0029 D9.9 — the PROMOTE doors ask the question the save door + * asks: a draft of an object bound to package Q, saved before package P's + * runtime-authored row of that object existed, is refused when it is promoted + * after P's row exists, with the save door's refusal + * (`OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422), and nothing is written. + * + * ## The defect + * + * The save door refuses a row bound to a package that does not own the object + * (`saveMetaItem`, D9.9 since #23416). A draft save is judged against the + * owner of ITS moment, so Q's draft saved while no row of the object existed + * passed, correctly. The promotion was the second way that row reached + * `active`, and it never asked: P's row saved, then Q's draft promoted, by the + * per-item publish (`publishMetaItem` naming Q) or by + * `POST /api/v1/packages/Q/publish-drafts`, answered success. The store then + * held `[P, Q]` active rows of one object while the registry kept serving P — + * the write-through's own D9.9 refusal was swallowed into a `console.warn` — + * which is the disagreement D9.9 exists to prevent, and an uninstall of P then + * refused `409 RESOURCE_CONFLICT` until a row was removed by hand (#23473). + * + * ## The pins + * + * - per door, the measured sequence is refused before anything is written: + * the per-item publish throws the save door's envelope (`code` + `status`, + * and the same message the save door answers for the same row); the batch + * door answers its declared per-item outcome (ADR-0067 D2, all-or-nothing): + * `outcome: 'refused'`, Q's draft in `failed[]` with that code and message; + * either way the active rows stay `[P]`, Q's draft stays stored and the + * registry still serves P; + * - CONTROL, per door: a promote with no second-package owner is unchanged — + * Q's draft of an object no other package holds is promoted, and so is the + * owner P's own draft; + * - CONTROL: the per-item publish naming no package still promotes a + * package-unbound draft INTO the owner's package (#23495's adoption): the + * question is asked of the package the promotion writes under. + * + * ## The composition — the shipped pieces + * + * The `package-uninstall-shared-object.integration.test.ts` harness: a REAL + * `ObjectQL` over a REAL `SqlDriver` (better-sqlite3, on disk); the REAL + * `PackageServicePlugin`; the REAL `ObjectStackProtocolImplementation`; and the + * REAL `HttpDispatcher`, whose `handlePackages` is the `publish-drafts` door. + * The rows are read out of the store, the owner out of the registry. + * + * ⚠️ `@objectstack/metadata-protocol` resolves through `exports` to its + * **`dist/`** (`check-test-source-alias.mjs`, `KNOWN_UNALIASED_TEST_IMPORTS`): + * rebuild it before reading a result here after touching `protocol.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataAuditObject, +} from '@objectstack/metadata-core'; +import { PackageServicePlugin } from '@objectstack/service-package'; +import { HttpDispatcher } from './http-dispatcher.js'; + +const P = 'com.probe.p'; +const Q = 'com.probe.q'; +const PLATFORM_PKG = 'com.objectstack.platform'; +const ENV = 'platform'; +const X = 'probe_ticket'; + +/** A signed-in admin — the caller of the sibling harness. */ +const CALLER: any = { + request: {}, + environmentId: ENV, + executionContext: { + userId: 'u_admin', + isSystem: false, + systemPermissions: ['manage_metadata', 'studio.access'], + tenantId: 'org_acme', + }, +}; + +const quiet = { debug() {}, info() {}, warn() {}, error() {} }; + +let cleanup: Array<() => void | Promise> = []; +afterEach(async () => { + for (const c of cleanup.reverse()) await c(); + cleanup = []; +}); + +const envSnapshot = { OS_HOME: process.env.OS_HOME }; +let home: string; +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), 'os-23630-home-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + if (envSnapshot.OS_HOME === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = envSnapshot.OS_HOME; + rmSync(home, { recursive: true, force: true }); +}); + +let warnSpy: ReturnType; +beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); +afterEach(() => { warnSpy.mockRestore(); }); + +/** `label` tells the writers apart: `probe_ticket com.probe.q`, `probe_ticket unbound`. */ +const objectBody = (label: string) => ({ + name: X, + label: `${X} ${label}`, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { title: { name: 'title', type: 'text' as const, label: 'Title' } }, +}); + +async function boot() { + const dir = mkdtempSync(join(tmpdir(), 'os-23630-db-')); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(dir, 'data.sqlite') }, + useNullAsDefault: true, + }); + const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject] as any[]; + await driver.initObjects(objects); + + const engine = new ObjectQL(); + engine.registerDriver(driver as any, true); + await engine.init(); + for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG); + cleanup.push(() => engine.destroy()); + + const services = new Map([['objectql', engine]]); + const ctx: any = { + logger: quiet, + getService: (name: string) => services.get(name), + registerService: (name: string, service: unknown) => { services.set(name, service); }, + }; + const plugin = new PackageServicePlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + + const protocol: any = new ObjectStackProtocolImplementation(engine as any, () => services as any, undefined, 'package-author'); + services.set('protocol', protocol); + const get = (name: string) => services.get(name) ?? null; + const dispatcher = new HttpDispatcher({ context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) } as any); + const knex = (driver as any).knex; + + const bindings = async (state: 'active' | 'draft') => { + const rows = (await knex('sys_metadata').where({ type: 'object', name: X, state }).select('package_id')) as any[]; + return rows.map((r) => (r.package_id ?? null) as string | null).sort((a, b) => String(a).localeCompare(String(b))); + }; + + return { + engine, + protocol, + /** A package verb at the real door (`handlePackages`). */ + async call(method: string, path: string) { + const res = await dispatcher.handlePackages(path, method, undefined, {}, CALLER); + const body = JSON.parse(JSON.stringify(res.response?.body ?? null)); + return { status: res.response?.status ?? 0, body }; + }, + async install(id: string) { + await protocol.installPackage({ manifest: { id, name: id, version: '1.0.0', type: 'app' } }); + }, + /** A draft save of the object, bound to `packageId`, or naming no package. */ + async saveDraft(packageId: string | undefined) { + const res = await protocol.saveMetaItem({ + type: 'object', name: X, item: objectBody(packageId ?? 'unbound'), mode: 'draft', + ...(packageId ? { packageId } : {}), + }); + expect(res.success, 'the draft save').toBe(true); + }, + /** A publish-mode save bound to `packageId` — the runtime-authored owner's row. */ + saveActive: (packageId: string) => + protocol.saveMetaItem({ type: 'object', name: X, item: objectBody(packageId), packageId }), + /** The object's stored rows of one state, by binding — the store, not the door's word for it. */ + activeBindings: () => bindings('active'), + draftBindings: () => bindings('draft'), + registryOwner: () => engine.registry.getObjectOwner(X)?.packageId, + registryLabel: () => (engine.registry.getObject(X) as { label?: string } | undefined)?.label, + }; +} + +type Process = Awaited>; + +/** + * The measured sequence: Q's draft saved while no row of the object exists + * (nothing owns it, so the save door's D9.9 question passes), THEN P's + * runtime-authored row saved — P owns the object. + */ +async function qDraftThenPOwner(): Promise { + const h = await boot(); + await h.install(P); + await h.install(Q); + await h.saveDraft(Q); + const saved = await h.saveActive(P); + expect(saved.success, 'P\'s save').toBe(true); + expect(await h.activeBindings(), 'precondition: P alone holds an active row').toEqual([P]); + expect(await h.draftBindings(), 'precondition: Q\'s draft is stored').toEqual([Q]); + expect(h.registryOwner(), 'precondition: the registry holds the object as P\'s').toBe(P); + return h; +} + +/** The save door's own answer for the row the promotion would write: Q's active row. */ +async function saveDoorRefusal(h: Process): Promise { + const err = await h.saveActive(Q).then(() => null, (e: any) => e); + expect(err?.code, 'the save door refuses Q\'s row (#23416)').toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + return err; +} + +/** Everything a refused promotion must leave as it was. */ +async function expectNothingWritten(h: Process) { + expect(await h.activeBindings(), 'no active row for Q').toEqual([P]); + expect(await h.draftBindings(), 'Q\'s draft stays stored').toEqual([Q]); + expect(h.registryOwner()).toBe(P); + expect(h.registryLabel()).toBe(`${X} ${P}`); +} + +describe('[#23630] ADR-0029 D9.9 at the promote doors — a draft promoted after a runtime owner\'s row exists', () => { + it('publishMetaItem naming Q: refused with the save door\'s envelope, before any write', async () => { + const h = await qDraftThenPOwner(); + + const err = await h.protocol.publishMetaItem({ type: 'object', name: X, packageId: Q }).then(() => null, (e: any) => e); + + expect(err).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 }); + expect({ packageId: err.packageId, ownerPackageId: err.ownerPackageId }).toEqual({ packageId: Q, ownerPackageId: P }); + await expectNothingWritten(h); + // One refusal, not a second dialect: the save door's answer for the same row. + expect(err.message).toBe((await saveDoorRefusal(h)).message); + }, 60_000); + + it('POST /packages/Q/publish-drafts: the batch is refused, Q\'s draft in failed[] with the save door\'s code, nothing written', async () => { + const h = await qDraftThenPOwner(); + + const answer = await h.call('POST', `/${Q}/publish-drafts`); + + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + const result = answer.body?.data; + expect(result).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, failedCount: 1, published: [] }); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]).toMatchObject({ type: 'object', name: X, code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH' }); + await expectNothingWritten(h); + expect(result.failed[0].error).toBe((await saveDoorRefusal(h)).message); + }, 60_000); + + it('CONTROL: with no second-package owner, publishMetaItem naming Q promotes Q\'s draft', async () => { + const h = await boot(); + await h.install(Q); + await h.saveDraft(Q); + + const res = await h.protocol.publishMetaItem({ type: 'object', name: X, packageId: Q }); + + expect(res.success).toBe(true); + expect(await h.activeBindings()).toEqual([Q]); + expect(await h.draftBindings()).toEqual([]); + expect(h.registryOwner()).toBe(Q); + expect(h.registryLabel()).toBe(`${X} ${Q}`); + }, 60_000); + + it('CONTROL: with no second-package owner, POST /packages/Q/publish-drafts publishes Q\'s draft', async () => { + const h = await boot(); + await h.install(Q); + await h.saveDraft(Q); + + const answer = await h.call('POST', `/${Q}/publish-drafts`); + + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(answer.body?.data).toMatchObject({ success: true, outcome: 'published', publishedCount: 1, failed: [] }); + expect(await h.activeBindings()).toEqual([Q]); + expect(await h.draftBindings()).toEqual([]); + expect(h.registryOwner()).toBe(Q); + }, 60_000); + + it('CONTROL: the owner\'s own draft — POST /packages/P/publish-drafts publishes P\'s draft over P\'s row', async () => { + const h = await boot(); + await h.install(P); + expect((await h.saveActive(P)).success).toBe(true); + await h.saveDraft(P); + + const answer = await h.call('POST', `/${P}/publish-drafts`); + + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(answer.body?.data).toMatchObject({ success: true, outcome: 'published', publishedCount: 1, failed: [] }); + expect(await h.activeBindings()).toEqual([P]); + expect(await h.draftBindings()).toEqual([]); + expect(h.registryOwner()).toBe(P); + }, 60_000); + + it('CONTROL: publishMetaItem naming no package promotes a package-unbound draft INTO the owner\'s package', async () => { + const h = await boot(); + await h.install(P); + await h.saveDraft(undefined); + expect((await h.saveActive(P)).success).toBe(true); + expect(await h.draftBindings(), 'precondition: the unbound draft').toEqual([null]); + + const res = await h.protocol.publishMetaItem({ type: 'object', name: X }); + + expect(res.success).toBe(true); + expect(await h.activeBindings()).toEqual([P]); + expect(await h.draftBindings()).toEqual([]); + expect(h.registryOwner()).toBe(P); + expect(h.registryLabel()).toBe(`${X} unbound`); + }, 60_000); +}); From 89da7a57f7e9af00720dd0db82ca5fcdb56f67e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 12:12:31 +0000 Subject: [PATCH 2/7] =?UTF-8?q?chore(changeset):=20the=20promote=20doors'?= =?UTF-8?q?=20D9.9=20refusal=20(Clause-=E2=91=A1=20no,=20narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- .changeset/23630-promote-doors-d99.md | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/23630-promote-doors-d99.md diff --git a/.changeset/23630-promote-doors-d99.md b/.changeset/23630-promote-doors-d99.md new file mode 100644 index 00000000000..3eac3c981a1 --- /dev/null +++ b/.changeset/23630-promote-doors-d99.md @@ -0,0 +1,30 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a draft of an object promoted into a second package after another package's runtime-authored row came to own the object is refused before any write, as its save 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 refuses an `object` row bound to a package that does not own the object. A draft save is judged against the owner of its own moment, so package `Q`'s draft of an object that no row held yet was stored, correctly. When package `P`'s runtime-authored row of that object was saved after it, `P` owned the object. Promoting `Q`'s draft then wrote `Q`'s active row beside `P`'s and answered success, while the registry refused it into a warning and kept serving `P`'s schema. An uninstall of `P` then refused `409 RESOURCE_CONFLICT` until a row was removed by hand. + +Both promotion doors now ask the save door's question of the row the promotion writes, and refuse it before any write, with the save door's code and prescription. + +What changes for you, where package `P`'s runtime-authored row owns object `NAME`, `Q` is any other package, and `Q`'s draft of `NAME` was saved before `P`'s row existed: + +- FROM `POST /api/v1/meta/object/NAME/publish?package=Q` (`publishMetaItem({ type: 'object', name: NAME, packageId: Q })`) answering `200` "Published draft …", with a `Q`-bound active row stored while the registry kept `P`'s schema, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written and `Q`'s draft still stored: "Cannot layer object 'NAME': the overlay is bound to package 'Q', but the object is owned by package 'P'. …", the save door's text for the same row. +- FROM `POST /api/v1/packages/Q/publish-drafts` (`publishPackageDrafts({ packageId: Q })`) answering `200` with `outcome: 'published'` and `failed: []`, TO `200` with `success: false`, `outcome: 'refused'` and `publishedCount: 0`. `NAME` is in `failed[]` with `code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH'` and that text. Every other draft of `Q` is in `failed[]` with `code: 'BATCH_ABORTED'`. Nothing is promoted (the batch is all-or-nothing, ADR-0067 D2). + +**Remedy, in one line.** Re-save the draft bound to the owner (`PUT /api/v1/meta/object/NAME?package=P&mode=draft`) and publish it there, or give `Q`'s object its own name, and discard `Q`'s draft with `DELETE /api/v1/meta/object/NAME?package=Q&state=draft`. + +**Unchanged.** + +- A promotion of `Q`'s draft of an object no other package's row holds, and the owner `P`'s promotion of its own draft. +- A publish naming no package that promotes a package-unbound draft into the owner's package. +- A package-less owner (the `sys_metadata` sentinel). Its rule is decided separately. +- Every type except `object`. Two packages may still hold one name. +- Rows stored before ADR-0029 D9.9 stay as they are. Nothing is rewritten on upgrade. From bc1a8fee6f2eccc7631a0b6503b6e35f82c33ce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 12:20:48 +0000 Subject: [PATCH 3/7] test(runtime): the promote pin's harness creates sys_metadata_commit, which the publish path writes Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- ...package-promote-runtime-owner-refusal.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts b/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts index 58b39fe6590..cff6a1a1d7c 100644 --- a/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts +++ b/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts @@ -61,6 +61,7 @@ import { SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, + SysMetadataCommitObject, } from '@objectstack/metadata-core'; import { PackageServicePlugin } from '@objectstack/service-package'; import { HttpDispatcher } from './http-dispatcher.js'; @@ -124,7 +125,9 @@ async function boot() { connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); - const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject] as any[]; + // `sys_metadata` holds the rows under test; the other three are what the + // real publish path writes through on its way there. + const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject] as any[]; await driver.initObjects(objects); const engine = new ObjectQL(); From 5c2385ebd146071baa8f8a257bc37a7dcea7d978 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 12:21:00 +0000 Subject: [PATCH 4/7] fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks promoteDraftForPublish, which both the per-item publish and publishPackageDrafts promote through, now refuses an object draft whose promotion would write an active row bound to a package that does not own the object, with the save door's judge (classifyObjectContribution) and envelope (OBJECT_OVERLAY_PACKAGE_MISMATCH / 422), before repo.promoteDraft writes. Judged on the package the row is written under (promoteInto). Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9cd7643342f..fa49911fd82 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -24462,6 +24462,32 @@ export class ObjectStackProtocolImplementation implements { type: singularType, name: request.name, org: 'env' } as Parameters[0], { state: 'draft', packageId: draftKey, ...memberOption(request.member) }, ); + // [ADR-0029 D9.9, #23630] …and the save door's representability + // refusal, asked of the row this promotion writes: an `object` row + // bound to a package that does not own the object. A draft save is + // judged against the owner of its own moment, so package `Q`'s draft + // of an object no row held yet was stored, correctly; when package + // `P`'s runtime-authored row of that object was saved after it, `P` + // owned the object, and promoting `Q`'s draft wrote `Q`'s active row + // beside `P`'s and answered success, while the registry write-through + // refused it into a `console.warn` and kept serving `P` — the + // disagreement {@link overlayPackageMismatchError} exists to prevent. + // + // Judged on `promoteInto`, the package the active row is written + // under (the stated binding, or for an unbound draft adopted into the + // item's package, that package), exactly as the save door judges the + // address it resolved; with the save door's judge and envelope, and + // before `repo.promoteDraft` writes, so a batch publish aborts on it + // as on the refusals below (ADR-0067 D2). Both doors reach it: the + // per-item publish and `publishPackageDrafts` promote through here. + if (draftForGate && singularType === 'object') { + const layer = this.classifyObjectContribution(request.name, promoteInto); + if (layer.kind === 'mismatch') { + throw ObjectStackProtocolImplementation.overlayPackageMismatchError( + request.name, layer.packageId, layer.ownerPackageId, + ); + } + } // [#21470] …and the divergent `name` refusal, on the same body and for // the same reason: a draft stored before `saveMetaItem` judged every // type can carry a `name` that is not its row's, and promoting it From 7cd3026a410bd7ae7d57f3d5b72b71a5198e7441 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:00:25 +0000 Subject: [PATCH 5/7] test(runtime): pin the code-packaged owner's promote answer, NOT_OVERRIDABLE / 403, per door (red first) Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- ...-runtime-owner-refusal.integration.test.ts | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts b/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts index cff6a1a1d7c..781ef8d1f9f 100644 --- a/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts +++ b/packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts @@ -35,7 +35,14 @@ * owner P's own draft; * - CONTROL: the per-item publish naming no package still promotes a * package-unbound draft INTO the owner's package (#23495's adoption): the - * question is asked of the package the promotion writes under. + * question is asked of the package the promotion writes under; + * - CONTROL, per door: a CODE-packaged owner answers what it answered before. + * An object a code package ships is sealed managed content (ADR-0131 D6), so + * the save door's package door answers `NOT_OVERRIDABLE` / 403 ahead of its + * D9.9 check, and a promotion of a Q-bound draft stored before the package + * shipped the object answers that same refusal from the store — never + * `OBJECT_OVERLAY_PACKAGE_MISMATCH`, whose prescription ("bind to the + * owner") the sealed rule refuses — and nothing is written. * * ## The composition — the shipped pieces * @@ -62,12 +69,15 @@ import { SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject, + hashSpec, } from '@objectstack/metadata-core'; import { PackageServicePlugin } from '@objectstack/service-package'; import { HttpDispatcher } from './http-dispatcher.js'; const P = 'com.probe.p'; const Q = 'com.probe.q'; +/** A CODE package shipping the object (registered as `registerApp` registers it). */ +const O = 'com.probe.code'; const PLATFORM_PKG = 'com.objectstack.platform'; const ENV = 'platform'; const X = 'probe_ticket'; @@ -177,6 +187,27 @@ async function boot() { }); expect(res.success, 'the draft save').toBe(true); }, + /** + * A draft row of the object bound to `packageId` as it exists at rest, + * written straight into the store, never through the save door: the shape + * a draft save stores (the body's checksum by `hashSpec`). The save door + * refuses a draft of a code-packaged object today, so such a row is one + * stored before the code package shipped the object. + */ + async seedStoredDraft(packageId: string) { + const body = objectBody(packageId); + const now = new Date().toISOString(); + await engine.insert('sys_metadata', { + id: `seed_draft_${packageId}_${X}`, type: 'object', name: X, + package_id: packageId, state: 'draft', metadata: JSON.stringify(body), checksum: hashSpec(body, 'object'), + version: 1, created_at: now, updated_at: now, + }, { context: { isSystem: true } } as any); + }, + /** The object as a code package ships it: contributors only, with the provenance a loader stamps. */ + registerCodeOwner(packageId: string) { + engine.registry.registerObject({ ...objectBody(packageId), _packageId: packageId, _provenance: 'package' } as any, packageId); + expect(engine.registry.getPackagedObjectOwner(X)?.packageId, 'precondition: a code-packaged owner').toBe(packageId); + }, /** A publish-mode save bound to `packageId` — the runtime-authored owner's row. */ saveActive: (packageId: string) => protocol.saveMetaItem({ type: 'object', name: X, item: objectBody(packageId), packageId }), @@ -208,6 +239,28 @@ async function qDraftThenPOwner(): Promise { return h; } +/** + * A code package O ships the object; Q's draft of it was stored before O + * shipped it (a row at rest — the save door refuses that draft today). + */ +async function qDraftThenCodeOwner(): Promise { + const h = await boot(); + await h.install(Q); + await h.seedStoredDraft(Q); + h.registerCodeOwner(O); + expect(await h.activeBindings(), 'precondition: no active row').toEqual([]); + expect(await h.draftBindings(), 'precondition: Q\'s draft is stored').toEqual([Q]); + return h; +} + +/** Everything a refused promotion over the code-packaged owner must leave as it was. */ +async function expectCodeOwnerUntouched(h: Process) { + expect(await h.activeBindings(), 'no active row for Q').toEqual([]); + expect(await h.draftBindings(), 'Q\'s draft stays stored').toEqual([Q]); + expect(h.engine.registry.getPackagedObjectOwner(X)?.packageId).toBe(O); + expect(h.registryLabel()).toBe(`${X} ${O}`); +} + /** The save door's own answer for the row the promotion would write: Q's active row. */ async function saveDoorRefusal(h: Process): Promise { const err = await h.saveActive(Q).then(() => null, (e: any) => e); @@ -294,6 +347,33 @@ describe('[#23630] ADR-0029 D9.9 at the promote doors — a draft promoted after expect(h.registryOwner()).toBe(P); }, 60_000); + it('CONTROL: a code-packaged owner — publishMetaItem naming Q answers the sealed refusal NOT_OVERRIDABLE / 403, as before', async () => { + const h = await qDraftThenCodeOwner(); + + const err = await h.protocol.publishMetaItem({ type: 'object', name: X, packageId: Q }).then(() => null, (e: any) => e); + + expect(err).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + await expectCodeOwnerUntouched(h); + // The save door's answer for the same row is that same refusal (the package + // door ahead of D9.9), never the D9.9 mismatch. + const saveErr = await h.saveActive(Q).then(() => null, (e: any) => e); + expect({ code: saveErr?.code, status: saveErr?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + }, 60_000); + + it('CONTROL: a code-packaged owner — POST /packages/Q/publish-drafts refuses the batch with NOT_OVERRIDABLE in failed[], as before', async () => { + const h = await qDraftThenCodeOwner(); + + const answer = await h.call('POST', `/${Q}/publish-drafts`); + + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + const result = answer.body?.data; + expect(result).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, failedCount: 1, published: [] }); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]).toMatchObject({ type: 'object', name: X, code: 'NOT_OVERRIDABLE' }); + await expectCodeOwnerUntouched(h); + }, 60_000); + it('CONTROL: publishMetaItem naming no package promotes a package-unbound draft INTO the owner\'s package', async () => { const h = await boot(); await h.install(P); From 3f0acc173c4377a005106f98fb7dd83bf80e104c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:00:42 +0000 Subject: [PATCH 6/7] chore(changeset): the code-packaged owner's promote answer is unchanged (NOT_OVERRIDABLE / 403) Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- .changeset/23630-promote-doors-d99.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/23630-promote-doors-d99.md b/.changeset/23630-promote-doors-d99.md index 3eac3c981a1..67fe5afcf24 100644 --- a/.changeset/23630-promote-doors-d99.md +++ b/.changeset/23630-promote-doors-d99.md @@ -25,6 +25,7 @@ What changes for you, where package `P`'s runtime-authored row owns object `NAME - A promotion of `Q`'s draft of an object no other package's row holds, and the owner `P`'s promotion of its own draft. - A publish naming no package that promotes a package-unbound draft into the owner's package. +- A code-packaged owner. An object a code package ships is sealed, so a promotion of another package's draft of it still answers `403 NOT_OVERRIDABLE` (the batch door: that code in `failed[]`), as the save door does, and never `OBJECT_OVERLAY_PACKAGE_MISMATCH`. - A package-less owner (the `sys_metadata` sentinel). Its rule is decided separately. - Every type except `object`. Two packages may still hold one name. - Rows stored before ADR-0029 D9.9 stay as they are. Nothing is rewritten on upgrade. From ea873b091f2297c29653d2c39ab613cae4ccfba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:05:20 +0000 Subject: [PATCH 7/7] fix(metadata-protocol): the promote doors' D9.9 check is not asked of sealed managed content An object a code package ships is sealed (ADR-0131 D6): the save door answers its package door, NOT_OVERRIDABLE / 403, ahead of D9.9, and the promotion reaches the same refusal at the store. The new promote-door check asked classifyObjectContribution with no sealed-item test in front, so its packaged mismatch arm answered 422 OBJECT_OVERLAY_PACKAGE_MISMATCH with a prescription the sealed rule refuses. Guarded with isSealedManagedItem, per the seat's route (A). Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 97a9d091bda..700377269e0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -24492,7 +24492,17 @@ export class ObjectStackProtocolImplementation implements // before `repo.promoteDraft` writes, so a batch publish aborts on it // as on the refusals below (ADR-0067 D2). Both doors reach it: the // per-item publish and `publishPackageDrafts` promote through here. - if (draftForGate && singularType === 'object') { + // + // ⛔ Not asked of SEALED managed content ({@link isSealedManagedItem}): + // an object a code package ships. The save door answers that row's + // package door first ({@link refusePackagedBaseOverride}, + // `NOT_OVERRIDABLE` / 403, ahead of its D9.9 call), and the promotion + // reaches the same refusal at the store (`repo.promoteDraft` → + // `assertAllowed`). Asking `classifyObjectContribution` there would + // answer its packaged `mismatch` arm instead — a second dialect for + // one row, whose prescription ("bind to the owner") the sealed rule + // refuses. One row, one refusal: the save door's. + if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) { const layer = this.classifyObjectContribution(request.name, promoteInto); if (layer.kind === 'mismatch') { throw ObjectStackProtocolImplementation.overlayPackageMismatchError(