Skip to content
17 changes: 17 additions & 0 deletions .changeset/23399-ceremony-not-declared-column-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/cli': patch
---

fix(cli): `os migrate organization-ownership --apply` no longer blames the release for a column the ceremony's composition does not declare (ADR-0131 D10, column drops)

Clause-②: no

The ceremony drops `organization_id` only through the schema-drift ceremony, so only on an object its boot declares, and its boot declares the composition it is run in (the project directory and environment). Run in a composition that does not mount an object's provider, the step used to answer that table with "the object this runtime declares still carries the column … Run the ceremony with the release whose declaration drops it". That sentence was false: the object was not declared at all, and the same release drops the column wherever it is declared. Measured cases are `sys_flow_dispatch` without `requires: ['automation']`, `sys_presence` without `realtime`, and `sys_audit_log` without the auth family (no `OS_AUTH_SECRET`).

Such a table is now answered `not-declared`:

- the step still blocks and keeps the column;
- its blocker names the table and the remedy: re-run the ceremony where the deployment's composition is declared, from its project directory and environment (the `requires` capability whose provider declares the object, and `OS_AUTH_SECRET` for the objects the auth family declares);
- the `column-drops` step's summary counts these tables as `notDeclared` in the apply report (the report file and `--json`).

The "not proposed" blocker is unchanged. It is now given only for an object the composition does declare.
18 changes: 17 additions & 1 deletion packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,9 +491,25 @@ export default class MigrateOrganizationOwnership extends Command {
});
const outcomes: ColumnDropOutcome[] = [];
const proposed = new Set(entries.map((d) => String(d.table)));
// The tables this booted composition declares, keyed as the driver
// keys its managed set. A table outside it can never be proposed,
// whatever a release declares: `not-declared`, and `not-proposed`
// only for a declared one. A rotation shard is judged by its base.
const { StorageNameMapping } = await import('@objectstack/spec/system');
const { rotationBaseOf } = await import('../../utils/unmanaged-tables.js');
const declared = new Set(
(liveStack.allObjects() as Array<{ name?: unknown } | null>)
.map((o) => o?.name)
.filter((name): name is string => typeof name === 'string' && name.length > 0)
.map((name) => StorageNameMapping.resolveTableName({ name })),
);
for (const table of tables) {
if (!proposed.has(table)) {
outcomes.push({ table, outcome: 'not-proposed', detail: 'the drift differ proposed no drop for it' });
outcomes.push(
declared.has(rotationBaseOf(table) ?? table)
? { table, outcome: 'not-proposed', detail: 'the drift differ proposed no drop for it' }
: { table, outcome: 'not-declared' },
);
}
}
if (entries.length === 0) return outcomes;
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/utils/organization-ownership-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,44 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
);
});

it('a column-drop table the booted composition does not declare is not-declared, counted, kept and blocking — not blamed on the release', async () => {
db.exec(`CREATE TABLE sys_flow_dispatch (id TEXT PRIMARY KEY, organization_id TEXT)`);
const c = catalog();
const l = ledger();
const answering = async (outcome: 'not-declared' | 'not-proposed'): Promise<OrganizationOwnershipApplyReport> => {
const real = columnDrops.drop;
columnDrops.drop = async (tables) => [
...(await real(tables.filter((t) => t !== 'sys_flow_dispatch'))),
...(tables.includes('sys_flow_dispatch') ? [{ table: 'sys_flow_dispatch', outcome }] : []),
];
try {
return await apply(c, l);
} finally {
columnDrops.drop = real;
}
};
const flowDispatchBlocker = (report: OrganizationOwnershipApplyReport): string[] =>
report.steps.find((s) => s.step === 'column-drops')!.blockers.filter((b) => b.includes('sys_flow_dispatch.organization_id'));

const notDeclared = await answering('not-declared');
const drops = notDeclared.steps.find((s) => s.step === 'column-drops')!;
expect(drops.status).toBe('blocked');
expect(drops.summary).toEqual({ dropped: 1, due: 2, notDeclared: 1 });
expect(flowDispatchBlocker(notDeclared)).toHaveLength(1);
expect(rows(`SELECT name FROM pragma_table_info('sys_flow_dispatch') WHERE name = 'organization_id'`)).toHaveLength(1);
expect(rows(`SELECT name FROM pragma_table_info('sys_job') WHERE name = 'organization_id'`)).toHaveLength(0);

// The control: the same table on the resumed run, answered `not-proposed`
// (a declared object the differ proposed no drop for).
const notProposed = await answering('not-proposed');
expect(notProposed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion']);
const control = notProposed.steps.find((s) => s.step === 'column-drops')!;
expect(control.status).toBe('blocked');
expect(control.summary).toEqual({ dropped: 0, due: 1, notDeclared: 0 });
expect(flowDispatchBlocker(notProposed)).toHaveLength(1);
expect(flowDispatchBlocker(notProposed)[0]).not.toBe(flowDispatchBlocker(notDeclared)[0]);
});

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);
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/src/utils/organization-ownership-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,17 @@ export interface TemplatePort {
find(name: string, locale: string): Promise<{ definition: Readonly<Record<string, unknown>>; environment: boolean } | undefined>;
}

