Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/22617-cli-organization-ownership-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/cli': minor
---

feat(cli): `os migrate organization-ownership` — the read-only plan of the v18 organization-ownership ceremony (ADR-0131 D10, ceremony item 1)

Clause-②: yes (widening)

A new command beside the `os migrate` family. It reads the database and writes a plan file the operator keeps; it never changes a row or a column, and it has no writing mode yet.

- **Per table:** the object's fate from the ADR-0131 D10 inventory (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation, the row counts that fate touches, the rows whose owner cannot be derived (listed by id, with the reason), and whether the table will receive the `NOT NULL` constraint.
- **Ruled populations are counted by name:** the organization-scoped `sys_metadata` rows that the ceremony promotes, with their conflict list; the customized email templates; and the global settings rung that moves to `sys_platform_setting`.
- **Refuses, naming the table:** a table it cannot enumerate. That covers an unsupported driver, a read that fails, a column the fate needs that the table lacks, a platform table the inventory gives no fate, a name it cannot quote, and a database with no `sys_organization`. ⛔ It never reports such a table as empty. An inventoried object with no table on the database is listed as `absent`.
- **Under the `single` posture,** a NULL row that no anchor derives is attributed to the Default Organization (slug `default`). Under a walled posture it is reported.

```bash
os migrate organization-ownership # writes organization-ownership-plan-TIMESTAMP.json
os migrate organization-ownership --out plan.json --json
```

The plan file is never overwritten. Nothing about bare `os migrate` (the schema-drift plan) or any other subcommand changes.
202 changes: 202 additions & 0 deletions packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { resolveTenancyPosture } from '@objectstack/types';
import {
printHeader,
printSuccess,
printError,
printInfo,
printStep,
printWarning,
createTimer,
emitJson,
errorCodeFields,
isExitSignal,
} from '../../utils/format.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { resolvePlannedDriverExec } from '../../utils/unmanaged-tables.js';
import {
buildOrganizationOwnershipPlan,
OrganizationOwnershipPlanRefusal,
type OrganizationOwnershipPlan,
} from '../../utils/organization-ownership-plan.js';

/**
* `os migrate organization-ownership` — the v18 organization-ownership
* ceremony's preflight (ADR-0131 D10, ceremony item 1): per table, the fate,
* the row counts each fate will touch, the rows whose owner cannot be derived
* (listed by id), and the tables that will and will not receive NOT NULL.
*
* ## Read-only, by construction
*
* The boot is the family's read-only boot (`deferSchemaDdl` + `readOnlyProbe`:
* no DDL, no seed, no database file brought into existence), and the plan
* itself issues SELECT statements only (`../../utils/organization-ownership-plan.ts`).
* The one thing it writes is the plan FILE, on the operator's disk, which the
* ADR asks for: "the plan is written to a file the operator keeps". It never
* overwrites one — a kept plan is evidence.
*
* ## Refusal
*
* A table the plan cannot enumerate (a dialect with no catalog statement, a
* read that fails, a column its fate reads that the table lacks, a
* platform table the inventory names no fate for, a database that is not an
* ObjectStack one) refuses the whole plan, naming it — exit 1 and no file. ⛔
* Never a table reported as empty.
*
* ## Why a subcommand, not a flag on `os migrate`
*
* 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.
*/
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.';

static override examples = [
'$ os migrate organization-ownership',
'$ os migrate organization-ownership --out plans/v18-ownership.json',
'$ os migrate organization-ownership --database-url postgres://… --json',
];

static override flags = {
'database-url': Flags.string({
description: 'Database URL to plan (defaults to $OS_DATABASE_URL / the project DB)',
env: 'OS_DATABASE_URL',
}),
out: Flags.string({
description:
'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' }),
};

