Skip to content

Commit c6cea94

Browse files
committed
fix(cli): the organization-ownership drop refusal says what 18.0 supports for a v17 database
os migrate apply --allow-destructive, os migrate organization-ownership --help and os dev's way-on line named steps of os migrate organization-ownership, which 18.0 refuses on every database. They now state 18.0's position in the boot refusal's terms: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
1 parent 65a8eeb commit c6cea94

8 files changed

Lines changed: 276 additions & 12 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal says what 18.0 supports for a v17 database, and names no step of the command 18.0 refuses
6+
7+
Clause-②: no
8+
9+
- **`os migrate apply --allow-destructive` on a v17-shaped database.** When the run would drop `organization_id` from a table the organization-ownership ceremony owns, and the ceremony has not verified on the database, the refusal now says that 18.0 supports fresh installs only and does not upgrade a v17 database: keep it on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. This is what the v18 boot refusal says. Before, it told the operator to run `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which 18.0 refuses on every database (`NOT_IMPLEMENTED`). The `--json` refusal's `detail` no longer names that command either: it reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count.
10+
- **`os migrate organization-ownership --help`** shows what the command answers in 18.0: exit `1` with `NOT_IMPLEMENTED`, nothing read or written, and the same refusal as JSON. Before, it showed six plan and apply examples that 18.0 refuses.
11+
- **`os dev` on a v17-shaped development database.** When it does not upgrade the database in place (the ceremony's plan is refused, or the database is not a local SQLite file), it now ends with the same position: keep this development database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there. Before, it told the developer to run `os migrate organization-ownership --dev` and then the same command with `--apply`.
12+
13+
Nothing else changes. The refusals still hold back the whole run, and their message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before.

‎packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ function caseDb(columns: readonly string[]): string {
104104

105105
const dropArgv = (file: string) => ['--database-url', `file:${file}`, '--allow-destructive', '--yes', '--json'];
106106

107+
/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */
108+
const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/;
109+
107110
beforeAll(async () => {
108111
for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key];
109112
for (const key of OVERRIDING_ENV) delete process.env[key];
@@ -151,7 +154,9 @@ describe('os migrate apply --allow-destructive waits for the organization-owners
151154
created: [],
152155
marker: null,
153156
});
154-
expect(payload.detail).toMatch(/os migrate organization-ownership --apply/);
157+
expect(payload.detail).toMatch(/organization-ownership ceremony/);
158+
// 18.0 refuses `os migrate organization-ownership` on every database: the refusal names no step of it.
159+
expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
155160
expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
156161
}, CASE_TIMEOUT_MS);
157162

@@ -164,6 +169,7 @@ describe('os migrate apply --allow-destructive waits for the organization-owners
164169
expect(exitCode).toBe(1);
165170
expect(payload).toMatchObject({ message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'] });
166171
expect(payload.detail).toMatch(/is not verified \(2 blocking/);
172+
expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
167173
expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
168174
}, CASE_TIMEOUT_MS);
169175

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D10 item 5] What `os migrate apply --allow-destructive` tells the
5+
* operator when it holds back an `organization_id` drop the organization-ownership
6+
* ceremony owns, in 18.0.
7+
*
8+
* 18.0 supports fresh installs only, and its registered
9+
* `os migrate organization-ownership` refuses on every database, plan and apply
10+
* alike (`organization-ownership.release-refusal.test.ts`). So the drop gate's
11+
* refusal, the human face and the `--json` `detail` both, names no step of that
12+
* command: it says what 18.0 supports for a v17 database, the 17.x release to
13+
* keep it on and the 18.x release that carries the upgrade path, as the boot
14+
* refusal does (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`).
15+
*
16+
* ## What is real here
17+
*
18+
* The command's own parse, its drift handling and the gate run for real. The
19+
* boot, the occupancy probe, the process exit and the ceremony's marker read
20+
* are replaced: the subject is the text the gate hands the operator, for each
21+
* marker state it can read, and a control proves the harness reaches the
22+
* writes once the marker is verified. The real command on a v17-shape SQLite
23+
* table is `apply-organization-ownership-gate.integration.test.ts`.
24+
*/
25+
26+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
27+
import { dirname, resolve } from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
import MigrateApply from './apply.js';
30+
import { isExitSignal } from '../../utils/format.js';
31+
32+
// ⛔ `vi.hoisted`, never a value import of `bootSchemaStack`: this suite boots
33+
// nothing, and a value import of that symbol files a test in the integration
34+
// tier (`vitest-tiers.ts`).
35+
const mocks = vi.hoisted(() => ({
36+
bootSchemaStack: vi.fn(),
37+
exitOneShotCommand: vi.fn(async (_code: number) => {}),
38+
readOrganizationOwnershipMarker: vi.fn(),
39+
}));
40+
41+
vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({
42+
...(await importOriginal<Record<string, unknown>>()),
43+
bootSchemaStack: mocks.bootSchemaStack,
44+
}));
45+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
46+
OCCUPANCY_HINT: 'occupancy hint',
47+
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
48+
}));
49+
vi.mock('../../utils/one-shot-exit.js', () => ({ exitOneShotCommand: mocks.exitOneShotCommand }));
50+
vi.mock('../../utils/organization-ownership-drop-gate.js', async (importOriginal) => ({
51+
...(await importOriginal<Record<string, unknown>>()),
52+
readOrganizationOwnershipMarker: mocks.readOrganizationOwnershipMarker,
53+
}));
54+
55+
const HERE = dirname(fileURLToPath(import.meta.url));
56+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
57+
/** oclif builds its whole command table on the first `run()` in a process. */
58+
const RUN_TIMEOUT = 60_000;
59+
60+
/**
61+
* The drift a v17-shape table offers: a physical `organization_id` on a table
62+
* whose v18 object declares none.
63+
*/
64+
const ORGANIZATION_DROP = {
65+
category: 'destructive',
66+
table: 'sys_platform_setting',
67+
column: 'organization_id',
68+
op: { type: 'drop_column', column: 'organization_id' },
69+
description: 'sys_platform_setting.organization_id: column exists in the database but not in metadata (orphaned)',
70+
};
71+
72+
/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */
73+
const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/;
74+
75+
const UNVERIFIED = { id: 'adr-0131-organization-ownership', last_run_at: '2026-10-01T00:00:00.000Z', verified_at: null, blocking: 2 };
76+
const VERIFIED = { ...UNVERIFIED, verified_at: '2026-10-01T00:00:00.000Z', blocking: 0 };
77+
78+
let flushSchemaDdl: ReturnType<typeof vi.fn>;
79+
let applyMigrationEntries: ReturnType<typeof vi.fn>;
80+
81+
function bootWithOrganizationDrop(): void {
82+
flushSchemaDdl = vi.fn(async () => []);
83+
applyMigrationEntries = vi.fn(async () => ({ applied: [ORGANIZATION_DROP], skipped: [] }));
84+
mocks.bootSchemaStack.mockResolvedValue({
85+
driver: {},
86+
drivers: [],
87+
dbLabel: 'file:v17.db',
88+
dbSource: { kind: 'flag' },
89+
telemetryDatabase: null,
90+
composition: { plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, notes: [] },
91+
detectManagedDrift: async () => [ORGANIZATION_DROP],
92+
driftApplyLimit: () => null,
93+
pendingSchemaWork: [],
94+
kernel: { getService: () => ({}) },
95+
flushSchemaDdl,
96+
applyMigrationEntries,
97+
shutdown: vi.fn(async () => {}),
98+
});
99+
}
100+
101+
let stdout: ReturnType<typeof vi.spyOn>;
102+
let log: ReturnType<typeof vi.spyOn>;
103+
let stderr: ReturnType<typeof vi.spyOn>;
104+
let savedExitCode: typeof process.exitCode;
105+
beforeEach(() => {
106+
savedExitCode = process.exitCode;
107+
process.exitCode = undefined;
108+
mocks.bootSchemaStack.mockReset();
109+
mocks.exitOneShotCommand.mockClear();
110+
mocks.readOrganizationOwnershipMarker.mockReset();
111+
// `emitJson` awaits the write's DRAIN callback, so the double must invoke it.
112+
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => {
113+
const done = typeof enc === 'function' ? enc : cb;
114+
if (typeof done === 'function') done();
115+
return true;
116+
}) as typeof process.stdout.write);
117+
log = vi.spyOn(console, 'log').mockImplementation(() => {});
118+
stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
119+
});
120+
afterEach(() => {
121+
stdout.mockRestore();
122+
log.mockRestore();
123+
stderr.mockRestore();
124+
process.exitCode = savedExitCode;
125+
});
126+
127+
/**
128+
* Run the command; answer the status it ends the process with, the JSON
129+
* document it emitted, and the refusal line the human face printed.
130+
*/
131+
async function runApply(argv: string[]) {
132+
const err = await MigrateApply.run(argv, { root: CLI_ROOT }).then(() => null, (e: unknown) => e);
133+
if (err !== null && !isExitSignal(err)) throw err;
134+
const exit = err !== null
135+
? (err as { oclif?: { exit?: number } }).oclif?.exit
136+
: mocks.exitOneShotCommand.mock.calls.at(-1)?.[0];
137+
const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('').trim();
138+
const refusal = log.mock.calls.map((c: unknown[]) => c.map(String).join(' ')).find((line: string) => line.includes('Refusing this --allow-destructive run'));
139+
return { exit, payload: out ? JSON.parse(out) : null, refusal };
140+
}
141+
142+
const ARGV = ['--database-url', 'file:v17.db', '--allow-destructive', '--yes'];
143+
144+
describe('os migrate apply --allow-destructive: the organization-ownership refusal names no command 18.0 refuses', () => {
145+
for (const [state, flag] of [['no ceremony run recorded', null], ['an unverified run recorded', UNVERIFIED]] as const) {
146+
it(`${state}: the human face refuses, exit 1, saying what 18.0 supports, and nothing is written`, async () => {
147+
bootWithOrganizationDrop();
148+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag });
149+
150+
const { exit, refusal } = await runApply(ARGV);
151+
152+
expect(exit).toBe(1);
153+
expect(refusal, 'the gate printed its refusal').toBeDefined();
154+
expect(refusal).toContain('sys_platform_setting');
155+
expect(refusal).not.toMatch(WITHDRAWN_COMMAND);
156+
expect(refusal).toContain('17.x release');
157+
expect(refusal).toContain('18.x release');
158+
if (flag) expect(refusal).toContain('2 blocking');
159+
expect(flushSchemaDdl).not.toHaveBeenCalled();
160+
expect(applyMigrationEntries).not.toHaveBeenCalled();
161+
}, RUN_TIMEOUT);
162+
163+
it(`${state}: under --json the refusal's detail names no command 18.0 refuses either`, async () => {
164+
bootWithOrganizationDrop();
165+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag });
166+
167+
const { exit, payload } = await runApply([...ARGV, '--json']);
168+
169+
expect(exit).toBe(1);
170+
expect(payload).toMatchObject({
171+
message: 'refused_organization_ownership_unmigrated',
172+
tables: ['sys_platform_setting'],
173+
applied: [],
174+
created: [],
175+
marker: flag,
176+
});
177+
expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
178+
expect(applyMigrationEntries).not.toHaveBeenCalled();
179+
}, RUN_TIMEOUT);
180+
}
181+
});
182+
183+
describe('control', () => {
184+
it('a verified marker lets the drop through and exits 0, so the harness can tell a refusal from a run', async () => {
185+
bootWithOrganizationDrop();
186+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: true, flag: VERIFIED });
187+
188+
const { exit, payload } = await runApply([...ARGV, '--json']);
189+
190+
expect(exit).toBe(0);
191+
expect(applyMigrationEntries).toHaveBeenCalledTimes(1);
192+
expect(payload.applied).toEqual([ORGANIZATION_DROP]);
193+
}, RUN_TIMEOUT);
194+
});

