Skip to content

Commit 0555b80

Browse files
committed
feat(cli): os migrate organization-ownership --apply — the ADR-0131 D10 ceremony's apply step and post-check
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 <noreply@anthropic.com>
1 parent 9432bac commit 0555b80

9 files changed

Lines changed: 1863 additions & 28 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: yes (widening)
8+
9+
The command that planned the ceremony now performs it. Without `--apply` nothing changes: the read-only plan, written to a file. With `--apply`:
10+
11+
- **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.
12+
- **The fates run in order**, each step stopping the run where it meets a blocker:
13+
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.
14+
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`.
15+
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.
16+
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.
17+
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.
18+
- **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.
19+
- **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.
20+
- A JSON report is written beside the plan (`organization-ownership-apply-TIMESTAMP.json`, or `--out`); an existing file is never overwritten.
21+
22+
```bash
23+
os migrate organization-ownership --out plan.json # read the plan first
24+
os migrate organization-ownership --apply --backup-taken # then perform it
25+
```
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate organization-ownership --apply` asks about the backup before it
5+
* opens the database (ADR-0131 D10 ceremony item 2): without `--backup-taken`
6+
* a non-interactive run is refused, and the boot never happens. The flag means
7+
* nothing without `--apply`, and is refused there too.
8+
*/
9+
10+
import { afterEach, describe, expect, it, vi } from 'vitest';
11+
import { fileURLToPath } from 'node:url';
12+
13+
const boot = vi.hoisted(() => vi.fn(async () => {
14+
throw new Error('the database was opened');
15+
}));
16+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
17+
18+
const { default: MigrateOrganizationOwnership } = await import('./organization-ownership.js');
19+
20+
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
21+
22+
async function runJson(argv: string[]): Promise<{ payload: Record<string, unknown>; exitCode: unknown }> {
23+
const chunks: string[] = [];
24+
const savedExit = process.exitCode;
25+
vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
26+
chunks.push(String(chunk));
27+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
28+
if (cb) cb();
29+
return true;
30+
}) as typeof process.stdout.write);
31+
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
32+
try {
33+
await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
34+
} catch (error) {
35+
if ((error as { oclif?: unknown }).oclif === undefined) throw error;
36+
}
37+
const exitCode = process.exitCode;
38+
process.exitCode = savedExit;
39+
vi.restoreAllMocks();
40+
return { payload: JSON.parse(chunks.join('').trim()) as Record<string, unknown>, exitCode };
41+
}
42+
43+
afterEach(() => {
44+
boot.mockClear();
45+
});
46+
47+
describe('os migrate organization-ownership --apply — the backup precondition', () => {
48+
it('refuses a non-interactive --apply without --backup-taken, before the boot', async () => {
49+
const { payload, exitCode } = await runJson(['--apply', '--json']);
50+
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'backup-unacknowledged' });
51+
expect(exitCode).toBe(1);
52+
expect(boot).not.toHaveBeenCalled();
53+
});
54+
55+
it('refuses --backup-taken without --apply, before the boot', async () => {
56+
const { payload, exitCode } = await runJson(['--backup-taken', '--json']);
57+
expect(payload).toMatchObject({ error: 'plan_refused', reason: 'flag-without-apply' });
58+
expect(exitCode).toBe(1);
59+
expect(boot).not.toHaveBeenCalled();
60+
});
61+
62+
it('with --backup-taken, goes on to the boot', async () => {
63+
const { payload } = await runJson(['--apply', '--backup-taken', '--json']);
64+
expect(payload).toMatchObject({ error: 'boot_failed', reason: 'boot-failed' });
65+
expect(boot).toHaveBeenCalledTimes(1);
66+
expect(boot.mock.calls[0]).toEqual([expect.objectContaining({ deferSchemaDdl: true, composeHostStack: true })]);
67+
});
68+
});

0 commit comments

Comments
 (0)