/** One table's column drop, as the schema-drift ceremony answered it. */
/**
* One table's column drop, as the schema-drift ceremony answered it.
*
* `not-declared`: the composition this run booted declares no object for the
* table, so the ceremony has no declaration to drop the column against. That
* is a fact about the composition the ceremony was run in, never about the
* release. `not-proposed` is only for a table it DOES declare.
*/
export interface ColumnDropOutcome {
table: string;
outcome: 'dropped' | 'not-proposed' | 'failed';
outcome: 'dropped' | 'not-proposed' | 'not-declared' | 'failed';
detail?: string;
}

Expand Down Expand Up @@ -1102,12 +1109,24 @@ async function runColumnDrops(run: Run): Promise<StepReport> {
due.push(...table.tables);
}
let dropped = 0;
let notDeclared = 0;
if (due.length) {
for (const outcome of await run.opts.columnDrops.drop(due)) {
if (outcome.outcome === 'dropped') {
dropped++;
continue;
}
if (outcome.outcome === 'not-declared') {
notDeclared++;
blockers.push(
`${outcome.table}.${ORG} was not dropped: the composition this run booted declares no object for ` +
`${outcome.table}, so the schema-drift ceremony has no declaration to drop it against, and no release ` +
'changes that. Re-run the ceremony where the deployment\'s composition is declared: its project directory ' +
'and environment (the requires capability whose provider declares this object; OS_AUTH_SECRET for the ' +
'objects the auth family declares).',
);
continue;
}
blockers.push(
outcome.outcome === 'not-proposed'
? `${outcome.table}.${ORG} was not proposed for dropping by the schema-drift ceremony: the object this ` +
Expand All @@ -1117,7 +1136,13 @@ async function runColumnDrops(run: Run): Promise<StepReport> {
);
}
}
return { step: 'column-drops', status: blockers.length ? 'blocked' : 'done', summary: { dropped, due: due.length }, blockers, notes: [] };
return {
step: 'column-drops',
status: blockers.length ? 'blocked' : 'done',
summary: { dropped, due: due.length, notDeclared },
blockers,
notes: [],
};
}

// ── The run ──────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os migrate organization-ownership --apply`, column drops: a table the
* booted composition declares no object for is answered `not-declared`, never
* blamed on the release (ADR-0131 D10, fate 1).
*
* The ceremony drops a column only through the schema-drift ceremony, so only
* for an object its boot declares, and its boot declares the composition it is
* run in. `sys_flow_dispatch`'s provider (`service-automation`) is composed
* only when that composition's `requires` names `automation`. So run in a
* composition that does not, the v17 column is kept and the step says why
* (`summary.notDeclared`, a blocker naming the table). The control is the same
* database run in a composition that does: the column is dropped.
*
* The fixture is a database a served boot of this tree left behind, shaped
* like v17: `organization_id` added to `sys_flow_dispatch`, and to
* `sys_migration_journal`, whose provider is composed on every boot, as the
* in-run control.
*/

import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isExitSignal } from './format.js';
import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js';

import '@objectstack/runtime';
import '@objectstack/objectql';
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
import { AutomationServicePlugin } from '@objectstack/service-automation';

const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(HERE, '..', '..');
const CASE_TIMEOUT_MS = 120_000;

/** Env that would point the boot somewhere other than the fixture, or change what it composes. */
const OVERRIDING_ENV = [
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH',
'OS_TELEMETRY_DB', 'OS_AUTH_SECRET',
] as const;

const OBJECT = { name: 'osnd_note', label: 'Note', fields: { body: { type: 'text', label: 'Body' } } };
const MANIFEST = { id: 'com.example.not-declared', name: 'Not Declared', version: '0.0.0', type: 'app' };

const FLOW_DISPATCH = 'sys_flow_dispatch';
const CONTROL = 'sys_migration_journal';

let dir: string;
let templateDb: string;
const savedEnv: Record<string, string | undefined> = {};
const savedCwd = process.cwd();

const hasOrganizationColumn = (dbFile: string, table: string): boolean => {
const db = new Database(dbFile, { readonly: true });
try {
return (db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string }>).some((c) => c.name === 'organization_id');
} finally {
db.close();
}
};