async run(): Promise<void> {
const { flags } = await this.parse(MigrateOrganizationOwnership);
const timer = createTimer();
const json = Boolean(flags.json);
const refuse = async (payload: Record<string, unknown>, message: string): Promise<void> => {
if (json) {
await emitJson({ error: payload.reason === 'boot-failed' ? 'boot_failed' : 'plan_refused', ...payload }, 1, { compact: true });
return;
}
printError(message);
this.exit(1);
};

let posture: string;
try {
posture = resolveTenancyPosture();
} catch (error) {
await refuse({ reason: 'posture-unresolved', detail: String((error as Error).message) }, (error as Error).message);
return;
}

if (!json) {
printHeader('Migrate · organization-ownership (plan)');
printStep('Booting data stack (read-only)…');
}

let stack;
try {
stack = await bootSchemaStack({
jsonOutput: json,
...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
deferSchemaDdl: true,
readOnlyProbe: true,
});
} catch (error: unknown) {
await refuse({ reason: 'boot-failed', detail: String((error as Error)?.message ?? error) }, String((error as Error)?.message ?? error));
return;
}

let plan: OrganizationOwnershipPlan;
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, so no table can be enumerated. The ceremony supports SQLite, PostgreSQL and MySQL.',
);
return;
}
const { normalizeRows } = await import('@objectstack/metadata-protocol');
const client = (stack.driver.config as { client?: unknown } | undefined)?.client;
plan = await buildOrganizationOwnershipPlan({
reader: {
client: client === undefined ? undefined : String(client),
query: async (sql, params) => normalizeRows(await exec(sql, params ? [...params] : [])),
},
posture,
database: stack.dbLabel,
});
} 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: 'plan-failed', detail: String((error as Error)?.message ?? error), ...errorCodeFields(error) },
String((error as Error)?.message ?? error),
);
return;
} finally {
await stack.shutdown();
}

const target = resolve(flags.out ?? `organization-ownership-plan-${plan.generatedAt.replace(/[:.]/g, '-')}.json`);
try {
await writeFile(target, `${JSON.stringify(plan, null, 2)}\n`, { flag: 'wx' });
} catch (error) {
await refuse(
{ reason: 'plan-file-unwritable', file: target, detail: String((error as Error).message) },
`The plan could not be written to ${target}: ${(error as Error).message}. An existing plan file is never overwritten.`,
);
return;
}

if (json) {
await emitJson({ file: target, plan });
return;
}
printInfo(`Database: ${chalk.white(plan.database)} · posture ${chalk.white(plan.posture)} · ` +
`Default Organization ${plan.defaultOrganization ? chalk.white(plan.defaultOrganization.id) : chalk.yellow('none')}`);
console.log('');
for (const table of plan.tables) {
if (table.physical === 'absent') continue;
const unattributable = table.unattributable.length;
console.log(
` ${chalk.white(table.object.padEnd(34))} ${table.fate.padEnd(16)} ` +
`rows ${String(table.rows?.total ?? 0).padStart(7)} null ${String(table.rows?.organizationNull ?? 0).padStart(6)} ` +
`${unattributable > 0 ? chalk.yellow(`unattributable ${unattributable}`) : ''}` +
`${table.notNull.willReceive ? chalk.green(' NOT NULL') : ''}`,
);
}
console.log('');
const { summary } = plan;
printInfo(
`${summary.present} table(s) planned, ${summary.absent} inventoried object(s) with no table here; ` +
`${summary.notNull.willReceive.length} will receive NOT NULL, ${summary.notNull.willNotReceive.length} will not.`,
);
if (summary.unattributableRows > 0) {
printWarning(`${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id in the plan file.`);
}
printSuccess(`Plan written to ${target} — nothing in the database was changed.`);
console.log(chalk.dim(` ${timer.display()}`));
}
}
137 changes: 137 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The ADR-0131 D10 inventory's enumeration pin: every object of both #13564
* censuses, of the platform registry as it stands, and of the cloud supplement
* carries exactly one fate and a citation. A platform object that lands
* without an inventory row turns this red by name — the plan would refuse its
* table on a real database, and this says so before any database does.
*/

