Skip to content
Merged
37 changes: 37 additions & 0 deletions .changeset/15211-cli-apply-reviewed-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/cli': minor
'@objectstack/spec': patch
---

feat(cli)!: `os migrate organization-ownership --apply` executes only the plan the operator reviewed (ADR-0131 D10)

Clause-②: yes (widening)

<!-- adr-0087: registered cli-organization-ownership-apply-plan-file-required -->

**BREAKING** (a narrowing of `--apply`'s accept set, released in 18.0.0-next.1), shipped as `minor` under the launch-window convention for breaking changes. It also widens the command: a new flag, `--plan-file`, new refusal reasons, and two new keys on the apply report.

`--apply` now requires `--plan-file FILE`, the plan file a plan run wrote. Before it writes anything, it compares that file with the plan it computes, and refuses when any of these differs:

- **The inventory digest** (`plan-digest-mismatch`). The fates it would run are not the ones the reviewed plan showed: a different `--fate-supplement`, or another release's inventory. Without this check, planning with supplement A and applying with supplement B ran B's fates unreviewed, including a mirror deletion A never listed.
- **The tenancy posture** (`plan-posture-mismatch`). The plan file records the posture it was computed under (`OS_TENANCY_POSTURE` / `OS_MULTI_ORG_ENABLED`), and the posture decides which rows attribution gives an owner and which it reports. A plan reviewed under a wall, which lists rows as reported, would otherwise give them owners when applied under `single`.
- **The database** (`plan-database-mismatch`).

The refusal names both values and the remedy: run the plan again with the same flags (or run the apply under the plan's posture), review it, and pass the new file. No flag skips the comparison.

A file that is not an organization-ownership plan, or that lacks its digest, posture or database, or cannot be read, is refused as `plan-file-unusable`. `--apply` without the flag is refused as `plan-file-required`. Both refusals come before the backup question and before the database is opened.

A checkpoint recorded under another digest is no longer discarded silently. The reviewed plan supersedes it: every step runs again (each is idempotent), and the apply report names the discarded checkpoint in `restartedFrom`, beside `reviewedPlan`.

## FROM → TO

| invocation | FROM (18.0.0-next.1) | TO |
|:--|:--|:--|
| `os migrate organization-ownership --apply --backup-taken` | performed the plan it computed at apply time | refused, `plan-file-required`: run `os migrate organization-ownership --out plan.json`, review it, then `--apply --backup-taken --plan-file plan.json` |
| `os migrate organization-ownership --fate-supplement host-fates.json --apply --backup-taken` | performed the plan it computed with that supplement | refused, `plan-file-required`: plan with the same `--fate-supplement`, then add `--plan-file plan.json` |
| `--apply … --plan-file plan.json`, where the file was planned with another supplement, release, posture or database | (no such flag) | refused with `plan-digest-mismatch`, `plan-posture-mismatch` or `plan-database-mismatch`, naming both values |

```bash
os migrate organization-ownership --out plan.json # plan, then review plan.json
os migrate organization-ownership --apply --plan-file plan.json --backup-taken # perform exactly that plan
```
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
* `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.
* nothing without `--apply`, and is refused there too. Before the backup
* question, `--apply` requires the plan file the operator reviewed
* (`--plan-file`), readable as a plan; the digest comparison itself is pinned
* in `organization-ownership-apply.test.ts`.
*/

import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const boot = vi.hoisted(() => vi.fn(async () => {
Expand All @@ -28,6 +34,20 @@ const CASE_TIMEOUT_MS = 60_000;

const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));

const PLAN_DIR = mkdtempSync(join(tmpdir(), 'os-c7b-plan-file-'));
/** A plan file as a plan run writes it (the fields the apply reads). */
const PLAN_FILE = join(PLAN_DIR, 'plan.json');
writeFileSync(PLAN_FILE, JSON.stringify({
kind: 'organization-ownership-plan',
ceremony: 'ADR-0131 D10',
generatedAt: '2026-10-14T00:00:00Z',
database: 'fixture.db',
inventoryDigest: 'f'.repeat(64),
posture: 'single',
}));
const NOT_A_PLAN = join(PLAN_DIR, 'report.json');
writeFileSync(NOT_A_PLAN, JSON.stringify({ kind: 'organization-ownership-apply' }));

async function runJson(argv: string[]): Promise<{ payload: Record<string, unknown>; exitCode: unknown }> {
const chunks: string[] = [];
const savedExit = process.exitCode;
Expand All @@ -53,9 +73,39 @@ afterEach(() => {
boot.mockClear();
});

describe('os migrate organization-ownership --apply — the reviewed plan file', () => {
it('refuses --apply without --plan-file, before the backup question and the boot', async () => {
const { payload, exitCode } = await runJson(['--apply', '--backup-taken', '--json']);
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'plan-file-required' });
expect(exitCode).toBe(1);
expect(boot).not.toHaveBeenCalled();
}, CASE_TIMEOUT_MS);

it('refuses a --plan-file that is not a plan, naming it, before the boot', async () => {
const { payload, exitCode } = await runJson(['--apply', '--plan-file', NOT_A_PLAN, '--backup-taken', '--json']);
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'plan-file-unusable', file: NOT_A_PLAN });
expect(exitCode).toBe(1);
expect(boot).not.toHaveBeenCalled();
}, CASE_TIMEOUT_MS);

it('refuses a --plan-file that does not exist, before the boot', async () => {
const missing = join(PLAN_DIR, 'missing.json');
const { payload } = await runJson(['--apply', '--plan-file', missing, '--backup-taken', '--json']);
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'plan-file-unusable', file: missing });
expect(boot).not.toHaveBeenCalled();
}, CASE_TIMEOUT_MS);

it('refuses --plan-file without --apply, before the boot', async () => {
const { payload, exitCode } = await runJson(['--plan-file', PLAN_FILE, '--json']);
expect(payload).toMatchObject({ error: 'plan_refused', reason: 'flag-without-apply' });
expect(exitCode).toBe(1);
expect(boot).not.toHaveBeenCalled();
}, CASE_TIMEOUT_MS);
});

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']);
const { payload, exitCode } = await runJson(['--apply', '--plan-file', PLAN_FILE, '--json']);
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'backup-unacknowledged' });
expect(exitCode).toBe(1);
expect(boot).not.toHaveBeenCalled();
Expand All @@ -69,7 +119,7 @@ describe('os migrate organization-ownership --apply — the backup precondition'
}, CASE_TIMEOUT_MS);

it('with --backup-taken, goes on to the boot', async () => {
const { payload } = await runJson(['--apply', '--backup-taken', '--json']);
const { payload } = await runJson(['--apply', '--plan-file', PLAN_FILE, '--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 })]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,23 @@ afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

/**
* A plan file as a plan run writes it: `--apply` executes only a reviewed plan.
* The apply step is mocked here, so only the file's parse runs.
*/
function writePlan(): string {
const file = join(dir, 'reviewed-plan.json');
writeFileSync(file, JSON.stringify({
kind: 'organization-ownership-plan',
ceremony: 'ADR-0131 D10',
generatedAt: '2026-10-13T00:00:00.000Z',
database: 'fixture.db',
inventoryDigest: 'f'.repeat(64),
posture: 'single',
}));
return file;
}

function writeSupplement(rows: unknown): string {
const file = join(dir, 'host-fates.json');
writeFileSync(file, JSON.stringify(rows));
Expand Down Expand Up @@ -144,20 +161,36 @@ describe('the plan reads the host fate supplement through --fate-supplement', ()
describe('--apply hands the merged inventory to the apply step', () => {
it('with --fate-supplement, the apply step receives the host row', async () => {
const file = writeSupplement([{ object: 'sys_domain', fate: 'report', citation: CITE }]);
const { payload } = await runJson(['--apply', '--backup-taken', '--fate-supplement', file, ...out('apply.json')]);
const { payload } = await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), '--fate-supplement', file, ...out('apply.json')]);
expect(payload).toMatchObject({ report: { kind: 'organization-ownership-apply' } });
expect(apply).toHaveBeenCalledTimes(1);
const inventory = (apply.mock.calls[0]![0] as { inventory?: Array<Record<string, unknown>> }).inventory;
expect(inventory?.find((e) => e.object === 'sys_domain')).toMatchObject({ census: ['host-supplement'], fate: 'report' });
}, CASE_TIMEOUT_MS);

it('control: without it, the apply step is handed no inventory and reads the platform\'s', async () => {
await runJson(['--apply', '--backup-taken', ...out('apply.json')]);
await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), ...out('apply.json')]);
expect(apply).toHaveBeenCalledTimes(1);
expect(apply.mock.calls[0]![0]).not.toHaveProperty('inventory');
}, CASE_TIMEOUT_MS);
});

describe('a refusal of the reviewed plan raised after the boot', () => {
it('reaches the operator as apply_refused with its reason and both values, never as a resumable failure', async () => {
const { ReviewedPlanRefusal } = await import('../../utils/organization-ownership-apply.js');
apply.mockImplementation(async () => {
throw new ReviewedPlanRefusal('plan-posture-mismatch', 'computed under isolated, this run resolves single', {
file: 'reviewed-plan.json',
reviewed: 'isolated',
current: 'single',
});
});
const { payload, exitCode } = await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), ...out('apply.json')]);
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'plan-posture-mismatch', reviewed: 'isolated', current: 'single' });
expect(exitCode).toBe(1);
}, CASE_TIMEOUT_MS);
});

describe('a supplement it cannot use is refused before the database is opened, inside the --json envelope', () => {
it('a missing file (plan)', async () => {
const file = join(dir, 'absent.json');
Expand Down
72 changes: 65 additions & 7 deletions packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { writeFile } from 'node:fs/promises';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { createInterface } from 'node:readline';
import { Command, Flags } from '@oclif/core';
Expand Down Expand Up @@ -34,11 +34,14 @@ import {
applyOrganizationOwnership,
checkpointFromDetails,
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
ReviewedPlanRefusal,
reviewedPlanFromFile,
TemplateChoiceRefusal,
type ApplyExecutor,
type CeremonyLedgerPort,
type ColumnDropOutcome,
type OrganizationOwnershipApplyReport,
type ReviewedPlan,
} from '../../utils/organization-ownership-apply.js';

/** Who the ceremony's metadata saves name as their actor. */
Expand Down Expand Up @@ -87,9 +90,11 @@ async function confirm(question: string): Promise<boolean> {
*
* ## `--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
* The writing mode. It reads the reviewed plan file first (`--plan-file`, the
* file a plan run wrote; without it the run is refused before the boot), then
* asks about the backup — 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 authored catalog conversion,
* the template promotion, the settings global-rung move, the verified id→name
* rewrite, mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
Expand All @@ -100,6 +105,17 @@ async function confirm(question: string): Promise<boolean> {
* `verified_at` when the post-check also reads zero discrepancies. A report
* file is written beside the plan's, never over an existing one.
*
* ## `--plan-file FILE` (the plan the operator reviewed)
*
* `--apply` requires the plan file a plan run wrote, and executes only that
* plan: it refuses, before anything is written, when the inventory digest it
* computes differs from the file's (a different `--fate-supplement`, another
* release) or the file names another database. Both digests and the remedy —
* plan again, review, hand the new file in — are named. A checkpoint recorded
* under another digest is superseded by the reviewed plan: the run starts over
* and its report says so (`restartedFrom`). There is no flag that skips the
* comparison.
*
* ## `--fate-supplement FILE` (the host's own objects, ADR-0131 D10)
*
* The inventory holds the platform's fates. An object a HOST provides — a
Expand Down Expand Up @@ -128,15 +144,15 @@ export default class MigrateOrganizationOwnership extends Command {
'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 authored catalog conversion, the template promotion, the settings move, the ' +
'(the reviewed plan file in --plan-file, and a backup), only that plan: attribution, the authored catalog conversion, the template promotion, the settings move, 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',
'$ os migrate organization-ownership --apply --plan-file plans/v18-ownership.json --backup-taken',
'$ os migrate organization-ownership --fate-supplement host-fates.json',
];

Expand All @@ -158,6 +174,12 @@ export default class MigrateOrganizationOwnership extends Command {
'post-check. Idempotent; an interrupted run resumes from its recorded checkpoint. Requires a backup',
default: false,
}),
'plan-file': Flags.string({
description:
'With --apply (required): the plan file a plan run wrote and you reviewed. The run refuses, before any write, ' +
'when its inventory digest differs from that file\'s (another --fate-supplement, another release) or the file ' +
'names another database',
}),
'backup-taken': Flags.boolean({
description:
'With --apply: state that a backup of this database has been taken. The platform takes none and assumes ' +
Expand Down Expand Up @@ -235,6 +257,10 @@ export default class MigrateOrganizationOwnership extends Command {
await refuse({ reason: 'flag-without-apply', detail: '--template-choice is only meaningful with --apply' }, '--template-choice is only meaningful with --apply.');
return;
}
if (flags['plan-file']) {
await refuse({ reason: 'flag-without-apply', detail: '--plan-file is only meaningful with --apply' }, '--plan-file is only meaningful with --apply.');
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;
Expand Down Expand Up @@ -349,13 +375,40 @@ export default class MigrateOrganizationOwnership extends Command {

/** `--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; 'template-choice'?: string[] },
flags: {
'database-url'?: string;
out?: string;
json?: boolean;
'backup-taken'?: boolean;
'template-choice'?: string[];
'plan-file'?: string;
},
posture: string,
refuse: (payload: Record<string, unknown>, message: string) => Promise<void>,
inventory: readonly InventoryEntry[] | undefined,
): Promise<void> {
const json = Boolean(flags.json);
const timer = createTimer();
// The reviewed plan, read before the backup question and the boot.
if (!flags['plan-file']) {
await refuse(
{ reason: 'plan-file-required', detail: 'pass --plan-file with the plan file you reviewed' },
'Refused: --apply executes only a plan you reviewed. Run the plan first (os migrate organization-ownership, with ' +
'the same --fate-supplement), review the file it writes, then re-run --apply with --plan-file FILE.',
);
return;
}
let reviewedPlan: ReviewedPlan;
const planFile = resolve(flags['plan-file']);
try {
reviewedPlan = reviewedPlanFromFile(planFile, await readFile(planFile, 'utf8'));
} catch (error) {
const refusal = error instanceof ReviewedPlanRefusal
? error
: new ReviewedPlanRefusal('plan-file-unusable', `The plan file ${planFile} cannot be read: ${(error as Error).message}.`, { file: planFile });
await refuse({ reason: refusal.reason, ...refusal.detail, detail: refusal.message }, `Refused: ${refusal.message}`);
return;
}
if (!flags['backup-taken']) {
const target = flags['database-url'] ?? process.env.OS_DATABASE_URL ?? 'the project database';
const yes = !json && (await confirm(
Expand Down Expand Up @@ -522,11 +575,16 @@ export default class MigrateOrganizationOwnership extends Command {
},
},
ledger,
reviewedPlan,
...(inventory ? { inventory } : {}),
...(json ? {} : { onProgress: (message: string) => printStep(message) }),
});
} catch (error: unknown) {
if (isExitSignal(error)) throw error;
if (error instanceof ReviewedPlanRefusal) {
await refuse({ reason: error.reason, ...error.detail, detail: error.message }, `Refused: ${error.message}`);
return;
}
if (error instanceof TemplateChoiceRefusal) {
await refuse({ reason: 'template-choice-refused', detail: error.message }, `Refused: ${error.message}`);
return;
Expand Down
Loading
Loading