Skip to content
Closed
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
29 changes: 29 additions & 0 deletions .changeset/23429-v18-boot-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/runtime': minor
'@objectstack/cli': minor
'@objectstack/spec': minor
---

feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape, and `os migrate apply` refuses to drop its `organization_id` columns, until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (already-registered sys-audit-log-organization-column-retired, sys-flow-dispatch-organization-column-retired, sys-job-organization-column-retired, sys-job-queue-organization-column-retired, sys-job-run-organization-column-retired, sys-migration-journal-organization-column-retired, sys-migration-organization-column-retired, sys-presence-organization-column-retired) The step-18 D3 entries named here, registered before this change, record the retirement of each organization column this refusal reads, with the remedy that clears it; the metadata family's four entries land with its schema change, which this refusal ships beside. This change adds no authorable surface, moves no spec key and rewrites no stored row: it refuses to boot while a retired column is still physically present, and the remedy is the operator's own migration: the ceremony, whose column-drops step drops what those entries prescribe dropping. No new D3 entry and no D2 conversion. -->

**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes.

A v18 runtime no longer serves a database it was never migrated for. At `kernel:ready`, `os serve` (and so `os dev`) checks every table whose object declares no organization column (`systemFields.tenant: false`). If one still carries a physical `organization_id`, the boot is refused unless the `sys_migration` row `adr-0131-organization-ownership` is verified. The refusal names the tables and the command. It is thrown before `kernel:listening`, so no request is served. Its code is `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`.

FROM → TO:

- A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply`, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime.
- `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table in the ceremony's inventory (the D7 tables and the `sys_metadata` family) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The ceremony drops these columns itself once it verifies; any other destructive drop is unchanged.
- There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check.

What does not change:

- A fresh database boots: no table it creates carries a retired column, so no marker is needed.
- A database whose ceremony verified boots, whatever its columns.
- The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only `os migrate apply`'s drop of an inventory table's `organization_id` waits, as above.
- A driver with no physical columns to read (memory, MongoDB) is not checked.
- A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`.
13 changes: 13 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,19 @@ occupancy on its own.
| `needs_confirm` | non-narrowing type change, rebuild a non-unique index whose columns changed | `os migrate apply` — except `manual_column_type_change` (only `os migrate multi-value-columns --apply` runs it), and `manual_widen_varchar_to_text` and `unbuildable_index`, which nothing applies |
| `destructive` | drop an orphaned column or index, tighten `NOT NULL`, narrow a type, rebuild an index as `UNIQUE`, create a `UNIQUE` index existing rows already violate | `os migrate apply --allow-destructive` |

`--allow-destructive` refuses some drops until the data they would lose is
safe. No flag skips these refusals:

- `sys_account.issuer` is dropped only when its retirement pre-flight reads the
table clean (`os migrate account-issuer` reports the colliding rows).
- `sys_comment.reactions` waits for a verified `os migrate comment-reactions --apply` run.
- `organization_id` on any table the organization-ownership ceremony covers
waits for a verified `os migrate organization-ownership --apply` run. These tables are the
ADR-0131 D7 tables and the `sys_metadata` family. The ceremony drops those
columns itself once it verifies. Dropping one first would turn every legacy
organization row into an environment row. Under `--json` the refusal answers
`message: 'refused_organization_ownership_unmigrated'` and lists the `tables`.

#### Index drift

`plan` covers indexes as well as columns:
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +326 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +327 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -261,6 +261,7 @@ const result = ApiErrorSchema.parse(data);
* `OBJECT_OWNERSHIP_CONFLICT`
* `OBJECT_PACKAGE_DISABLED`
* `OPENAPI_UNAVAILABLE`
* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`
* `OS_PROTOCOL_INCOMPATIBLE`
* `PACKAGE_DELETE_FAILED`
* `PACKAGE_DELETE_PARTIAL`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ const result = ErrorCode.parse(data);
* `OBJECT_OWNERSHIP_CONFLICT`
* `OBJECT_PACKAGE_DISABLED`
* `OPENAPI_UNAVAILABLE`
* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`
* `OS_PROTOCOL_INCOMPATIBLE`
* `PACKAGE_DELETE_FAILED`
* `PACKAGE_DELETE_PARTIAL`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23429 E1 · ADR-0131 D10 item 5] `os migrate apply --allow-destructive`
* refuses to drop `organization_id` from a table in the organization-ownership
* ceremony's inventory until the ceremony's marker is verified — the
* #17440 / #23281 shape, `--json` refusal included, no flag that stands it
* down. Pinned on a v17-shape `sys_platform_setting` (an inventory table v18 declares
* no organization column on, so the drift report offers its physical one for
* the destructive apply):
*
* 1. the drop is refused, naming the ceremony, and nothing is dropped;
* 2. a control: an unrelated destructive drop on the same table is not gated;
* 3. once the ceremony's marker is verified, the drop proceeds.
*/

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
import { isExitSignal } from '../../utils/format.js';
import MigrateApply from './apply.js';

// Pay the first transform of the dist-resolved workspace deps the command
// reaches through dynamic `import()`s at MODULE LOAD, not inside a case.
import '@objectstack/runtime';
import '@objectstack/objectql';

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

const OVERRIDING_ENV = [
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME',
'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH',
] as const;

async function runJson(argv: string[]): Promise<{ payload: any; exitCode: number }> {
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(() => {});
let processExit: number | undefined;
vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
processExit = code ?? 0;
throw new Error(`__PROCESS_EXIT__:${processExit}`);
}) as never);
let thrownExit: number | undefined;
try {
try {
await MigrateApply.run(argv, { root: CLI_ROOT });
} catch (error) {
const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__');
if (!trapped && !isExitSignal(error)) throw error;
if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
}
const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim();
let payload: any;
try { payload = JSON.parse(out); } catch { payload = { unparsed: out.slice(-400) }; }
return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 };
} finally {
process.exitCode = savedExit;
vi.restoreAllMocks();
}
}

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

function withDb<T>(file: string, fn: (db: Database.Database) => T): T {
const db = new Database(file);
try {
return fn(db);
} finally {
db.close();
}
}

const columnsOf = (file: string, table: string): string[] =>
withDb(file, (db) => (db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>).map((c) => c.name));

/** A copy of the v18 schema with `columns` added raw to `sys_platform_setting`. */
let seq = 0;
function caseDb(columns: readonly string[]): string {
seq += 1;
const file = join(dir, `case-${seq}.db`);
copyFileSync(v18Db, file);
withDb(file, (db) => { for (const c of columns) db.exec(`ALTER TABLE sys_platform_setting ADD COLUMN ${c} TEXT`); });
return file;
}

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

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];
dir = mkdtempSync(join(tmpdir(), 'os-23429-drop-gate-'));
mkdirSync(join(dir, 'dist'), { recursive: true });
writeFileSync(
join(dir, 'objectstack.config.ts'),
"export default { manifest: { id: 'com.example.os23429gate', name: 'gate', version: '0.0.0', type: 'app' } };\n",
);
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
process.env.NODE_ENV = 'production';
process.env.OS_TELEMETRY_DB = '0';
v18Db = join(dir, 'v18.db');
process.chdir(dir);
try {
const created = await runJson(['--database-url', `file:${v18Db}`, '--yes', '--json']);
expect(created.exitCode, JSON.stringify(created.payload).slice(0, 400)).toBe(0);
} finally {
process.chdir(savedCwd);
}
expect(columnsOf(v18Db, 'sys_platform_setting'), 'fixture: v18 declares no organization column here').not.toContain('organization_id');
}, 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;
}
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
});

beforeEach(() => { process.chdir(dir); });
afterEach(() => { process.chdir(savedCwd); });

describe('os migrate apply --allow-destructive waits for the organization-ownership ceremony (#23429 E1)', () => {
it('1. a v17-shape table: the organization_id drop is refused, naming the ceremony, and nothing is dropped', async () => {
const file = caseDb(['organization_id']);
const { payload, exitCode } = await runJson(dropArgv(file));
expect(exitCode).toBe(1);
expect(payload).toMatchObject({
message: 'refused_organization_ownership_unmigrated',
tables: ['sys_platform_setting'],
applied: [],
created: [],
marker: null,
});
expect(payload.detail).toMatch(/os migrate organization-ownership --apply/);
expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
}, CASE_TIMEOUT_MS);

it('1b. an unverified marker refuses too', async () => {
const file = caseDb(['organization_id']);
withDb(file, (db) => db.prepare(
'INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, NULL, 2)',
).run(ORGANIZATION_OWNERSHIP_MIGRATION_ID, new Date().toISOString()));
const { payload, exitCode } = await runJson(dropArgv(file));
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(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
}, CASE_TIMEOUT_MS);

it('2. control: an unrelated destructive drop on the same table is not gated', async () => {
const file = caseDb(['legacy_23429_note']);
const { payload, exitCode } = await runJson(dropArgv(file));
expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0);
expect(payload.message).not.toBe('refused_organization_ownership_unmigrated');
expect(columnsOf(file, 'sys_platform_setting')).not.toContain('legacy_23429_note');
}, CASE_TIMEOUT_MS);

it('3. once the ceremony\'s marker is verified, the drop proceeds', async () => {
const file = caseDb(['organization_id']);
const now = new Date().toISOString();
withDb(file, (db) => db.prepare(
'INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, ?, 0)',
).run(ORGANIZATION_OWNERSHIP_MIGRATION_ID, now, now));
const { payload, exitCode } = await runJson(dropArgv(file));
expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0);
expect(payload.message).not.toBe('refused_organization_ownership_unmigrated');
expect(columnsOf(file, 'sys_platform_setting')).not.toContain('organization_id');
}, CASE_TIMEOUT_MS);
});
49 changes: 49 additions & 0 deletions packages/cli/src/commands/migrate/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
REACTIONS_COLUMN,
readCommentReactionsConversion,
} from '../../utils/comment-reactions-conversion.js';
import { ceremonyOrganizationColumnDrops, readOrganizationOwnershipMarker } from '../../utils/organization-ownership-drop-gate.js';

async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
Expand Down Expand Up @@ -480,6 +481,54 @@ export default class MigrateApply extends Command {
}
}

// ── [#23429 E1, ADR-0131 D10 item 5] REFUSE AN organization_id DROP AHEAD OF THE CEREMONY ──
//
// The third sibling, placed by the same rule: below the report, above the
// confirmation prompt, above both writes. A v18 runtime declares no
// organization column on the ceremony's column-drop tables, so the drift
// report offers each physical one for `--allow-destructive`. Dropped here
// first, the ceremony never promotes or reports the legacy organization
// rows, they become environment rows, and the v18 boot refusal finds no
// column left to refuse over — the escape hatch D10 item 5 forbids. So
// the drop waits for the ceremony's verified marker; no flag stands it
// down (`utils/organization-ownership-drop-gate.ts`).
const organizationDrops = ceremonyOrganizationColumnDrops(intended);
if (organizationDrops.length > 0) {
const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call(
stack.kernel,
'objectql',
);
const marker = await readOrganizationOwnershipMarker(engine as never);
if (!marker.verified) {
const detail = marker.flag
? `The last run of "os migrate organization-ownership --apply" (${marker.flag.last_run_at}) is not verified `
+ `(${marker.flag.blocking} blocking item(s)).`
: 'No run of "os migrate organization-ownership --apply" is recorded on this database.';
if (flags.json) {
await emitJson({
...target, created: [], applied: [], skipped: drift, pending,
message: 'refused_organization_ownership_unmigrated', tables: organizationDrops, detail, marker: marker.flag,
}, 1, { compact: true });
return;
}
printError(
`Refusing to drop organization_id from ${organizationDrops.join(', ')}: the organization-ownership ceremony `
+ `owns that drop, and it 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" to review its plan, then "os migrate organization-ownership --apply"; '
+ 'it drops these columns itself once it verifies.',
);
this.exit(1);
return;
}
if (!flags.json) {
printSuccess(
`organization_id on ${organizationDrops.join(', ')}: the organization-ownership ceremony verified `
+ `(${marker.flag?.verified_at}) — the drop may proceed.`,
);
}
}

const totalIntended = intended.length + pending.length;
if (totalIntended === 0) {
if (flags.json) { await emitJson({ ...target, applied: [], skipped: deferred, created: [], message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; }
Expand Down
Loading
Loading