Skip to content

Commit 5d66021

Browse files
fix(driver-sql): the boot drift line for an organization_id drop names the ownership ceremony on an uncertified database (#25188)
Fixes #24956 Clause-②: no (operator log line text only) Metadata-protocol: none ## What changes `SqlDriver`'s boot reconcile (`reconcileAndWarnDrift`) logs each drift entry as `[schema-drift] MESSAGE`. When an entry is an `organization_id` `drop_column`, it now asks the driver's own reader, `isTenantKeyCertified()`, once per table and only when such a line is about to print. The reader goes through the engine-handed resolver over `isOrganizationOwnershipCertified()`: a birth marker, or a ceremony marker of the current shape generation. No second list is involved. - **Not certified:** the line keeps the finding (`driftFindingWithoutRemedy`). It replaces the `--allow-destructive` sentence with PR #24954's vocabulary for the same entry: `— a drop the organization-ownership ceremony owns, and it has not verified on this database: run "os migrate organization-ownership", which drops it itself.` - **Certified, or any other column or drift kind:** the line is the differ's message, byte for byte. - **Unchanged:** what the boot applies or skips, the `driftWarned` de-duplication, and the drop gate (`packages/cli`). Files: `packages/drivers/driver-sql/src/schema-drift.ts` adds `isCeremonyOwnedColumnDrop` and `ceremonyOwnedDropFinding`. They are module exports only and are not added to the package's `index.ts`, so the public surface does not change. `sql-driver.ts` adds the branch. There is one new test file and a `patch` changeset. ## Measured: a real `create-objectstack@17.7.0` SQLite database, `OS_AUTH_SECRET` set The fixture was scaffolded with `npx create-objectstack@17.7.0`; it ran on `@objectstack/*@17.7.0` and booted once with `objectstack dev -d file:./v17.db`. It carries 43 tables with an `organization_id` column. Each door ran on a fresh copy, through this tree's CLI (`packages/cli/bin/run.js`) and a copy of the scaffold's app with `engines.protocol` set to `^18`. | door | `main` `82cfaf792` | this branch `bdd1ac563` | |---|---|---| | `os serve` | exit 1 (boot guard refusal); 28 `[schema-drift]` lines, **9** `organization_id` drop lines naming `--allow-destructive` | exit 1 (same refusal); 28 lines, **0** naming `--allow-destructive` for `organization_id`, 9 naming `os migrate organization-ownership` | | `os migrate apply --yes` | exit 0; 28 lines, **9** as above | exit 0; 28 lines, **0** as above, 9 naming the ceremony | | `os migrate plan` | exit 0; no `[schema-drift]` lines (its own renderer already names the ceremony, PR #24954) | unchanged | | `os migrate apply --yes --allow-destructive` (main) | exit 1, refused by the drop gate; `sys_job.organization_id` still present | not re-run (the gate is untouched) | The 9 tables are `sys_metadata`, `sys_metadata_history`, `sys_metadata_commit`, `sys_migration`, `sys_migration_journal`, `sys_secret`, `sys_flow_credential`, `sys_job` and `sys_job_queue`. The card counted 22 lines at `003e41ab5`; today's `main` prints 28. On both `serve` and `apply --yes`, the 19 other drift lines are identical between `main` and this branch (`diff` empty). **Certified control.** A fresh database from this branch (birth marker `adr-0131-organization-ownership` verified), plus a hand-added `sys_user.organization_id` column. Its boot prints `[schema-drift] sys_user.organization_id: column exists in the database but not in metadata (orphaned) — "os migrate apply --allow-destructive" to drop it.`, unchanged. The resolver answers "certified" at boot time. **The fork the dispatch named did not occur.** The driver's reader answers "not certified" for this card's population. That is the same reading the boot's `recreate_index` lines already print ("This database is not certified for the organization-ownership shape"). ## Pins and ablation `packages/drivers/driver-sql/src/sql-driver.ceremony-owned-drop-line.test.ts` runs on real SQLite through `initObjects`, with a 17.x-shaped table: 1. not certified (the resolver answers no): the `organization_id` line names `"os migrate organization-ownership"` and never `--allow-destructive`; the reader is asked once; 2. no resolver handed over: the same line; 3. certified: the line equals the differ's message exactly; 4. control, not certified: an orphaned `legacy_code` drop is unchanged (exact); 5. control, not certified: a `tighten_not_null` is unchanged (exact); 6. a table with no `organization_id` drop never asks the reader. **Ablation** (`scripts/ablation-replace.mjs`): the guard was prefixed with `false &&`, so the anchor went from 1 hit to 0 and the blob from `24448296bf26` to `cb74d2820589`. Pins 1 and 2 went red; pins 3 to 6 stayed green (2 failed, 4 passed). After restore the blob equals HEAD (`24448296bf26`) and `git diff HEAD` is empty. The test imports `./sql-driver.js` from `src`, so no `dist` preflight applies. ## Verification - `pnpm --filter @objectstack/driver-sql typecheck`: exit 0. The new test file is in the program (`--listFiles` count 1). - `pnpm --filter @objectstack/driver-sql test`: exit 0, `Test Files 231 passed | 12 skipped (243)`, `Tests 3748 passed | 212 skipped (3960)`. This is on the merged head `970f966b9`, and again before the merge. It ran SQLite only; live PG and MySQL are left to CI. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: exit 0, `323 passed (323)` files, `4769 passed` tests (at `bdd1ac563`). - `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2`: exit 0, `166 passed | 1 skipped (167)` files, `1447 passed | 9 skipped` tests (at `bdd1ac563`). It ran locally because the diff touches the driver boot path. - After merging `origin/main` (`6251f5905`): `pnpm exec turbo run build --concurrency=2` exit 0 (77/77 tasks), then typecheck and the driver-sql suite as above. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `970f966b9` derived 70 commands, and all 70 exit 0. The first run, at `bdd1ac563`, had `check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET, unbuilt packages); it exited 0 after the full build. `--ran`: `70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero)`. ## Acceptance notes - **Docs.** No page under `content/docs/**` quotes the boot line. The `destructive` row of `deployment/cli.mdx`'s drift table and its `--allow-destructive` refusals list stay true, so I found no page this change falsifies. - **The column name.** `organization_id` is spelled once in `schema-drift.ts` (`CEREMONY_OWNED_DROP_COLUMN`). That matches the CLI gate's `ORGANIZATION_COLUMN`. It names the ceremony's column, not a list of tables. The decision to hold comes from `isTenantKeyCertified()` alone. - **A resolver that throws.** It reads "not certified", and `isTenantKeyCertified()` logs its existing one-time warning. That warning's wording is about index comparison. A database whose ledger read fails, and which has an `organization_id` drop, would now see it printed. Before, it printed only with a COALESCE-keyed index. - **Orphaned indexes keyed on `organization_id`** (for example `idx_sys_metadata_org_type`, a `drop_index`) still name `--allow-destructive` on this database, at boot and in `os migrate plan`. The CLI gate judges `drop_column` only, so those lines are not the ceremony's under today's gate. They are out of this card's scope and noted only. - **18.0-born databases** (#24638's `tenant: false` case) are certified by their birth marker, and their line is unchanged. #24638 is not addressed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NQBsTsRUMvji8sjVyHJkvY --- _Generated by [Claude Code](https://claude.ai/code/session_01NQBsTsRUMvji8sjVyHJkvY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2069b81 commit 5d66021

4 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): on a database the organization-ownership ceremony has not certified, the boot's drift line for an orphaned `organization_id` column names the ceremony instead of `os migrate apply --allow-destructive`
6+
7+
Clause-②: no (operator log line text only)
8+
Metadata-protocol: none
9+
10+
A 17.x database booted on 18.x printed one `[schema-drift]` line per `organization_id` column ending `— "os migrate apply --allow-destructive" to drop it.`, during `os serve` and `os migrate apply --yes`. That command is refused on such a database: the drop is the ceremony's (ADR-0131 D10 item 5). The boot now asks the driver's own reader, `isTenantKeyCertified()`, once per table that has such a drop. When the answer is "not certified", the line keeps the finding and replaces that sentence with `— a drop the organization-ownership ceremony owns, and it has not verified on this database: run "os migrate organization-ownership", which drops it itself.` These are the words `os migrate plan` shows under the same entry.
11+
12+
A certified database prints the same line as before. So does every other column and every other drift kind. Nothing the boot applies or skips changes.

