diff --git a/.changeset/24827-migrate-apply-birth-marker.md b/.changeset/24827-migrate-apply-birth-marker.md new file mode 100644 index 00000000000..05109dc5f8e --- /dev/null +++ b/.changeset/24827-migrate-apply-birth-marker.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': minor +--- + +fix(cli): `os migrate apply` on an empty database writes the organization-ownership birth marker, so the fresh install's first `os serve` starts + +Clause-②: yes + +`os serve` (and so `os dev` and `os start`) serves a database this release created itself, recognised by the birth marker in `sys_migration` (row `adr-0131-organization-ownership`), or one the ownership ceremony certified. `os migrate apply` holds its schema DDL back until the operator confirms, so its boot saw no table created and wrote no marker, and the tables it then created carried none. A deploy script that ran `os migrate apply` before the first `os serve` was refused as a database "17.x or an 18.0 pre-release created", and sent through the upgrade ceremony. + +- **Now.** When `os migrate apply` creates the database from empty, it writes the birth marker right after it creates the tables, and the first `os serve` starts. The log line is the one a serving boot prints: `[organization-ownership] a datastore created from empty: birth marker written (shape generation 1, …)`. +- **Never on a database that was not empty.** The decision is the serving boot's own: the database counts as created from empty only when none of the tables this release declares existed before the apply. A database an earlier release created gains no marker from `os migrate apply`, even when the apply creates a table in it, and is still refused until the ceremony runs: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`. +- **A failed write fails the command.** If the tables are created and the marker cannot be written, `os migrate apply` exits `1` and names the ceremony, which certifies such a database. +- **Unchanged.** A database that already carries a marker keeps it as it is. `os migrate plan` writes nothing. On a Turso remote (`libsql://`) primary, `os migrate apply` creates no tables (its schema sync is skipped there), so the first serving boot creates them and writes the marker, as before. 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 index 38d867f9be2..a2ff4daa75a 100644 --- 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 @@ -125,6 +125,10 @@ beforeAll(async () => { } finally { process.chdir(savedCwd); } + // This apply of THIS tree created the store from empty, so it wrote this + // release's birth marker; the v17 database each case stands in for carries + // none, and the drop gate below reads that absence. + withDb(v18Db, (db) => db.prepare('DELETE FROM sys_migration WHERE id = ?').run(ORGANIZATION_OWNERSHIP_MIGRATION_ID)); expect(columnsOf(v18Db, 'sys_platform_setting'), 'fixture: v18 declares no organization column here').not.toContain('organization_id'); }, CASE_TIMEOUT_MS); diff --git a/packages/cli/src/commands/migrate/apply.organization-ownership-birth.integration.test.ts b/packages/cli/src/commands/migrate/apply.organization-ownership-birth.integration.test.ts new file mode 100644 index 00000000000..eff0000f26f --- /dev/null +++ b/packages/cli/src/commands/migrate/apply.organization-ownership-birth.integration.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10; ruling record 6269955919, ① A] `os migrate apply` is the + * boot that creates a fresh install's tables when an operator's deploy script + * runs it before the first `os serve`, so it writes the organization-ownership + * BIRTH MARKER — and only on a database it created from empty. + * + * Its boot defers schema DDL: each object is stored and skipped before the + * driver counts it, so the birth plugin its boot composes sees nothing created + * and writes nothing. The tables are created at the confirmed flush, and the + * birth half runs there (`SchemaStack.flushSchemaDdl`). The served boot's guard + * then judges the file as `os serve` does — read here through + * `assertOrganizationOwnershipMigrated` on a boot that creates nothing, so the + * verdict is the marker's alone: + * + * (a) an empty file: the apply writes one verified birth row, and the guard + * admits the database; + * (b) a database the apply did not create — no marker, the shape an 18.0 + * pre-release leaves, and a 17.x one with a retired organization column — + * gets NO birth marker, though the flush creates a table in it and creates + * nothing else, and the guard still refuses it, naming the ceremony; + * (c, d) the apply run again over the database it created and marked, with a + * table to create: one row, unchanged. + * + * Case (b) is the rule that must not break: a false birth would let a database + * an earlier release created skip the ownership ceremony. + */ + +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, ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; +import { assertOrganizationOwnershipMigrated, isOrganizationOwnershipBootRefusal } from '@objectstack/runtime'; +import { isExitSignal } from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.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/objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 240_000; + +/** The ceremony's first step, as the boot refusal prescribes it. */ +const CEREMONY_PLAN = 'os migrate organization-ownership --out PLAN'; + +/** A table the composition declares, dropped from a copy so the apply's flush has one to create. */ +const DROPPED_TABLE = 'sys_platform_setting_audit'; + +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(); + } +} + +const applyArgv = (file: string) => ['--database-url', `file:${file}`, '--yes', '--json']; + +function withDb(file: string, fn: (db: Database.Database) => T): T { + const db = new Database(file); + try { + return fn(db); + } finally { + db.close(); + } +} + +/** Every `sys_migration` row naming the ceremony: the marker, read straight from the file. */ +const markerRows = (file: string): Array> => + withDb(file, (db) => db.prepare('SELECT * FROM sys_migration WHERE id = ?').all(ORGANIZATION_OWNERSHIP_MIGRATION_ID) as Array>); + +/** + * The served boot's verdict on `file`, as `os serve`'s guard reaches it, from a + * boot that holds its DDL back: it creates nothing, so the guard cannot read the + * store as created from empty and decides on the marker alone. Resolves when the + * database would be served; rejects with the refusal when it would not. + * + * Composed as the apply composes, so the ledger object is registered: a boot of + * the data stack alone registers no `sys_migration`, and the guard is inert + * there — it would admit every file. Case (b)'s refusal is the control. + */ +async function guardVerdict(file: string): Promise { + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${file}`, + deferSchemaDdl: true, + readOnlyProbe: true, + composeHostStack: true, + composeServedPlatform: true, + projectRoot: dir, + }); + try { + const engine = (stack.kernel as { getService: (name: string) => unknown }).getService('objectql'); + await assertOrganizationOwnershipMigrated(engine as never); + } finally { + await stack.shutdown(); + } +} + +let dir: string; +let freshDb: string; +let freshApply: { payload: any; exitCode: number }; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +/** A copy of the database the first apply created, edited raw by `edit`. */ +let seq = 0; +function copyOfFresh(edit: (db: Database.Database) => void): string { + seq += 1; + const file = join(dir, `case-${seq}.db`); + copyFileSync(freshDb, file); + withDb(file, edit); + return file; +} + +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-apply-birth-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync( + join(dir, 'objectstack.config.ts'), + "export default { manifest: { id: 'com.example.applybirth', name: 'birth', 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'; + // (a)'s act: `os migrate apply` on a file that does not exist yet. + freshDb = join(dir, 'fresh.db'); + process.chdir(dir); + try { + freshApply = await runJson(applyArgv(freshDb)); + } finally { + process.chdir(savedCwd); + } +}, 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 writes the organization-ownership birth marker on a database it created from empty', () => { + it('(a) an empty file: one verified birth row at the current shape generation, and the guard admits it', async () => { + expect(freshApply.exitCode, JSON.stringify(freshApply.payload).slice(0, 400)).toBe(0); + expect(freshApply.payload.created.length, 'the apply created the tables').toBeGreaterThan(0); + + const rows = markerRows(freshDb); + expect(rows).toHaveLength(1); + expect(rows[0].verified_at).not.toBeNull(); + expect(Number(rows[0].blocking)).toBe(0); + expect(JSON.parse(String(rows[0].details)).birth).toMatchObject({ + shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, + }); + + await expect(guardVerdict(freshDb)).resolves.toBeUndefined(); + }, CASE_TIMEOUT_MS); + + it.each([ + ['an 18.0 pre-release shape (no marker, no retired column)', [] as string[]], + ['a 17.x shape (no marker, a retired organization column)', ['ALTER TABLE sys_platform_setting ADD COLUMN organization_id TEXT']], + ])('(b) a database the apply did not create, %s: no birth marker, and the guard still refuses it', async (_shape, extra) => { + const file = copyOfFresh((db) => { + db.prepare('DELETE FROM sys_migration WHERE id = ?').run(ORGANIZATION_OWNERSHIP_MIGRATION_ID); + db.exec(`DROP TABLE ${DROPPED_TABLE}`); + for (const sql of extra) db.exec(sql); + }); + + const { payload, exitCode } = await runJson(applyArgv(file)); + expect(exitCode, JSON.stringify(payload).slice(0, 400)).toBe(0); + // The trap the birth half must not fall into: the flush's own work names + // only tables it CREATED — a populated database that merely gains one reads + // exactly like an empty one there. + expect(payload.created).toEqual([expect.objectContaining({ table: DROPPED_TABLE, kind: 'create_table' })]); + + expect(markerRows(file)).toEqual([]); + const refusal = await guardVerdict(file).then(() => null, (error: unknown) => error); + expect(isOrganizationOwnershipBootRefusal(refusal), String(refusal)).toBe(true); + expect((refusal as Error).message).toContain(CEREMONY_PLAN); + expect((refusal as { tables: string[] }).tables).toEqual(extra.length > 0 ? ['sys_platform_setting'] : []); + }, CASE_TIMEOUT_MS); + + it('(c, d) the apply run again over the database it created and marked, with a table to create: one row, unchanged', async () => { + const file = copyOfFresh((db) => db.exec(`DROP TABLE ${DROPPED_TABLE}`)); + const before = markerRows(file); + expect(before).toHaveLength(1); + + const { payload, exitCode } = await runJson(applyArgv(file)); + expect(exitCode, JSON.stringify(payload).slice(0, 400)).toBe(0); + expect(payload.created).toEqual([expect.objectContaining({ table: DROPPED_TABLE, kind: 'create_table' })]); + + expect(markerRows(file)).toEqual(before); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 2de13adab01..091e6bbbe10 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -629,6 +629,10 @@ export default class MigrateApply extends Command { // reconciled. Drift was detected against the pre-flush database, and a // just-created table matches metadata by construction, so the two sets // never overlap. + // [ADR-0131 D10] The flush also writes the organization-ownership birth + // marker when it created the database from empty — the half this + // deferred boot's birth plugin could not run — so the first `os serve` + // admits it. A database that was not empty gets none. const created = await stack.flushSchemaDdl(); const { applied, skipped } = await stack.applyMigrationEntries(drift, { allowDestructive }); diff --git a/packages/cli/src/utils/schema-migrate.birth-after-flush.test.ts b/packages/cli/src/utils/schema-migrate.birth-after-flush.test.ts new file mode 100644 index 00000000000..f5cc3154c1b --- /dev/null +++ b/packages/cli/src/utils/schema-migrate.birth-after-flush.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10] The birth half `SchemaStack.flushSchemaDdl` runs once a + * deferred boot's DDL is performed (`recordBirthOfFlushedStore`), at the seam + * the real-database pins cannot reach: a birth marker that cannot be written. + * + * The tables exist and the marker does not, so the first `os serve` would + * refuse the database as one an earlier release created. The flush must hand + * that failure to its caller — `os migrate apply` then exits 1 — naming what + * was not written and the ceremony that certifies such a database, never + * report success. The real driver, the real guard and the decision itself + * are `apply.organization-ownership-birth.integration.test.ts`'s. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; +import { recordBirthOfFlushedStore } from './schema-migrate.js'; + +/** The ceremony's two steps, as the boot refusal prescribes them. */ +const CEREMONY_STEPS = [ + 'os migrate organization-ownership --out PLAN', + 'os migrate organization-ownership --apply --plan-file PLAN --backup-taken', +]; + +const engine = { getDriverForObject: () => ({}) }; +const kernelWith = (service: unknown) => ({ getService: (name: string) => (name === 'objectql' ? service : undefined) }); + +describe('recordBirthOfFlushedStore — the birth half after a deferred flush', () => { + it('hands the runtime its engine and the logger, and resolves when the birth half does', async () => { + const logger = { info: vi.fn() }; + const recordBirth = vi.fn(async () => null); + await expect(recordBirthOfFlushedStore(kernelWith(engine), recordBirth, logger)).resolves.toBeUndefined(); + expect(recordBirth).toHaveBeenCalledWith(engine, logger); + }); + + it('a birth marker that cannot be written fails the flush, naming the row, the cause and the ceremony', async () => { + const cause = new Error('SQLITE_READONLY: attempt to write a readonly database'); + const recordBirth = vi.fn(async () => { throw cause; }); + const failure = await recordBirthOfFlushedStore(kernelWith(engine), recordBirth, undefined).then( + () => null, + (error: unknown) => error as Error, + ); + expect(failure).toBeInstanceOf(Error); + expect(failure!.cause).toBe(cause); + expect(failure!.message).toContain(ORGANIZATION_OWNERSHIP_MIGRATION_ID); + expect(failure!.message).toContain(cause.message); + for (const step of CEREMONY_STEPS) expect(failure!.message).toContain(step); + }); + + it('a kernel with no engine has no ledger to write: nothing is asked', async () => { + const recordBirth = vi.fn(async () => null); + await expect(recordBirthOfFlushedStore(kernelWith(undefined), recordBirth, undefined)).resolves.toBeUndefined(); + await expect( + recordBirthOfFlushedStore({ getService: () => { throw new Error('not registered'); } }, recordBirth, undefined), + ).resolves.toBeUndefined(); + expect(recordBirth).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index f97922f7ea0..3e683542789 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -56,7 +56,7 @@ import type { PendingSchemaWork, } from '@objectstack/driver-sql'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; -import { StorageNameMapping } from '@objectstack/spec/system'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID, StorageNameMapping } from '@objectstack/spec/system'; import { isDevelopmentBoot } from '@objectstack/core'; import { doctorNodeEnv, @@ -236,6 +236,11 @@ export interface SchemaStack { /** * Perform the deferred sync — call only once the operator has confirmed the * plan. Returns the work it actually ran (`[]` when nothing was deferred). + * + * Then the organization-ownership BIRTH half the deferred boot could not + * run ({@link recordBirthOfFlushedStore}): when this flush created the + * ledger's store from empty, it writes the birth marker, so the first `os + * serve` admits the database this run created. Throws when that write fails. */ flushSchemaDdl: () => Promise; /** @@ -468,6 +473,9 @@ export class DeferSchemaDdlPlugin { /** Every driver this boot deferred, in the order it was armed. */ readonly drivers: SqlDriverLike[] = []; + /** The kernel's logger, as `init()` was handed it: what the flush's birth half reports through. */ + logger: BirthLogger | undefined; + /** * [#23816] `exemptPrimary`: leave the engine's DEFAULT driver un-armed. Set * only by a `skipSchemaSync` boot (`deferredDdlUnsupported: @@ -487,6 +495,7 @@ export class DeferSchemaDdlPlugin { }> = []; init = async (ctx: any) => { + this.logger = ctx.logger; if (this.options.exemptPrimary) this.exempt = defaultDriverOf(ctx); for (const name of SQL_DRIVER_SERVICES) { try { this.arm(ctx.getService(name)); } catch { /* not registered */ } @@ -579,6 +588,75 @@ export class DeferSchemaDdlPlugin { } } +/** `@objectstack/runtime`'s birth half, loaded lazily as the rest of the runtime is here. */ +type RecordOrganizationOwnershipBirth = typeof import('@objectstack/runtime').recordOrganizationOwnershipBirth; + +/** + * What the birth half reports through: the runtime's own parameter, which reads + * `info` alone. A failure is never logged on this path — it is thrown to the + * caller ({@link recordBirthOfFlushedStore}) — so the sink has no error channel. + */ +type BirthLogger = Parameters[1]; + +/** + * [ADR-0131 D10; ruling record 6269955919, ① A] The organization-ownership + * BIRTH half of a deferred boot, run once {@link SchemaStack.flushSchemaDdl} + * has performed the DDL the boot held back. + * + * Every boot through here composes `OrganizationOwnershipBirthMarkerPlugin` + * (`createStandaloneStack`), which writes the birth marker when its `start()` + * sees the ledger's store created from empty. A deferred boot creates nothing + * by then: each driver stores its objects and skips them before counting, so + * the store reports nothing created and the plugin writes nothing. The tables + * are created HERE, at the flush. Without this step a fresh install whose + * first command is `os migrate apply` carries no marker, and its first `os + * serve` refuses it as a database an earlier release created. + * + * The decision is the runtime's own (`recordOrganizationOwnershipBirth`), read + * now: the ledger store's driver counts, from its `hasTable` probe, every + * table the flush created and every one it found, so `existing === 0 && + * created > 0` holds only when no declared table was there before this run. + * ⛔ Never judged from the work the flush returns: a table already in sync + * produces no entry there, so "every entry is a create_table" also describes + * a populated database that merely gained a table — exactly the database + * that must never receive a birth marker. A store the flush did not touch + * (the Turso remote primary, whose schema sync this boot skipped) counts + * nothing created and is left to its first serving boot. + * + * A failed write throws: the tables are there and the marker is not, so the + * first `os serve` would refuse this database, and the command must not end + * reporting success. The ceremony certifies such a database. + * + * Exported for its unit pin (`schema-migrate.birth-after-flush.test.ts`), which + * holds the failed-write refusal without booting a kernel. + */ +export async function recordBirthOfFlushedStore( + kernel: { getService?: (name: string) => unknown }, + recordBirth: RecordOrganizationOwnershipBirth, + logger: BirthLogger | undefined, +): Promise { + let engine: unknown; + try { + engine = kernel.getService?.('objectql'); + } catch { + return; // no engine — no ledger to write + } + if (!engine || typeof (engine as { getDriverForObject?: unknown }).getDriverForObject !== 'function') return; + try { + await recordBirth(engine as Parameters[0], logger); + } catch (error) { + throw new Error( + `This run created the database from empty, and its organization-ownership birth marker (sys_migration ` + + `'${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') could not be written ` + + `(${error instanceof Error ? error.message : String(error)}). Without it the first 'os serve' takes this ` + + `database for one an earlier release created and refuses it. Fix the write, then run 'os migrate ` + + `organization-ownership --out PLAN' and 'os migrate organization-ownership --apply --plan-file PLAN ` + + `--backup-taken', which certifies it.`, + { cause: error }, + ); + } +} + /** * [#23816] The engine's DEFAULT driver — the primary datasource's — read * through the accessors the engine makes public, or `null` when the engine @@ -1196,7 +1274,8 @@ export async function bootSchemaStack( const projectRoot = opts.projectRoot ?? process.cwd(); const envLoad = loadProjectEnvFiles(projectRoot, { dev: opts.serveFlags?.dev === true }); - const { createStandaloneStack, Runtime, resolveStandaloneDatabase } = await import('@objectstack/runtime'); + const { createStandaloneStack, Runtime, resolveStandaloneDatabase, recordOrganizationOwnershipBirth } = + await import('@objectstack/runtime'); // The inputs the database is resolved from — by `createStandaloneStack` // below, the occupancy probe before it, and the telemetry sibling's key. @@ -1533,6 +1612,9 @@ export async function bootSchemaStack( // [#21529] The tables exist now; nothing is measured absent any more. absentTables.clear(); deferral?.release(); + // [ADR-0131 D10] The birth half the deferred boot could not run: only a + // deferred boot holds DDL back, so only it can have created the store here. + if (deferral) await recordBirthOfFlushedStore(kernel, recordOrganizationOwnershipBirth, deferral.logger); return sortPendingSchemaWork(performed); }, composition, diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts index 7f298a87f59..cf2e9113eb5 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -366,9 +366,12 @@ type MarkerReading = * first `os serve` of an install whose store a CLI boot created would find no * marker on a store it did not create, and refuse a fresh install. * - * The ceremony's own boots never trip it: they hold their DDL back - * (`deferSchemaDdl`) or skip schema sync, so their store reports nothing - * created. + * A boot that holds its DDL back (`deferSchemaDdl`) or skips schema sync + * reports nothing created, so the plugin writes nothing there: the ceremony's + * own boots never trip it. The one deferred boot that then creates the tables + * — `os migrate apply`, at its confirmed flush — calls + * {@link recordOrganizationOwnershipBirth} itself once they exist (the CLI's + * `flushSchemaDdl`), judged on the same counts. */ export class OrganizationOwnershipBirthMarkerPlugin implements Plugin { readonly name = ORGANIZATION_OWNERSHIP_BIRTH_MARKER_PLUGIN_NAME;