Skip to content

Commit 68bb8bd

Browse files
committed
wip: advisory tests, harness pins, docs, changeset
Claude-Session: https://claude.ai/code/session_01RXxXFcq659RGJE6SsXAc3u Co-authored-by: Claude <noreply@anthropic.com>
1 parent 105aa6d commit 68bb8bd

9 files changed

Lines changed: 562 additions & 6 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os migrate plan` lists the tenant columns still nullable and names the voluntary organization-ownership ceremony; the ceremony reports a rotation shard already NOT NULL as such (#25134)
6+
7+
Clause-②: yes (widening) — `os migrate plan --json` gains the `nullableTenantColumns` field, and the ceremony's `not-null` step gains the `already-not-null` outcome and the `alreadyNotNull` summary count. No exit code, drift entry, op, refusal or accepted input moves.
8+
9+
Metadata-protocol: none
10+
11+
A database the published 18.0.0 created is certified at birth with its tenant columns (`organization_id`) nullable, and `os migrate organization-ownership`, which tightens them to `NOT NULL`, is voluntary on it. Nothing told the operator that.
12+
13+
- **`os migrate plan`: a new advisory section.** It lists every tenant column the database still holds nullable (table and column) and names `os migrate organization-ownership` as the remedy. Each column says whether the ceremony tightens it or leaves it, and why. The ceremony leaves a rotation shard created before shards were created `NOT NULL`, `sys_email_template` (its rows are mirrors the ceremony deletes, and the table retires), a table its inventory exempts, and every table on a database whose transport cannot rebuild a table (the Turso remote face). When the ceremony would tighten none of the listed columns, the section says so and does not offer it. A fresh install lists nothing. A column named `organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a tenant column and is not listed.
14+
- **Advisory only.** It is not drift: it proposes no change, adds nothing to `total` or `changes`, and the exit code does not change. Under `--json` it is the separate field `nullableTenantColumns`: `{ status: 'read', adr, remedy, columns: [{ table, column, database?, ceremony: 'tightens' | 'leaves', reason? }] }`, or `{ status: 'unreadable', adr, detail }` when the plan could not read it.
15+
- **The ceremony's `not-null` step.** A rotation shard that the driver created `NOT NULL` used to be reported as `unsupported`, with a note saying each new shard is created nullable. That has not been true since the driver began creating shards `NOT NULL`. Such a shard is now `already-not-null` (counted in `alreadyNotNull`) and gets no note. A shard rotated in before then stays `unsupported`, and its note says it is dropped when its period leaves the base table's retention.
16+
17+
**What to do.** Nothing is required: the server starts either way. To tighten the columns the plan lists, run `os migrate organization-ownership --out PLAN`, review it, then run `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,37 @@ without `NODE_ENV=development` describes a production `os serve`, which keeps th
986986
objects in the primary unless `OS_TELEMETRY_DB` names a file. A `plan` against a
987987
sibling that does not exist yet lists its tables to create and leaves no file behind.
988988
989+
#### Tenant columns still nullable (advisory)
990+
991+
`os migrate plan` lists every tenant column the database still holds nullable, in a
992+
section of its own, and names `os migrate organization-ownership` as the remedy. A
993+
database the published 18.0.0 created is certified at birth with its tenant columns
994+
nullable, and the [organization-ownership ceremony](#os-migrate-organization-ownership)
995+
that tightens them is voluntary on it: the server starts either way. A database the
996+
ceremony upgraded keeps only the few the ceremony leaves, and each of those is listed
997+
with the reason it stays nullable:
998+
999+
```text
1000+
ℹ 2 tenant column(s) are still nullable (ADR-0131 D1). This is advisory: it is not drift, and nothing in this plan or in "os migrate apply" changes it.
1001+
• sys_activity__r20261020.organization_id — the ceremony leaves it nullable: a rotation shard of sys_activity, created before shards were created NOT NULL: the ceremony rebuilds no shard, and it is dropped when its period leaves sys_activity's retention
1002+
• sys_email_template.organization_id — the ceremony leaves it nullable: its rows are mirrors the ceremony deletes, and the table retires (ADR-0131 D10 fate 2, D13), so the ceremony does not tighten it
1003+
None of them is one the organization-ownership ceremony tightens; each line says why it stays nullable.
1004+
```
1005+
1006+
When the ceremony would tighten any listed column, the section's last line names its
1007+
two commands instead. On a Turso remote database every listed column is one the
1008+
ceremony leaves, because that transport cannot rebuild a table. A fresh install creates
1009+
every tenant column `NOT NULL` and the section prints nothing. A column named
1010+
`organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a
1011+
tenant column and is not listed.
1012+
1013+
The section is advisory only. It is not drift: it proposes no change, adds nothing to
1014+
the change count, and does not change the exit code. Under `--json` it is the separate
1015+
`nullableTenantColumns` field: `{ status: "read", adr, remedy, columns }`, where each
1016+
column is `{ table, column, database?, ceremony: "tightens" | "leaves", reason? }`. It is
1017+
`{ status: "unreadable", detail }` when the plan could not read it; `columns: []` means
1018+
it read and found none.
1019+
9891020
#### Nothing is applied before you confirm
9901021
9911022
Both commands boot your app to read its metadata. That boot writes no row and no

