From 9c77155aabc5eed5ae1ce1f109e1a31ae81e3d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:31:52 +0000 Subject: [PATCH 1/3] test(runtime): pin adopt-orphans against a read-only package (red on main) POST /packages/:id/adopt-orphans binds package-less sys_metadata rows into any id it is handed. These pins read the rows back over a real store: a code-booted, marketplace, system-scoped or cloud-scoped target answers 200 and the orphans are rebound into it today. Committed ahead of the gate so the reproduction is a commit. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../packages-adopt-orphans-readonly.test.ts | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 packages/runtime/src/domains/packages-adopt-orphans-readonly.test.ts diff --git a/packages/runtime/src/domains/packages-adopt-orphans-readonly.test.ts b/packages/runtime/src/domains/packages-adopt-orphans-readonly.test.ts new file mode 100644 index 00000000000..412d2b55a70 --- /dev/null +++ b/packages/runtime/src/domains/packages-adopt-orphans-readonly.test.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /packages/:id/adopt-orphans` — the ADR-0070 D2 read-only rule at the + * orphan-adoption door (#24813). + * + * ## The defect + * + * The arm asked `requireManageMetadata` and the walled refusal, and then called + * `protocol.reassignOrphanedMetadata({ targetPackageId: id })`, which asks no + * writability question of its own: it rebinds every package-less + * `sys_metadata` row to whatever id it is handed. So a `manage_metadata` + * holder could bind the environment's loose items INTO a package the + * deployment ships — booted by code, installed from the marketplace, or a + * stored row whose manifest `scope` is `system` / `cloud` — and the door + * answered `200` with the rows it moved. Every other state-changing arm on the + * domain (install-overwrite, disable, publish, revert, the manifest PATCH and + * delete) refuses that same id `422 WRITABLE_PACKAGE_REQUIRED`. + * + * ## What is asserted, and why it is not the status alone + * + * The harm is the rebinding, so every refusal reads the rows back: the + * orphans the real producer minted (a package-less `saveMetaItem`, which still + * lands `package_id = NULL` today) are still package-less after the refusal, + * and the protocol's `reassignOrphanedMetadata` was never called. The store is + * REAL — better-sqlite3 `:memory:`, the real `sys_metadata*` objects, a real + * `ObjectStackProtocolImplementation` over a real `ObjectQL` — so the rows are + * the store's, not a double's. The code-booted and marketplace ids are booted + * through the engine's own `registerApp`; the two scope-only ids are installed + * the way `sys_packages` hydration installs a stored row (`source: + * 'authored'`, no booted manifest), so their refusal can only come from the + * scope signal. + * + * ## Order + * + * The gate sits where its siblings put it: after the capability gate and the + * walled refusal (both `403`, both still answer first on a read-only id), and + * before the protocol is resolved, so a read-only id answers `422` even on a + * deployment whose protocol cannot adopt (`501` for a writable id there). + * + * ## The gate is not an outage + * + * CONTROL: a writable package still adopts (`200`, and the rows really move), + * and an id nothing installed is writable by the predicate's own rule, so it + * still reaches the protocol, which binds the orphans to it exactly as before. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** Booted by the deployment's code: `registerApp`, default source `'code'`. */ +const CODE_BOOTED = 'com.acme.codepkg'; +/** Installed from the marketplace: `registerApp(…, 'marketplace')`. */ +const MARKETPLACE = 'com.acme.marketpkg'; +/** A stored row whose manifest declares `scope: 'system'`; nothing booted it. */ +const SYSTEM_SCOPED = 'com.acme.systempkg'; +/** A stored row whose manifest declares `scope: 'cloud'`; nothing booted it. */ +const CLOUD_SCOPED = 'com.acme.cloudpkg'; +/** An org's own base. */ +const AUTHORED = 'com.acme.authoredpkg'; +/** An id nothing has installed. */ +const UNKNOWN = 'com.acme.nopkg'; +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; + +/** The loose items the real producer leaves behind. */ +const ORPHANS = ['loose_grid', 'loose_board'] as const; + +const manifest = (id: string, extra: Record = {}) => ({ + id, + name: id, + version: '1.0.0', + type: 'app', + ...extra, +}); + +const TASK = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const view = (name: string) => ({ + name, + label: name, + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], +}); + +const silentLogger: any = { + debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, + child() { return silentLogger; }, +}; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +interface BootOptions { + /** Compose a protocol that cannot adopt (no `reassignOrphanedMetadata`). */ + protocolWithoutAdoption?: boolean; + /** Compose a `tenancy` service answering this posture. */ + posture?: 'isolated' | 'group'; +} + +/** The composed door over a real store, with the orphans already minted. */ +async function boot(options: BootOptions = {}) { + const engine = new ObjectQL({ logger: silentLogger }); + liveEngines.push(engine); + (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + + engine.registerApp(manifest(CODE_BOOTED)); + engine.registerApp(manifest(MARKETPLACE), undefined, 'marketplace'); + engine.registry.installPackage(manifest(SYSTEM_SCOPED, { scope: 'system' }) as any, 'authored'); + engine.registry.installPackage(manifest(CLOUD_SCOPED, { scope: 'cloud' }) as any, 'authored'); + engine.registry.installPackage(manifest(AUTHORED, { scope: 'project' }) as any, 'authored'); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + // The real producer of an orphan: a save naming no package. + for (const name of ORPHANS) { + await protocol.saveMetaItem({ type: 'view', name, item: view(name), mode: 'publish' }); + } + const adopt = vi.spyOn(protocol, 'reassignOrphanedMetadata'); + + const served = options.protocolWithoutAdoption + ? { saveMetaItem: protocol.saveMetaItem.bind(protocol) } + : protocol; + const services: Record = { protocol: served, objectql: engine }; + if (options.posture) services.tenancy = { posture: options.posture }; + const get = (name: string) => services[name] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + + /** Each loose item's binding, read off the store. */ + const bindings = async (): Promise> => { + const rows = (await engine.find('sys_metadata', { where: { type: 'view' } })) as any[]; + return Object.fromEntries( + rows.filter((r) => (ORPHANS as readonly string[]).includes(r.name)).map((r) => [r.name, r.package_id ?? null]), + ); + }; + + return { engine, dispatcher, adopt, bindings }; +} + +type Caller = Record; +/** Holds `manage_metadata`; the operator rung, so no wall refuses it. */ +const ADMIN: Caller = { userId: 'u_pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'], posture: 'PLATFORM_ADMIN' }; +/** Signed in, without `manage_metadata`. */ +const NO_CAPABILITY: Caller = { userId: 'u_reader', systemPermissions: ['studio.access'] }; +/** An organization's `manage_metadata` holder below the operator rung. */ +const TENANT_AUTHOR: Caller = { userId: 'u_tenant', tenantId: 'org_a', systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN' }; + +type Door = Awaited>; +const adoptInto = async (d: Door, id: string, caller: Caller = ADMIN) => { + const r = await d.dispatcher.handlePackages( + `/${encodeURIComponent(id)}/adopt-orphans`, 'POST', {}, {}, + { request: {}, environmentId: 'pkg-adopt-orphans-readonly-test', executionContext: caller } as any, + ); + expect(r.handled).toBe(true); + return { status: r.response?.status, body: r.response?.body as any }; +}; + +const UNBOUND = Object.fromEntries(ORPHANS.map((n) => [n, null])); +const boundTo = (id: string) => Object.fromEntries(ORPHANS.map((n) => [n, id])); + +// ══════════════════════════════════════════════════════════════════════════════ +// 1. REFUSED: adopting into a read-only package binds nothing +// ══════════════════════════════════════════════════════════════════════════════ + +const READ_ONLY: Array<{ label: string; id: string; booted: boolean }> = [ + { label: 'code-booted (registerApp)', id: CODE_BOOTED, booted: true }, + { label: 'marketplace-installed (registerApp, marketplace)', id: MARKETPLACE, booted: true }, + { label: 'system-scoped stored row (scope signal only)', id: SYSTEM_SCOPED, booted: false }, + { label: 'cloud-scoped stored row (scope signal only)', id: CLOUD_SCOPED, booted: false }, +]; + +describe('POST /packages/:id/adopt-orphans on a read-only package is refused', () => { + for (const pkg of READ_ONLY) { + it(`422s the ${pkg.label} package and binds nothing`, async () => { + const d = await boot(); + expect(await d.bindings(), 'precondition: the producer left orphans').toEqual(UNBOUND); + expect((d.engine as unknown as { manifests: Map }).manifests.has(pkg.id), + 'precondition: the boot signal').toBe(pkg.booted); + + const r = await adoptInto(d, pkg.id); + + expect(r.status).toBe(422); + expect(r.body?.success).toBe(false); + expect(r.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + expect(r.body?.error?.httpStatus).toBe(422); + expect(r.body?.error?.details?.packageId).toBe(pkg.id); + expect(r.body?.error?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md'); + // Nothing was rebound: the protocol was never asked, and the rows say so. + expect(d.adopt).not.toHaveBeenCalled(); + expect(await d.bindings()).toEqual(UNBOUND); + }, 30_000); + } +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 2. ORDER: which refusal answers when more than one applies to a read-only id +// ══════════════════════════════════════════════════════════════════════════════ + +describe('POST /packages/:id/adopt-orphans — the gate\'s place among the refusals', () => { + it('a caller without manage_metadata is refused 403 by the capability gate first', async () => { + const d = await boot(); + + const r = await adoptInto(d, CODE_BOOTED, NO_CAPABILITY); + + expect(r.status).toBe(403); + expect(r.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED'); + expect(String(r.body?.error?.message)).toContain('manage_metadata'); + expect(d.adopt).not.toHaveBeenCalled(); + expect(await d.bindings()).toEqual(UNBOUND); + }, 30_000); + + it('under a wall, a tenant holder is refused 403 PERMISSION_DENIED by the walled refusal first', async () => { + const d = await boot({ posture: 'isolated' }); + + const r = await adoptInto(d, CODE_BOOTED, TENANT_AUTHOR); + + expect(r.status).toBe(403); + expect(r.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(String(r.body?.error?.message)).toContain('tenancy posture'); + expect(d.adopt).not.toHaveBeenCalled(); + expect(await d.bindings()).toEqual(UNBOUND); + }, 30_000); + + it('the gate precedes the protocol: a read-only id answers 422 where a writable id answers 501', async () => { + const d = await boot({ protocolWithoutAdoption: true }); + + const readOnly = await adoptInto(d, CODE_BOOTED); + expect(readOnly.status).toBe(422); + expect(readOnly.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + + const writable = await adoptInto(d, AUTHORED); + expect(writable.status).toBe(501); + expect(writable.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED'); + expect(await d.bindings()).toEqual(UNBOUND); + }, 30_000); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 3. CONTROL: the gate is not an outage +// ══════════════════════════════════════════════════════════════════════════════ + +describe('POST /packages/:id/adopt-orphans still adopts into what an org owns', () => { + it('a writable package adopts: 200, and every orphan is really bound to it', async () => { + const d = await boot(); + + const r = await adoptInto(d, AUTHORED); + + expect(r.status).toBe(200); + expect(r.body?.data).toMatchObject({ success: true, reassignedCount: ORPHANS.length, targetPackageId: AUTHORED }); + expect(d.adopt).toHaveBeenCalledTimes(1); + expect(await d.bindings()).toEqual(boundTo(AUTHORED)); + }, 30_000); + + it('an id nothing installed is writable by the predicate\'s own rule, so it still reaches the protocol, as before', async () => { + const d = await boot(); + expect(d.engine.registry.getPackage(UNKNOWN), 'precondition: nothing installed it').toBeUndefined(); + + const r = await adoptInto(d, UNKNOWN); + + expect(r.status).toBe(200); + expect(r.body?.error?.code).toBeUndefined(); + expect(d.adopt).toHaveBeenCalledTimes(1); + expect(await d.bindings()).toEqual(boundTo(UNKNOWN)); + }, 30_000); +}); From 6d9e82dc45863cc3aa9688e3cbe8d3a3558534ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:32:29 +0000 Subject: [PATCH 2/3] fix(runtime)!: adopt-orphans refuses a read-only package with 422 WRITABLE_PACKAGE_REQUIRED POST /packages/:id/adopt-orphans now asks requireWritablePackage (the ADR-0070 D2 predicate isWritablePackage) after the capability gate and the walled refusal and before the protocol is resolved, the order its sibling write arms use. reassignOrphanedMetadata asks no writability question of its own, so the door bound the environment's package-less items into a code-booted, marketplace, system- or cloud-scoped package at 200. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/runtime/src/domains/packages.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 767f03991e4..3bc96afefdd 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -2141,6 +2141,16 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Adopting orphaned metadata into package "${id}"`); if (walled) return walled; + // [#24813] ADR-0070 D2: a package the deployment ships is not a + // base, so nothing is adopted into it. `reassignOrphanedMetadata` + // asks no writability question and rebinds every package-less row + // to whatever id it is handed, so this door refuses with the answer + // install-overwrite, disable, publish, revert, the manifest PATCH + // and delete give. Asked BEFORE the protocol is resolved, so a + // read-only id answers 422 whether or not this deployment can + // adopt. An id nothing installed is writable by the predicate's own + // rule, so it still reaches the protocol. + const readOnly = requireWritablePackage(deps, qlService, id, 'adopt items into'); if (readOnly) return readOnly; const protocol = await resolveProtocol(deps, _context); if (!protocol || typeof protocol.reassignOrphanedMetadata !== 'function') { return { handled: true, response: deps.error('Orphan adoption not supported', 501) }; From e0844d5eff86bd4529648e907cf7e24068ae5a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:34:22 +0000 Subject: [PATCH 3/3] chore(changeset): adopt-orphans read-only refusal for @objectstack/runtime Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../24813-adopt-orphans-readonly-package.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/24813-adopt-orphans-readonly-package.md diff --git a/.changeset/24813-adopt-orphans-readonly-package.md b/.changeset/24813-adopt-orphans-readonly-package.md new file mode 100644 index 00000000000..d4b960078f7 --- /dev/null +++ b/.changeset/24813-adopt-orphans-readonly-package.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: `POST /api/v1/packages/:id/adopt-orphans` refuses a read-only package with `422 WRITABLE_PACKAGE_REQUIRED`, as the other package write doors already do + +ADR-0070 D2 makes a package the deployment ships read-only: one booted by code, one installed from the marketplace, and one whose manifest `scope` is `system` or `cloud`. `isWritablePackage` is that rule, and every other door that changes a package (`POST /packages` with `overwrite`, `PATCH /packages/:id`, `PATCH /packages/:id/disable`, `POST /packages/:id/publish`, `POST /packages/:id/revert`, `DELETE /packages/:id`) refuses such a package with `422 WRITABLE_PACKAGE_REQUIRED`. The orphan-adoption door never asked the rule, and the protocol's `reassignOrphanedMetadata` asks none of its own: it rebinds every package-less `sys_metadata` row to whatever id it is handed. So a `manage_metadata` holder's adoption into such a package answered `200` and bound the environment's loose items into it. The door now asks the same rule after the capability gate and the walled refusal, and before the protocol is resolved. + +Clause-②: no (narrowing) + + + +**BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. + +- **FROM:** `POST /api/v1/packages/:id/adopt-orphans` naming a read-only package answered `200` with `{ success, reassignedCount, reassigned, targetPackageId }` and rebound every package-less `sys_metadata` row to that package. +- **TO:** the same request answers `422` with `error.code: 'WRITABLE_PACKAGE_REQUIRED'` and `error.details.packageId`, and rebinds nothing. Because the refusal comes before the protocol is resolved, a deployment whose protocol cannot adopt answers the same `422` for a read-only package where it answered `501`. +- **Unchanged:** a caller without `manage_metadata` is still refused `403` first, and under an `isolated` or `group` tenancy posture a caller below the operator rung is still refused `403 PERMISSION_DENIED` first. A package an organization owns adopts as before, and an id nothing installed still reaches the protocol, which binds the orphans to it as before. +- **The fix for an integrator:** a package the deployment ships is managed by the deployment, not over this API. Adopt the loose items into a package you own (create one with `POST /api/v1/packages` if none exists).