import { describe, expect, it } from 'vitest';
import { CLOUD_PROVIDED_OBJECT_NAMES, PLATFORM_OBJECTS_BY_PACKAGE } from '@objectstack/spec/system';
import {
ORGANIZATION_OWNERSHIP_FATES,
ORGANIZATION_OWNERSHIP_INVENTORY,
inventoryEntryFor,
} from './organization-ownership-inventory.js';

/**
* The first census's platform population — 59 carrying the column, 25 not —
* as #13564 comment 5479883784 lists it (measured at `00d8f6541b`). Frozen:
* it is a record of what was counted, not of the tree.
*/
const CENSUS_2026_08_31_PLATFORM_WITH_COLUMN = [
// accidental (7) + global (1)
'sys_file', 'sys_upload_session', 'sys_approval_request', 'sys_approval_action', 'sys_approval_approver',
'sys_automation_run', 'sys_notification_delivery', 'sys_permission_set',
// load-bearing confirmed beyond the ledger
'sys_metadata', 'sys_view_definition',
// split-verdict (7)
'sys_position', 'sys_business_unit', 'sys_business_unit_member', 'sys_user_position',
'sys_position_permission_set', 'sys_user_permission_set', 'sys_capability',
// structural siblings (3)
'sys_metadata_audit', 'sys_metadata_commit', 'sys_metadata_history',
// not individually examined (39)
'sys_activity', 'sys_approval_delegation', 'sys_approval_token', 'sys_attachment',
'sys_audience_binding_suggestion', 'sys_audit_log', 'sys_comment', 'sys_email', 'sys_email_template',
'sys_flow_dispatch', 'sys_http_delivery', 'sys_import_job', 'sys_inbox_message', 'sys_invitation', 'sys_job',
'sys_job_queue', 'sys_job_run', 'sys_member', 'sys_metadata_activation', 'sys_migration', 'sys_migration_journal',
'sys_notification', 'sys_notification_preference', 'sys_notification_receipt', 'sys_notification_subscription',
'sys_notification_template', 'sys_presence', 'sys_record_share', 'sys_report_schedule', 'sys_saved_report',
'sys_scim_connection_credential', 'sys_secret', 'sys_setting', 'sys_setting_audit', 'sys_share_link',
'sys_sharing_rule', 'sys_team', 'sys_user_preference', 'sys_webhook',
] as const;

const CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN = [
'sys_account', 'sys_api_key', 'sys_device_code', 'sys_jwks', 'sys_oauth_access_token', 'sys_oauth_application',
'sys_oauth_client_assertion', 'sys_oauth_client_resource', 'sys_oauth_consent', 'sys_oauth_refresh_token',
'sys_oauth_resource', 'sys_organization', 'sys_scim_connection_binding', 'sys_scim_group', 'sys_scim_group_member',
'sys_scim_identity_tombstone', 'sys_scim_projection_grant', 'sys_scim_subject', 'sys_scim_user', 'sys_session',
'sys_team_member', 'sys_two_factor', 'sys_user', 'sys_verification', 'sys_sso_provider',
] as const;

/**
* The census's "28 non-platform objects" — it counted the 28 files; they
* declare these 33 objects, read at the census commit and unchanged on `main`.
*/
const CENSUS_2026_08_31_EXAMPLES = [
'blank_note', 'crm_account', 'crm_activity', 'crm_contact', 'crm_lead', 'crm_opportunity',
'crm_opportunity_line_item', 'dc_account', 'showcase_account', 'showcase_announcement', 'showcase_business_unit',
'showcase_cascade', 'showcase_category', 'showcase_client_brief', 'showcase_contact', 'showcase_expense_line',
'showcase_expense_report', 'showcase_ext_customer', 'showcase_ext_order', 'showcase_field_zoo', 'showcase_inquiry',
'showcase_invoice', 'showcase_invoice_line', 'showcase_preference', 'showcase_private_note', 'showcase_product',
'showcase_project', 'showcase_project_membership', 'showcase_semantic_zoo', 'showcase_semantic_zoo_legacy',
'showcase_task', 'showcase_team', 'todo_task',
] as const;