‎packages/drivers/driver-sql/src/schema-drift.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,32 @@ export function driftFindingWithoutRemedy(entry: Pick<ManagedDriftEntry, 'messag
441441
: entry.message;
442442
}
443443

444+
/**
445+
* [ADR-0131 D10 item 5] The column whose drop the organization-ownership
446+
* ceremony owns: on a database it has not certified, the ceremony drops it
447+
* itself and `os migrate apply --allow-destructive` refuses the run.
448+
*/
449+
const CEREMONY_OWNED_DROP_COLUMN = 'organization_id';
450+
451+
/** Is `entry` the drop of a column the organization-ownership ceremony owns? */
452+
export function isCeremonyOwnedColumnDrop(entry: Pick<ManagedDriftEntry, 'op'>): boolean {
453+
return entry.op.type === 'drop_column' && entry.op.column === CEREMONY_OWNED_DROP_COLUMN;
454+
}
455+
456+
/**
457+
* [ADR-0131 D10 item 5] The boot's `[schema-drift]` line for a ceremony-owned
458+
* column drop ({@link isCeremonyOwnedColumnDrop}) on a database the driver
459+
* reads as not certified: the finding without its `--allow-destructive`
460+
* sentence, which that database refuses, and the ceremony in its place, in
461+
* the words `os migrate plan` shows under the same entry.
462+
*/
463+
export function ceremonyOwnedDropFinding(entry: Pick<ManagedDriftEntry, 'message' | 'op'>): string {
464+
return (
465+
`${driftFindingWithoutRemedy(entry)} — a drop the organization-ownership ceremony owns, and it has not ` +
466+
`verified on this database: run "os migrate organization-ownership", which drops it itself.`
467+
);
468+
}
469+
444470
/** Columns the driver creates unconditionally — never metadata fields. */
445471
export const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
446472

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
import { SqlDriver } from './sql-driver.js';
5+
6+
/**
7+
* [ADR-0131 D10 item 5] The boot's `[schema-drift]` line for an orphaned
8+
* `organization_id` column.
9+
*
10+
* On a database the organization-ownership ceremony has not certified, that
11+
* drop is the ceremony's: `os migrate apply --allow-destructive` refuses the
12+
* run, so a line prescribing it sends the operator to a refused command. The
13+
* boot asks the driver's own reader ({@link SqlDriver.isTenantKeyCertified},
14+
* the engine-handed resolver) and, on "not certified", names the ceremony in
15+
* the place of that sentence. A certified database, every other column and
16+
* every other drift kind print exactly the message the differ wrote.
17+
*/
18+
19+
const TABLE = 'biz_unit';
20+
const ORPHAN_ORG = `${TABLE}.organization_id: column exists in the database but not in metadata (orphaned)`;
21+
const ALLOW_DESTRUCTIVE_DROP = ' — "os migrate apply --allow-destructive" to drop it.';
22+
23+
let driver: SqlDriver | undefined;
24+
25+
afterEach(async () => {
26+
await driver?.disconnect();
27+
driver = undefined;
28+
});
29+
30+
/** A 17.x-shaped table: `organization_id` and `legacy_code` declared by nothing, `name` nullable under a declared NOT NULL. */
31+
async function bootOn(resolver?: () => boolean): Promise<{ lines: string[]; asked: () => number }> {
32+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
33+
const warn = vi.fn();
34+
(driver as any).logger = { warn, info: vi.fn() };
35+
let asked = 0;
36+
if (resolver) {
37+
driver.setTenantKeyCertifiedResolver(() => {
38+
asked++;
39+
return resolver();
40+
});
41+
}
42+
await (driver as any).knex.schema.createTable(TABLE, (t: any) => {
43+
t.string('id').primary();
44+
t.string('name');
45+
t.string('organization_id');
46+
t.string('legacy_code');
47+
});
48+
await driver.initObjects([
49+
{ name: TABLE, fields: { name: { type: 'string', required: true, storage: { notNull: true } } } },
50+
]);
51+
const lines = warn.mock.calls.map((c: any[]) => String(c[0])).filter((l) => l.startsWith('[schema-drift] '));
52+
return { lines, asked: () => asked };
53+
}
54+
55+
const lineFor = (lines: string[], prefix: string): string => {
56+
const found = lines.filter((l) => l.startsWith(`[schema-drift] ${prefix}`));
57+
expect(found, lines.join('\n')).toHaveLength(1);
58+
return found[0]!;
59+
};
60+
61+
describe('the boot drift line for an orphaned organization_id column', () => {
62+
it('not certified: names the ceremony, never --allow-destructive', async () => {
63+
const { lines, asked } = await bootOn(() => false);
64+
const line = lineFor(lines, ORPHAN_ORG);
65+
expect(line.startsWith(`[schema-drift] ${ORPHAN_ORG} — `)).toBe(true);
66+
expect(line).toContain('"os migrate organization-ownership"');
67+
expect(line).not.toContain('--allow-destructive');
68+
expect(asked()).toBe(1);
69+
});
70+
71+
it('no resolver handed over reads as not certified: the same line', async () => {
72+
const { lines } = await bootOn();
73+
const line = lineFor(lines, ORPHAN_ORG);
74+
expect(line).toContain('"os migrate organization-ownership"');
75+
expect(line).not.toContain('--allow-destructive');
76+
});
77+
78+
it('certified: the line the differ wrote, unchanged', async () => {
79+
const { lines } = await bootOn(() => true);
80+
expect(lineFor(lines, ORPHAN_ORG)).toBe(`[schema-drift] ${ORPHAN_ORG}${ALLOW_DESTRUCTIVE_DROP}`);
81+
});
82+
83+
it('control, not certified: another orphaned column keeps its --allow-destructive remedy', async () => {
84+
const { lines } = await bootOn(() => false);
85+
expect(lineFor(lines, `${TABLE}.legacy_code:`)).toBe(
86+
`[schema-drift] ${TABLE}.legacy_code: column exists in the database but not in metadata (orphaned)${ALLOW_DESTRUCTIVE_DROP}`,
87+
);
88+
});
89+
90+
it('control, not certified: a tighten_not_null keeps its --allow-destructive remedy', async () => {
91+
const { lines } = await bootOn(() => false);
92+
expect(lineFor(lines, `${TABLE}.name:`)).toBe(
93+
`[schema-drift] ${TABLE}.name: metadata declares \`storage.notNull\` but the column is nullable — existing nulls ` +
94+
'must be backfilled. Run "os migrate apply --allow-destructive".',
95+
);
96+
});
97+
98+
it('a table with no organization_id drop never asks the question', async () => {
99+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
100+
(driver as any).logger = { warn: vi.fn(), info: vi.fn() };
101+
let asked = 0;
102+
driver.setTenantKeyCertifiedResolver(() => {
103+
asked++;
104+
return false;
105+
});
106+
await (driver as any).knex.schema.createTable(TABLE, (t: any) => {
107+
t.string('id').primary();
108+
t.string('name');
109+
t.string('legacy_code');
110+
});
111+
await driver.initObjects([{ name: TABLE, fields: { name: { type: 'string' } } }]);
112+
expect(asked).toBe(0);
113+
});
114+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ import {
121121
GLOBAL_TENANT,
122122
canonicalIndexKey,
123123
carriesNullSafeTenantKeyPart,
124+
ceremonyOwnedDropFinding,
125+
isCeremonyOwnedColumnDrop,
124126
hashShadowColumnFor,
125127
isHashShadowCarrier,
126128
parseHashShadowKeyParts,
@@ -14446,10 +14448,19 @@ export class SqlDriver implements IDataDriver {
1444614448
}
1444714449
}
1444814450

14451+
// [ADR-0131 D10 item 5] An `organization_id` drop is the ownership
14452+
// ceremony's until the database is certified: `--allow-destructive` is
14453+
// refused there, so the line names the ceremony instead. Asked once per
14454+
// table, and only when such a drop is about to be warned.
14455+
let certified: boolean | undefined;
1444914456
for (const d of drift) {
1445014457
const k = driftKey(d);
1445114458
if (this.driftWarned.has(k)) continue;
1445214459
this.driftWarned.add(k);
14460+
if (isCeremonyOwnedColumnDrop(d) && !(certified ??= await this.isTenantKeyCertified())) {
14461+
this.logger.warn(`[schema-drift] ${ceremonyOwnedDropFinding(d)}`);
14462+
continue;
14463+
}
1445314464
this.logger.warn(`[schema-drift] ${d.message}`);
1445414465
}
1445514466
}

0 commit comments

Comments
 (0)