‎packages/cli/src/commands/migrate/apply.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,19 @@ export default class MigrateApply extends Command {
571571
);
572572
const marker = await readOrganizationOwnershipMarker(engine as never);
573573
if (!marker.verified) {
574+
// 18.0 supports fresh installs only, and its `os migrate
575+
// organization-ownership` refuses on every database, plan and apply
576+
// alike (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`, ruling record
577+
// 6259608309 on the v18 epic, item 1). So neither the `detail` nor the
578+
// refusal names a step of that command: they say what 18.0 supports
579+
// for a v17 database, in the boot refusal's terms
580+
// (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`).
581+
// The 18.x release that registers the ceremony names its steps here
582+
// again.
574583
const detail = marker.flag
575-
? `The last run of "os migrate organization-ownership --apply --plan-file FILE" (${marker.flag.last_run_at}) is not verified `
576-
+ `(${marker.flag.blocking} blocking item(s)).`
577-
: 'No run of "os migrate organization-ownership --apply --plan-file FILE" is recorded on this database.';
584+
? `The last run of the organization-ownership ceremony recorded on this database (${marker.flag.last_run_at}) `
585+
+ `is not verified (${marker.flag.blocking} blocking item(s)).`
586+
: 'No run of the organization-ownership ceremony is recorded on this database.';
578587
if (flags.json) {
579588
await emitJson({
580589
...target, created: [], applied: [], skipped: drift, pending,
@@ -585,9 +594,9 @@ export default class MigrateApply extends Command {
585594
printError(
586595
`Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, a drop the organization-ownership ceremony `
587596
+ `owns, and the ceremony has not verified on this database. ${detail} Dropping the column first would turn `
588-
+ 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run '
589-
+ '"os migrate organization-ownership --out PLAN" to write and review its plan, then '
590-
+ '"os migrate organization-ownership --apply --plan-file PLAN"; it drops these columns itself, in its '
597+
+ 'every legacy organization row into an environment row without the ceremony\'s promotion or report. 18.0 '
598+
+ 'supports fresh installs only and does not upgrade a v17 database: keep it on a 17.x release until the '
599+
+ '18.x release that carries the upgrade path, whose ceremony drops these columns itself, in its '
591600
+ 'column-drops step, before it records the run verified. Nothing in this run was applied.',
592601
);
593602
this.exit(1);

‎packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ describe('os migrate organization-ownership — refused in 18.0', () => {
143143
expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('an 18.x release');
144144
});
145145

146+
it('its --help examples show the refusal: none is a plan or an apply 18.0 refuses on every database', () => {
147+
const examples = mod.default.examples.map((e) => (typeof e === 'string' ? { command: e, description: '' } : e));
148+
expect(examples.length).toBeGreaterThan(0);
149+
for (const { command, description } of examples) {
150+
expect(command).toMatch(/migrate organization-ownership/);
151+
expect(command).not.toMatch(/--out|--apply|--plan-file|--fate-supplement/);
152+
expect(description).toContain('NOT_IMPLEMENTED');
153+
}
154+
// The ceremony class keeps its own examples, for the 18.x release that registers it.
155+
expect(mod.OrganizationOwnershipCeremony.examples.some((e) => String(e).includes('--apply --plan-file'))).toBe(true);
156+
});
157+
146158
it('the registered command keeps the ceremony class whole underneath: same flags, its own run', () => {
147159
expect(Object.getPrototypeOf(mod.default)).toBe(mod.OrganizationOwnershipCeremony);
148160
expect(mod.default.flags).toBe(mod.OrganizationOwnershipCeremony.flags);

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ export class OrganizationOwnershipCeremony extends Command {
327327
'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
328328
'resumable from a recorded checkpoint, then the post-check.';
329329

330-
static override examples = [
330+
static override examples: Command.Example[] = [
331331
'$ os migrate organization-ownership',
332332
'$ os migrate organization-ownership --out plans/v18-ownership.json',
333333
'$ os migrate organization-ownership --database-url postgres://… --json',
@@ -1125,6 +1125,25 @@ export default class MigrateOrganizationOwnership extends OrganizationOwnershipC
11251125
'Refused in 18.0, which supports fresh installs only: the v17 → v18 upgrade path ships in an 18.x release. ' +
11261126
OrganizationOwnershipCeremony.description;
11271127

1128+
/**
1129+
* The ceremony's own examples show a plan and an apply, which 18.0 refuses on
1130+
* every database, fresh or not, so `--help` shows what an invocation answers
1131+
* in this release instead. The ceremony class keeps its examples for the 18.x
1132+
* release that registers it.
1133+
*/
1134+
static override examples: Command.Example[] = [
1135+
{
1136+
description:
1137+
'Refused in 18.0, plan and apply alike: exit 1 with NOT_IMPLEMENTED, nothing read or written. Keep a v17 ' +
1138+
'database on a 17.x release until the 18.x release that carries the upgrade path',
1139+
command: '<%= config.bin %> migrate organization-ownership',
1140+
},
1141+
{
1142+
description: 'The same refusal as JSON: error, reason upgrade-unavailable, code NOT_IMPLEMENTED, and the detail',
1143+
command: '<%= config.bin %> migrate organization-ownership --json',
1144+
},
1145+
];
1146+
11281147
override async run(): Promise<void> {
11291148
const { flags } = await this.parse(MigrateOrganizationOwnership);
11301149
if (flags.json) {

‎packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
8686
expect(result.lines[0]).toContain('✗ Refused (sys_mystery)');
8787
expect(result.lines.at(-1)).toContain('nothing was changed');
8888
expect(result.lines.at(-1)).toContain(BACKUP());
89+
expect(result.lines.at(-1)).toContain('17.x release');
90+
expect(result.lines.at(-1)).not.toContain('--plan-file');
8991
});
9092

9193
it('a blocked step boots nothing: each blocker is printed, and how to resume', () => {
@@ -118,12 +120,15 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
118120
expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`);
119121
});
120122

121-
it('a database that is not a local SQLite file is not upgraded in place: the ceremony is named instead', () => {
123+
it('a database that is not a local SQLite file is not upgraded in place: 18.0\'s position is named instead', () => {
122124
const result = upgradeDevelopmentDatabase(input(fakeRun({}), { sqliteFile: null, databaseUrl: 'postgres://db/app' }));
123125
expect(result.ok).toBe(false);
124126
expect(calls).toEqual([]);
125127
expect(existsSync(join(dir, 'backups'))).toBe(false);
126-
expect(result.lines[0]).toContain('os migrate organization-ownership --dev');
128+
expect(result.lines[0]).toContain('17.x release');
129+
// 18.0 refuses `os migrate organization-ownership` on every database: the way on names no step of it.
130+
expect(result.lines[0]).not.toContain('os migrate organization-ownership');
131+
expect(result.lines[0]).not.toContain('--plan-file');
127132
});
128133

129134
it('the IPC message is recognised by its type alone', () => {

0 commit comments

Comments
 (0)