‎content/docs/upgrading.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ ceremony, under any tenancy posture. A v18 server serves a database it created
354354
itself or one the ceremony certified, and refuses any other at boot, naming the
355355
command.
356356

357+
**A database the published 18.0.0 created** carries a birth marker and is certified
358+
with its tenant columns (`organization_id`) still nullable. `os migrate plan` lists
359+
them, and the ceremony, which such a database does not require, tightens them to
360+
`NOT NULL` when you run it.
361+
357362
**The sequence:**
358363

359364
1. Before the first 18.0 boot, run `os migrate security-catalog-overlays`. It
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterAll } from 'vitest';
4+
import { spawnSync } from 'node:child_process';
5+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6+
import { tmpdir } from 'node:os';
7+
import { dirname, join, resolve } from 'node:path';
8+
import { fileURLToPath } from 'node:url';
9+
import Database from 'better-sqlite3';
10+
11+
/**
12+
* [ADR-0131 D1; ruling record 6328195425, letter A, item 2] `os migrate plan`
13+
* lists the tenant columns a database still holds nullable, in an advisory
14+
* section of its own that names `os migrate organization-ownership`.
15+
*
16+
* A database the published 18.0.0 created is certified with its tenant columns
17+
* nullable; this one is brought to that shape by rebuilding a table a fresh
18+
* boot created with its `organization_id` nullable — the 18.0.0 DDL — keeping
19+
* its rows and indexes.
20+
*
21+
* What is pinned, through the public door (`serve`'s provision-and-exit, then
22+
* the command, from the source entry):
23+
*
24+
* - a fresh store lists nothing (`columns: []`, read — not unreadable);
25+
* - the 18.0.0-shaped store lists the nullable tenant column as one the
26+
* ceremony tightens, and a rotation shard created nullable as one it leaves;
27+
* - ⭐ it is advisory: the plan's `changes` and `total` are the fresh store's,
28+
* byte for byte — no drift entry and no op is added — and the exit code is
29+
* 0 in both faces;
30+
* - the human face prints the section and the ceremony's two commands.
31+
*/
32+
33+
const HERE = dirname(fileURLToPath(import.meta.url));
34+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
35+
const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js');
36+
const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx');
37+
const CASE_TIMEOUT_MS = 300_000;
38+
39+
const APP_OBJECT = 'os25134_deal';
40+
const SHARD = `${APP_OBJECT}__r20261019`;
41+
42+
const CONFIG = [
43+
'export default {',
44+
" manifest: { id: 'com.example.os25134advisory', name: 'advisory', version: '0.0.0', type: 'app' },",
45+
" requires: ['auth'],",
46+
` objects: [{ name: '${APP_OBJECT}', fields: { title: { type: 'text' } } }],`,
47+
'};',
48+
'',
49+
].join('\n');
50+
51+
const dirs: string[] = [];
52+
afterAll(() => {
53+
for (const d of dirs) {
54+
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
55+
}
56+
});
57+
58+
interface Fixture {
59+
dir: string;
60+
db: string;
61+
}
62+
63+
function fixture(): Fixture {
64+
const dir = mkdtempSync(join(tmpdir(), 'os-25134-advisory-'));
65+
dirs.push(dir);
66+
mkdirSync(join(dir, 'dist'), { recursive: true });
67+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG);
68+
return { dir, db: join(dir, 'boot.db') };
69+
}
70+
71+
/** The child's environment: the fixture's database, one file, nothing inherited that retargets it. */
72+
function childEnv(f: Fixture, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
73+
const env: NodeJS.ProcessEnv = { ...process.env };
74+
for (const key of [
75+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME',
76+
'OS_TELEMETRY_DB', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'NO_COLOR',
77+
]) {
78+
delete env[key];
79+
}
80+
return {
81+
...env,
82+
OS_DATABASE_URL: `file:${f.db}`,
83+
OS_ARTIFACT_PATH: join(f.dir, 'dist', 'objectstack.json'),
84+
OS_CLOUD_URL: 'off',
85+
OS_TELEMETRY_DISABLED: '1',
86+
OS_TELEMETRY_DB: '0',
87+
OS_AUTH_SECRET: 'os25134-advisory-secret-at-least-32-characters',
88+
OS_LOG_LEVEL: 'warn',
89+
NO_COLOR: '1',
90+
...extra,
91+
};
92+
}
93+
94+
/** `os serve`'s own provision-and-exit: a fresh store, the full composition schema-synced into it. */
95+
function serveBoot(f: Fixture): void {
96+
const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
97+
cwd: f.dir, env: childEnv(f, { OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: 240_000,
98+
});
99+
expect(boot.status, `the serving boot did not provision the database:\n${(boot.stderr ?? '').slice(-6000)}`).toBe(0);
100+
}
101+
102+
function plan(f: Fixture, json: boolean): { exit: number | null; payload: any; text: string } {
103+
const run = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', ...(json ? ['--json'] : [])], {
104+
cwd: f.dir, env: childEnv(f), encoding: 'utf8', timeout: 240_000,
105+
});
106+
let payload: any;
107+
if (json) {
108+
try { payload = JSON.parse(run.stdout); } catch { payload = { unparsed: `${run.stdout}\n${run.stderr}`.slice(-800) }; }
109+
}
110+
return { exit: run.status, payload, text: `${run.stdout}\n${run.stderr}` };
111+
}
112+
113+
/**
114+
* Rebuild `table` with its `organization_id` nullable — the shape the 18.0.0
115+
* driver created — keeping its rows and its indexes.
116+
*/
117+
function relaxTenantColumn(file: string, table: string): void {
118+
const db = new Database(file);
119+
try {
120+
const { sql } = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as { sql: string };
121+
const relaxed = sql.replace(/([`"]organization_id[`"][^,]*?)\s+not null/i, '$1');
122+
if (relaxed === sql) throw new Error(`${table}.organization_id is not NOT NULL in ${sql}`);
123+
const indexes = (db.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL").all(table) as Array<{ sql: string }>)
124+
.map((i) => i.sql);
125+
db.exec('PRAGMA foreign_keys = OFF');
126+
db.transaction(() => {
127+
db.exec(`ALTER TABLE "${table}" RENAME TO "${table}__pre"`);
128+
db.exec(relaxed);
129+
db.exec(`INSERT INTO "${table}" SELECT * FROM "${table}__pre"`);
130+
db.exec(`DROP TABLE "${table}__pre"`);
131+
for (const index of indexes) db.exec(index);
132+
})();
133+
} finally {
134+
db.close();
135+
}
136+
}
137+
138+
function notNull(file: string, table: string): boolean {
139+
const db = new Database(file, { readonly: true });
140+
try {
141+
const col = (db.prepare(`SELECT "notnull" AS nn FROM pragma_table_info(?) WHERE name = 'organization_id'`).get(table)) as { nn: number } | undefined;
142+
if (!col) throw new Error(`${table} carries no organization_id`);
143+
return col.nn === 1;
144+
} finally {
145+
db.close();
146+
}
147+
}
148+
149+
describe('os migrate plan lists the nullable tenant columns as an advisory (ADR-0131 D1)', () => {
150+
it('a fresh store lists nothing; an 18.0.0-shaped one lists them, and neither the drift nor the exit code moves', () => {
151+
const f = fixture();
152+
serveBoot(f);
153+
expect(notNull(f.db, APP_OBJECT), 'a table this release creates carries its tenant column NOT NULL').toBe(true);
154+
155+
const fresh = plan(f, true);
156+
expect(fresh.exit, JSON.stringify(fresh.payload).slice(0, 800)).toBe(0);
157+
expect(fresh.payload.nullableTenantColumns).toEqual({
158+
status: 'read', adr: 'ADR-0131 D1', remedy: 'os migrate organization-ownership', columns: [],
159+
});
160+
161+
// The 18.0.0 shape: the app's tenant column nullable, and a shard rotated in nullable.
162+
relaxTenantColumn(f.db, APP_OBJECT);
163+
expect(notNull(f.db, APP_OBJECT)).toBe(false);
164+
const db = new Database(f.db);
165+
try { db.exec(`CREATE TABLE "${SHARD}" ("id" varchar(255) PRIMARY KEY, "organization_id" varchar(255))`); } finally { db.close(); }
166+
167+
const shaped = plan(f, true);
168+
expect(shaped.exit, JSON.stringify(shaped.payload).slice(0, 800)).toBe(0);
169+
expect(shaped.payload.nullableTenantColumns).toEqual({
170+
status: 'read',
171+
adr: 'ADR-0131 D1',
172+
remedy: 'os migrate organization-ownership',
173+
columns: [
174+
{ table: APP_OBJECT, column: 'organization_id', ceremony: 'tightens' },
175+
{ table: SHARD, column: 'organization_id', ceremony: 'leaves', reason: expect.stringMatching(/^a rotation shard of os25134_deal/) },
176+
],
177+
});
178+
// ⭐ Advisory, not drift: the plan's changes are the fresh store's, and no
179+
// entry names a tenant column.
180+
expect(shaped.payload.changes).toEqual(fresh.payload.changes);
181+
expect(shaped.payload.total).toBe(fresh.payload.total);
182+
expect(shaped.payload.changes.filter((c: any) => c.column === 'organization_id')).toEqual([]);
183+
184+
const human = plan(f, false);
185+
expect(human.exit, human.text.slice(-2000)).toBe(0);
186+
expect(human.text).toContain('2 tenant column(s) are still nullable (ADR-0131 D1). This is advisory: it is not drift');
187+
expect(human.text).toContain(`• ${APP_OBJECT}.organization_id\n`);
188+
expect(human.text).toContain(`• ${SHARD}.organization_id — the ceremony leaves it nullable: a rotation shard of ${APP_OBJECT}`);
189+
expect(human.text).toContain('tightens 1 of them: run "os migrate organization-ownership --out PLAN"');
190+
}, CASE_TIMEOUT_MS);
191+
});

0 commit comments

Comments
 (0)