From 0555b80a0a9bf5e75423857896a879edede527c3 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 11:25:55 +0000
Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20os=20migrate=20organization-ow?=
=?UTF-8?q?nership=20--apply=20=E2=80=94=20the=20ADR-0131=20D10=20ceremony?=
=?UTF-8?q?'s=20apply=20step=20and=20post-check?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The read-only plan gains its writing mode. --apply asks about the backup
first (--backup-taken, or a yes at the prompt; refused non-interactively),
then performs the fates in order, stopping at the first blocker:
1. attribution — the owners the plan's own walk derives (anchors, else the
Default Organization under single), to a fixed point; an unattributable
row is never assigned and stays listed by id;
2. authored catalog rows — permission sets and positions become environment
metadata through the metadata door via core's row-to-definition mappers,
with position bindings carried into permissionSets (single only);
3. the verified id-to-name rewrite of sys_user_permission_set.permission_set,
then every grant verified to name a set the security catalog resolves
(permission_set_id stays);
4. mirror deletion, gated on that verification read again before the first
delete; only mirror rows no named category claims;
5. column drops through the schema-drift ceremony's drop_column entries.
Every write is conditioned on the state it changes, and each finished step
is checkpointed in sys_migration (adr-0131-organization-ownership), so an
interrupted run resumes at its step. The post-check re-reads the plan; the
ledger row's applied_at / verified_at are the ceremony's marker.
The acceptance fixture carries every fate: effective access identical, with
a positive control; mirrors gone; attributed rows stamped; the orphan row
present and reported; row counts reconciling.
Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR
Co-authored-by: Claude
---
.../22937-cli-organization-ownership-apply.md | 25 +
...organization-ownership.apply-flags.test.ts | 68 ++
.../migrate/organization-ownership.ts | 294 +++++-
.../organization-ownership-apply.test.ts | 464 +++++++++
.../src/utils/organization-ownership-apply.ts | 942 ++++++++++++++++++
.../src/utils/organization-ownership-plan.ts | 71 +-
...igrate.one-shot-family.integration.test.ts | 7 +-
packages/cli/src/utils/schema-migrate.ts | 15 +-
.../cli/test/json-stdout-purity.e2e.test.ts | 5 +-
9 files changed, 1863 insertions(+), 28 deletions(-)
create mode 100644 .changeset/22937-cli-organization-ownership-apply.md
create mode 100644 packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
create mode 100644 packages/cli/src/utils/organization-ownership-apply.test.ts
create mode 100644 packages/cli/src/utils/organization-ownership-apply.ts
diff --git a/.changeset/22937-cli-organization-ownership-apply.md b/.changeset/22937-cli-organization-ownership-apply.md
new file mode 100644
index 00000000000..4b2c410f716
--- /dev/null
+++ b/.changeset/22937-cli-organization-ownership-apply.md
@@ -0,0 +1,25 @@
+---
+'@objectstack/cli': minor
+---
+
+feat(cli): `os migrate organization-ownership --apply` — the v18 organization-ownership ceremony's apply step and post-check (ADR-0131 D10, ceremony items 2 to 4)
+
+Clause-②: yes (widening)
+
+The command that planned the ceremony now performs it. Without `--apply` nothing changes: the read-only plan, written to a file. With `--apply`:
+
+- **The backup comes first.** It is the operator's act: pass `--backup-taken`, or answer yes at the prompt. A non-interactive run without the flag is refused before the database is opened.
+- **The fates run in order**, each step stopping the run where it meets a blocker:
+ 1. **Attribution** — every NULL `organization_id` row the plan derives an owner for takes it (a parent, subject or holder anchor; under `single`, the Default Organization). A row with no derivable owner is never assigned one: it stays NULL and is listed by id.
+ 2. **Authored catalog rows** — permission sets and positions an organization authored in Setup become environment metadata through the metadata door (under `single`; under a wall they are reported), with each position's binding rows carried into its `permissionSets`.
+ 3. **The verified id→name rewrite** — every `sys_user_permission_set` grant names its set in `permission_set`, then every grant is verified to name a set the security catalog resolves. `permission_set_id` stays.
+ 4. **Mirror deletion** — only after that verification, read again just before the first delete: the seeded catalog copies (per organization and NULL residue), the binding rows, seeded capabilities and seeded templates. Authored, customized and organization-stamped rows stay.
+ 5. **Column drops** — `organization_id` leaves the deployment-level objects through the schema-drift ceremony (`os migrate apply`'s machinery), never a hand `ALTER`. A table whose rows still need another step keeps its column, and the run says why.
+- **Idempotent and resumable.** Every write is conditioned on the state it changes, and each finished step is checkpointed in `sys_migration` (row `adr-0131-organization-ownership`); a run interrupted mid-step resumes at that step and never re-derives an owner already written.
+- **The post-check** re-reads the plan and reports, per table, what remains. The ledger row records `applied_at` once every step has finished and `verified_at` when the post-check also reads zero discrepancies. Exit code 1 while the ceremony is unfinished or the post-check finds a discrepancy.
+- A JSON report is written beside the plan (`organization-ownership-apply-TIMESTAMP.json`, or `--out`); an existing file is never overwritten.
+
+```bash
+os migrate organization-ownership --out plan.json # read the plan first
+os migrate organization-ownership --apply --backup-taken # then perform it
+```
diff --git a/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
new file mode 100644
index 00000000000..19b7de0a2e3
--- /dev/null
+++ b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os migrate organization-ownership --apply` asks about the backup before it
+ * opens the database (ADR-0131 D10 ceremony item 2): without `--backup-taken`
+ * a non-interactive run is refused, and the boot never happens. The flag means
+ * nothing without `--apply`, and is refused there too.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fileURLToPath } from 'node:url';
+
+const boot = vi.hoisted(() => vi.fn(async () => {
+ throw new Error('the database was opened');
+}));
+vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
+
+const { default: MigrateOrganizationOwnership } = await import('./organization-ownership.js');
+
+const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
+
+async function runJson(argv: string[]): Promise<{ payload: Record; exitCode: unknown }> {
+ const chunks: string[] = [];
+ const savedExit = process.exitCode;
+ vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
+ chunks.push(String(chunk));
+ const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
+ if (cb) cb();
+ return true;
+ }) as typeof process.stdout.write);
+ vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
+ try {
+ await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
+ } catch (error) {
+ if ((error as { oclif?: unknown }).oclif === undefined) throw error;
+ }
+ const exitCode = process.exitCode;
+ process.exitCode = savedExit;
+ vi.restoreAllMocks();
+ return { payload: JSON.parse(chunks.join('').trim()) as Record, exitCode };
+}
+
+afterEach(() => {
+ boot.mockClear();
+});
+
+describe('os migrate organization-ownership --apply — the backup precondition', () => {
+ it('refuses a non-interactive --apply without --backup-taken, before the boot', async () => {
+ const { payload, exitCode } = await runJson(['--apply', '--json']);
+ expect(payload).toMatchObject({ error: 'apply_refused', reason: 'backup-unacknowledged' });
+ expect(exitCode).toBe(1);
+ expect(boot).not.toHaveBeenCalled();
+ });
+
+ it('refuses --backup-taken without --apply, before the boot', async () => {
+ const { payload, exitCode } = await runJson(['--backup-taken', '--json']);
+ expect(payload).toMatchObject({ error: 'plan_refused', reason: 'flag-without-apply' });
+ expect(exitCode).toBe(1);
+ expect(boot).not.toHaveBeenCalled();
+ });
+
+ it('with --backup-taken, goes on to the boot', async () => {
+ const { payload } = await runJson(['--apply', '--backup-taken', '--json']);
+ expect(payload).toMatchObject({ error: 'boot_failed', reason: 'boot-failed' });
+ expect(boot).toHaveBeenCalledTimes(1);
+ expect(boot.mock.calls[0]).toEqual([expect.objectContaining({ deferSchemaDdl: true, composeHostStack: true })]);
+ });
+});
diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts
index 327c136b882..b01f152cf12 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.ts
@@ -2,6 +2,7 @@
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
+import { createInterface } from 'node:readline';
import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { resolveTenancyPosture } from '@objectstack/types';
@@ -24,6 +25,29 @@ import {
OrganizationOwnershipPlanRefusal,
type OrganizationOwnershipPlan,
} from '../../utils/organization-ownership-plan.js';
+import {
+ applyOrganizationOwnership,
+ checkpointFromDetails,
+ ORGANIZATION_OWNERSHIP_MIGRATION_ID,
+ type ApplyExecutor,
+ type CeremonyLedgerPort,
+ type ColumnDropOutcome,
+ type OrganizationOwnershipApplyReport,
+} from '../../utils/organization-ownership-apply.js';
+
+/** Who the ceremony's metadata saves name as their actor. */
+const CEREMONY_ACTOR = 'os migrate organization-ownership';
+
+async function confirm(question: string): Promise {
+ if (!process.stdin.isTTY) return false; // non-interactive → require --backup-taken
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
+ try {
+ const answer: string = await new Promise((resolve) => rl.question(question, resolve));
+ return /^y(es)?$/i.test(answer.trim());
+ } finally {
+ rl.close();
+ }
+}
/**
* `os migrate organization-ownership` — the v18 organization-ownership
@@ -53,20 +77,43 @@ import {
* Bare `os migrate` is the schema-drift plan (#2186) and stays exactly that.
* Every data step in this family is a named subcommand whose bare run is the
* read-only preview and whose `--apply` is the only writing mode; the ADR's
- * `--plan` names this mode. C7b adds `--apply` (fate order: attribution, the
- * verified id-to-name rewrite, mirror deletion, column drops) and the
- * post-check to THIS command; until then it has no writing mode at all.
+ * `--plan` names this mode.
+ *
+ * ## `--apply` (ADR-0131 D10 ceremony items 2 to 4)
+ *
+ * The writing mode. It asks about the backup first — the operator's act and
+ * the precondition (D10 item 2): `--backup-taken`, or a yes at the prompt; a
+ * non-interactive run without the flag is refused before the boot. It then
+ * performs the fates in order — attribution, the verified id→name rewrite,
+ * mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
+ * checkpointing each finished step in the deployment ledger (`sys_migration`,
+ * row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run
+ * resumes where it stopped, and ends with the post-check. The ledger row is
+ * the ceremony's marker: `applied_at` once every step has finished,
+ * `verified_at` when the post-check also reads zero discrepancies. A report
+ * file is written beside the plan's, never over an existing one.
+ *
+ * The apply boot composes the deployment's served object set
+ * (`composeHostStack`, with the auth-gated security declarations and the
+ * served platform) so the security catalog it verifies names against is the
+ * one the deployment serves, and the schema-drift ceremony proposes the
+ * column drops of exactly the objects this release declares without the
+ * column. It still holds the boot's own DDL back (`deferSchemaDdl`): the only
+ * schema change the ceremony makes is the drops it asked for.
*/
export default class MigrateOrganizationOwnership extends Command {
static override description =
- 'The v18 organization-ownership ceremony\'s read-only plan (ADR-0131 D10): per table, the fate, the row counts, the ' +
- 'rows whose owner cannot be derived (by id), and which tables will receive NOT NULL. Writes the plan to a file; ' +
- 'never changes a row or a column. Refuses — naming it — any table it cannot enumerate.';
+ 'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' +
+ 'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' +
+ 'to a file — never a row or a column changed; it refuses, naming it, any table it cannot enumerate. With --apply ' +
+ '(and a backup): attribution, the verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
+ 'resumable from a recorded checkpoint, then the post-check.';
static override examples = [
'$ os migrate organization-ownership',
'$ os migrate organization-ownership --out plans/v18-ownership.json',
'$ os migrate organization-ownership --database-url postgres://… --json',
+ '$ os migrate organization-ownership --apply --backup-taken',
];
static override flags = {
@@ -79,7 +126,19 @@ export default class MigrateOrganizationOwnership extends Command {
'Where to write the plan (default: organization-ownership-plan-TIMESTAMP.json in the current directory). ' +
'An existing file is never overwritten',
}),
- json: Flags.boolean({ description: 'Also print the plan document on stdout' }),
+ json: Flags.boolean({ description: 'Also print the plan (or, with --apply, the apply report) on stdout' }),
+ apply: Flags.boolean({
+ description:
+ 'Perform the ceremony: attribution, the verified id-to-name rewrite, mirror deletion, column drops, then the ' +
+ 'post-check. Idempotent; an interrupted run resumes from its recorded checkpoint. Requires a backup',
+ default: false,
+ }),
+ 'backup-taken': Flags.boolean({
+ description:
+ 'With --apply: state that a backup of this database has been taken. The platform takes none and assumes ' +
+ 'none; without this flag the run asks, and a non-interactive run is refused',
+ default: false,
+ }),
};
async run(): Promise {
@@ -88,7 +147,12 @@ export default class MigrateOrganizationOwnership extends Command {
const json = Boolean(flags.json);
const refuse = async (payload: Record, message: string): Promise => {
if (json) {
- await emitJson({ error: payload.reason === 'boot-failed' ? 'boot_failed' : 'plan_refused', ...payload }, 1, { compact: true });
+ const error = payload.reason === 'boot-failed'
+ ? 'boot_failed'
+ : payload.reason === 'apply-failed'
+ ? 'apply_failed'
+ : flags.apply ? 'apply_refused' : 'plan_refused';
+ await emitJson({ error, ...payload }, 1, { compact: true });
return;
}
printError(message);
@@ -103,6 +167,15 @@ export default class MigrateOrganizationOwnership extends Command {
return;
}
+ if (flags.apply) {
+ await this.runApply(flags, posture, refuse);
+ return;
+ }
+ if (flags['backup-taken']) {
+ await refuse({ reason: 'flag-without-apply', detail: '--backup-taken is only meaningful with --apply' }, '--backup-taken is only meaningful with --apply.');
+ return;
+ }
+
if (!json) {
printHeader('Migrate · organization-ownership (plan)');
printStep('Booting data stack (read-only)…');
@@ -199,4 +272,209 @@ export default class MigrateOrganizationOwnership extends Command {
printSuccess(`Plan written to ${target} — nothing in the database was changed.`);
console.log(chalk.dim(` ${timer.display()}`));
}
+
+ /** `--apply`: the backup precondition, the boot, the ceremony, the report file. */
+ private async runApply(
+ flags: { 'database-url'?: string; out?: string; json?: boolean; 'backup-taken'?: boolean },
+ posture: string,
+ refuse: (payload: Record, message: string) => Promise,
+ ): Promise {
+ const json = Boolean(flags.json);
+ const timer = createTimer();
+ if (!flags['backup-taken']) {
+ const target = flags['database-url'] ?? process.env.OS_DATABASE_URL ?? 'the project database';
+ const yes = !json && (await confirm(
+ `${chalk.yellow('Backup')}: this run rewrites ownership, deletes mirror rows and drops columns in ${target}. ` +
+ 'Has a backup of it been taken? [y/N] ',
+ ));
+ if (!yes) {
+ await refuse(
+ { reason: 'backup-unacknowledged', detail: 'pass --backup-taken once a backup of this database exists' },
+ 'Refused: --apply needs a backup of this database first. Take one, then re-run with --backup-taken ' +
+ '(or answer yes at the prompt). The platform takes no backup and assumes none.',
+ );
+ return;
+ }
+ }
+
+ if (!json) {
+ printHeader('Migrate · organization-ownership (apply)');
+ printStep('Booting the deployment\'s served stack (schema DDL held back)…');
+ }
+ let stack;
+ try {
+ stack = await bootSchemaStack({
+ jsonOutput: json,
+ ...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
+ deferSchemaDdl: true,
+ composeHostStack: true,
+ composeServedPlatform: true,
+ composeAuthGatedSecurity: true,
+ });
+ } catch (error: unknown) {
+ await refuse({ reason: 'boot-failed', detail: String((error as Error)?.message ?? error) }, String((error as Error)?.message ?? error));
+ return;
+ }
+
+ let report: OrganizationOwnershipApplyReport;
+ try {
+ const exec = resolvePlannedDriverExec(stack.driver);
+ if (!stack.driver || !exec) {
+ await refuse(
+ { reason: 'driver-unsupported', detail: 'no SQL driver with a raw-SQL seam is active' },
+ 'No SQL driver with a raw-SQL seam is active. The ceremony supports SQLite, PostgreSQL and MySQL.',
+ );
+ return;
+ }
+ if (stack.tableAbsent('sys_migration')) {
+ await refuse(
+ { reason: 'ledger-absent', table: 'sys_migration', detail: 'the deployment ledger the ceremony checkpoints into does not exist' },
+ 'Refused: this database has no sys_migration table, where the ceremony records its checkpoint. Boot this ' +
+ 'release against it once (its schema sync creates the table), then re-run.',
+ );
+ return;
+ }
+ const { normalizeRows } = await import('@objectstack/metadata-protocol');
+ const { createSecurityCatalogReader } = await import('@objectstack/core');
+ const { readDataMigrationFlag, recordDataMigrationRun } = await import('@objectstack/platform-objects/system');
+ const kernel = stack.kernel as { getService(name: string): unknown };
+ const engine = kernel.getService('objectql') as Parameters[0] & { registry?: unknown };
+ const protocol = kernel.getService('protocol') as {
+ saveMetaItem?: (request: Record) => Promise;
+ } | undefined;
+ const catalog = createSecurityCatalogReader({
+ registry: engine.registry as never,
+ metadata: kernel.getService('metadata') as never,
+ });
+ const client = (stack.driver.config as { client?: unknown } | undefined)?.client;
+ const executor: ApplyExecutor = {
+ client: client === undefined ? undefined : String(client),
+ query: async (sql, params) => normalizeRows(await exec(sql, params ? [...params] : [])),
+ execute: async (sql, params) => {
+ await exec(sql, params ? [...params] : []);
+ },
+ };
+ const ledger: CeremonyLedgerPort = {
+ read: async () => checkpointFromDetails((await readDataMigrationFlag(engine, ORGANIZATION_OWNERSHIP_MIGRATION_ID))?.details),
+ write: async (checkpoint, verdict) => {
+ await recordDataMigrationRun(engine, {
+ migrationId: ORGANIZATION_OWNERSHIP_MIGRATION_ID,
+ passed: verdict.complete && verdict.blocking === 0,
+ applied: verdict.complete,
+ blocking: verdict.blocking,
+ advisory: verdict.advisory,
+ details: { checkpoint },
+ });
+ },
+ };
+ const liveStack = stack;
+ report = await applyOrganizationOwnership({
+ executor,
+ posture,
+ database: stack.dbLabel,
+ catalog,
+ door: {
+ save: async (type, name, body) => {
+ if (typeof protocol?.saveMetaItem !== 'function') {
+ throw new Error('no metadata protocol is composed on this stack');
+ }
+ await protocol.saveMetaItem({ type, name, item: body, actor: CEREMONY_ACTOR });
+ },
+ },
+ columnDrops: {
+ drop: async (tables) => {
+ const drift = await liveStack.detectManagedDrift();
+ const wanted = new Set(tables);
+ const entries = drift.filter((d) => {
+ const op = (d as { op?: { type?: string; column?: string } }).op;
+ return op?.type === 'drop_column' && op.column === 'organization_id' && wanted.has(String(d.table));
+ });
+ const outcomes: ColumnDropOutcome[] = [];
+ const proposed = new Set(entries.map((d) => String(d.table)));
+ for (const table of tables) {
+ if (!proposed.has(table)) {
+ outcomes.push({ table, outcome: 'not-proposed', detail: 'the drift differ proposed no drop for it' });
+ }
+ }
+ if (entries.length === 0) return outcomes;
+ const { applied, skipped } = await liveStack.applyMigrationEntries(entries, { allowDestructive: true });
+ for (const d of applied) outcomes.push({ table: String(d.table), outcome: 'dropped' });
+ for (const d of skipped) {
+ outcomes.push({ table: String(d.table), outcome: 'failed', detail: String((d as { message?: unknown }).message ?? 'skipped by the schema-drift ceremony') });
+ }
+ return outcomes;
+ },
+ },
+ ledger,
+ ...(json ? {} : { onProgress: (message: string) => printStep(message) }),
+ });
+ } catch (error: unknown) {
+ if (isExitSignal(error)) throw error;
+ if (error instanceof OrganizationOwnershipPlanRefusal) {
+ await refuse(
+ { reason: error.reason, ...(error.table ? { table: error.table } : {}), detail: error.message },
+ `Refused${error.table ? ` (${error.table})` : ''}: ${error.message}`,
+ );
+ return;
+ }
+ await refuse(
+ { reason: 'apply-failed', detail: String((error as Error)?.message ?? error), ...errorCodeFields(error) },
+ `${String((error as Error)?.message ?? error)} — every step finished before this one is checkpointed; ` +
+ 're-run --apply to resume.',
+ );
+ return;
+ } finally {
+ await stack.shutdown();
+ }
+
+ const target = resolve(flags.out ?? `organization-ownership-apply-${report.generatedAt.replace(/[:.]/g, '-')}.json`);
+ try {
+ await writeFile(target, `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx' });
+ } catch (error) {
+ await refuse(
+ { reason: 'report-file-unwritable', file: target, detail: String((error as Error).message) },
+ `The ceremony ran, and its report could not be written to ${target}: ${(error as Error).message}. ` +
+ 'An existing file is never overwritten; re-run --apply (it resumes, writing nothing new) with --out.',
+ );
+ return;
+ }
+ const verdictCode = report.complete && report.blocking === 0 ? 0 : 1;
+ if (json) {
+ await emitJson({ file: target, report }, verdictCode);
+ return;
+ }
+ console.log('');
+ for (const step of report.steps) {
+ const mark = step.status === 'done' || step.status === 'checkpointed' ? chalk.green('✓') : step.status === 'blocked' ? chalk.red('✗') : chalk.dim('·');
+ console.log(` ${mark} ${step.step.padEnd(22)} ${step.status.padEnd(12)} ${chalk.dim(JSON.stringify(step.summary))}`);
+ for (const blocker of step.blockers) console.log(` ${chalk.red(blocker)}`);
+ for (const note of step.notes) console.log(` ${chalk.dim(note)}`);
+ }
+ console.log('');
+ for (const table of report.tables) {
+ if (!table.before) continue;
+ if (table.attributed + table.deleted + table.unattributable.length + table.remaining === 0 && !table.columnDropped) continue;
+ console.log(
+ ` ${chalk.white(table.object.padEnd(34))} ${table.fate.padEnd(16)} attributed ${String(table.attributed).padStart(6)} ` +
+ `deleted ${String(table.deleted).padStart(6)} ${table.columnDropped ? chalk.green('column dropped ') : ''}` +
+ `${table.unattributable.length ? chalk.yellow(`reported ${table.unattributable.length} `) : ''}` +
+ `${table.remaining ? chalk.red(`remaining ${table.remaining}`) : ''}`,
+ );
+ }
+ console.log('');
+ if (report.advisory > 0) {
+ printWarning(`${report.advisory} row(s) whose owner cannot be derived stay NULL; they are listed by id in the report file.`);
+ }
+ if (verdictCode === 0) {
+ printSuccess(`The ceremony is complete; recorded as ${ORGANIZATION_OWNERSHIP_MIGRATION_ID}. Report: ${target}`);
+ } else {
+ printError(
+ report.complete
+ ? `The post-check found ${report.blocking} discrepancy(ies). Report: ${target}`
+ : `The ceremony stopped at a blocker (above). Resolve it and re-run --apply; finished steps are not repeated. Report: ${target}`,
+ );
+ }
+ console.log(chalk.dim(` ${timer.display()}`));
+ if (verdictCode !== 0) this.exit(1);
+ }
}
diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts
new file mode 100644
index 00000000000..6591616de41
--- /dev/null
+++ b/packages/cli/src/utils/organization-ownership-apply.test.ts
@@ -0,0 +1,464 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The ADR-0131 D10 ceremony's acceptance: a fixture database carrying every
+ * fate comes through `--apply` with effective access IDENTICAL (and a positive
+ * control: a grant the rewrite would lose reddens the comparison), mirrors
+ * gone, attributed rows carrying their organization, the unattributable row
+ * still present and reported, and row counts reconciling. Plus the ceremony's
+ * guarantees: idempotent, resumable from the recorded checkpoint, gated
+ * (nothing deleted while a reference does not resolve by name), and no
+ * deletion outside fate 2.
+ *
+ * The shape is the post-stage-8 one: the catalog objects are no longer
+ * registered, so every read and write here goes through the raw-SQL seam, and
+ * `permission_set_id` is a plain text column beside `permission_set`.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import Database from 'better-sqlite3';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { permissionSetDefinitionFromRow } from '@objectstack/core';
+import {
+ applyOrganizationOwnership,
+ type ApplyExecutor,
+ type CatalogPort,
+ type CeremonyCheckpoint,
+ type CeremonyLedgerPort,
+ type ColumnDropPort,
+ type MetadataDoorPort,
+ type OrganizationOwnershipApplyReport,
+} from './organization-ownership-apply.js';
+
+let dir: string;
+let file: string;
+let db: Database.Database;
+/** Every write statement the ceremony issued, in order. */
+let writes: string[];
+
+function executor(opts: { failOnceOn?: RegExp } = {}): ApplyExecutor {
+ let failed = false;
+ return {
+ client: 'better-sqlite3',
+ async query(sql, params = []) {
+ return db.prepare(sql).all(...(params as unknown[])) as Record[];
+ },
+ async execute(sql, params = []) {
+ if (opts.failOnceOn && !failed && opts.failOnceOn.test(sql)) {
+ failed = true;
+ throw new Error('SQLITE_IOERR: the process was killed mid-step');
+ }
+ writes.push(sql);
+ db.prepare(sql).run(...(params as unknown[]));
+ },
+ };
+}
+
+/**
+ * The declared catalog (code) plus the environment metadata the door saved.
+ * Like the registry, it serves a saved definition only from the next boot on
+ * ({@link boot}), never the moment it is saved.
+ */
+function catalog(): CatalogPort & MetadataDoorPort & { saved: Map>; boot(): void } {
+ const declared = new Map>([
+ ['permission|member_default', permissionSetDefinitionFromRow(MEMBER_DEFAULT_ROW)],
+ ['position|org_admin', { name: 'org_admin', label: 'Org Admin', delegatable: false, permissionSets: ['member_default'] }],
+ ]);
+ const saved = new Map>();
+ let hydrated = new Map>();
+ return {
+ saved,
+ boot() {
+ hydrated = new Map(saved);
+ },
+ async resolve(type, name) {
+ const definition = hydrated.get(`${type}|${name}`) ?? declared.get(`${type}|${name}`);
+ return definition ? { definition } : undefined;
+ },
+ async save(type, name, body) {
+ if (declared.has(`${type}|${name}`)) throw new Error(`NAMESPACE_CONFLICT: ${type} ${name} is held by a package`);
+ saved.set(`${type}|${name}`, body);
+ },
+ };
+}
+
+function ledger(): CeremonyLedgerPort & { checkpoint: CeremonyCheckpoint | null; verdicts: Array<{ complete: boolean; blocking: number; advisory: number }> } {
+ const state = {
+ checkpoint: null as CeremonyCheckpoint | null,
+ verdicts: [] as Array<{ complete: boolean; blocking: number; advisory: number }>,
+ async read() {
+ return state.checkpoint ? (JSON.parse(JSON.stringify(state.checkpoint)) as CeremonyCheckpoint) : null;
+ },
+ async write(checkpoint: CeremonyCheckpoint, verdict: { complete: boolean; blocking: number; advisory: number }) {
+ state.checkpoint = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint;
+ state.verdicts.push(verdict);
+ },
+ };
+ return state;
+}
+
+/** The schema-drift ceremony, as SQLite answers it: the column goes. */
+const columnDrops: ColumnDropPort = {
+ async drop(tables) {
+ return tables.map((table) => {
+ writes.push(`ALTER TABLE ${table} DROP COLUMN organization_id`);
+ db.exec(`ALTER TABLE "${table}" DROP COLUMN organization_id`);
+ return { table, outcome: 'dropped' as const };
+ });
+ },
+};
+
+const MEMBER_DEFAULT_ROW = {
+ name: 'member_default',
+ label: 'Member',
+ object_permissions: '{"crm_deal":{"allowRead":true}}',
+ system_permissions: '[]',
+};
+
+/** A 17.x-shaped database carrying every fate, on the post-stage-8 shape. */
+function seed(): void {
+ db.exec(`
+ CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
+ INSERT INTO sys_organization VALUES ('org_default', 'default'), ('org_b', 'b');
+
+ -- fate 2: the catalog. Per-organization mirrors and NULL residue of one
+ -- declared set and one declared position, beside Setup-authored rows.
+ CREATE TABLE sys_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT,
+ active INTEGER, object_permissions TEXT, system_permissions TEXT);
+ INSERT INTO sys_permission_set VALUES
+ ('ps_null', NULL, 'member_default', 'Member', 'platform', 1, '{"crm_deal":{"allowRead":true}}', '[]'),
+ ('ps_org', 'org_default', 'member_default', 'Member', 'package', 1, '{"crm_deal":{"allowRead":true}}', '[]'),
+ ('ps_auth', 'org_default', 'field_auditor', 'Field Auditor', 'admin', 1,
+ '{"crm_deal":{"allowRead":true,"allowEdit":true}}', '["export_reports"]');
+ CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT,
+ delegatable INTEGER, active INTEGER);
+ INSERT INTO sys_position VALUES
+ ('pos_null', NULL, 'org_admin', 'Org Admin', 'platform', 0, 1),
+ ('pos_org', 'org_default', 'org_admin', 'Org Admin', 'platform', 0, 1),
+ ('pos_auth', 'org_default', 'sales_lead', 'Sales Lead', 'admin', 1, 1);
+ CREATE TABLE sys_position_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, position_id TEXT, permission_set_id TEXT);
+ INSERT INTO sys_position_permission_set VALUES
+ ('bind_1', NULL, 'pos_null', 'ps_null'),
+ ('bind_2', 'org_default', 'pos_org', 'ps_org'),
+ ('bind_3', 'org_default', 'pos_auth', 'ps_auth');
+ CREATE TABLE sys_capability (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, managed_by TEXT);
+ INSERT INTO sys_capability VALUES ('cap_seeded', NULL, 'manage_users', 'platform'), ('cap_authored', 'org_default', 'audit_fields', 'admin');
+ -- fate 2: templates — seeded mirrors, one customized, one organization-stamped
+ CREATE TABLE sys_email_template (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, customized INTEGER, managed_by TEXT);
+ INSERT INTO sys_email_template VALUES
+ ('tpl_seeded', NULL, 'welcome', 0, 'platform'),
+ ('tpl_customized', NULL, 'reset_password', 1, 'platform'),
+ ('tpl_org', 'org_b', 'welcome', 0, 'platform');
+
+ -- the references: grants by id (the rewrite names them) and assignments by name
+ CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT,
+ permission_set_id TEXT, permission_set TEXT);
+ INSERT INTO sys_user_permission_set VALUES
+ ('grant_1', 'org_default', 'usr_1', 'ps_org', NULL),
+ ('grant_2', NULL, 'usr_2', 'ps_null', NULL),
+ ('grant_3', 'org_default', 'usr_3', 'ps_auth', NULL),
+ ('grant_4', 'org_default', 'usr_4', 'ps_null', 'member_default');
+ CREATE TABLE sys_business_unit (id TEXT PRIMARY KEY, organization_id TEXT, parent_business_unit_id TEXT);
+ INSERT INTO sys_business_unit VALUES ('bu_b', 'org_b', NULL);
+ CREATE TABLE sys_user_position (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, position TEXT, business_unit_id TEXT);
+ INSERT INTO sys_user_position VALUES
+ ('up_1', 'org_default', 'usr_5', 'sales_lead', NULL),
+ ('up_2', NULL, 'usr_6', 'org_admin', NULL);
+
+ -- fate 3: NULL sys_file rows whose sys_attachment holder carries the organization
+ CREATE TABLE crm_deal (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT);
+ INSERT INTO crm_deal VALUES ('deal_1', 'org_b', 'Acme'), ('deal_2', NULL, 'Orphan');
+ CREATE TABLE sys_attachment (id TEXT PRIMARY KEY, organization_id TEXT, file_id TEXT, parent_object TEXT, parent_id TEXT);
+ INSERT INTO sys_attachment VALUES ('att_1', NULL, 'file_1', 'crm_deal', 'deal_1');
+ CREATE TABLE sys_file (id TEXT PRIMARY KEY, organization_id TEXT, ref_object TEXT, ref_id TEXT);
+ INSERT INTO sys_file VALUES ('file_1', NULL, NULL, NULL), ('file_2', 'org_b', NULL, NULL);
+
+ -- fate 4: one genuinely unattributable row
+ CREATE TABLE sys_saved_report (id TEXT PRIMARY KEY, organization_id TEXT);
+ INSERT INTO sys_saved_report VALUES ('report_orphan', NULL);
+
+ -- fate 1: a D7 object whose column is still there
+ CREATE TABLE sys_job (id TEXT PRIMARY KEY, organization_id TEXT);
+ INSERT INTO sys_job VALUES ('job_1', NULL), ('job_2', 'org_b');
+ `);
+}
+
+const counts = (): Record => {
+ const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).all() as { name: string }[];
+ return Object.fromEntries(tables.map(({ name }) => [name, (db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get() as { n: number }).n]));
+};
+const rows = (sql: string): Record[] => db.prepare(sql).all() as Record[];
+const digestOf = (definition: Record): string => {
+ const keys = ['name', 'label', 'objects', 'fields', 'systemPermissions', 'rowLevelSecurity', 'tabPermissions'];
+ return JSON.stringify(keys.map((k) => definition[k] ?? null));
+};
+
+/**
+ * Effective access BEFORE — the 17.x reading: a grant reaches its set row by
+ * id, a position reaches its sets through its rows' bindings. Under `single`
+ * an organization-less row is the one organization's.
+ */
+function accessBefore(): string[] {
+ const out = new Set();
+ const sets = new Map(rows('SELECT * FROM sys_permission_set').map((r) => [String(r.id), r]));
+ const sees = (row: Record, org: string): boolean => row.organization_id === null || row.organization_id === org;
+ for (const grant of rows('SELECT * FROM sys_user_permission_set')) {
+ const org = String(grant.organization_id ?? 'org_default');
+ const set = sets.get(String(grant.permission_set_id));
+ if (set && sees(set, org)) out.add(`${grant.user_id}|${org}|${set.name}|${digestOf(permissionSetDefinitionFromRow(set))}`);
+ }
+ const bindings = rows('SELECT * FROM sys_position_permission_set');
+ for (const up of rows('SELECT * FROM sys_user_position')) {
+ const org = String(up.organization_id ?? 'org_default');
+ for (const position of rows('SELECT * FROM sys_position').filter((p) => p.name === up.position && sees(p, org))) {
+ for (const binding of bindings.filter((b) => b.position_id === position.id)) {
+ const set = sets.get(String(binding.permission_set_id));
+ if (set) out.add(`${up.user_id}|${org}|${set.name}|${digestOf(permissionSetDefinitionFromRow(set))}`);
+ }
+ }
+ }
+ return [...out].sort();
+}
+
+/** Effective access AFTER — the v18 reading, on the next boot: every reference is a name, resolved in the catalog. */
+async function accessAfter(c: ReturnType): Promise {
+ c.boot();
+ const out = new Set();
+ const add = async (user: unknown, org: string, name: string): Promise => {
+ const set = await c.resolve('permission', name);
+ if (set) out.add(`${user}|${org}|${name}|${digestOf(set.definition as Record)}`);
+ };
+ for (const grant of rows('SELECT * FROM sys_user_permission_set')) {
+ if (grant.permission_set) await add(grant.user_id, String(grant.organization_id), String(grant.permission_set));
+ }
+ for (const up of rows('SELECT * FROM sys_user_position')) {
+ const position = await c.resolve('position', String(up.position));
+ for (const name of (position?.definition.permissionSets as string[] | undefined) ?? []) {
+ await add(up.user_id, String(up.organization_id), name);
+ }
+ }
+ return [...out].sort();
+}
+
+const apply = (
+ c: ReturnType,
+ l: ReturnType,
+ opts: { posture?: string; executor?: ApplyExecutor } = {},
+): Promise => {
+ c.boot();
+ return applyOrganizationOwnership({
+ executor: opts.executor ?? executor(),
+ posture: opts.posture ?? 'single',
+ database: file,
+ catalog: c,
+ door: c,
+ columnDrops,
+ ledger: l,
+ now: () => new Date('2026-10-12T00:00:00Z'),
+ });
+};
+
+const tableOf = (report: OrganizationOwnershipApplyReport, object: string) => {
+ const found = report.tables.find((t) => t.object === object);
+ if (!found) throw new Error(`no ${object} in the report`);
+ return found;
+};
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'os-c7b-apply-'));
+ file = join(dir, 'fixture.db');
+ db = new Database(file);
+ writes = [];
+ seed();
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('ADR-0131 D10 --apply — the acceptance fixture', () => {
+ it('comes through with effective access identical, mirrors gone, owners written, the orphan reported, counts reconciling', async () => {
+ const before = accessBefore();
+ const countsBefore = counts();
+ const c = catalog();
+ const l = ledger();
+
+ const report = await apply(c, l);
+
+ expect(report.steps.map((s) => [s.step, s.status])).toEqual([
+ ['attribution', 'done'],
+ ['authored-conversion', 'done'],
+ ['rewrite', 'done'],
+ ['mirror-deletion', 'done'],
+ ['column-drops', 'done'],
+ ]);
+ expect(report.complete).toBe(true);
+ expect(report.blocking).toBe(0);
+ expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: 1 });
+
+ // Effective access: identical, read the 17.x way before and the v18 way after.
+ expect(before.length).toBeGreaterThan(0);
+ expect(await accessAfter(c)).toEqual(before);
+
+ // The authored catalog rows became environment metadata, through the door.
+ expect(c.saved.get('permission|field_auditor')).toMatchObject({ name: 'field_auditor', systemPermissions: ['export_reports'] });
+ expect(c.saved.get('position|sales_lead')).toMatchObject({ name: 'sales_lead', delegatable: true, permissionSets: ['field_auditor'] });
+ expect(c.saved.has('position|org_admin')).toBe(false);
+
+ // Mirrors gone; authored, customized and organization-stamped rows stay.
+ expect(rows('SELECT id FROM sys_permission_set').map((r) => r.id)).toEqual(['ps_auth']);
+ expect(rows('SELECT id FROM sys_position').map((r) => r.id)).toEqual(['pos_auth']);
+ expect(rows('SELECT id FROM sys_position_permission_set')).toEqual([]);
+ expect(rows('SELECT id FROM sys_capability').map((r) => r.id)).toEqual(['cap_authored']);
+ expect(rows('SELECT id FROM sys_email_template ORDER BY id').map((r) => r.id)).toEqual(['tpl_customized', 'tpl_org']);
+
+ // Every grant names its set; the id column is still there (C7b's later drop).
+ expect(rows('SELECT id, permission_set, permission_set_id FROM sys_user_permission_set ORDER BY id')).toEqual([
+ { id: 'grant_1', permission_set: 'member_default', permission_set_id: 'ps_org' },
+ { id: 'grant_2', permission_set: 'member_default', permission_set_id: 'ps_null' },
+ { id: 'grant_3', permission_set: 'field_auditor', permission_set_id: 'ps_auth' },
+ { id: 'grant_4', permission_set: 'member_default', permission_set_id: 'ps_null' },
+ ]);
+
+ // Attributed rows carry their organization — the anchor's, else the Default Organization.
+ expect(rows(`SELECT organization_id FROM sys_file WHERE id = 'file_1'`)).toEqual([{ organization_id: 'org_b' }]);
+ expect(rows(`SELECT organization_id FROM sys_attachment WHERE id = 'att_1'`)).toEqual([{ organization_id: 'org_b' }]);
+ expect(rows(`SELECT organization_id FROM crm_deal WHERE id = 'deal_2'`)).toEqual([{ organization_id: 'org_default' }]);
+ expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_2'`)).toEqual([{ organization_id: 'org_default' }]);
+
+ // The unattributable row is still present, still NULL, and reported by id.
+ expect(rows(`SELECT organization_id FROM sys_saved_report`)).toEqual([{ organization_id: null }]);
+ expect(tableOf(report, 'sys_saved_report').unattributable.map((u) => u.id)).toEqual(['report_orphan']);
+
+ // Fate 1: the column is gone through the column-drop port.
+ expect(tableOf(report, 'sys_job').columnDropped).toBe(true);
+ expect(rows(`SELECT name FROM pragma_table_info('sys_job')`).map((r) => r.name)).toEqual(['id']);
+
+ // Row counts reconcile: only fate 2 deletes, and exactly what it reports.
+ const countsAfter = counts();
+ for (const table of report.tables) {
+ if (!table.before || !table.after) continue;
+ expect(table.before.total - table.deleted, table.object).toBe(table.after.total);
+ expect(countsAfter[table.object], table.object).toBe(table.after.total);
+ expect(countsBefore[table.object], table.object).toBe(table.before.total);
+ if (table.fate !== 'mirror-deletion') expect(table.deleted, table.object).toBe(0);
+ }
+ expect(writes.filter((w) => /^DELETE/.test(w)).every((w) =>
+ /sys_permission_set|sys_position|sys_position_permission_set|sys_capability|sys_email_template/.test(w))).toBe(true);
+ });
+
+ it('positive control: a grant the rewrite would lose reddens the effective-access pin', async () => {
+ const before = accessBefore();
+ const c = catalog();
+ await apply(c, ledger());
+ expect(await accessAfter(c)).toEqual(before);
+
+ db.exec(`UPDATE sys_user_permission_set SET permission_set = NULL WHERE id = 'grant_3'`);
+ const after = await accessAfter(c);
+ expect(after).not.toEqual(before);
+ expect(before.filter((g) => !after.includes(g))).toEqual([expect.stringMatching(/^usr_3\|org_default\|field_auditor\|/)]);
+ });
+});
+
+describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
+ it('a second run resumes from the checkpoint and writes nothing', async () => {
+ const c = catalog();
+ const l = ledger();
+ await apply(c, l);
+ const hash = createHash('sha256').update(readFileSync(file)).digest('hex');
+ writes = [];
+
+ const again = await apply(c, l);
+
+ expect(again.resumedFrom).toEqual(['attribution', 'authored-conversion', 'rewrite', 'mirror-deletion', 'column-drops']);
+ expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true);
+ expect(again.complete).toBe(true);
+ expect(writes).toEqual([]);
+ expect(createHash('sha256').update(readFileSync(file)).digest('hex')).toBe(hash);
+ });
+
+ it('a run killed inside mirror deletion resumes there, never re-deriving an owner it already wrote', async () => {
+ const c = catalog();
+ const l = ledger();
+ await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE/ }) })).rejects.toThrow(/killed mid-step/);
+ expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'rewrite']);
+ expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 3 }]);
+ writes = [];
+
+ const resumed = await apply(c, l);
+
+ expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'rewrite']);
+ expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'done', 'done']);
+ expect(writes.some((w) => /^UPDATE/.test(w))).toBe(false);
+ expect(resumed.complete).toBe(true);
+ expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 0 }]);
+ });
+
+ it('reads the gate again before the first delete: a grant written between runs keeps every mirror', async () => {
+ const c = catalog();
+ const l = ledger();
+ await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE/ }) })).rejects.toThrow(/killed mid-step/);
+ db.exec(`INSERT INTO sys_user_permission_set VALUES ('grant_late', 'org_default', 'usr_8', 'ps_gone', NULL)`);
+ const countsBefore = counts();
+
+ const resumed = await apply(c, l);
+
+ const deletion = resumed.steps.find((s) => s.step === 'mirror-deletion')!;
+ expect(deletion.status).toBe('blocked');
+ expect(deletion.blockers.join('\n')).toMatch(/grant_late/);
+ expect(counts()).toEqual(countsBefore);
+ expect(resumed.complete).toBe(false);
+ });
+
+ it('deletes nothing while a reference does not resolve by name — the gate of fate 2', async () => {
+ // Another organization's set row: a name carries no organization, so it is never written.
+ db.exec(`
+ INSERT INTO sys_permission_set VALUES ('ps_b', 'org_b', 'b_only', 'B', 'admin', 1, '{}', '[]');
+ INSERT INTO sys_user_permission_set VALUES ('grant_x', 'org_default', 'usr_9', 'ps_b', NULL);
+ `);
+ const countsBefore = counts();
+ const l = ledger();
+
+ const report = await apply(catalog(), l);
+
+ const rewrite = report.steps.find((s) => s.step === 'rewrite')!;
+ expect(rewrite.status).toBe('blocked');
+ expect(rewrite.blockers.join('\n')).toMatch(/grant_x/);
+ expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached');
+ expect(writes.filter((w) => /^DELETE/.test(w))).toEqual([]);
+ expect(counts()).toEqual(countsBefore);
+ expect(report.complete).toBe(false);
+ expect(report.blocking).toBeGreaterThan(0);
+ expect(l.verdicts.at(-1)!.complete).toBe(false);
+ });
+
+ it('under a wall: an anchorless row is reported, never assigned, and authored rows are not converted', async () => {
+ const c = catalog();
+ const report = await apply(c, ledger(), { posture: 'isolated' });
+
+ expect(rows(`SELECT organization_id FROM crm_deal WHERE id = 'deal_2'`)).toEqual([{ organization_id: null }]);
+ expect(tableOf(report, 'crm_deal').unattributable.map((u) => u.id)).toEqual(['deal_2']);
+ expect(rows(`SELECT organization_id FROM sys_file WHERE id = 'file_1'`)).toEqual([{ organization_id: 'org_b' }]);
+ expect(c.saved.size).toBe(0);
+ expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached');
+ expect(rows('SELECT COUNT(*) AS n FROM sys_permission_set')).toEqual([{ n: 3 }]);
+ });
+
+ it('holds a column whose rows another stage owns, and reports it', async () => {
+ db.exec(`
+ CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, package_id TEXT);
+ INSERT INTO sys_metadata VALUES ('md_1', 'org_b', 'view', 'deal_list', 'active', NULL);
+ `);
+ const report = await apply(catalog(), ledger());
+
+ const drops = report.steps.find((s) => s.step === 'column-drops')!;
+ expect(drops.status).toBe('blocked');
+ expect(drops.blockers.join('\n')).toMatch(/sys_metadata\.organization_id is not dropped/);
+ expect(rows(`SELECT name FROM pragma_table_info('sys_metadata') WHERE name = 'organization_id'`)).toHaveLength(1);
+ expect(rows(`SELECT name FROM pragma_table_info('sys_job') WHERE name = 'organization_id'`)).toHaveLength(0);
+ });
+});
diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts
new file mode 100644
index 00000000000..3c8c9b6f8ba
--- /dev/null
+++ b/packages/cli/src/utils/organization-ownership-apply.ts
@@ -0,0 +1,942 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import {
+ convertPositionBindingRows,
+ permissionSetDefinitionFromRow,
+ positionDefinitionFromRow,
+} from '@objectstack/core';
+import {
+ inventoryEntryFor,
+ type InventoryEntry,
+ type RowPredicate,
+} from './organization-ownership-inventory.js';
+import {
+ buildOrganizationOwnershipPlan,
+ columnListSql,
+ columnNameOf,
+ dialectOf,
+ predicateSql,
+ Sql,
+ type AttributionSink,
+ type OrganizationOwnershipPlan,
+ type PlanReader,
+ type TablePlan,
+ type UnattributableRow,
+} from './organization-ownership-plan.js';
+
+/**
+ * The ADR-0131 D10 ceremony's apply step — ceremony items 2 to 4: the backup
+ * precondition is the command's, and this module performs the fates, in fate
+ * order, then the post-check.
+ *
+ * 1. `attribution` (fate 3) — every NULL row the plan derives an owner for
+ * takes it. The owners come from the plan's own walk
+ * ({@link AttributionSink}): the apply writes exactly what the plan
+ * reported, derived once, never a second derivation. Repeated to a fixed
+ * point, so a child whose parent was itself NULL in the first pass takes
+ * the parent's owner in the next.
+ * 2. `authored-conversion` — the catalog rows an organization authored
+ * (the inventory's `authored` categories) become environment metadata
+ * through the metadata door, by core's row→definition mappers; the
+ * position bindings are carried into the position definitions
+ * (`convertPositionBindingRows`). Only under `single` (ADR-0131 D3: a
+ * Setup catalog write is an environment metadata write under `single` and
+ * refused under a wall); under a wall the rows are reported.
+ * 3. `rewrite` — the verified id→name rewrite (D4):
+ * `sys_user_permission_set.permission_set` is named from the set row its
+ * `permission_set_id` points at, then EVERY grant is verified to name a set
+ * the security catalog resolves. The id column stays (C7b drops it).
+ * 4. `mirror-deletion` (fate 2) — gated on the rewrite's verification, which
+ * is read again immediately before the first delete. Only rows of a
+ * `mirror-deletion` object that match its mirror selector and none of its
+ * named categories are deleted.
+ * 5. `column-drops` (fate 1) — `organization_id` leaves the D7 objects
+ * through the schema-drift ceremony ({@link ColumnDropPort}), never a hand
+ * `ALTER`.
+ *
+ * Then the post-check re-runs the plan and reports, per table, what remains.
+ *
+ * ## Idempotent and resumable
+ *
+ * Every write is conditioned on the state it changes — an owner is written
+ * only where `organization_id IS NULL`, a name only where `permission_set` is
+ * empty, a delete selects only mirrors — so a second run writes nothing the
+ * first one wrote. On top of that, each step that finishes is recorded in the
+ * ceremony's checkpoint ({@link CeremonyLedgerPort}) and is not run again: a
+ * run interrupted inside step N resumes at step N. A step that stops on a
+ * blocker stops the ceremony there — fate order is the contract — and the
+ * report names the blocker and its remedy.
+ *
+ * ## What it never does
+ *
+ * ⛔ Delete a row outside fate 2. ⛔ Give an unattributable row an owner:
+ * owners come only from the plan's anchors and, under `single`, the Default
+ * 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';
+
+export const CEREMONY_STEPS = ['attribution', 'authored-conversion', 'rewrite', 'mirror-deletion', 'column-drops'] as const;
+export type CeremonyStep = (typeof CEREMONY_STEPS)[number];
+
+/** The raw-SQL seam: the plan's reader plus the one write verb. */
+export interface ApplyExecutor extends PlanReader {
+ execute(sql: string, params?: readonly unknown[]): Promise;
+}
+
+/** The security catalog read (core's `SecurityCatalogReader` satisfies it). */
+export interface CatalogPort {
+ resolve(type: 'position' | 'permission', name: string): Promise<{ definition: Readonly> } | undefined>;
+}
+
+/** The metadata door: an environment metadata save. Throws when the door refuses. */
+export interface MetadataDoorPort {
+ save(type: 'position' | 'permission', name: string, body: Record): Promise;
+}
+
+/** One table's column drop, as the schema-drift ceremony answered it. */
+export interface ColumnDropOutcome {
+ table: string;
+ outcome: 'dropped' | 'not-proposed' | 'failed';
+ detail?: string;
+}
+
+/** Drops `organization_id` on the named tables through the schema-drift ceremony (ADR-0120 D4). */
+export interface ColumnDropPort {
+ drop(tables: readonly string[]): Promise;
+}
+
+/** What the ceremony records after each finished step. */
+export interface CeremonyCheckpoint {
+ ceremony: 'ADR-0131 D10';
+ inventoryDigest: string;
+ completed: Partial }>>;
+}
+
+/** Where the checkpoint lives (the command: the `sys_migration` row). */
+export interface CeremonyLedgerPort {
+ read(): Promise;
+ write(checkpoint: CeremonyCheckpoint, verdict: { complete: boolean; blocking: number; advisory: number }): Promise;
+}
+
+export interface StepReport {
+ step: CeremonyStep;
+ /** `checkpointed`: finished by an earlier run; `not-reached`: an earlier step stopped the run. */
+ status: 'done' | 'checkpointed' | 'blocked' | 'not-reached';
+ summary: Record;
+ /** Each blocker, with its remedy, in the operator's terms. */
+ blockers: string[];
+ /** Reported, never blocking. */
+ notes: string[];
+}
+
+export interface TableOutcome {
+ object: string;
+ fate: TablePlan['fate'];
+ before: { total: number; organizationNull: number } | null;
+ after: { total: number; organizationNull: number } | null;
+ attributed: number;
+ deleted: number;
+ /** Fate 1: the column is gone after this run. */
+ columnDropped: boolean;
+ /** Rows still NULL and listed by id — reported, never guessed. */
+ unattributable: UnattributableRow[];
+ /** Post-check: what is left for this table's fate, `0` when the fate is done. */
+ remaining: number;
+ notNull: TablePlan['notNull'];
+}
+
+export interface OrganizationOwnershipApplyReport {
+ kind: 'organization-ownership-apply';
+ ceremony: 'ADR-0131 D10';
+ migrationId: typeof ORGANIZATION_OWNERSHIP_MIGRATION_ID;
+ generatedAt: string;
+ database: string;
+ posture: string;
+ inventoryDigest: string;
+ /** Steps an earlier run finished, read from the checkpoint. */
+ resumedFrom: CeremonyStep[];
+ steps: StepReport[];
+ tables: TableOutcome[];
+ /** Every step finished. */
+ complete: boolean;
+ /** Blockers (an unfinished ceremony) or post-check discrepancies (a finished one). Zero is the verdict. */
+ blocking: number;
+ /** Rows reported, never guessed: unattributable rows and rows held for another stage. */
+ advisory: number;
+}
+
+export interface ApplyOptions {
+ executor: ApplyExecutor;
+ posture: string;
+ database: string;
+ catalog: CatalogPort;
+ door: MetadataDoorPort;
+ columnDrops: ColumnDropPort;
+ ledger: CeremonyLedgerPort;
+ inventory?: readonly InventoryEntry[];
+ now?: () => Date;
+ /** One line per milestone, for the command's human face. */
+ onProgress?: (message: string) => void;
+}
+
+const ORG = 'organization_id';
+const IN_CHUNK = 200;
+const PAGE = 500;
+const MAX_ATTRIBUTION_PASSES = 8;
+
+const GRANTS = 'sys_user_permission_set';
+const SETS = 'sys_permission_set';
+const POSITIONS = 'sys_position';
+const BINDINGS = 'sys_position_permission_set';
+const CAPABILITIES = 'sys_capability';
+
+const text = (value: unknown): string | null =>
+ value === null || value === undefined || String(value) === '' ? null : String(value);
+
+/** Shared state of one run. */
+interface Run {
+ opts: ApplyOptions;
+ sql: Sql;
+ plan: OrganizationOwnershipPlan;
+ columns: Map | null>;
+ attributed: Map;
+ deleted: Map;
+ /**
+ * What the door accepted this run. The registry hydrates environment
+ * metadata at boot, so a definition saved a moment ago is not in it until
+ * the next one; the door's acceptance is what makes it resolve now.
+ */
+ saved: Map>>;
+}
+
+/** The catalog read, then what the door accepted this run. */
+async function resolveName(run: Run, type: 'position' | 'permission', name: string): Promise<{ definition: Readonly> } | undefined> {
+ const found = await run.opts.catalog.resolve(type, name);
+ if (found) return found;
+ const saved = run.saved.get(`${type}|${name}`);
+ return saved ? { definition: saved } : undefined;
+}
+
+async function saveDefinition(run: Run, type: 'position' | 'permission', name: string, body: Record): Promise {
+ await run.opts.door.save(type, name, body);
+ run.saved.set(`${type}|${name}`, body);
+}
+
+async function columnsOf(run: Run, table: string): Promise | null> {
+ if (run.columns.has(table)) return run.columns.get(table)!;
+ const present = run.plan.tables.some((t) => t.tables.includes(table));
+ let cols: Set | null = null;
+ if (present) {
+ const rows = await run.sql.read(table, columnListSql(run.sql.dialect), [table]);
+ cols = new Set(rows.map(columnNameOf).filter((c): c is string => c !== null));
+ }
+ run.columns.set(table, cols);
+ return cols;
+}
+
+/** Every row of `table` matching `where`, keyset-paged by id. */
+async function readAll(run: Run, table: string, select: readonly string[], where: { text: string; params: unknown[] } = { text: '1 = 1', params: [] }): Promise[]> {
+ const { sql } = run;
+ const t = sql.q(table, table);
+ const id = sql.q('id', table);
+ const cols = [...new Set(['id', ...select])].map((c) => `${sql.q(c, table)} AS ${sql.q(c, table)}`).join(', ');
+ const out: Record[] = [];
+ let after: unknown;
+ for (;;) {
+ const rows = await sql.read(
+ table,
+ `SELECT ${cols} FROM ${t} WHERE (${where.text})${after === undefined ? '' : ` AND ${id} > ?`} ORDER BY ${id} LIMIT ${PAGE}`,
+ after === undefined ? where.params : [...where.params, after],
+ );
+ out.push(...rows);
+ if (rows.length < PAGE) return out;
+ after = rows[rows.length - 1]!.id;
+ }
+}
+
+async function execute(run: Run, table: string, statement: string, params: readonly unknown[]): Promise {
+ try {
+ await run.opts.executor.execute(statement, params);
+ } catch (error) {
+ throw new Error(`The write to ${table} failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
+ }
+}
+
+const planFor = (run: Run, extra: { onAttribution?: AttributionSink } = {}): Promise =>
+ buildOrganizationOwnershipPlan({
+ reader: run.opts.executor,
+ posture: run.opts.posture,
+ database: run.opts.database,
+ ...(run.opts.inventory ? { inventory: run.opts.inventory } : {}),
+ ...(run.opts.now ? { now: run.opts.now() } : {}),
+ ...extra,
+ });
+
+const nullsUnderAttribution = (plan: OrganizationOwnershipPlan): number =>
+ plan.tables.filter((t) => t.fate === 'attribution').reduce((n, t) => n + (t.rows?.organizationNull ?? 0), 0);
+
+// ── Step 1: attribution ──────────────────────────────────────────────────────
+
+async function runAttribution(run: Run): Promise {
+ let passes = 0;
+ let total = 0;
+ let lastNulls = Number.POSITIVE_INFINITY;
+ for (;;) {
+ passes++;
+ let wrote = 0;
+ const plan = await planFor(run, {
+ onAttribution: async ({ object, assignments }) => {
+ const groups = new Map();
+ for (const a of assignments) {
+ const key = `${a.table}\u0000${a.organizationId}`;
+ const ids = groups.get(key) ?? [];
+ ids.push(a.id);
+ groups.set(key, ids);
+ }
+ for (const [key, ids] of groups) {
+ const [table, organizationId] = key.split('\u0000') as [string, string];
+ const t = run.sql.q(table, table);
+ for (let i = 0; i < ids.length; i += IN_CHUNK) {
+ const chunk = ids.slice(i, i + IN_CHUNK);
+ await execute(
+ run,
+ table,
+ `UPDATE ${t} SET ${run.sql.q(ORG, table)} = ? WHERE ${run.sql.q(ORG, table)} IS NULL AND ${run.sql.q('id', table)} IN (${chunk.map(() => '?').join(', ')})`,
+ [organizationId, ...chunk],
+ );
+ }
+ }
+ wrote += assignments.length;
+ run.attributed.set(object, (run.attributed.get(object) ?? 0) + assignments.length);
+ },
+ });
+ const nulls = nullsUnderAttribution(plan);
+ if (wrote === 0) break;
+ if (nulls >= lastNulls || passes >= MAX_ATTRIBUTION_PASSES) {
+ return {
+ step: 'attribution',
+ status: 'blocked',
+ summary: { rowsAttributed: total, passes },
+ blockers: [
+ `Attribution did not reach a fixed point: pass ${passes} still derived ${wrote} owner(s) while the NULL rows did ` +
+ `not decrease (${nulls}). The writes are not landing — check that the database accepts the UPDATE, then re-run.`,
+ ],
+ notes: [],
+ };
+ }
+ total += wrote;
+ lastNulls = nulls;
+ run.opts.onProgress?.(`attribution pass ${passes}: ${wrote} row(s) took their organization`);
+ }
+ return { step: 'attribution', status: 'done', summary: { rowsAttributed: total, passes }, blockers: [], notes: [] };
+}
+
+// ── Step 2: authored catalog rows → environment metadata ────────────────────
+
+/** The inventory's `authored` selector for a catalog object. */
+function authoredWhere(object: string, inventory: readonly InventoryEntry[] | undefined): RowPredicate | null {
+ const entry = inventory ? inventory.find((e) => e.object === object) : inventoryEntryFor(object);
+ return entry?.categories?.find((c) => c.id === 'authored')?.where ?? null;
+}
+
+const rowIsOff = (value: unknown): boolean => value === false || value === 0 || value === '0' || value === 'false';
+
+/** The JSON of a definition's own keys, for comparing two bodies. */
+const bodyKey = (body: Readonly>, keys: readonly string[]): string =>
+ JSON.stringify(keys.map((k) => [k, body[k] ?? null]));
+
+interface CatalogRows {
+ positions: Record[];
+ sets: Record[];
+ bindings: Record[];
+}
+
+async function readCatalogRows(run: Run): Promise {
+ const read = async (table: string, select: string[]): Promise[]> => {
+ const cols = await columnsOf(run, table);
+ if (!cols) return [];
+ return readAll(run, table, select.filter((c) => cols.has(c)));
+ };
+ return {
+ positions: await read(POSITIONS, ['name', ORG]),
+ sets: await read(SETS, ['name', ORG]),
+ bindings: await read(BINDINGS, ['position_id', 'permission_set_id']),
+ };
+}
+
+/** The `permissionSets` each catalog position definition names today, by name. */
+async function declaredPositionSets(run: Run, names: Iterable): Promise
', 1, '[]', 0, 'platform'),
+ ('tpl_customized', NULL, 'auth.reset_password', 'Reset', 'en-US', 'Reset your password', 'our code
', 1, '[]', 1, 'platform'),
+ ('tpl_org', 'org_b', 'auth.welcome', 'Welcome', 'en-US', 'Hi', 'hi
', 1, '[]', 0, 'platform'),
+ ('tpl_admin', 'org_b', 'crm.renewal', 'Renewal', 'zh-CN', '续约', '续约
', 1, '[]', 0, 'admin');
+
+ -- the settings global rung, still in sys_setting, and its new home
+ CREATE TABLE sys_setting (id TEXT PRIMARY KEY, organization_id TEXT, namespace TEXT, key TEXT, scope TEXT, user_id TEXT,
+ value TEXT, encrypted INTEGER, locked INTEGER, locked_reason TEXT, value_enc TEXT);
+ INSERT INTO sys_setting VALUES
+ ('set_global', NULL, 'mail', 'transport', 'global', NULL, '"smtp"', 0, 1, 'ops', NULL),
+ ('set_secret', NULL, 'ai', 'api_key', 'global', NULL, NULL, 1, 0, NULL, 'secret:h_1'),
+ ('set_tenant', 'org_b', 'mail', 'from', 'tenant', NULL, '"b@x.test"', 0, 0, NULL, NULL);
+ CREATE TABLE sys_platform_setting (id TEXT PRIMARY KEY, namespace TEXT, key TEXT, value TEXT, encrypted INTEGER,
+ locked INTEGER, locked_reason TEXT, value_enc TEXT);
-- the references: grants by id (the rewrite names them) and assignments by name
CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT,
@@ -255,6 +290,7 @@ const apply = (
database: file,
catalog: c,
door: c,
+ templates: c,
columnDrops,
ledger: l,
now: () => new Date('2026-10-12T00:00:00Z'),
@@ -292,6 +328,8 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => {
expect(report.steps.map((s) => [s.step, s.status])).toEqual([
['attribution', 'done'],
['authored-conversion', 'done'],
+ ['template-promotion', 'done'],
+ ['settings-move', 'done'],
['rewrite', 'done'],
['mirror-deletion', 'done'],
['column-drops', 'done'],
@@ -314,7 +352,19 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => {
expect(rows('SELECT id FROM sys_position').map((r) => r.id)).toEqual(['pos_auth']);
expect(rows('SELECT id FROM sys_position_permission_set')).toEqual([]);
expect(rows('SELECT id FROM sys_capability').map((r) => r.id)).toEqual(['cap_authored']);
- expect(rows('SELECT id FROM sys_email_template ORDER BY id').map((r) => r.id)).toEqual(['tpl_customized', 'tpl_org']);
+ expect(rows('SELECT id FROM sys_email_template')).toEqual([]);
+
+ // Ruling C: the customized and the authored template became environment
+ // items before any row went; the seeded copies were not promoted.
+ expect([...c.templates.keys()].sort()).toEqual(['auth.reset_password|en-US', 'crm.renewal|zh-CN']);
+ expect(c.templates.get('auth.reset_password|en-US')).toMatchObject({ subject: 'Reset your password', bodyHtml: 'our code
' });
+
+ // The global rung moved to sys_platform_setting, value and secret handle verbatim; the tenant row stayed.
+ expect(rows('SELECT id, scope FROM sys_setting')).toEqual([{ id: 'set_tenant', scope: 'tenant' }]);
+ expect(rows('SELECT * FROM sys_platform_setting ORDER BY id')).toEqual([
+ { id: 'set_global', namespace: 'mail', key: 'transport', value: '"smtp"', encrypted: 0, locked: 1, locked_reason: 'ops', value_enc: null },
+ { id: 'set_secret', namespace: 'ai', key: 'api_key', value: null, encrypted: 1, locked: 0, locked_reason: null, value_enc: 'secret:h_1' },
+ ]);
// Every grant names its set; the id column is still there (C7b's later drop).
expect(rows('SELECT id, permission_set, permission_set_id FROM sys_user_permission_set ORDER BY id')).toEqual([
@@ -342,13 +392,16 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => {
const countsAfter = counts();
for (const table of report.tables) {
if (!table.before || !table.after) continue;
- expect(table.before.total - table.deleted, table.object).toBe(table.after.total);
+ expect(table.before.total - table.deleted - table.movedOut + table.movedIn, table.object).toBe(table.after.total);
expect(countsAfter[table.object], table.object).toBe(table.after.total);
expect(countsBefore[table.object], table.object).toBe(table.before.total);
if (table.fate !== 'mirror-deletion') expect(table.deleted, table.object).toBe(0);
}
+ expect(tableOf(report, 'sys_setting').movedOut).toBe(2);
+ expect(tableOf(report, 'sys_platform_setting').movedIn).toBe(2);
+ // Every delete is fate 2's, or the moved rows leaving sys_setting.
expect(writes.filter((w) => /^DELETE/.test(w)).every((w) =>
- /sys_permission_set|sys_position|sys_position_permission_set|sys_capability|sys_email_template/.test(w))).toBe(true);
+ /sys_permission_set|sys_position|sys_position_permission_set|sys_capability|sys_email_template|"sys_setting" WHERE \("scope" = \?\)/.test(w))).toBe(true);
});
it('positive control: a grant the rewrite would lose reddens the effective-access pin', async () => {
@@ -374,7 +427,9 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
const again = await apply(c, l);
- expect(again.resumedFrom).toEqual(['attribution', 'authored-conversion', 'rewrite', 'mirror-deletion', 'column-drops']);
+ expect(again.resumedFrom).toEqual([
+ 'attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'column-drops',
+ ]);
expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true);
expect(again.complete).toBe(true);
expect(writes).toEqual([]);
@@ -384,15 +439,15 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
it('a run killed inside mirror deletion resumes there, never re-deriving an owner it already wrote', async () => {
const c = catalog();
const l = ledger();
- await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE/ }) })).rejects.toThrow(/killed mid-step/);
- expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'rewrite']);
+ await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/);
+ expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite']);
expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 3 }]);
writes = [];
const resumed = await apply(c, l);
- expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'rewrite']);
- expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'done', 'done']);
+ expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite']);
+ expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'done', 'done']);
expect(writes.some((w) => /^UPDATE/.test(w))).toBe(false);
expect(resumed.complete).toBe(true);
expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 0 }]);
@@ -401,7 +456,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
it('reads the gate again before the first delete: a grant written between runs keeps every mirror', async () => {
const c = catalog();
const l = ledger();
- await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE/ }) })).rejects.toThrow(/killed mid-step/);
+ await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/);
db.exec(`INSERT INTO sys_user_permission_set VALUES ('grant_late', 'org_default', 'usr_8', 'ps_gone', NULL)`);
const countsBefore = counts();
@@ -414,6 +469,30 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
expect(resumed.complete).toBe(false);
});
+ it('moves no setting over a different value the new table already serves, and deletes nothing for it', async () => {
+ db.exec(`INSERT INTO sys_platform_setting VALUES ('ps_1', 'mail', 'transport', '"ses"', 0, 0, NULL, NULL)`);
+ const report = await apply(catalog(), ledger());
+
+ const move = report.steps.find((s) => s.step === 'settings-move')!;
+ expect(move.status).toBe('blocked');
+ expect(move.blockers.join('\n')).toMatch(/mail\.transport/);
+ expect(rows(`SELECT id FROM sys_setting WHERE scope = 'global' ORDER BY id`).map((r) => r.id)).toEqual(['set_global']);
+ expect(rows(`SELECT value FROM sys_platform_setting WHERE key = 'transport'`)).toEqual([{ value: '"ses"' }]);
+ expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached');
+ });
+
+ it('never overwrites a template the environment already holds, and keeps every template row', async () => {
+ const c = catalog();
+ c.templates.set('crm.renewal|zh-CN', { name: 'crm.renewal', label: 'Renewal', locale: 'zh-CN', subject: '新的', bodyHtml: '新的
' });
+ const report = await apply(c, ledger());
+
+ const promotion = report.steps.find((s) => s.step === 'template-promotion')!;
+ expect(promotion.status).toBe('blocked');
+ expect(promotion.blockers.join('\n')).toMatch(/crm\.renewal \(zh-CN\)/);
+ expect(c.templates.get('crm.renewal|zh-CN')).toMatchObject({ subject: '新的' });
+ expect(rows('SELECT COUNT(*) AS n FROM sys_email_template')).toEqual([{ n: 4 }]);
+ });
+
it('deletes nothing while a reference does not resolve by name — the gate of fate 2', async () => {
// Another organization's set row: a name carries no organization, so it is never written.
db.exec(`
@@ -429,8 +508,11 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
expect(rewrite.status).toBe('blocked');
expect(rewrite.blockers.join('\n')).toMatch(/grant_x/);
expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached');
- expect(writes.filter((w) => /^DELETE/.test(w))).toEqual([]);
- expect(counts()).toEqual(countsBefore);
+ // The only rows that left are the settings rung, which moved before the rewrite.
+ expect(writes.filter((w) => /^DELETE/.test(w) && !/^DELETE FROM "sys_setting"/.test(w))).toEqual([]);
+ const { sys_setting: _s, sys_platform_setting: _p, ...after } = counts();
+ const { sys_setting: _sb, sys_platform_setting: _pb, ...before } = countsBefore;
+ expect(after).toEqual(before);
expect(report.complete).toBe(false);
expect(report.blocking).toBeGreaterThan(0);
expect(l.verdicts.at(-1)!.complete).toBe(false);
@@ -446,6 +528,8 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
expect(c.saved.size).toBe(0);
expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached');
expect(rows('SELECT COUNT(*) AS n FROM sys_permission_set')).toEqual([{ n: 3 }]);
+ expect(c.templates.size).toBe(0);
+ expect(rows('SELECT COUNT(*) AS n FROM sys_email_template')).toEqual([{ n: 4 }]);
});
it('holds a column whose rows another stage owns, and reports it', async () => {
diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts
index 3c8c9b6f8ba..bdc1f4bf3cc 100644
--- a/packages/cli/src/utils/organization-ownership-apply.ts
+++ b/packages/cli/src/utils/organization-ownership-apply.ts
@@ -2,6 +2,7 @@
import {
convertPositionBindingRows,
+ emailTemplateDefinitionFromRow,
permissionSetDefinitionFromRow,
positionDefinitionFromRow,
} from '@objectstack/core';
@@ -42,6 +43,17 @@ import {
* (`convertPositionBindingRows`). Only under `single` (ADR-0131 D3: a
* Setup catalog write is an environment metadata write under `single` and
* refused under a wall); under a wall the rows are reported.
+ * 2b. `template-promotion` — ruling C on ADR-0131 §6 Q1: every
+ * `sys_email_template` row an organization customized or authored becomes
+ * an environment `email_template` item through the metadata door (core's
+ * `emailTemplateDefinitionFromRow`), so the deletion that follows loses no
+ * customer edit. Under `single` only; under a wall one organization's edit
+ * would become every organization's, so the rows are reported and kept.
+ * 2c. `settings-move` — the inventory's `global-rung-move` departure,
+ * executed (#15207's ruling: "C7's ceremony moving the existing rows"):
+ * each `sys_setting` row of `scope = 'global'` is copied, by the database
+ * itself, into `sys_platform_setting` and then removed from `sys_setting`.
+ * A key the new table already holds with a different value is a blocker.
* 3. `rewrite` — the verified id→name rewrite (D4):
* `sys_user_permission_set.permission_set` is named from the set row its
* `permission_set_id` points at, then EVERY grant is verified to name a set
@@ -77,7 +89,15 @@ import {
/** The `sys_migration` row the ceremony checkpoints into and completes. */
export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership';
-export const CEREMONY_STEPS = ['attribution', 'authored-conversion', 'rewrite', 'mirror-deletion', 'column-drops'] as const;
+export const CEREMONY_STEPS = [
+ 'attribution',
+ 'authored-conversion',
+ 'template-promotion',
+ 'settings-move',
+ 'rewrite',
+ 'mirror-deletion',
+ 'column-drops',
+] as const;
export type CeremonyStep = (typeof CEREMONY_STEPS)[number];
/** The raw-SQL seam: the plan's reader plus the one write verb. */
@@ -92,7 +112,12 @@ export interface CatalogPort {
/** The metadata door: an environment metadata save. Throws when the door refuses. */
export interface MetadataDoorPort {
- save(type: 'position' | 'permission', name: string, body: Record): Promise;
+ save(type: 'position' | 'permission' | 'email_template', name: string, body: Record): Promise;
+}
+
+/** The `email_template` item the registry serves for one `(name, locale)` bundle member. */
+export interface TemplatePort {
+ find(name: string, locale: string): Promise<{ definition: Readonly>; environment: boolean } | undefined>;
}
/** One table's column drop, as the schema-drift ceremony answered it. */
@@ -138,6 +163,9 @@ export interface TableOutcome {
after: { total: number; organizationNull: number } | null;
attributed: number;
deleted: number;
+ /** Rows a `moved` departure carried out of this table, and into it. */
+ movedOut: number;
+ movedIn: number;
/** Fate 1: the column is gone after this run. */
columnDropped: boolean;
/** Rows still NULL and listed by id — reported, never guessed. */
@@ -173,6 +201,7 @@ export interface ApplyOptions {
database: string;
catalog: CatalogPort;
door: MetadataDoorPort;
+ templates: TemplatePort;
columnDrops: ColumnDropPort;
ledger: CeremonyLedgerPort;
inventory?: readonly InventoryEntry[];
@@ -191,6 +220,48 @@ const SETS = 'sys_permission_set';
const POSITIONS = 'sys_position';
const BINDINGS = 'sys_position_permission_set';
const CAPABILITIES = 'sys_capability';
+const TEMPLATES = 'sys_email_template';
+const SETTINGS = 'sys_setting';
+const PLATFORM_SETTINGS = 'sys_platform_setting';
+
+/** Provenance the seeders stamped: such a row is a copy of code unless an admin customized it. */
+const SEEDED_BY = ['package', 'platform', 'system'];
+
+/**
+ * The `sys_email_template` rows the promotion carries: customized seeded rows
+ * and every row an admin authored. A seeded, uncustomized row — NULL residue or
+ * an organization's copy — is a copy of code and is not promoted.
+ */
+const templatePromotionSql = (sql: Sql, cols: ReadonlySet): { text: string; params: unknown[] } => {
+ const t = TEMPLATES;
+ const customized = cols.has('customized') ? `${sql.q('customized', t)} = ?` : '1 = 0';
+ const params: unknown[] = cols.has('customized') ? [sql.bind(true)] : [];
+ if (!cols.has('managed_by')) return { text: '1 = 1', params: [] };
+ const managed = sql.q('managed_by', t);
+ return {
+ text: `${customized} OR ${managed} IS NULL OR ${managed} NOT IN (${SEEDED_BY.map(() => '?').join(', ')})`,
+ params: [...params, ...SEEDED_BY],
+ };
+};
+
+/**
+ * Categories of a fate-2 object that the promotion discharges: once it has
+ * run (under `single`), the rows they claim have their environment copy and
+ * are mirrors like the rest.
+ */
+const DISCHARGED_BY_PROMOTION: Readonly> = {
+ [TEMPLATES]: ['template-promotion', 'organization-stamped'],
+};
+
+/** The `moved` departures the ceremony executes, by departure id. */
+const MOVES: Readonly> = {
+ 'global-rung-move': {
+ from: SETTINGS,
+ to: PLATFORM_SETTINGS,
+ key: ['namespace', 'key'],
+ compare: ['value', 'value_enc', 'encrypted', 'locked', 'locked_reason'],
+ },
+};
const text = (value: unknown): string | null =>
value === null || value === undefined || String(value) === '' ? null : String(value);
@@ -203,6 +274,8 @@ interface Run {
columns: Map | null>;
attributed: Map;
deleted: Map;
+ movedOut: Map;
+ movedIn: Map;
/**
* What the door accepted this run. The registry hydrates environment
* metadata at boot, so a definition saved a moment ago is not in it until
@@ -535,6 +608,159 @@ async function runAuthoredConversion(run: Run): Promise {
return { step: 'authored-conversion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes };
}
+// ── Step 2b: template promotion (ruling C) ──────────────────────────────────
+
+async function runTemplatePromotion(run: Run): Promise {
+ const summary: Record = { promoted: 0, alreadyPromoted: 0, reported: 0 };
+ const blockers: string[] = [];
+ const notes: string[] = [];
+ const cols = await columnsOf(run, TEMPLATES);
+ if (!cols) return { step: 'template-promotion', status: 'done', summary, blockers, notes };
+ const rows = await readAll(run, TEMPLATES, [...cols], templatePromotionSql(run.sql, cols));
+ if (rows.length === 0) return { step: 'template-promotion', status: 'done', summary, blockers, notes };
+ if (run.opts.posture !== 'single') {
+ summary.reported = rows.length;
+ notes.push(
+ `${rows.length} customized or authored ${TEMPLATES} row(s) are reported and kept, not promoted: under the ` +
+ `${run.opts.posture} posture an environment template would carry one organization's edit to every organization.`,
+ );
+ return { step: 'template-promotion', status: 'done', summary, blockers, notes };
+ }
+ const groups = new Map[]; bodies: Record[] }>();
+ for (const row of rows) {
+ const body = emailTemplateDefinitionFromRow(row);
+ const name = text(body.name);
+ if (!name) {
+ summary.reported++;
+ notes.push(`A ${TEMPLATES} row (${String(row.id)}) carries no name and is reported, never promoted.`);
+ continue;
+ }
+ const locale = String(body.locale);
+ const key = `${name}\u0000${locale}`;
+ const group = groups.get(key) ?? { name, locale, rows: [], bodies: [] };
+ group.rows.push(row);
+ group.bodies.push(body);
+ groups.set(key, group);
+ }
+ for (const { name, locale, rows: group, bodies } of groups.values()) {
+ const keys = [...new Set(bodies.flatMap(Object.keys))];
+ if (new Set(bodies.map((b) => bodyKey(b, keys))).size > 1) {
+ blockers.push(
+ `The template ${name} (${locale}) is held by ${group.length} customized or authored rows ` +
+ `(${group.map((r) => String(r.id)).join(', ')}) that differ. Which edit the environment keeps is never ` +
+ 'guessed: make them agree, or delete all but one, then re-run.',
+ );
+ continue;
+ }
+ const body = bodies[0]!;
+ const existing = await run.opts.templates.find(name, locale);
+ const saved = run.saved.get(`email_template|${name}|${locale}`);
+ const current = saved ?? existing?.definition;
+ if (current && bodyKey(current, Object.keys(body)) === bodyKey(body, Object.keys(body))) {
+ summary.alreadyPromoted++;
+ continue;
+ }
+ if (existing?.environment && !saved) {
+ blockers.push(
+ `The environment already holds its own template ${name} (${locale}), and it differs from the customized row(s) ` +
+ `${group.map((r) => String(r.id)).join(', ')}. The environment's edit is never overwritten: carry the ` +
+ 'row\'s change into it in Studio (or delete the row), then re-run.',
+ );
+ continue;
+ }
+ try {
+ await run.opts.door.save('email_template', name, body);
+ run.saved.set(`email_template|${name}|${locale}`, body);
+ summary.promoted++;
+ run.opts.onProgress?.(`promoted the template ${name} (${locale}) to environment metadata`);
+ } catch (error) {
+ blockers.push(
+ `The metadata door refused the template ${name} (${locale}): ${error instanceof Error ? error.message : String(error)}. ` +
+ 'Correct the row so the definition validates, then re-run.',
+ );
+ }
+ }
+ return { step: 'template-promotion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes };
+}
+
+// ── Step 2c: the settings global rung moves (D7) ────────────────────────────
+
+async function runSettingsMove(run: Run): Promise {
+ const summary: Record = { moved: 0, alreadyMoved: 0 };
+ const blockers: string[] = [];
+ for (const entry of run.opts.inventory ?? [inventoryEntryFor(SETTINGS)!]) {
+ for (const departure of entry?.departures ?? []) {
+ const move = MOVES[departure.id];
+ if (departure.as !== 'moved' || !move || entry.object !== move.from) continue;
+ const fromCols = await columnsOf(run, move.from);
+ if (!fromCols) continue;
+ const where = predicateSql(run.sql, move.from, fromCols, departure.where);
+ const rows = await readAll(run, move.from, [...move.key, ...move.compare].filter((c) => fromCols.has(c)), where);
+ if (rows.length === 0) continue;
+ const toCols = await columnsOf(run, move.to);
+ if (!toCols) {
+ blockers.push(
+ `${rows.length} ${move.from} row(s) move to ${move.to}, which this database does not have. Boot this release ` +
+ 'against it once (its schema sync creates the table), then re-run.',
+ );
+ continue;
+ }
+ const same = (a: Record, b: Record): boolean =>
+ move.compare.every((c) => JSON.stringify(a[c] ?? null) === JSON.stringify(b[c] ?? null));
+ const keyOf = (r: Record): string => move.key.map((k) => String(r[k])).join('.');
+ const byKey = new Map[]>();
+ for (const row of rows) byKey.set(keyOf(row), [...(byKey.get(keyOf(row)) ?? []), row]);
+ const shared = [...fromCols].filter((c) => toCols.has(c));
+ const sql = run.sql;
+ for (const [key, group] of byKey) {
+ if (group.some((r) => !same(r, group[0]!))) {
+ blockers.push(
+ `${move.from} holds ${group.length} differing ${departure.id} rows for ${key} (${group.map((r) => String(r.id)).join(', ')}). ` +
+ 'Which value the deployment keeps is never guessed: delete all but one, then re-run.',
+ );
+ continue;
+ }
+ const target = (await readAll(run, move.to, move.compare.filter((c) => toCols.has(c)), {
+ text: move.key.map((k) => `${sql.q(k, move.to)} = ?`).join(' AND '),
+ params: move.key.map((k) => group[0]![k]),
+ }));
+ if (target.length > 0 && !same(target[0]!, group[0]!)) {
+ blockers.push(
+ `${move.to} already holds ${key} with a different value — the one the runtime serves — and the ${move.from} ` +
+ `row(s) ${group.map((r) => String(r.id)).join(', ')} hold another. Delete the stale ${move.from} row(s) or ` +
+ 'align them, then re-run.',
+ );
+ continue;
+ }
+ if (target.length === 0) {
+ const list = shared.map((c) => sql.q(c, move.to)).join(', ');
+ await execute(
+ run,
+ move.to,
+ `INSERT INTO ${sql.q(move.to, move.to)} (${list}) SELECT ${shared.map((c) => sql.q(c, move.from)).join(', ')} ` +
+ `FROM ${sql.q(move.from, move.from)} WHERE ${sql.q('id', move.from)} = ?`,
+ [group[0]!.id],
+ );
+ summary.moved++;
+ run.movedIn.set(move.to, (run.movedIn.get(move.to) ?? 0) + 1);
+ } else {
+ summary.alreadyMoved++;
+ }
+ const ids = group.map((r) => r.id);
+ await execute(
+ run,
+ move.from,
+ `DELETE FROM ${sql.q(move.from, move.from)} WHERE (${where.text}) AND ${sql.q('id', move.from)} IN (${ids.map(() => '?').join(', ')})`,
+ [...where.params, ...ids],
+ );
+ run.movedOut.set(move.from, (run.movedOut.get(move.from) ?? 0) + ids.length);
+ }
+ if (summary.moved) run.opts.onProgress?.(`settings move: ${summary.moved} ${departure.id} key(s) now in ${move.to}`);
+ }
+ }
+ return { step: 'settings-move', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes: [] };
+}
+
// ── Step 3: the verified id→name rewrite (D4) ───────────────────────────────
interface Verification {
@@ -700,8 +926,17 @@ function deletableWhere(run: Run, table: string, cols: ReadonlySet, entr
parts.push(`(${mirror.text})`);
params.push(...mirror.params);
}
+ // The promotion runs under `single` only; elsewhere its rows were not
+ // carried anywhere, so they stay, whatever category claims them or not.
+ const promoted = run.opts.posture === 'single';
+ const discharged = promoted ? DISCHARGED_BY_PROMOTION[entry.object] ?? [] : [];
+ if (!promoted && entry.object === TEMPLATES) {
+ const kept = templatePromotionSql(run.sql, cols);
+ parts.push(`(CASE WHEN ${kept.text} THEN 1 ELSE 0 END) = 0`);
+ params.push(...kept.params);
+ }
for (const category of entry.categories ?? []) {
- if (!category.where || category.conflictBy) continue;
+ if (!category.where || category.conflictBy || discharged.includes(category.id)) continue;
const claimed = predicateSql(run.sql, table, cols, category.where);
parts.push(`(CASE WHEN ${claimed.text} THEN 1 ELSE 0 END) = 0`);
params.push(...claimed.params);
@@ -796,6 +1031,8 @@ async function runColumnDrops(run: Run): Promise {
const RUNNERS: Record Promise> = {
attribution: runAttribution,
'authored-conversion': runAuthoredConversion,
+ 'template-promotion': runTemplatePromotion,
+ 'settings-move': runSettingsMove,
rewrite: runRewrite,
'mirror-deletion': runMirrorDeletion,
'column-drops': runColumnDrops,
@@ -806,7 +1043,8 @@ async function remainingFor(run: Run, table: TablePlan, complete: boolean): Prom
if (table.physical !== 'present' || !table.rows) return 0;
switch (table.fate) {
case 'attribution':
- return Number(table.fateCounts.rowsToAttribute ?? 0);
+ return Number(table.fateCounts.rowsToAttribute ?? 0)
+ + table.departures.filter((d) => d.as === 'moved' && MOVES[d.id]).reduce((n, d) => n + d.rows, 0);
case 'mirror-deletion': {
const entry = mirrorEntry(run, table.object);
if (!entry) return 0;
@@ -839,6 +1077,8 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise