Skip to content

Commit 2808069

Browse files
feat(cli): os migrate organization-ownership --apply — the ADR-0131 D10 ceremony (C7b, PR 2 of 3) (#23034)
Part of #22937 Clause-②: yes PR 2 of 3 for the C7b build-ahead card: `os migrate organization-ownership --apply`, the operator ceremony of ADR-0131 D10 (ceremony items 2 to 4). PR 1 (the core mappers, #22959) is on `main`. PR 3 (the template promotion and the `sys_setting` global-rung move, the maintainer's Q3 and Q4 rulings) opens off `main` after this one lands, and closes the card. #15211 remains open: its boot refusal is not built here (see "Decisions" below). Stage 8 is on `main` (`66949b7729`), and CI here checks the merge ref that carries it. The contract-tier review went FAIL first (6147223040: B1, a deletion outside fate 2 on `sys_email_template`; B2, a post-check that certified while departures were unexecuted). Both are fixed in `8b615d0f9c`, and the delta review is PASS (6150575317). ## What it does `os migrate organization-ownership` keeps its read-only plan as the default. `--apply` is the new writing mode: - **Backup first** (D10 item 2). It needs `--backup-taken` or a yes at the prompt. A non-interactive run without the flag is refused before the boot. `--backup-taken` without `--apply` is refused. - **The fates, in order.** The run stops at the first step that meets a blocker, and the report names the blocker and its remedy. 1. `attribution` (fate 3). The plan's own walk derives the owners: parent, subject or holder anchors, and under `single` the Default Organization. The apply hands the walk a sink (`AttributionSink`), so it writes exactly the owners the plan reports and never runs a second derivation. It repeats to a fixed point, so a child whose parent was NULL in pass 1 takes the parent's owner in pass 2. ⛔ No other owner is ever written: an unattributable row stays NULL and stays listed by id. 2. `authored-conversion`. The inventory's `authored` rows of `sys_permission_set` and `sys_position` become environment metadata through the metadata door (`protocol.saveMetaItem`). The bodies come from core's `permissionSetDefinitionFromRow` / `positionDefinitionFromRow`, and the position bindings are carried into `permissionSets` (`convertPositionBindingRows`). This runs under `single` only (ADR-0131 D3). Under a wall the rows are reported. The step blocks on: two rows of one name that define it differently; a name the catalog already holds with a different body; a door refusal; organizations binding different sets to one position. Deactivated authored rows are reported and not converted: converting one would make it grant. Authored `sys_capability` rows are fate 4, reported (seat answer 6110777865). 3. `rewrite` (D4), verified. Each unnamed `sys_user_permission_set` grant gets the name of the set row its `permission_set_id` points at. Three cases are never named: a grant pointing at another organization's set row (a name carries no organization), a dangling id, and a name the catalog does not resolve. Then EVERY grant is verified: it carries a name, the security catalog resolves that name, and the name agrees with the row its id points at. Every binding row's sets are verified to be in its position's definition. `permission_set_id` and its indexes stay (C7b's later drop, E3). 4. `mirror-deletion` (fate 2). It is gated on that verification, which is read again just before the first delete, so a grant written between runs keeps every mirror. A row is deleted only when it is in a `mirror-deletion` object, matches the object's mirror selector, and no named category of the object claims it. On a table that carries `managed_by`, a row whose provenance is not `package`, `platform` or `system` is also kept: that is the catalog objects' authored boundary, and it holds for templates too, NULL provenance included. So the seeded catalog copies (per organization and NULL residue), all binding rows, seeded capabilities and seeded uncustomized templates go. Authored rows stay, and so do admin-authored, customized and organization-stamped templates. 5. `column-drops` (fate 1). `organization_id` leaves the D7 objects through the schema-drift ceremony: the `drop_column` entries `detectManagedDrift()` proposes, applied with `applyMigrationEntries(…, { allowDestructive: true })`, which is `os migrate apply`'s own machinery and never a hand `ALTER`. A column-drop table with rows in a named category (the `sys_metadata` promotion categories) keeps its column, and the run says so. A table the drift differ does not propose is reported as "the declaration still carries the column". - **Idempotent and resumable.** Every write is conditioned on the state it changes: - `… WHERE organization_id IS NULL AND id IN (…)`; - `… AND (permission_set IS NULL OR permission_set = '')`; - the deletes select only mirrors. Each finished step is checkpointed in `sys_migration` (row `adr-0131-organization-ownership`, `details.checkpoint`, through `recordDataMigrationRun`). A re-run skips finished steps and resumes at the interrupted one. A checkpoint from a different inventory digest is discarded, and every step runs again; each step is idempotent. - **Post-check.** The plan is read again, and each table reports what remains for its fate. The ledger row is the ceremony's marker: `applied_at` once every step has finished, and `verified_at` once the post-check also reads zero discrepancies. A departure whose step has not run yet (the `sys_setting` global-rung rows, the declared sharing-rule mirrors) counts as remaining. Each table's report names it with its row count, and `verified_at` stays null until it has left. The command exits 1 while the ceremony is unfinished or has a discrepancy. A JSON report file is written (`--out`, never overwritten). - **Boot.** The apply boot composes the served object set (`composeHostStack` + `composeServedPlatform` + `composeAuthGatedSecurity`), so the catalog it verifies against is the one the deployment serves, and the column drops are that set's drift. It still holds the boot's own DDL back (`deferSchemaDdl`). ## Files (9, +1,927 / −29; 1,956 changed lines, generated included: none) - `packages/cli/src/utils/organization-ownership-apply.ts`: new, the ceremony. - `packages/cli/src/utils/organization-ownership-plan.ts`: the attribution sink (`AttributionAssignment`, `AttributionSink`, `onAttribution`). It also exports `Sql`, `predicateSql`, `requireColumn`, `columnListSql` and `columnNameOf` for the apply. The plan's own run passes no sink and stays SELECT-only. - `packages/cli/src/commands/migrate/organization-ownership.ts`: `--apply`, `--backup-taken`, the ports and the report. - `packages/cli/src/utils/organization-ownership-apply.test.ts`: new, the acceptance fixture (9 cases). - `packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts`: new, the backup precondition (3 cases). - `packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts`: the family pin gains the `--apply` write mode, which it previously recorded as missing. - `packages/cli/src/utils/schema-migrate.ts`, `packages/cli/test/json-stdout-purity.e2e.test.ts`: doc comments naming the new caller. - `.changeset/22937-cli-organization-ownership-apply.md`: `@objectstack/cli` minor, `Clause-②: yes (widening)`. ⛔ Untouched: `packages/core/src/security/` (read only), `packages/plugins/plugin-security/**`, `organization-ownership-inventory.ts` and its test (PR #22993 and PR #22962 edit them), and every D / S8-T / S8 file. ## Acceptance (#15211), measured The fixture database `organization-ownership-apply.test.ts` carries every fate the card lists, on the post-stage-8 physical shape: the catalog tables are read and written through raw SQL only, and `permission_set_id` is a plain text column. It holds: - per-organization mirrors and NULL residue of one declared set and one declared position, with grants (by id) and bindings pointing at them; - Setup-authored sets, positions and capabilities; - seeded, customized and organization-stamped templates; - NULL `sys_file` rows with a `sys_attachment` holder; - an application object's NULL row; - a D7 table with its column; - one genuinely unattributable row (`sys_saved_report`, fate 4). After `--apply`: - **Effective access is identical.** It is read the 17.x way before (grant → set row by id, position → binding rows) and the v18 way after (grant → name → catalog, position → definition `permissionSets`). - **Positive control:** after the run, un-naming one grant reddens the comparison, naming exactly that grant's user and set. - **Mirrors are gone.** Authored, customized and organization-stamped rows stay. - **Attributed rows carry their organization.** The anchor's organization wins: `org_b` through the holder, not the Default Organization. - **The unattributable row is still present, still NULL, and reported by id.** - **Row counts reconcile:** for every table, before − deleted = after, and only fate-2 objects delete. Guarantee cases, measured: - a second run writes nothing, and the database file is byte-identical; - a run killed inside mirror deletion resumes there and issues no `UPDATE`; - a cross-organization grant blocks the rewrite, and nothing is deleted; - a grant written between runs blocks the re-read gate, and nothing is deleted; - under a wall, an anchorless row stays NULL and authored rows are not converted; - a column-drop table with promotion-category rows keeps its column. **Ablations**, through `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty: - Dropping the category exclusion from the delete predicate reddens the acceptance case: the customized template is deleted. - Disabling the pre-delete gate re-read reddens the between-runs case. **End to end on today's `main` composition.** A smoke run used the built CLI against a SQLite database whose schema `os migrate apply` had created, plus 17.x rows: - the door wrote the authored set to `sys_metadata`, and it resolved within the run; - both grants were named and verified; - `sys_job.organization_id` was dropped through the drift machinery; - the `sys_metadata` family was correctly reported as not proposed, because its declarations on `main` still carry the column; - the second run resumed from the `sys_migration` checkpoint. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 derived. All were run with exit codes captured before any pipe. `--ran` reconciliation: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - Three first runs exited 3 on prerequisites, and each was re-run to exit 0 after the prerequisite was met: - `check-plugin-teardown-shape --self-test`: shallow clone; the pinned fixture commit was fetched. - `check:dual-build-cjs-loads`: unbuilt sibling dists; they were built under the verify lock. - `check:i18n-coverage`: re-run green. - `pnpm --filter @objectstack/cli typecheck` (tsc + test-typecheck): exit 0. - vitest. First at HEAD `0555b80a`, then at `aa6bc6d9` after the merge of `main`, where `apply-flags` loads the command in `beforeAll` with 60 s per case (on the merged tree its first case took 5.4 s). 5 files, 52 tests passed: - `organization-ownership-apply.test.ts`; - `organization-ownership.apply-flags.test.ts`; - `organization-ownership-plan.integration.test.ts`; - `organization-ownership-inventory.test.ts`; - `vitest-tiers-partition.test.ts`. - `schema-migrate.one-shot-family.integration.test.ts -t "organization-ownership|every module"`: 6 passed. - On a scratch tree with S8 merged (`aa6bc6d9` + #23222's head `23e450a6`, never pushed), report 6150092087: the full `@objectstack/cli` suite, unit and integration, as CI shards it; the 66 gates; and an end-to-end `--apply` from `examples/app-crm` against the four unregistered catalog tables. All green. - `8b615d0f9c` (B1, B2): CI's full Test Core on the merge ref with `main` is green. - NOT MEASURED: `test/json-stdout-purity.e2e.test.ts`. The edit there is a comment only, and the file is in the nightly e2e tier, outside both local projects. ## Decisions (settled by the four axes; also in the report's `open_questions`) 1. **The boot refusal is not built here.** Its parts live outside `packages/cli`: - the fresh-database attestation is `CREATION_ATTESTED_MIGRATION_IDS` in `@objectstack/spec/system`, plus `attestFreshDatastore` in `@objectstack/platform-objects`; - the refusal itself would sit at the serving boot (`serve.ts`, and the runtime for hosts that do not boot through it). Landing it before C8 would refuse every existing database on `main` before the ceremony can complete there. This PR writes the marker the refusal will read: the `sys_migration` row, through the existing `readDataMigrationFlag` / `isDataMigrationFlagVerified` contract. The refusal belongs with C8 (or a follow-up C7b card) and names that row. 2. **The fate-2 gate is literal:** every grant must name a set the catalog resolves. A named grant whose set no longer resolves blocks mirror deletion too, even though deleting mirrors would not change what it grants. The remedy is printed per name. 3. **Not performed here, reported instead:** - admin-authored, customized and organization-stamped template rows are kept. Their promotion is PR 3's (#22937, the maintainer's Q3 ruling); - declared sharing-rule mirrors (`sys_sharing_rule` departure) are kept, because rule-sourced `sys_record_share` rows reference rules by `source_id`. Their follow-up step rides D2; - the `sys_setting` global-rung move is not performed here. It is PR 3's (Q4). None of these is deleted or attributed. The two departures count as remaining, so the marker is not verified until they have left. 5. **Known limit on today's `main`: column-drops stops on the `sys_metadata` family.** `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` still declare `organization_id`, so the drift differ proposes no drop. `--apply` exits 1 there, names the remedy, and leaves the marker unapplied. The family's declared no-column schema is #15211's stage, and it lands before C8 (seat answer 6150126995, pointer 6150140797). 4. **One checkpoint and marker row.** It reuses the deployment ledger (`sys_migration`, `recordDataMigrationRun`) rather than ADR-0119's chunk journal. The journal is engine-bound, and stage 8 unregisters the catalog objects this ceremony must still read and delete. ## Acceptance notes - #14570 (`sys_business_unit_member` org-less rows) and #15086 (the NULL-organization seeded business unit) are absorbed. Both objects are fate 3 in the inventory (parent anchors `business_unit_id` → `sys_business_unit` and `parent_business_unit_id`), and both fall back to the Default Organization under `single`. `--apply` writes those owners. #14570 and #15086 remain open for their own closure. - Stage 8 dependency: the ceremony never reads the catalog objects through the engine, so S8 unregistering them, and S8's `Field.text` redeclaration of `permission_set_id`, change nothing it does. Both shapes are covered: the post-S8 shape in the unit fixture, today's `main` in the smoke run. --- _Generated by [Claude Code](https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c7cfbf commit 2808069

9 files changed

Lines changed: 1927 additions & 29 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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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, beforeAll, 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+
type Command = (typeof import('./organization-ownership.js'))['default'];
19+
let MigrateOrganizationOwnership: Command;
20+
21+
// The command's module graph is large, and oclif reads the package's config on
22+
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
23+
beforeAll(async () => {
24+
({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
25+
}, 60_000);
26+
27+
const CASE_TIMEOUT_MS = 60_000;
28+
29+
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
30+
31+
async function runJson(argv: string[]): Promise<{ payload: Record<string, unknown>; exitCode: unknown }> {
32+
const chunks: string[] = [];
33+
const savedExit = process.exitCode;
34+
vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
35+
chunks.push(String(chunk));
36+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
37+
if (cb) cb();
38+
return true;
39+
}) as typeof process.stdout.write);
40+
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
41+
try {
42+
await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
43+
} catch (error) {
44+
if ((error as { oclif?: unknown }).oclif === undefined) throw error;
45+
}
46+
const exitCode = process.exitCode;
47+
process.exitCode = savedExit;
48+
vi.restoreAllMocks();
49+
return { payload: JSON.parse(chunks.join('').trim()) as Record<string, unknown>, exitCode };
50+
}
51+
52+
afterEach(() => {
53+
boot.mockClear();
54+
});
55+
56+
describe('os migrate organization-ownership --apply — the backup precondition', () => {
57+
it('refuses a non-interactive --apply without --backup-taken, before the boot', async () => {
58+
const { payload, exitCode } = await runJson(['--apply', '--json']);
59+
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'backup-unacknowledged' });
60+
expect(exitCode).toBe(1);
61+
expect(boot).not.toHaveBeenCalled();
62+
}, CASE_TIMEOUT_MS);
63+
64+
it('refuses --backup-taken without --apply, before the boot', async () => {
65+
const { payload, exitCode } = await runJson(['--backup-taken', '--json']);
66+
expect(payload).toMatchObject({ error: 'plan_refused', reason: 'flag-without-apply' });
67+
expect(exitCode).toBe(1);
68+
expect(boot).not.toHaveBeenCalled();
69+
}, CASE_TIMEOUT_MS);
70+
71+
it('with --backup-taken, goes on to the boot', async () => {
72+
const { payload } = await runJson(['--apply', '--backup-taken', '--json']);
73+
expect(payload).toMatchObject({ error: 'boot_failed', reason: 'boot-failed' });
74+
expect(boot).toHaveBeenCalledTimes(1);
75+
expect(boot.mock.calls[0]).toEqual([expect.objectContaining({ deferSchemaDdl: true, composeHostStack: true })]);
76+
}, CASE_TIMEOUT_MS);
77+
});

0 commit comments

Comments
 (0)