/**
* #14570 and #15086, read in: each population's table has its own fate, citing
* the record that read it in (a runtime string carries no tracker number).
*/
const READ_IN = { sys_business_unit_member: '6067123924', sys_business_unit: '5536478573' } as const;

const unlisted = (names: readonly string[]): string[] => names.filter((name) => inventoryEntryFor(name) === undefined);

describe('ADR-0131 D10 inventory — every censused object has one fate and a citation', () => {
it('the first census: 59 + 25 platform objects', () => {
expect(CENSUS_2026_08_31_PLATFORM_WITH_COLUMN).toHaveLength(59);
expect(CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN).toHaveLength(25);
expect(unlisted([...CENSUS_2026_08_31_PLATFORM_WITH_COLUMN, ...CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN])).toEqual([]);
});

it('the first census: the example objects of its 28 files', () => {
expect(unlisted(CENSUS_2026_08_31_EXAMPLES)).toEqual([]);
});

it('the platform registry as it stands — a platform object without a row reds here, by name', () => {
const registered = Object.values(PLATFORM_OBJECTS_BY_PACKAGE).flat();
expect(registered.length).toBeGreaterThan(80);
expect(unlisted(registered)).toEqual([]);
});

it('the cloud supplement: listed as cloud-carried, fate 4, never a guessed fate', () => {
expect(CLOUD_PROVIDED_OBJECT_NAMES.length).toBeGreaterThan(0);
expect(unlisted(CLOUD_PROVIDED_OBJECT_NAMES)).toEqual([]);
for (const name of CLOUD_PROVIDED_OBJECT_NAMES) {
expect(inventoryEntryFor(name), name).toMatchObject({ fate: 'report', cloudCarried: true });
}
});

it('#14570 and #15086 are read in, each with its own fate and citation', () => {
for (const [object, record] of Object.entries(READ_IN)) {
const entry = inventoryEntryFor(object);
expect(entry?.fate, object).toBe('attribution');
expect(entry?.citation, object).toContain(record);
}
});

it('one row per object, a fate from the four, a non-empty citation', () => {
const seen = new Set<string>();
for (const entry of ORGANIZATION_OWNERSHIP_INVENTORY) {
expect(seen.has(entry.object), `${entry.object} listed twice`).toBe(false);
seen.add(entry.object);
expect(ORGANIZATION_OWNERSHIP_FATES, entry.object).toContain(entry.fate);
expect(entry.citation.trim().length, entry.object).toBeGreaterThan(20);
expect(entry.census.length, entry.object).toBeGreaterThan(0);
}
});

it('each fate carries only the fields that fate reads', () => {
for (const entry of ORGANIZATION_OWNERSHIP_INVENTORY) {
if (entry.anchors) expect(entry.fate, `${entry.object}: anchors outside fate 3`).toBe('attribution');
if (entry.mirror) expect(entry.fate, `${entry.object}: a mirror selector outside fate 2`).toBe('mirror-deletion');
if (entry.notNullExempt || entry.cloudCarried) expect(entry.fate, entry.object).toBe('report');
if (entry.fate === 'attribution') expect(entry.anchors, `${entry.object}: fate 3 names its anchors`).toBeDefined();
}
});

it('every parent and holder an anchor names is itself inventoried', () => {
for (const entry of ORGANIZATION_OWNERSHIP_INVENTORY) {
for (const anchor of entry.anchors ?? []) {
const target = anchor.kind === 'parent' ? anchor.parentObject : anchor.kind === 'holder' ? anchor.holderObject : null;
if (target) expect(inventoryEntryFor(target), `${entry.object} → ${target}`).toBeDefined();
}
}
});
});
Loading
Loading