From e28d7dd502cc1c175c3876c6f74e79f9c3ee30f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:07:37 +0000 Subject: [PATCH 01/10] feat(runtime,cli,spec): the v18 boot refusal; os serve refuses a v17-shape database until the ownership ceremony is verified (ADR-0131 D10 item 5, C7b S5) WIP: tests not yet run. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- packages/cli/src/commands/serve.ts | 19 ++ .../src/utils/organization-ownership-apply.ts | 5 +- packages/runtime/src/index.ts | 9 + .../organization-ownership-boot-guard.test.ts | 163 ++++++++++++ .../src/organization-ownership-boot-guard.ts | 232 ++++++++++++++++++ .../spec/src/api/error-code-ledger.zod.ts | 11 + packages/spec/src/system/migration.zod.ts | 16 ++ 7 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 packages/runtime/src/organization-ownership-boot-guard.test.ts create mode 100644 packages/runtime/src/organization-ownership-boot-guard.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0fa07d1065a..3889ce729b2 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3862,6 +3862,25 @@ export default class Serve extends Command { } } + // 5c-ter. [ADR-0131 D10 item 5] The v18 boot refusal, beside the + // recovery scan and in its shape: at `kernel:ready` it refuses to start + // a database still in the v17 organization-ownership shape whose + // ceremony marker is not verified, naming `os migrate + // organization-ownership`. Composed HERE, on served kernels only — + // ⛔ never in `createStandaloneStack`, which every CLI data and schema + // boot shares, the ceremony's own boot among them. A host that composes + // its own instance keeps it; a guard that cannot be composed fails the + // boot rather than leave the database unjudged (no escape hatch, D10). + const hasOrganizationOwnershipGuard = plugins.some( + (p: any) => p?.name === 'com.objectstack.organization-ownership-boot-guard' + || p?.constructor?.name === 'OrganizationOwnershipBootGuardPlugin' + ); + if (!hasOrganizationOwnershipGuard) { + const { OrganizationOwnershipBootGuardPlugin } = await import('@objectstack/runtime'); + await kernel.use(new OrganizationOwnershipBootGuardPlugin()); + trackPlugin('OrganizationOwnershipBootGuard'); + } + // 5d. Auto-register AuthPlugin (and paired Security/Audit) when the // 'auth' tier is enabled and no auth plugin is already configured. // The Console expects /api/v1/auth/* to be served by better-auth via diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 5805c2d00e2..8b320700b74 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; import { convertPositionBindingRows, permissionSetDefinitionFromRow, @@ -95,8 +96,8 @@ import { * Organization. ⛔ Run at boot: the command is the only caller. */ -/** The `sys_migration` row the ceremony checkpoints into and completes. */ -export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership'; +/** The `sys_migration` row the ceremony checkpoints into and completes; the v18 boot guard reads it. */ +export { ORGANIZATION_OWNERSHIP_MIGRATION_ID }; export const CEREMONY_STEPS = [ 'attribution', diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 5d42ad0c478..fba777003d5 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -67,6 +67,15 @@ export { DriverPlugin } from './driver-plugin.js'; // runs that started and never concluded, and owns the `migration-plans` // registry `os migrate resume` looks plans up in. export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-recovery-plugin.js'; +export { + OrganizationOwnershipBootGuardPlugin, + OrganizationOwnershipBootRefusal, + ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, + assertOrganizationOwnershipMigrated, + findV17ShapeTables, + isOrganizationOwnershipBootRefusal, + organizationOwnershipRefusalMessage, +} from './organization-ownership-boot-guard.js'; export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts new file mode 100644 index 00000000000..43e6599c958 --- /dev/null +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5; ruling 6163114098, Q3 A] The v18 boot refusal's pins: + * a database still in the v17 shape is refused and the command named; a + * database whose ceremony verified boots; a fresh database boots. Plus the + * fail-closed read, and a driver with no physical columns left inert. + * + * The columns are read from a real SQLite database through the guard's own + * raw-SQL probe; the engine is a minimal double exposing the three seams the + * guard reads (the registry, the driver an object binds to, `findOne`). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { + assertOrganizationOwnershipMigrated, + isOrganizationOwnershipBootRefusal, + OrganizationOwnershipBootGuardPlugin, + ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, +} from './organization-ownership-boot-guard.js'; + +let db: DatabaseSync; + +/** The objects the registry serves: two no-column objects and one tenant-walled one. */ +const OBJECTS = [ + { name: 'sys_job', systemFields: { tenant: false }, fields: { status: {} } }, + { name: 'sys_metadata', systemFields: { tenant: false }, fields: { type: {}, name: {} } }, + { name: 'crm_deal', fields: { amount: {} } }, +]; + +function engine(opts: { client?: string; failRead?: boolean } = {}) { + const driver = { + config: { client: opts.client ?? 'better-sqlite3' }, + async execute(sql: string, params: unknown[] = []) { + if (opts.failRead) throw new Error('SQLITE_BUSY: database is locked'); + return db.prepare(sql).all(...(params as string[])); + }, + }; + return { + registry: { getAllObjects: () => OBJECTS }, + getDriverForObject: () => driver, + async findOne(object: string, query: { where: { id: string } }) { + const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).all(object).length > 0; + if (!exists) return null; + return db.prepare(`SELECT * FROM ${object} WHERE id = ?`).get(query.where.id) ?? null; + }, + }; +} + +/** The tables a v17 runtime created: every one carries the injected organization column. */ +function v17Shape(): void { + db.exec(` + CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT, organization_id TEXT); + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT, organization_id TEXT); + CREATE TABLE crm_deal (id TEXT PRIMARY KEY, amount REAL, organization_id TEXT); + CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER, advisory INTEGER); + `); +} + +const marker = (verifiedAt: string | null, blocking = 0): void => { + db.prepare('INSERT INTO sys_migration VALUES (?, ?, ?, ?, ?, ?)').run( + 'adr-0131-organization-ownership', '2026-10-14T00:00:00Z', verifiedAt, '2026-10-14T00:00:00Z', blocking, 0, + ); +}; + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return error; + } + throw new Error('expected the boot to be refused, and it was not'); +} + +beforeEach(() => { + db = new DatabaseSync(':memory:'); +}); +afterEach(() => { + db.close(); +}); + +describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { + it('⭐ a v17-shape database is refused, naming the command and every table still carrying the column', async () => { + v17Shape(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(err.tables).toEqual(['sys_job', 'sys_metadata']); + expect(err.message).toMatch(/still in the v17 organization-ownership shape: sys_job, sys_metadata/); + expect(err.message).toMatch(/os migrate organization-ownership --apply/); + expect(err.message).toMatch(/the ceremony has not run on it/); + }); + + it('a marker that is not verified (an unfinished ceremony, or one whose post-check found discrepancies) is refused', async () => { + v17Shape(); + marker(null); + expect((await refusalOf(() => assertOrganizationOwnershipMigrated(engine()))).message).toMatch(/not finished on it/); + db.exec(`DELETE FROM sys_migration`); + marker('2026-10-14T01:00:00Z', 2); + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine())))).toBe(true); + }); + + it('⭐ a database whose ceremony verified boots', async () => { + v17Shape(); + marker('2026-10-14T01:00:00Z'); + await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + }); + + it('⭐ a migrated database (the columns dropped) boots, marker or not', async () => { + db.exec(` + CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT); + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT); + CREATE TABLE crm_deal (id TEXT PRIMARY KEY, amount REAL, organization_id TEXT); + `); + await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + }); + + it('⭐ a fresh database boots: no table it creates carries a retired column, and no marker is needed', async () => { + await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + }); + + it('a column read that fails is refused, never taken for a migrated database', async () => { + v17Shape(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ failRead: true }))); + expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); + expect(err.message).toMatch(/could not read whether this database is still in the v17/); + }); + + it('a driver with no physical columns (memory, MongoDB) is inert', async () => { + v17Shape(); + await expect(assertOrganizationOwnershipMigrated(engine({ client: 'memory' }))).resolves.toBeUndefined(); + }); + + it('the plugin refuses at kernel:ready: its hook throws the refusal, which stops the boot', async () => { + v17Shape(); + const hooks = new Map Promise>(); + const ctx: any = { + hook: (event: string, handler: () => Promise) => hooks.set(event, handler), + getService: (name: string) => (name === 'objectql' ? engine() : undefined), + logger: {}, + }; + const plugin = new OrganizationOwnershipBootGuardPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + expect([...hooks.keys()]).toEqual(['kernel:ready']); + const err = await refusalOf(() => hooks.get('kernel:ready')!()); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + }); + + it('no escape hatch: no environment variable stands the refusal down', async () => { + v17Shape(); + const saved = { ...process.env }; + try { + for (const key of ['OS_ALLOW_UNMIGRATED_ORGANIZATION_OWNERSHIP', 'OS_SKIP_ORGANIZATION_OWNERSHIP_CHECK', 'OS_ALLOW_DEGRADED_TENANCY']) { + process.env[key] = '1'; + } + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine())))).toBe(true); + } finally { + process.env = saved; + } + }); +}); diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts new file mode 100644 index 00000000000..169394c2f8d --- /dev/null +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { + isDataMigrationFlagVerified, + DATA_MIGRATION_FLAG_OBJECT, + ORGANIZATION_OWNERSHIP_MIGRATION_ID, + type DataMigrationFlag, +} from '@objectstack/spec/system'; + +/** + * OrganizationOwnershipBootGuardPlugin — the v18 boot refusal (ADR-0131 D10 + * item 5; ruling 6163114098, Q3 A). + * + * A v18 runtime against a database still in the v17 shape would serve it with + * v18 semantics it was never migrated for: the organization columns the D7 + * objects and the `sys_metadata` family no longer declare still hold their + * legacy rows, and nothing reads them. The ceremony that carries those rows, + * `os migrate organization-ownership --apply`, is an operator's act, never a + * boot step (D10). So the runtime refuses to start until it has run, and says + * which command to run. + * + * ## What is refused + * + * At `kernel:ready` — the reader shape of {@link MigrationRecoveryPlugin}, + * refusing where that plugin warns — the guard asks one question: does a + * table whose object declares NO organization column (`systemFields.tenant: + * false` and no declared `organization_id`) still carry a PHYSICAL + * `organization_id`? Schema sync only adds, so on a database a v17 runtime + * created the answer is yes until the ceremony's column-drops step has run. If + * it is, the guard reads the ceremony's `sys_migration` marker + * ({@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}); unless that marker is + * verified, it throws {@link OrganizationOwnershipBootRefusal}, which stops + * the boot before `kernel:listening` opens the socket. + * + * - A FRESH database is never refused: no table it creates carries a retired + * column, so the question answers no without a marker. + * - A database whose ceremony verified boots, whatever its columns. + * - A read that fails is refused, never passed: "I could not check" is not + * "there is nothing to find" (the `readDataMigrationFlag` asymmetry). + * - ⛔ No escape hatch: no `OS_ALLOW_*` / `OS_SKIP_*` variable and no flag + * skips the check (D10 verbatim). A 17.x runtime is the way to keep 17.x + * semantics on an unmigrated database. + * + * ## Where it runs, and where it must not + * + * Composed by `os serve` (and so `os dev`) only, beside + * `MigrationRecoveryPlugin`. ⛔ Never in `createStandaloneStack`'s own list: + * every CLI data and schema boot shares that builder, the ceremony's own boot + * among them, and a guard there would refuse the one command that clears it. + * + * A driver with no physical columns to read (memory, MongoDB) has no v17 + * shape to find: the guard is inert there, as the recovery plugin is on a + * kernel with no journal. + */ + +/** The machine code a host discriminates this refusal by, across module instances. */ +export const ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE = 'ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED' as const; + +const ORG = 'organization_id'; +const CEREMONY = 'os migrate organization-ownership'; + +export class OrganizationOwnershipBootRefusal extends Error { + readonly code = ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE; + constructor( + message: string, + /** The tables still carrying a retired organization column; empty when the read itself failed. */ + readonly tables: readonly string[], + ) { + super(message); + this.name = 'OrganizationOwnershipBootRefusal'; + } +} + +/** Structural, not `instanceof`: a host may hold two module instances. */ +export function isOrganizationOwnershipBootRefusal(error: unknown): error is OrganizationOwnershipBootRefusal { + return !!error && typeof error === 'object' && (error as { code?: unknown }).code === ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE; +} + +type Dialect = 'sqlite' | 'postgres' | 'mysql'; + +function dialectOf(client: unknown): Dialect | null { + const c = String(client ?? '').toLowerCase(); + if (['sqlite3', 'sqlite', 'better-sqlite3'].includes(c)) return 'sqlite'; + if (['postgres', 'pg', 'postgresql', 'pgnative'].includes(c)) return 'postgres'; + if (['mysql', 'mysql2'].includes(c)) return 'mysql'; + return null; +} + +/** One table's column names, per dialect (the ceremony's own `columnListSql`). */ +function columnListSql(dialect: Dialect): string { + if (dialect === 'sqlite') return 'SELECT name AS column_name FROM pragma_table_info(?)'; + if (dialect === 'postgres') { + return 'SELECT column_name FROM information_schema.columns WHERE table_schema = ANY (current_schemas(false)) AND table_name = ?'; + } + return 'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?'; +} + +/** The rows of a raw `execute`, whatever shape the dialect hands back. */ +function rowsOf(result: unknown): Record[] { + if (Array.isArray(result)) { + return (Array.isArray(result[0]) ? result[0] : result) as Record[]; + } + const rows = (result as { rows?: unknown } | null)?.rows; + return Array.isArray(rows) ? (rows as Record[]) : []; +} + +function columnNameOf(row: Record): string | null { + const v = row.column_name ?? row.COLUMN_NAME ?? row.name; + return typeof v === 'string' ? v : null; +} + +interface GuardEngine { + registry?: { getAllObjects?: () => unknown[] }; + getDriverForObject?: (object: string) => unknown; + findOne?: (object: string, query: Record) => Promise; + getObject?: (name: string) => unknown; +} + +/** + * The tables in the v17 shape: an object that declares no organization column + * whose physical table still carries one. Only SQL drivers are read; any other + * driver is skipped. Throws when a column read fails. + */ +export async function findV17ShapeTables(engine: GuardEngine): Promise { + const objects = (engine.registry?.getAllObjects?.() ?? []) as Array<{ + name?: string; + systemFields?: { tenant?: unknown }; + fields?: Record; + }>; + const found: string[] = []; + for (const object of objects) { + const name = object?.name; + if (typeof name !== 'string' || object.systemFields?.tenant !== false) continue; + if (object.fields && Object.prototype.hasOwnProperty.call(object.fields, ORG)) continue; + const driver = engine.getDriverForObject?.(name) as + | { config?: { client?: unknown }; execute?: (sql: string, params?: unknown[]) => Promise } + | undefined; + const dialect = dialectOf(driver?.config?.client); + if (!dialect || typeof driver?.execute !== 'function') continue; + const columns = rowsOf(await driver.execute(columnListSql(dialect), [name])) + .map(columnNameOf) + .filter((c): c is string => c !== null); + if (columns.includes(ORG)) found.push(name); + } + return found.sort(); +} + +/** The refusal text: what was found, that the server refuses to start, the command, and the 17.x way out. */ +export function organizationOwnershipRefusalMessage(tables: readonly string[], marker: 'absent' | 'unverified' | 'unreadable'): string { + const state = marker === 'absent' + ? 'the ceremony has not run on it' + : marker === 'unverified' + ? 'the ceremony has not finished on it (its marker is not verified)' + : 'its marker could not be read'; + return ( + `This database is still in the v17 organization-ownership shape: ${tables.join(', ')} still carr` + + `${tables.length === 1 ? 'ies' : 'y'} a physical organization_id column, and ${state}. The server refuses to ` + + `start rather than serve v17 data with v18 semantics. Run '${CEREMONY}' (review its plan), then ` + + `'${CEREMONY} --apply', and start again. To keep 17.x semantics, run a 17.x runtime.` + ); +} + +export class OrganizationOwnershipBootGuardPlugin implements Plugin { + readonly name = 'com.objectstack.organization-ownership-boot-guard'; + readonly type = 'standard'; + readonly version = '1.0.0'; + readonly dependencies: string[] = []; + /** Needs the engine to read columns and the marker; still loads (inert) without it. */ + readonly optionalDependencies: string[] = ['com.objectstack.engine.objectql']; + + async init(_ctx: PluginContext): Promise {} + + async start(ctx: PluginContext): Promise { + (ctx as any)?.hook?.('kernel:ready', async () => { + let engine: GuardEngine | undefined; + try { + engine = (ctx as any).getService?.('objectql'); + } catch { + return; // no engine — no database to judge + } + if (!engine || typeof engine.getDriverForObject !== 'function') return; + await assertOrganizationOwnershipMigrated(engine, (ctx as any)?.logger); + }); + } +} + +/** + * The check itself, for the plugin and for a host that composes its own boot: + * resolves when the database may be served, throws + * {@link OrganizationOwnershipBootRefusal} when it may not. + */ +export async function assertOrganizationOwnershipMigrated( + engine: GuardEngine, + logger?: { info?: (msg: string) => void }, +): Promise { + let tables: string[]; + try { + tables = await findV17ShapeTables(engine); + } catch (error) { + throw new OrganizationOwnershipBootRefusal( + `The server refuses to start: it could not read whether this database is still in the v17 organization-ownership ` + + `shape (${error instanceof Error ? error.message : String(error)}), and an unread answer is never taken for a ` + + `migrated database. Fix the read, or run '${CEREMONY} --apply', then start again.`, + [], + ); + } + if (tables.length === 0) return; + let marker: 'absent' | 'unverified' | 'unreadable' = 'absent'; + try { + const row = typeof engine.findOne === 'function' + ? ((await engine.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID }, + context: { isSystem: true }, + })) as DataMigrationFlag | null) + : null; + if (row) { + const flag = { ...row, blocking: Number((row as { blocking?: unknown }).blocking) } as DataMigrationFlag; + if (isDataMigrationFlagVerified(flag)) { + logger?.info?.( + `[organization-ownership] ${tables.join(', ')} still carry a physical organization_id column, and the ` + + `ceremony's marker is verified: the server starts.`, + ); + return; + } + marker = 'unverified'; + } + } catch { + marker = 'unreadable'; + } + throw new OrganizationOwnershipBootRefusal(organizationOwnershipRefusalMessage(tables, marker), tables); +} diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 373a846df7c..f9c0022b28a 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -469,6 +469,17 @@ export const ERROR_CODE_LEDGER = { // rows for the addressed object. Provenance, not identity (see above). 'OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED', + // [ADR-0131 D10 item 5, ruling 6163114098 Q3 A] The v18 boot refusal + // (`OrganizationOwnershipBootGuardPlugin`, `organization-ownership-boot- + // guard.ts`): `os serve` refuses to start against a database still in the + // v17 shape — a table whose object declares no organization column still + // carries a physical `organization_id` — while the organization-ownership + // ceremony's `sys_migration` marker is not verified. Thrown from the + // plugin's `kernel:ready` hook, before `kernel:listening` opens the socket, + // so no request is ever served (`door: 'none'`, the + // WALLED_MEMBERSHIP_POLICY_UNDECLARED precedent); the `code` lets a host + // discriminate the refusal structurally. No escape hatch exists (D10). + 'ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED', // [#7557] `DELETE /packages/:id` on the DISPATCHER door — per-item failures // used to ride inside a 200 with a hardcoded `success: true`. Second // EMITTER of the code `@objectstack/rest` already registers for the diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 8f4b654b923..5f91853fd18 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -169,6 +169,22 @@ export const FILE_REFERENCES_MIGRATION_ID = 'adr-0104-file-references'; */ export const VALUE_SHAPES_MIGRATION_ID = 'adr-0104-value-shapes'; +/** + * Well-known migration id: ADR-0131 D10, the v18 organization-ownership + * ceremony (`os migrate organization-ownership --apply`). Its row in + * `sys_migration` is the ceremony's checkpoint and, once verified, its + * completion marker: `verified_at` is set only by a run that finished every + * step and whose post-check found zero discrepancies. The v18 runtime refuses + * to start against a database still in the v17 shape while this marker is not + * verified (D10 item 5). + * + * ⛔ Not a member of {@link CREATION_ATTESTED_MIGRATION_IDS}: a fresh store is + * not in the v17 shape (no table it creates carries a retired organization + * column), so the boot needs no attestation to start it, and a row claiming a + * ceremony that never ran would read as one that did. + */ +export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership'; + /** * ⚰️ RETIRED migration id: ADR-0030 notification convergence. * From 008010be574461eb8a3448e055ef645a72b99892 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:41:18 +0000 Subject: [PATCH 02/10] test(runtime): the boot refusal stops a real kernel's bootstrap; a verified marker and a fresh database boot (ADR-0131 D10 item 5, C7b S5) Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- ...zation-ownership-boot-guard.kernel.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts diff --git a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts new file mode 100644 index 00000000000..b8c6c782ac0 --- /dev/null +++ b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5; ruling 6163114098, Q3 A] The v18 boot refusal on a + * REAL kernel: the guard's `kernel:ready` throw must stop `bootstrap()`, which + * the unit pins beside this file cannot show (they call the hook by hand). + * + * The composition is `os serve`'s data spine — the SQL driver over a SQLite + * file, ObjectQL, the platform objects — plus the guard. Each case seeds the + * file before the boot, the way a v17 runtime or the ceremony left it: + * + * 1. a v17-shape database (a table whose object declares no organization + * column still carries a physical `organization_id`) is refused, with the + * refusal's code, and the kernel never reaches `running`; + * 2. the same database with the ceremony's marker verified boots; + * 3. a fresh database boots. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { DriverPlugin } from './driver-plugin.js'; +import { + isOrganizationOwnershipBootRefusal, + OrganizationOwnershipBootGuardPlugin, +} from './organization-ownership-boot-guard.js'; + +const dirs: string[] = []; +const kernels: ObjectKernel[] = []; + +function databaseFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-ownership-guard-')); + dirs.push(dir); + return join(dir, 'data.db'); +} + +/** + * What a v17 runtime left: `sys_migration_journal` (its object declares no + * organization column, `systemFields.tenant: false`) still carrying the + * injected one. + */ +function seedV17Shape(file: string, marker?: { verifiedAt: string | null }): void { + const db = new DatabaseSync(file); + db.exec('CREATE TABLE sys_migration_journal (id TEXT PRIMARY KEY, organization_id TEXT)'); + if (marker) { + db.exec('CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, blocking INTEGER)'); + db.prepare('INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, ?, ?)') + .run('adr-0131-organization-ownership', '2026-10-14T00:00:00.000Z', marker.verifiedAt, 0); + } + db.close(); +} + +async function boot(file: string): Promise<{ kernel: ObjectKernel; error: unknown }> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new DriverPlugin(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: file }, + useNullAsDefault: true, + }))); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new PlatformObjectsPlugin() as never); + await kernel.use(new OrganizationOwnershipBootGuardPlugin()); + try { + await kernel.bootstrap(); + return { kernel, error: null }; + } catch (error) { + return { kernel, error }; + } +} + +afterEach(async () => { + for (const kernel of kernels.splice(0)) { + if (kernel.getState() === 'running') await kernel.shutdown(); + } + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('the v18 boot refusal on a real kernel (ADR-0131 D10 item 5)', () => { + it('⭐ a v17-shape database stops the boot with the refusal, before the kernel runs', async () => { + const file = databaseFile(); + seedV17Shape(file); + const { kernel, error } = await boot(file); + expect(isOrganizationOwnershipBootRefusal(error)).toBe(true); + expect((error as { tables?: string[] }).tables).toEqual(['sys_migration_journal']); + expect(kernel.getState()).not.toBe('running'); + }); + + it('⭐ the same database, its ceremony marker verified, boots', async () => { + const file = databaseFile(); + seedV17Shape(file, { verifiedAt: '2026-10-14T01:00:00.000Z' }); + const { kernel, error } = await boot(file); + expect(error).toBeNull(); + expect(kernel.getState()).toBe('running'); + }); + + it('control: an unverified marker is still refused', async () => { + const file = databaseFile(); + seedV17Shape(file, { verifiedAt: null }); + const { error } = await boot(file); + expect(isOrganizationOwnershipBootRefusal(error)).toBe(true); + }); + + it('⭐ a fresh database boots', async () => { + const { kernel, error } = await boot(databaseFile()); + expect(error).toBeNull(); + expect(kernel.getState()).toBe('running'); + }); +}); From 86270dffcb7f67ae7c4d34822541ec9fab59d9ba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:44:13 +0000 Subject: [PATCH 03/10] docs(changeset): the v18 boot refusal's changeset (ADR-0131 D10 item 5, C7b S5) Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- .changeset/23429-v18-boot-refusal.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/23429-v18-boot-refusal.md diff --git a/.changeset/23429-v18-boot-refusal.md b/.changeset/23429-v18-boot-refusal.md new file mode 100644 index 00000000000..8f126a1b011 --- /dev/null +++ b/.changeset/23429-v18-boot-refusal.md @@ -0,0 +1,28 @@ +--- +'@objectstack/runtime': minor +'@objectstack/cli': minor +'@objectstack/spec': minor +--- + +feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5) + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +A v18 runtime no longer serves a database it was never migrated for. At `kernel:ready`, `os serve` (and so `os dev`) checks every table whose object declares no organization column (`systemFields.tenant: false`). If one still carries a physical `organization_id`, the boot is refused unless the `sys_migration` row `adr-0131-organization-ownership` is verified. The refusal names the tables and the command. It is thrown before `kernel:listening`, so no request is served. Its code is `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`. + +FROM → TO: + +- A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply`, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime. +- There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips the check. + +What does not change: + +- A fresh database boots: no table it creates carries a retired column, so no marker is needed. +- A database whose ceremony verified boots, whatever its columns. +- The `os migrate` commands, the ceremony's own boot among them, are not refused: the guard is composed by `os serve` only, never by the shared standalone stack. +- A driver with no physical columns to read (memory, MongoDB) is not checked. +- A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`. From 3c35dba636b6f2b174d84988d61d97cfa52dac51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:52:06 +0000 Subject: [PATCH 04/10] chore(spec): regenerate the api surface, export origins and error-code docs for the boot refusal; changeset wording (C7b S5) Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- .changeset/23429-v18-boot-refusal.md | 2 +- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/23429-v18-boot-refusal.md b/.changeset/23429-v18-boot-refusal.md index 8f126a1b011..dcfec5cd5ea 100644 --- a/.changeset/23429-v18-boot-refusal.md +++ b/.changeset/23429-v18-boot-refusal.md @@ -8,7 +8,7 @@ feat(runtime,cli)!: `os serve` refuses to start against a database still in the Clause-②: yes (narrowing) - + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 3b37dbda0d0..df6e8350e7b 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +326 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +327 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -261,6 +261,7 @@ const result = ApiErrorSchema.parse(data); * `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` +* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` * `PACKAGE_DELETE_PARTIAL` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 9240b2241cb..197f568e0cc 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -428,6 +428,7 @@ const result = ErrorCode.parse(data); * `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` +* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` * `PACKAGE_DELETE_PARTIAL` diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9bae0980883..fc0407b6735 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -446,6 +446,7 @@ "NotificationChannelSchema (const)", "OPERATION_MESSAGE_FALLBACK_LOCALE (const)", "OPERATION_MESSAGE_KEY_PREFIX (const)", + "ORGANIZATION_OWNERSHIP_MIGRATION_ID (const)", "ORSet (type)", "ORSetElement (type)", "ORSetElementParsed (type)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index b6f920e6b93..63ea593a0ac 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -427,6 +427,7 @@ "NotificationChannelSchema": "src/system/notification.zod.ts#NotificationChannelSchema (const)", "OPERATION_MESSAGE_FALLBACK_LOCALE": "src/system/operation-message.ts#OPERATION_MESSAGE_FALLBACK_LOCALE (const)", "OPERATION_MESSAGE_KEY_PREFIX": "src/system/operation-message.ts#OPERATION_MESSAGE_KEY_PREFIX (const)", + "ORGANIZATION_OWNERSHIP_MIGRATION_ID": "src/system/migration.zod.ts#ORGANIZATION_OWNERSHIP_MIGRATION_ID (const)", "ORSet": "src/system/collaboration.zod.ts#ORSet (type)", "ORSetElement": "src/system/collaboration.zod.ts#ORSetElement (type)", "ORSetElementParsed": "src/system/collaboration.zod.ts#ORSetElementParsed (type)", From 540c5b3c28ee760c608d6bc037e78243dc463045 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:55:14 +0000 Subject: [PATCH 05/10] test(runtime): the guard's engine double takes the engine's findOne shape (C7b S5) Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- .../runtime/src/organization-ownership-boot-guard.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts index 43e6599c958..9e41410d431 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -40,10 +40,11 @@ function engine(opts: { client?: string; failRead?: boolean } = {}) { return { registry: { getAllObjects: () => OBJECTS }, getDriverForObject: () => driver, - async findOne(object: string, query: { where: { id: string } }) { + async findOne(object: string, query: Record) { const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).all(object).length > 0; if (!exists) return null; - return db.prepare(`SELECT * FROM ${object} WHERE id = ?`).get(query.where.id) ?? null; + const id = String((query.where as { id?: unknown } | undefined)?.id); + return db.prepare(`SELECT * FROM ${object} WHERE id = ?`).get(id) ?? null; }, }; } From ffae8a6c0951668bda195eb78397f539d92b2f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:34:20 +0000 Subject: [PATCH 06/10] fix(runtime): the boot refusal reads a driver's dialectName, so it judges SqliteWasmDriver, the default SQLite driver of a served file database (C7b S5) Measured on the showcase composition: the database is served by SqliteWasmDriver, whose knex client is a dialect class, not a string. The guard read config.client alone, resolved no dialect and skipped every table, so a v17-shape database booted. SqlDriver.dialectName is the public, structural answer; the client string stays the fallback. Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- packages/qa/dogfood/package.json | 1 + .../organization-ownership-boot-guard.test.ts | 11 ++++++++++- .../src/organization-ownership-boot-guard.ts | 18 ++++++++++++++++-- pnpm-lock.yaml | 3 +++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index a5e03ffb103..1377e29f1fb 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -30,6 +30,7 @@ "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/plugin-webhooks": "workspace:*", + "@objectstack/runtime": "workspace:*", "@objectstack/service-analytics": "workspace:*", "@objectstack/service-datasource": "workspace:*", "@objectstack/service-messaging": "workspace:*", diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts index 9e41410d431..565fdf93cb2 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -29,8 +29,9 @@ const OBJECTS = [ { name: 'crm_deal', fields: { amount: {} } }, ]; -function engine(opts: { client?: string; failRead?: boolean } = {}) { +function engine(opts: { client?: unknown; dialectName?: string; failRead?: boolean } = {}) { const driver = { + ...(opts.dialectName ? { dialectName: opts.dialectName } : {}), config: { client: opts.client ?? 'better-sqlite3' }, async execute(sql: string, params: unknown[] = []) { if (opts.failRead) throw new Error('SQLITE_BUSY: database is locked'); @@ -128,6 +129,14 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { expect(err.message).toMatch(/could not read whether this database is still in the v17/); }); + it('⭐ a driver whose knex client is a dialect CLASS (SqliteWasmDriver, the default for a served file database) is read by its dialectName, and refused', async () => { + v17Shape(); + class Client_WasmSqlite {} + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ client: Client_WasmSqlite, dialectName: 'sqlite' }))); + expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); + expect(err.tables).toEqual(['sys_job', 'sys_metadata']); + }); + it('a driver with no physical columns (memory, MongoDB) is inert', async () => { v17Shape(); await expect(assertOrganizationOwnershipMigrated(engine({ client: 'memory' }))).resolves.toBeUndefined(); diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts index 169394c2f8d..db41037901a 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -79,6 +79,20 @@ export function isOrganizationOwnershipBootRefusal(error: unknown): error is Org type Dialect = 'sqlite' | 'postgres' | 'mysql'; +/** + * The driver's dialect. `SqlDriver.dialectName` is the public, structural + * answer, and the only one that names a driver whose knex `client` is a + * dialect CLASS rather than a string — `SqliteWasmDriver`, the default SQLite + * driver of a served file database. Reading `config.client` alone left the + * guard inert on exactly that driver. The client string is the fallback for a + * driver that exposes no `dialectName`. + */ +function dialectOfDriver(driver: { dialectName?: unknown; config?: { client?: unknown } } | undefined): Dialect | null { + const named = driver?.dialectName; + if (named === 'sqlite' || named === 'postgres' || named === 'mysql') return named; + return dialectOf(driver?.config?.client); +} + function dialectOf(client: unknown): Dialect | null { const c = String(client ?? '').toLowerCase(); if (['sqlite3', 'sqlite', 'better-sqlite3'].includes(c)) return 'sqlite'; @@ -134,9 +148,9 @@ export async function findV17ShapeTables(engine: GuardEngine): Promise if (typeof name !== 'string' || object.systemFields?.tenant !== false) continue; if (object.fields && Object.prototype.hasOwnProperty.call(object.fields, ORG)) continue; const driver = engine.getDriverForObject?.(name) as - | { config?: { client?: unknown }; execute?: (sql: string, params?: unknown[]) => Promise } + | { dialectName?: unknown; config?: { client?: unknown }; execute?: (sql: string, params?: unknown[]) => Promise } | undefined; - const dialect = dialectOf(driver?.config?.client); + const dialect = dialectOfDriver(driver); if (!dialect || typeof driver?.execute !== 'function') continue; const columns = rowsOf(await driver.execute(columnListSql(dialect), [name])) .map(columnNameOf) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d290d546a0a..86020314d46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2122,6 +2122,9 @@ importers: '@objectstack/plugin-webhooks': specifier: workspace:* version: link:../../plugins/plugin-webhooks + '@objectstack/runtime': + specifier: workspace:* + version: link:../../runtime '@objectstack/service-analytics': specifier: workspace:* version: link:../../services/service-analytics From 9727b61dde716913a6ab746e8105648dde7ba27c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:36:53 +0000 Subject: [PATCH 07/10] =?UTF-8?q?test(dogfood):=20a=2017.x=20database=20up?= =?UTF-8?q?grades=20through=20the=20boot=20refusal=20=E2=80=94=20refused,?= =?UTF-8?q?=20the=20ceremony=20completes=20with=20no=20boot=20between,=20t?= =?UTF-8?q?hen=20boots=20(C7b=20S5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measures the order the seat asked for: the refusal fires at kernel:ready, after every plugin's start(), where the additive schema sync runs, so the refused boot has already added sys_user_permission_set.permission_set, the column the ceremony waits for. No deadlock. Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- ...nership-boot-refusal-order.dogfood.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts diff --git a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts new file mode 100644 index 00000000000..e53efe0e6d0 --- /dev/null +++ b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D10 item 5] The v18 boot refusal must not deadlock the upgrade. +// +// The ceremony (`os migrate organization-ownership --apply`) refuses to name a +// grant's set until `sys_user_permission_set.permission_set` exists, and says +// "Boot this release once against the database (its schema sync adds the +// column)". The boot refusal refuses a v17-shape database until the ceremony's +// marker is verified. If the refusal fired BEFORE the boot's additive schema +// sync, the refused boot would add nothing, the ceremony would keep refusing, +// and a 17.x database could never upgrade. +// +// Measured here end to end, on the showcase composition over a SQLite file a +// v17 runtime could have left — `sys_user_permission_set` without its +// `permission_set` column; `sys_migration_journal` and `sys_metadata` (objects +// that declare no organization column) still carrying a physical +// `organization_id`: +// +// 1. the v18 boot is refused, naming the command — and the refused boot has +// ALREADY added the column the ceremony waits for: the guard runs at +// `kernel:ready`, after every plugin's `start()`, which is where the schema +// sync runs; +// 2. `os migrate organization-ownership --apply` (the real CLI, as an +// operator runs it) completes with no boot in between, and verifies its +// marker; +// 3. the next v18 boot succeeds. +// +// ⚠ Needs the `sys_metadata` family's no-column schema beside the refusal: +// against a release whose family still declares the column, step 2 stops at +// `column-drops` (the declaration keeps it). The two land together. + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { + OrganizationOwnershipBootGuardPlugin, + isOrganizationOwnershipBootRefusal, +} from '@objectstack/runtime'; +import { bootShowcase } from './showcase-boot.js'; + +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); +const CLI_BIN = join(dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')), 'bin', 'run.js'); + +const columnsOf = (file: string, table: string): string[] => { + const db = new DatabaseSync(file); + try { + return (db.prepare('SELECT name FROM pragma_table_info(?)').all(table) as Array<{ name: string }>).map((r) => r.name); + } finally { + db.close(); + } +}; + +/** A v18 boot of the showcase over `file`, the guard composed as `os serve` composes it; the refusal, or null. */ +async function boot(file: string): Promise { + try { + const stack = await bootShowcase({ + databaseFile: file, + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), + extraPlugins: [new OrganizationOwnershipBootGuardPlugin()], + }); + await stack.stop(); + return null; + } catch (error) { + return error; + } +} + +describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusal, never around it', () => { + const dir = mkdtempSync(join(tmpdir(), 'dogfood-ownership-order-')); + const file = join(dir, 'showcase.db'); + let refusal: unknown; + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('1. a v17-shape database is refused, naming the command', async () => { + const seed = new DatabaseSync(file); + seed.exec(` + CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, user_id TEXT, permission_set_id TEXT, organization_id TEXT); + CREATE TABLE sys_migration_journal (id TEXT PRIMARY KEY, organization_id TEXT); + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT); + `); + seed.close(); + expect(columnsOf(file, 'sys_user_permission_set')).not.toContain('permission_set'); + + refusal = await boot(file); + expect(isOrganizationOwnershipBootRefusal(refusal), String(refusal)).toBe(true); + expect(String((refusal as Error).message)).toContain('os migrate organization-ownership'); + expect((refusal as { tables?: string[] }).tables).toEqual(expect.arrayContaining(['sys_metadata', 'sys_migration_journal'])); + }, 240_000); + + it('⭐ 1b. the refused boot has already run the additive schema sync: the column the ceremony waits for exists', () => { + expect(isOrganizationOwnershipBootRefusal(refusal)).toBe(true); + expect(columnsOf(file, 'sys_user_permission_set')).toContain('permission_set'); + }); + + it('⭐ 2. the ceremony completes with no boot in between, and verifies its marker', () => { + const run = spawnSync(process.execPath, [ + CLI_BIN, 'migrate', 'organization-ownership', + '--apply', '--backup-taken', '--database-url', `file:${file}`, '--out', join(dir, 'apply.json'), + ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } }); + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + const db = new DatabaseSync(file); + try { + const marker = db.prepare('SELECT verified_at, blocking FROM sys_migration WHERE id = ?') + .get('adr-0131-organization-ownership') as { verified_at: unknown; blocking: unknown } | undefined; + expect(marker?.verified_at, 'the ceremony verified its marker').toBeTruthy(); + expect(Number(marker?.blocking)).toBe(0); + } finally { + db.close(); + } + expect(columnsOf(file, 'sys_migration_journal')).not.toContain('organization_id'); + expect(columnsOf(file, 'sys_metadata')).not.toContain('organization_id'); + }, 320_000); + + it('⭐ 3. the next boot succeeds', async () => { + expect(await boot(file)).toBeNull(); + }, 240_000); +}); From a8813aca402606c1aed50d1d856070842fc191e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:45:11 +0000 Subject: [PATCH 08/10] test(dogfood): the upgrade pin boots the ceremony's verified bytes, as an operator's next process does (C7b S5) Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- ...on-ownership-boot-refusal-order.dogfood.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts index e53efe0e6d0..e5d1c48ef70 100644 --- a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts @@ -30,7 +30,7 @@ // `column-drops` (the declaration keeps it). The two land together. import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -74,6 +74,15 @@ async function boot(file: string): Promise { describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusal, never around it', () => { const dir = mkdtempSync(join(tmpdir(), 'dogfood-ownership-order-')); const file = join(dir, 'showcase.db'); + /** + * The ceremony's output, copied the moment it is verified. An operator's + * refused `os serve` process has exited before the ceremony runs; the + * refused boot here lives on in this process, its SQLite driver never torn + * down (bootstrap failed before any handle came back), and a later boot over + * the SAME path met its stale image — measured: the columns back and no + * marker. Booting the copied bytes is the operator's sequence. + */ + const migrated = join(dir, 'showcase.migrated.db'); let refusal: unknown; afterAll(() => { @@ -118,9 +127,10 @@ describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusa } expect(columnsOf(file, 'sys_migration_journal')).not.toContain('organization_id'); expect(columnsOf(file, 'sys_metadata')).not.toContain('organization_id'); + copyFileSync(file, migrated); }, 320_000); it('⭐ 3. the next boot succeeds', async () => { - expect(await boot(file)).toBeNull(); + expect(await boot(migrated)).toBeNull(); }, 240_000); }); From e39386f2cddb9199b59441dd56fc928c12b1a130 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:52:55 +0000 Subject: [PATCH 09/10] test(dogfood): alias @objectstack/runtime to source for the boot-refusal pin (check:test-source-alias) (C7b S5) Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ Co-authored-by: Claude --- packages/qa/dogfood/vitest.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 0516660b120..53dd5406012 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -299,6 +299,16 @@ export default defineConfig({ find: /^@objectstack\/cloud-connection$/, replacement: path.resolve(__dirname, '../../cloud-connection/src/index.ts'), }, + // [ADR-0131 D10 item 5] `organization-ownership-boot-refusal-order.dogfood.test.ts` + // mounts `OrganizationOwnershipBootGuardPlugin` on a real boot; the + // refusal is the pin's subject, so the verdict is aliased to THIS + // checkout's source, not to the last `pnpm build`. The refusal is + // discriminated structurally (its `code`), so the harness's own + // built runtime instance beside it changes no verdict. + { + find: /^@objectstack\/runtime$/, + replacement: path.resolve(__dirname, '../../runtime/src/index.ts'), + }, // [#21788] `external-import-saves-like-meta.dogfood.test.ts` mounts // `ExternalDatasourceServicePlugin` on a real boot (the harness // does not) and drives the import door. The plugin's save path is From ba6d9c5de89ffb42a0454b852d3e41838648be8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:54:03 +0000 Subject: [PATCH 10/10] feat(cli)!: os migrate apply refuses an organization_id drop ahead of the organization-ownership ceremony (#23429 E1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seat answer 6171152049 (E1 → S5). A v18 runtime declares no organization column on the ceremony's column-drop tables, so the drift report offered each physical one to `os migrate apply --allow-destructive`; dropped ahead of the ceremony it skips the promotion and report steps, turns legacy organization rows into environment rows, and leaves the v18 boot refusal no column to find — the escape hatch D10 item 5 forbids. `os migrate apply` now refuses `drop_column organization_id` on any table in the ceremony's inventory while marker adr-0131-organization-ownership is not verified, naming `os migrate organization-ownership`; exit 1, and under --json `refused_organization_ownership_unmigrated` with the tables (the #17440 / #23281 shape). No flag stands it down. The ceremony's own column-drops step applies its entries directly and is not gated. Pins (integration, real CLI over a v17-shape sys_platform_setting): refused with no marker and with an unverified one; an unrelated destructive drop on the same table is not gated; the drop proceeds once the marker is verified. Ablation: forcing the gate's table set empty turns both refusals red. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ --- .changeset/23429-v18-boot-refusal.md | 7 +- content/docs/deployment/cli.mdx | 13 ++ ...ization-ownership-gate.integration.test.ts | 188 ++++++++++++++++++ packages/cli/src/commands/migrate/apply.ts | 49 +++++ .../utils/organization-ownership-drop-gate.ts | 54 +++++ 5 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts create mode 100644 packages/cli/src/utils/organization-ownership-drop-gate.ts diff --git a/.changeset/23429-v18-boot-refusal.md b/.changeset/23429-v18-boot-refusal.md index dcfec5cd5ea..2e5db7efc2f 100644 --- a/.changeset/23429-v18-boot-refusal.md +++ b/.changeset/23429-v18-boot-refusal.md @@ -4,7 +4,7 @@ '@objectstack/spec': minor --- -feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5) +feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape, and `os migrate apply` refuses to drop its `organization_id` columns, until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5) Clause-②: yes (narrowing) @@ -17,12 +17,13 @@ A v18 runtime no longer serves a database it was never migrated for. At `kernel: FROM → TO: - A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply`, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime. -- There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips the check. +- `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table in the ceremony's inventory (the D7 tables and the `sys_metadata` family) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The ceremony drops these columns itself once it verifies; any other destructive drop is unchanged. +- There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check. What does not change: - A fresh database boots: no table it creates carries a retired column, so no marker is needed. - A database whose ceremony verified boots, whatever its columns. -- The `os migrate` commands, the ceremony's own boot among them, are not refused: the guard is composed by `os serve` only, never by the shared standalone stack. +- The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only `os migrate apply`'s drop of an inventory table's `organization_id` waits, as above. - A driver with no physical columns to read (memory, MongoDB) is not checked. - A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 9f1b1b6f69b..8139f92cd0e 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -989,6 +989,19 @@ occupancy on its own. | `needs_confirm` | non-narrowing type change, rebuild a non-unique index whose columns changed | `os migrate apply` — except `manual_column_type_change` (only `os migrate multi-value-columns --apply` runs it), and `manual_widen_varchar_to_text` and `unbuildable_index`, which nothing applies | | `destructive` | drop an orphaned column or index, tighten `NOT NULL`, narrow a type, rebuild an index as `UNIQUE`, create a `UNIQUE` index existing rows already violate | `os migrate apply --allow-destructive` | +`--allow-destructive` refuses some drops until the data they would lose is +safe. No flag skips these refusals: + +- `sys_account.issuer` is dropped only when its retirement pre-flight reads the + table clean (`os migrate account-issuer` reports the colliding rows). +- `sys_comment.reactions` waits for a verified `os migrate comment-reactions --apply` run. +- `organization_id` on any table the organization-ownership ceremony covers + waits for a verified `os migrate organization-ownership --apply` run. These tables are the + ADR-0131 D7 tables and the `sys_metadata` family. The ceremony drops those + columns itself once it verifies. Dropping one first would turn every legacy + organization row into an environment row. Under `--json` the refusal answers + `message: 'refused_organization_ownership_unmigrated'` and lists the `tables`. + #### Index drift `plan` covers indexes as well as columns: diff --git a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts new file mode 100644 index 00000000000..e66905c567d --- /dev/null +++ b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23429 E1 · ADR-0131 D10 item 5] `os migrate apply --allow-destructive` + * refuses to drop `organization_id` from a table in the organization-ownership + * ceremony's inventory until the ceremony's marker is verified — the + * #17440 / #23281 shape, `--json` refusal included, no flag that stands it + * down. Pinned on a v17-shape `sys_platform_setting` (an inventory table v18 declares + * no organization column on, so the drift report offers its physical one for + * the destructive apply): + * + * 1. the drop is refused, naming the ceremony, and nothing is dropped; + * 2. a control: an unrelated destructive drop on the same table is not gated; + * 3. once the ceremony's marker is verified, the drop proceeds. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; +import { isExitSignal } from '../../utils/format.js'; +import MigrateApply from './apply.js'; + +// Pay the first transform of the dist-resolved workspace deps the command +// reaches through dynamic `import()`s at MODULE LOAD, not inside a case. +import '@objectstack/runtime'; +import '@objectstack/objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 240_000; + +const OVERRIDING_ENV = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', + 'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH', +] as const; + +async function runJson(argv: string[]): Promise<{ payload: any; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let processExit: number | undefined; + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + processExit = code ?? 0; + throw new Error(`__PROCESS_EXIT__:${processExit}`); + }) as never); + let thrownExit: number | undefined; + try { + try { + await MigrateApply.run(argv, { root: CLI_ROOT }); + } catch (error) { + const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); + if (!trapped && !isExitSignal(error)) throw error; + if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim(); + let payload: any; + try { payload = JSON.parse(out); } catch { payload = { unparsed: out.slice(-400) }; } + return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +let dir: string; +let v18Db: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +function withDb(file: string, fn: (db: Database.Database) => T): T { + const db = new Database(file); + try { + return fn(db); + } finally { + db.close(); + } +} + +const columnsOf = (file: string, table: string): string[] => + withDb(file, (db) => (db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>).map((c) => c.name)); + +/** A copy of the v18 schema with `columns` added raw to `sys_platform_setting`. */ +let seq = 0; +function caseDb(columns: readonly string[]): string { + seq += 1; + const file = join(dir, `case-${seq}.db`); + copyFileSync(v18Db, file); + withDb(file, (db) => { for (const c of columns) db.exec(`ALTER TABLE sys_platform_setting ADD COLUMN ${c} TEXT`); }); + return file; +} + +const dropArgv = (file: string) => ['--database-url', `file:${file}`, '--allow-destructive', '--yes', '--json']; + +beforeAll(async () => { + for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key]; + for (const key of OVERRIDING_ENV) delete process.env[key]; + dir = mkdtempSync(join(tmpdir(), 'os-23429-drop-gate-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync( + join(dir, 'objectstack.config.ts'), + "export default { manifest: { id: 'com.example.os23429gate', name: 'gate', version: '0.0.0', type: 'app' } };\n", + ); + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + process.env.NODE_ENV = 'production'; + process.env.OS_TELEMETRY_DB = '0'; + v18Db = join(dir, 'v18.db'); + process.chdir(dir); + try { + const created = await runJson(['--database-url', `file:${v18Db}`, '--yes', '--json']); + expect(created.exitCode, JSON.stringify(created.payload).slice(0, 400)).toBe(0); + } finally { + process.chdir(savedCwd); + } + expect(columnsOf(v18Db, 'sys_platform_setting'), 'fixture: v18 declares no organization column here').not.toContain('organization_id'); +}, CASE_TIMEOUT_MS); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } +}); + +beforeEach(() => { process.chdir(dir); }); +afterEach(() => { process.chdir(savedCwd); }); + +describe('os migrate apply --allow-destructive waits for the organization-ownership ceremony (#23429 E1)', () => { + it('1. a v17-shape table: the organization_id drop is refused, naming the ceremony, and nothing is dropped', async () => { + const file = caseDb(['organization_id']); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode).toBe(1); + expect(payload).toMatchObject({ + message: 'refused_organization_ownership_unmigrated', + tables: ['sys_platform_setting'], + applied: [], + created: [], + marker: null, + }); + expect(payload.detail).toMatch(/os migrate organization-ownership --apply/); + expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); + }, CASE_TIMEOUT_MS); + + it('1b. an unverified marker refuses too', async () => { + const file = caseDb(['organization_id']); + withDb(file, (db) => db.prepare( + 'INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, NULL, 2)', + ).run(ORGANIZATION_OWNERSHIP_MIGRATION_ID, new Date().toISOString())); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode).toBe(1); + expect(payload).toMatchObject({ message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'] }); + expect(payload.detail).toMatch(/is not verified \(2 blocking/); + expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); + }, CASE_TIMEOUT_MS); + + it('2. control: an unrelated destructive drop on the same table is not gated', async () => { + const file = caseDb(['legacy_23429_note']); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0); + expect(payload.message).not.toBe('refused_organization_ownership_unmigrated'); + expect(columnsOf(file, 'sys_platform_setting')).not.toContain('legacy_23429_note'); + }, CASE_TIMEOUT_MS); + + it('3. once the ceremony\'s marker is verified, the drop proceeds', async () => { + const file = caseDb(['organization_id']); + const now = new Date().toISOString(); + withDb(file, (db) => db.prepare( + 'INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, ?, 0)', + ).run(ORGANIZATION_OWNERSHIP_MIGRATION_ID, now, now)); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0); + expect(payload.message).not.toBe('refused_organization_ownership_unmigrated'); + expect(columnsOf(file, 'sys_platform_setting')).not.toContain('organization_id'); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 4fe54313fc9..02b952f063c 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -38,6 +38,7 @@ import { REACTIONS_COLUMN, readCommentReactionsConversion, } from '../../utils/comment-reactions-conversion.js'; +import { ceremonyOrganizationColumnDrops, readOrganizationOwnershipMarker } from '../../utils/organization-ownership-drop-gate.js'; async function confirm(question: string): Promise { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -480,6 +481,54 @@ export default class MigrateApply extends Command { } } + // ── [#23429 E1, ADR-0131 D10 item 5] REFUSE AN organization_id DROP AHEAD OF THE CEREMONY ── + // + // The third sibling, placed by the same rule: below the report, above the + // confirmation prompt, above both writes. A v18 runtime declares no + // organization column on the ceremony's column-drop tables, so the drift + // report offers each physical one for `--allow-destructive`. Dropped here + // first, the ceremony never promotes or reports the legacy organization + // rows, they become environment rows, and the v18 boot refusal finds no + // column left to refuse over — the escape hatch D10 item 5 forbids. So + // the drop waits for the ceremony's verified marker; no flag stands it + // down (`utils/organization-ownership-drop-gate.ts`). + const organizationDrops = ceremonyOrganizationColumnDrops(intended); + if (organizationDrops.length > 0) { + const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call( + stack.kernel, + 'objectql', + ); + const marker = await readOrganizationOwnershipMarker(engine as never); + if (!marker.verified) { + const detail = marker.flag + ? `The last run of "os migrate organization-ownership --apply" (${marker.flag.last_run_at}) is not verified ` + + `(${marker.flag.blocking} blocking item(s)).` + : 'No run of "os migrate organization-ownership --apply" is recorded on this database.'; + if (flags.json) { + await emitJson({ + ...target, created: [], applied: [], skipped: drift, pending, + message: 'refused_organization_ownership_unmigrated', tables: organizationDrops, detail, marker: marker.flag, + }, 1, { compact: true }); + return; + } + printError( + `Refusing to drop organization_id from ${organizationDrops.join(', ')}: the organization-ownership ceremony ` + + `owns that drop, and it has not verified on this database. ${detail} Dropping the column first would turn ` + + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run ' + + '"os migrate organization-ownership" to review its plan, then "os migrate organization-ownership --apply"; ' + + 'it drops these columns itself once it verifies.', + ); + this.exit(1); + return; + } + if (!flags.json) { + printSuccess( + `organization_id on ${organizationDrops.join(', ')}: the organization-ownership ceremony verified ` + + `(${marker.flag?.verified_at}) — the drop may proceed.`, + ); + } + } + const totalIntended = intended.length + pending.length; if (totalIntended === 0) { if (flags.json) { await emitJson({ ...target, applied: [], skipped: deferred, created: [], message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; } diff --git a/packages/cli/src/utils/organization-ownership-drop-gate.ts b/packages/cli/src/utils/organization-ownership-drop-gate.ts new file mode 100644 index 00000000000..cc8fbd92ef3 --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-drop-gate.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5 · #23429 E1] The question `os migrate apply` asks + * before it drops an `organization_id` column: has the organization-ownership + * ceremony verified on this database? + * + * A v18 runtime declares no organization column on the ceremony's + * column-drop tables (the D7 tables and the `sys_metadata` family), so the + * drift report lists each physical one as an unmapped, destructive column and + * names `os migrate apply --allow-destructive` as its remedy. Applied ahead of + * the ceremony, that drop skips its promotion and report steps, turns every + * legacy organization row into an environment row, and leaves no physical + * column for the v18 boot refusal to find — so the server starts with the + * marker never written. D10 item 5 forbids exactly that escape hatch. So the + * drop of `organization_id` on any table in the ceremony's inventory waits for + * the ceremony's verified marker, as `sys_account.issuer` (#17440) and + * `sys_comment.reactions` (#23281) wait for theirs. ⛔ No flag stands it down. + */ + +import type { MigrationFlagEngine } from '@objectstack/platform-objects/system'; +import type { DataMigrationFlag } from '@objectstack/spec/system'; +import { ORGANIZATION_OWNERSHIP_INVENTORY } from './organization-ownership-inventory.js'; + +/** The column the ceremony owns the drop of. */ +export const ORGANIZATION_COLUMN = 'organization_id'; + +/** Every table the ceremony's inventory names. */ +const CEREMONY_TABLES: ReadonlySet = new Set(ORGANIZATION_OWNERSHIP_INVENTORY.map((entry) => entry.object)); + +/** The tables whose `organization_id` drop is among `intended`, sorted. */ +export function ceremonyOrganizationColumnDrops( + intended: ReadonlyArray<{ table: string; op?: { type?: string; column?: string } }>, +): string[] { + const tables = new Set(); + for (const d of intended) { + if (d.op?.type === 'drop_column' && d.op.column === ORGANIZATION_COLUMN && CEREMONY_TABLES.has(d.table)) tables.add(d.table); + } + return [...tables].sort(); +} + +/** + * Is the ceremony's run verified on this database? Fails toward retention: no + * `sys_migration` object, no row, an unreadable row or an unverified one all + * answer `verified: false`. + */ +export async function readOrganizationOwnershipMarker( + engine: MigrationFlagEngine, +): Promise<{ verified: boolean; flag: DataMigrationFlag | null }> { + const { readDataMigrationFlag } = await import('@objectstack/platform-objects/system'); + const { isDataMigrationFlagVerified, ORGANIZATION_OWNERSHIP_MIGRATION_ID } = await import('@objectstack/spec/system'); + const flag = await readDataMigrationFlag(engine, ORGANIZATION_OWNERSHIP_MIGRATION_ID); + return { verified: isDataMigrationFlagVerified(flag), flag }; +}