From c6cea949b5680c771451d828e1fb91e236c30ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:52:20 +0000 Subject: [PATCH 1/3] 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 Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T --- .changeset/24567-drop-gate-refusal-18-0.md | 13 ++ ...ization-ownership-gate.integration.test.ts | 8 +- ...ply.organization-ownership-refusal.test.ts | 194 ++++++++++++++++++ packages/cli/src/commands/migrate/apply.ts | 21 +- ...nization-ownership.release-refusal.test.ts | 12 ++ .../migrate/organization-ownership.ts | 21 +- ...dev-organization-ownership-upgrade.test.ts | 9 +- .../dev-organization-ownership-upgrade.ts | 10 +- 8 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 .changeset/24567-drop-gate-refusal-18-0.md create mode 100644 packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts diff --git a/.changeset/24567-drop-gate-refusal-18-0.md b/.changeset/24567-drop-gate-refusal-18-0.md new file mode 100644 index 00000000000..fb56c07493a --- /dev/null +++ b/.changeset/24567-drop-gate-refusal-18-0.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': patch +--- + +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 + +Clause-②: no + +- **`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. +- **`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. +- **`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`. + +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. diff --git a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts index 8a2e7ef0bc6..9510bb0aa3b 100644 --- a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts +++ b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts @@ -104,6 +104,9 @@ function caseDb(columns: readonly string[]): string { const dropArgv = (file: string) => ['--database-url', `file:${file}`, '--allow-destructive', '--yes', '--json']; +/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */ +const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/; + beforeAll(async () => { for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key]; 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 created: [], marker: null, }); - expect(payload.detail).toMatch(/os migrate organization-ownership --apply/); + expect(payload.detail).toMatch(/organization-ownership ceremony/); + // 18.0 refuses `os migrate organization-ownership` on every database: the refusal names no step of it. + expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND); expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); }, CASE_TIMEOUT_MS); @@ -164,6 +169,7 @@ describe('os migrate apply --allow-destructive waits for the organization-owners expect(exitCode).toBe(1); expect(payload).toMatchObject({ message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'] }); expect(payload.detail).toMatch(/is not verified \(2 blocking/); + expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND); expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); }, CASE_TIMEOUT_MS); diff --git a/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts new file mode 100644 index 00000000000..0cd96b6978c --- /dev/null +++ b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5] What `os migrate apply --allow-destructive` tells the + * operator when it holds back an `organization_id` drop the organization-ownership + * ceremony owns, in 18.0. + * + * 18.0 supports fresh installs only, and its registered + * `os migrate organization-ownership` refuses on every database, plan and apply + * alike (`organization-ownership.release-refusal.test.ts`). So the drop gate's + * refusal, the human face and the `--json` `detail` both, names no step of that + * command: it says what 18.0 supports for a v17 database, the 17.x release to + * keep it on and the 18.x release that carries the upgrade path, as the boot + * refusal does (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`). + * + * ## What is real here + * + * The command's own parse, its drift handling and the gate run for real. The + * boot, the occupancy probe, the process exit and the ceremony's marker read + * are replaced: the subject is the text the gate hands the operator, for each + * marker state it can read, and a control proves the harness reaches the + * writes once the marker is verified. The real command on a v17-shape SQLite + * table is `apply-organization-ownership-gate.integration.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import MigrateApply from './apply.js'; +import { isExitSignal } from '../../utils/format.js'; + +// ⛔ `vi.hoisted`, never a value import of `bootSchemaStack`: this suite boots +// nothing, and a value import of that symbol files a test in the integration +// tier (`vitest-tiers.ts`). +const mocks = vi.hoisted(() => ({ + bootSchemaStack: vi.fn(), + exitOneShotCommand: vi.fn(async (_code: number) => {}), + readOrganizationOwnershipMarker: vi.fn(), +})); + +vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({ + ...(await importOriginal>()), + bootSchemaStack: mocks.bootSchemaStack, +})); +vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ + OCCUPANCY_HINT: 'occupancy hint', + probeMigrationTarget: vi.fn(async () => ({ status: 'free' })), +})); +vi.mock('../../utils/one-shot-exit.js', () => ({ exitOneShotCommand: mocks.exitOneShotCommand })); +vi.mock('../../utils/organization-ownership-drop-gate.js', async (importOriginal) => ({ + ...(await importOriginal>()), + readOrganizationOwnershipMarker: mocks.readOrganizationOwnershipMarker, +})); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +/** oclif builds its whole command table on the first `run()` in a process. */ +const RUN_TIMEOUT = 60_000; + +/** + * The drift a v17-shape table offers: a physical `organization_id` on a table + * whose v18 object declares none. + */ +const ORGANIZATION_DROP = { + category: 'destructive', + table: 'sys_platform_setting', + column: 'organization_id', + op: { type: 'drop_column', column: 'organization_id' }, + description: 'sys_platform_setting.organization_id: column exists in the database but not in metadata (orphaned)', +}; + +/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */ +const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/; + +const UNVERIFIED = { id: 'adr-0131-organization-ownership', last_run_at: '2026-10-01T00:00:00.000Z', verified_at: null, blocking: 2 }; +const VERIFIED = { ...UNVERIFIED, verified_at: '2026-10-01T00:00:00.000Z', blocking: 0 }; + +let flushSchemaDdl: ReturnType; +let applyMigrationEntries: ReturnType; + +function bootWithOrganizationDrop(): void { + flushSchemaDdl = vi.fn(async () => []); + applyMigrationEntries = vi.fn(async () => ({ applied: [ORGANIZATION_DROP], skipped: [] })); + mocks.bootSchemaStack.mockResolvedValue({ + driver: {}, + drivers: [], + dbLabel: 'file:v17.db', + dbSource: { kind: 'flag' }, + telemetryDatabase: null, + composition: { plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, notes: [] }, + detectManagedDrift: async () => [ORGANIZATION_DROP], + driftApplyLimit: () => null, + pendingSchemaWork: [], + kernel: { getService: () => ({}) }, + flushSchemaDdl, + applyMigrationEntries, + shutdown: vi.fn(async () => {}), + }); +} + +let stdout: ReturnType; +let log: ReturnType; +let stderr: ReturnType; +let savedExitCode: typeof process.exitCode; +beforeEach(() => { + savedExitCode = process.exitCode; + process.exitCode = undefined; + mocks.bootSchemaStack.mockReset(); + mocks.exitOneShotCommand.mockClear(); + mocks.readOrganizationOwnershipMarker.mockReset(); + // `emitJson` awaits the write's DRAIN callback, so the double must invoke it. + stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => { + const done = typeof enc === 'function' ? enc : cb; + if (typeof done === 'function') done(); + return true; + }) as typeof process.stdout.write); + log = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + stdout.mockRestore(); + log.mockRestore(); + stderr.mockRestore(); + process.exitCode = savedExitCode; +}); + +/** + * Run the command; answer the status it ends the process with, the JSON + * document it emitted, and the refusal line the human face printed. + */ +async function runApply(argv: string[]) { + const err = await MigrateApply.run(argv, { root: CLI_ROOT }).then(() => null, (e: unknown) => e); + if (err !== null && !isExitSignal(err)) throw err; + const exit = err !== null + ? (err as { oclif?: { exit?: number } }).oclif?.exit + : mocks.exitOneShotCommand.mock.calls.at(-1)?.[0]; + const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('').trim(); + const refusal = log.mock.calls.map((c: unknown[]) => c.map(String).join(' ')).find((line: string) => line.includes('Refusing this --allow-destructive run')); + return { exit, payload: out ? JSON.parse(out) : null, refusal }; +} + +const ARGV = ['--database-url', 'file:v17.db', '--allow-destructive', '--yes']; + +describe('os migrate apply --allow-destructive: the organization-ownership refusal names no command 18.0 refuses', () => { + for (const [state, flag] of [['no ceremony run recorded', null], ['an unverified run recorded', UNVERIFIED]] as const) { + it(`${state}: the human face refuses, exit 1, saying what 18.0 supports, and nothing is written`, async () => { + bootWithOrganizationDrop(); + mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag }); + + const { exit, refusal } = await runApply(ARGV); + + expect(exit).toBe(1); + expect(refusal, 'the gate printed its refusal').toBeDefined(); + expect(refusal).toContain('sys_platform_setting'); + expect(refusal).not.toMatch(WITHDRAWN_COMMAND); + expect(refusal).toContain('17.x release'); + expect(refusal).toContain('18.x release'); + if (flag) expect(refusal).toContain('2 blocking'); + expect(flushSchemaDdl).not.toHaveBeenCalled(); + expect(applyMigrationEntries).not.toHaveBeenCalled(); + }, RUN_TIMEOUT); + + it(`${state}: under --json the refusal's detail names no command 18.0 refuses either`, async () => { + bootWithOrganizationDrop(); + mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag }); + + const { exit, payload } = await runApply([...ARGV, '--json']); + + expect(exit).toBe(1); + expect(payload).toMatchObject({ + message: 'refused_organization_ownership_unmigrated', + tables: ['sys_platform_setting'], + applied: [], + created: [], + marker: flag, + }); + expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND); + expect(applyMigrationEntries).not.toHaveBeenCalled(); + }, RUN_TIMEOUT); + } +}); + +describe('control', () => { + it('a verified marker lets the drop through and exits 0, so the harness can tell a refusal from a run', async () => { + bootWithOrganizationDrop(); + mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: true, flag: VERIFIED }); + + const { exit, payload } = await runApply([...ARGV, '--json']); + + expect(exit).toBe(0); + expect(applyMigrationEntries).toHaveBeenCalledTimes(1); + expect(payload.applied).toEqual([ORGANIZATION_DROP]); + }, RUN_TIMEOUT); +}); diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index f1a9263957b..8b807856d25 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -571,10 +571,19 @@ export default class MigrateApply extends Command { ); const marker = await readOrganizationOwnershipMarker(engine as never); if (!marker.verified) { + // 18.0 supports fresh installs only, and its `os migrate + // organization-ownership` refuses on every database, plan and apply + // alike (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`, ruling record + // 6259608309 on the v18 epic, item 1). So neither the `detail` nor the + // refusal names a step of that command: they say what 18.0 supports + // for a v17 database, in the boot refusal's terms + // (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`). + // The 18.x release that registers the ceremony names its steps here + // again. const detail = marker.flag - ? `The last run of "os migrate organization-ownership --apply --plan-file FILE" (${marker.flag.last_run_at}) is not verified ` - + `(${marker.flag.blocking} blocking item(s)).` - : 'No run of "os migrate organization-ownership --apply --plan-file FILE" is recorded on this database.'; + ? `The last run of the organization-ownership ceremony recorded on this database (${marker.flag.last_run_at}) ` + + `is not verified (${marker.flag.blocking} blocking item(s)).` + : 'No run of the organization-ownership ceremony is recorded on this database.'; if (flags.json) { await emitJson({ ...target, created: [], applied: [], skipped: drift, pending, @@ -585,9 +594,9 @@ export default class MigrateApply extends Command { printError( `Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, a drop the organization-ownership ceremony ` + `owns, and the ceremony has not verified on this database. ${detail} Dropping the column first would turn ` - + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run ' - + '"os migrate organization-ownership --out PLAN" to write and review its plan, then ' - + '"os migrate organization-ownership --apply --plan-file PLAN"; it drops these columns itself, in its ' + + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. 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, in its ' + 'column-drops step, before it records the run verified. Nothing in this run was applied.', ); this.exit(1); diff --git a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts index 20929cce72d..d700cba367b 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts @@ -143,6 +143,18 @@ describe('os migrate organization-ownership — refused in 18.0', () => { expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('an 18.x release'); }); + it('its --help examples show the refusal: none is a plan or an apply 18.0 refuses on every database', () => { + const examples = mod.default.examples.map((e) => (typeof e === 'string' ? { command: e, description: '' } : e)); + expect(examples.length).toBeGreaterThan(0); + for (const { command, description } of examples) { + expect(command).toMatch(/migrate organization-ownership/); + expect(command).not.toMatch(/--out|--apply|--plan-file|--fate-supplement/); + expect(description).toContain('NOT_IMPLEMENTED'); + } + // The ceremony class keeps its own examples, for the 18.x release that registers it. + expect(mod.OrganizationOwnershipCeremony.examples.some((e) => String(e).includes('--apply --plan-file'))).toBe(true); + }); + it('the registered command keeps the ceremony class whole underneath: same flags, its own run', () => { expect(Object.getPrototypeOf(mod.default)).toBe(mod.OrganizationOwnershipCeremony); expect(mod.default.flags).toBe(mod.OrganizationOwnershipCeremony.flags); diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index e9f30602b3a..0eac0d5700b 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -327,7 +327,7 @@ export class OrganizationOwnershipCeremony extends Command { 'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' + 'resumable from a recorded checkpoint, then the post-check.'; - static override examples = [ + static override examples: Command.Example[] = [ '$ os migrate organization-ownership', '$ os migrate organization-ownership --out plans/v18-ownership.json', '$ os migrate organization-ownership --database-url postgres://… --json', @@ -1125,6 +1125,25 @@ export default class MigrateOrganizationOwnership extends OrganizationOwnershipC 'Refused in 18.0, which supports fresh installs only: the v17 → v18 upgrade path ships in an 18.x release. ' + OrganizationOwnershipCeremony.description; + /** + * The ceremony's own examples show a plan and an apply, which 18.0 refuses on + * every database, fresh or not, so `--help` shows what an invocation answers + * in this release instead. The ceremony class keeps its examples for the 18.x + * release that registers it. + */ + static override examples: Command.Example[] = [ + { + description: + 'Refused in 18.0, plan and apply alike: exit 1 with NOT_IMPLEMENTED, nothing read or written. Keep a v17 ' + + 'database on a 17.x release until the 18.x release that carries the upgrade path', + command: '<%= config.bin %> migrate organization-ownership', + }, + { + description: 'The same refusal as JSON: error, reason upgrade-unavailable, code NOT_IMPLEMENTED, and the detail', + command: '<%= config.bin %> migrate organization-ownership --json', + }, + ]; + override async run(): Promise { const { flags } = await this.parse(MigrateOrganizationOwnership); if (flags.json) { diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts index e1f18ce3d33..16980324527 100644 --- a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts +++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts @@ -86,6 +86,8 @@ describe('os dev — the in-place upgrade of a v17-shape development database', expect(result.lines[0]).toContain('✗ Refused (sys_mystery)'); expect(result.lines.at(-1)).toContain('nothing was changed'); expect(result.lines.at(-1)).toContain(BACKUP()); + expect(result.lines.at(-1)).toContain('17.x release'); + expect(result.lines.at(-1)).not.toContain('--plan-file'); }); 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', expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`); }); - it('a database that is not a local SQLite file is not upgraded in place: the ceremony is named instead', () => { + it('a database that is not a local SQLite file is not upgraded in place: 18.0\'s position is named instead', () => { const result = upgradeDevelopmentDatabase(input(fakeRun({}), { sqliteFile: null, databaseUrl: 'postgres://db/app' })); expect(result.ok).toBe(false); expect(calls).toEqual([]); expect(existsSync(join(dir, 'backups'))).toBe(false); - expect(result.lines[0]).toContain('os migrate organization-ownership --dev'); + expect(result.lines[0]).toContain('17.x release'); + // 18.0 refuses `os migrate organization-ownership` on every database: the way on names no step of it. + expect(result.lines[0]).not.toContain('os migrate organization-ownership'); + expect(result.lines[0]).not.toContain('--plan-file'); }); it('the IPC message is recognised by its type alone', () => { diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts index a6f71cde019..89b708e5268 100644 --- a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts +++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts @@ -118,9 +118,15 @@ function refusalLines(logFile: string): string[] { * Synchronous on purpose — nothing else may open the files while it runs. */ export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): DevelopmentUpgradeResult { + // The way on, when os dev does not upgrade the database itself. 18.0 supports + // fresh installs only, and its `os migrate organization-ownership` refuses on + // every database (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`), so this names + // no step of that command: it says what 18.0 supports for a v17 database, in + // the boot refusal's terms. The 18.x release that registers the ceremony names + // its steps here again. const manual = - 'Run `os migrate organization-ownership --dev` (review its plan), then the same command with --apply ' + - '--plan-file FILE --backup-taken, and start os dev again.'; + '18.0 supports fresh installs only and does not upgrade a v17 database: keep this development database on a ' + + '17.x release until the 18.x release that carries the upgrade path, and upgrade it there.'; if (!input.sqliteFile) { return { ok: false, From 69778e478d698e34e303489c22b0a2abeb098d3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:19:01 +0000 Subject: [PATCH 2/3] fix(cli): say keep a v17 database, so the refusal stays true on a database 18.0 created The drop gate also fires on a fresh 18.0 database whose object newly declares systemFields.tenant false over its injected organization_id, so the refusal names a v17 database rather than this one. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T --- .changeset/24567-drop-gate-refusal-18-0.md | 4 ++-- packages/cli/src/commands/migrate/apply.ts | 4 ++-- packages/cli/src/utils/dev-organization-ownership-upgrade.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/24567-drop-gate-refusal-18-0.md b/.changeset/24567-drop-gate-refusal-18-0.md index fb56c07493a..e28c7313491 100644 --- a/.changeset/24567-drop-gate-refusal-18-0.md +++ b/.changeset/24567-drop-gate-refusal-18-0.md @@ -6,8 +6,8 @@ fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusa Clause-②: no -- **`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. +- **`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 a v17 database on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. The v18 boot refusal and `os migrate organization-ownership`'s own refusal say the same. 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. - **`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. -- **`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`. +- **`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 a v17 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`. 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. diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 8b807856d25..7a6414669b7 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -595,8 +595,8 @@ export default class MigrateApply extends Command { `Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, a drop the organization-ownership ceremony ` + `owns, and the ceremony has not verified on this database. ${detail} Dropping the column first would turn ` + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. 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, in its ' + + 'supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release ' + + 'until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself, in its ' + 'column-drops step, before it records the run verified. Nothing in this run was applied.', ); this.exit(1); diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts index 89b708e5268..47a5a0318ec 100644 --- a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts +++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts @@ -125,7 +125,7 @@ export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): Deve // the boot refusal's terms. The 18.x release that registers the ceremony names // its steps here again. const manual = - '18.0 supports fresh installs only and does not upgrade a v17 database: keep this development database on a ' + + '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 development database on a ' + '17.x release until the 18.x release that carries the upgrade path, and upgrade it there.'; if (!input.sqliteFile) { return { From 0df297be078c9575aeddc013b7064f9453efa90e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:14:43 +0000 Subject: [PATCH 3/3] test(cli): the platform-global drop-gate pin reads the refusal's new detail The integration pin still matched the withdrawn os migrate organization-ownership --apply in the --json detail; it now asserts the ceremony is named and no step of the command 18.0 refuses is. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T --- ...-ownership-platform-global-column-drop.integration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts index 9f7bd1482e9..71da34a2ad2 100644 --- a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts @@ -436,7 +436,9 @@ describe('os migrate apply --allow-destructive: the organization_id drop on a ho created: [], marker: null, }); - expect(payload.detail).toMatch(/os migrate organization-ownership --apply/); + expect(payload.detail).toMatch(/organization-ownership ceremony/); + // 18.0 refuses `os migrate organization-ownership` on every database: the refusal names no step of it. + expect(payload.detail).not.toMatch(/os migrate organization-ownership|--plan-file|organization-ownership --out/); // The drops the refusal held back are the ones the drift offered. expect(organizationDropsIn(payload.skipped)).toEqual([...DECLARED].sort()); for (const table of HOST_OBJECTS) expect(hasOrganizationColumn(dbFile, table), table).toBe(true);