/** A served boot of this tree over `dbFile`: every table these plugins declare. */
async function bootServed(projectDir: string, dbFile: string): Promise<void> {
const { createStandaloneStack, Runtime } = await import('@objectstack/runtime');
const stack = await createStandaloneStack({
projectRoot: projectDir,
databaseUrl: `file:${dbFile}`,
skipSeedData: true,
armLifecycleSweep: false,
});
const runtime = new Runtime({ cluster: false });
const kernel = runtime.getKernel();
for (const plugin of stack.plugins) await kernel.use(plugin as any);
await kernel.use(new PlatformObjectsPlugin() as any);
// The default store, so `sys_flow_dispatch` is declared and created.
await kernel.use(new AutomationServicePlugin({ armRuntime: false }) as any);
await runtime.start();
await kernel.shutdown();
}

/** A project directory whose config is `config` (no artifact). */
function projectFor(name: string, config: Record<string, unknown>): string {
const projectDir = join(dir, 'projects', name);
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, 'objectstack.config.mjs'), `export default ${JSON.stringify(config, null, 2)};\n`);
return projectDir;
}

/** The real command, `--apply`, from `projectDir`, over a fresh copy of the fixture. */
async function applyIn(projectDir: string): Promise<{ dbFile: string; report: any; exitCode: number }> {
const dbFile = join(projectDir, 'v17.db');
copyFileSync(templateDb, dbFile);
const savedExit = process.exitCode;
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
if (cb) cb();
return true;
}) as typeof process.stdout.write;
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
process.chdir(projectDir);
let exitCode: number | undefined;
try {
try {
await MigrateOrganizationOwnership.run(
['--apply', '--backup-taken', '--out', join(projectDir, 'apply.json'), '--database-url', `file:${dbFile}`, '--json'],
{ root: CLI_ROOT },
);
} catch (error) {
if (!isExitSignal(error)) throw error;
exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit;
}
const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim());
expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined();
return { dbFile, report: payload.report, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 };
} finally {
process.chdir(savedCwd);
process.exitCode = savedExit;
vi.restoreAllMocks();
}
}

const columnDrops = (report: any): { status: string; summary: Record<string, number>; blockers: string[] } =>
report.steps.find((s: { step: string }) => s.step === 'column-drops');
const blockersFor = (report: any, table: string): string[] =>
columnDrops(report).blockers.filter((b) => b.includes(`${table}.organization_id`));

beforeAll(async () => {
for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key];
for (const key of OVERRIDING_ENV) delete process.env[key];
// The deployed posture: no telemetry sibling, and the crypto provider's key.
process.env.NODE_ENV = 'production';
process.env.OS_SECRET_KEY = randomBytes(32).toString('hex');

dir = mkdtempSync(join(tmpdir(), 'os-ceremony-not-declared-'));
const served = join(dir, 'served');
mkdirSync(served, { recursive: true });
templateDb = join(dir, 'template.db');
await bootServed(served, templateDb);
// The v17 shape: the injected tenant column both objects carried before
// they declared `systemFields: { tenant: false }`.
const db = new Database(templateDb);
try {
for (const table of [FLOW_DISPATCH, CONTROL]) db.exec(`ALTER TABLE "${table}" ADD COLUMN "organization_id" varchar(255)`);
} finally {
db.close();
}
}, CASE_TIMEOUT_MS);

afterAll(() => {
process.chdir(savedCwd);
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
rmSync(dir, { recursive: true, force: true });
});

describe('organization-ownership --apply: a column-drop table the booted composition does not declare', () => {
it('(i) a config that requests no automation: sys_flow_dispatch is not-declared, kept, and named', async () => {
const { dbFile, report, exitCode } = await applyIn(projectFor('no-automation', { manifest: MANIFEST, objects: [OBJECT] }));

const step = columnDrops(report);
expect(step.status).toBe('blocked');
expect(step.summary.notDeclared).toBe(1);
expect(blockersFor(report, FLOW_DISPATCH)).toHaveLength(1);
expect(hasOrganizationColumn(dbFile, FLOW_DISPATCH)).toBe(true);
// The in-run control: a declared column-drop table is dropped by the same run.
expect(blockersFor(report, CONTROL)).toEqual([]);
expect(hasOrganizationColumn(dbFile, CONTROL)).toBe(false);
expect(exitCode).toBe(1);
}, CASE_TIMEOUT_MS);

it('(ii) the control — a config with requires: [automation]: the same column is proposed and dropped', async () => {
const { dbFile, report } = await applyIn(
projectFor('automation', { manifest: MANIFEST, objects: [OBJECT], requires: ['automation'] }),
);

expect(columnDrops(report).summary.notDeclared).toBe(0);
expect(blockersFor(report, FLOW_DISPATCH)).toEqual([]);
expect(report.tables.find((t: { object: string }) => t.object === FLOW_DISPATCH)).toMatchObject({ columnDropped: true });
expect(hasOrganizationColumn(dbFile, FLOW_DISPATCH)).toBe(false);
}, CASE_TIMEOUT_MS);
});
Loading