Skip to content

Commit bf3bbf1

Browse files
os-steveclaude
andauthored
fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone (#14053)
* wip: declaration boot write guard * wip: tests * wip: guard + tests green * changeset * test: make the 13332 integration fixture independent of the fix * fix(cli): R1-R4 from at-tier contract review — report the contract's execute() escape hatch, correct the census R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/ contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a driver-sql extension, and the guard did not touch it: in one guarded boot create() was refused and reported while execute("INSERT ...") landed a row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not cover — a raw command is unknown by contract ('SQL string, shell command, or API payload') and SQL text cannot be classified read-vs-write reliably (a SELECT can quote INSERT in a literal; a CTE can write), while the framework's own boot work (ensureOverlayIndex's index DDL) runs through this seam, so a refusal would either break legitimate calls or rest on a guess. A boot-window execute() is now forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and no note claims the plan wrote nothing on such a run: the flat claim is an OUTCOME, printed by disarm() only when it held. R2: 'DDL already held back by deferSchemaDdl' was false for dropTable / rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate, not a deferral check) and execute immediately. The census now states the split and names them a genuinely open boundary. R3: drivers the engine holds for a non-default datasource are never published as driver.* (the only registration is default-datasource-plugin.ts) and are invisible to the guard's scan — now stated in the census, module header and changeset. R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the driver.* services; the interface is IDataDriver, not IDataSourceDriver (no such symbol exists in spec). Corrected in docblocks, tests and the changeset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffea19c commit bf3bbf1

6 files changed

Lines changed: 1301 additions & 9 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)
6+
7+
`composeForDeclarations` documented the plan path's guarantee in its own words —
8+
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
9+
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
10+
three phases unconditionally after the suppressed start pass: `kernel:ready`
11+
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
12+
writing hook **registered from `init()`** survives the suppression and executes
13+
on all three. The guarantee was therefore a property of plugins that happen to
14+
seed from `start()` — the shape of the one plugin that had been measured — and
15+
not of the plan path.
16+
17+
Measured, twice. The module header records 14 `Insert operation failed` rows
18+
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
19+
database whose tables already exist those inserts **succeed**: a command
20+
documented as writing nothing seeds rows into an operator's production control
21+
plane. Downstream, a control plane hit exactly this on the `apply=false` run
22+
that is its mandatory human review gate before a production schema apply
23+
(`driver.create` / `driver.update` on `sys_ai_model`, from an
24+
`init()`-registered `kernel:ready` hook).
25+
26+
**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
27+
`os migrate apply` now refuse the row-write members of the data-driver contract
28+
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
29+
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
30+
instance the kernel publishes. The refusal sits at the driver, not at a list of
31+
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
32+
on the day it ships), it covers writes that arrive through the ObjectQL engine
33+
as well as direct `driver.*` calls (the engine holds the same instance), and
34+
read/log-only hooks still run — which is what an operator reading a plan before
35+
a production apply needs them to do. A refused write returns a contract-shaped
36+
value rather than throwing (boot hooks dispatch propagating, so throwing would
37+
abort the bootstrap and leave the operator with no plan at all), and every
38+
refusal is reported: one warning on stderr per driver/method/object triple,
39+
plus a line in the composition notes the plan prints and `--json` carries.
40+
41+
The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
42+
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
43+
command is `unknown` by contract ("SQL string, shell command, or API payload"),
44+
and SQL text cannot be classified as read-vs-write reliably, so refusing would
45+
break boot-legitimate reads and the framework's own index DDL on a guess. A
46+
boot-window `execute()` is counted per driver, warned once per driver on
47+
stderr, and named in the composition notes — and on such a run the notes do
48+
NOT claim the plan wrote nothing, because the guard cannot verify it.
49+
50+
The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
51+
confirmed DDL flush and the coverage measurement are untouched.
52+
53+
**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
54+
from anywhere other than a suppressed `start()`. Contract row writes previously
55+
landed and now do not; the run says so. A host whose plugins call raw
56+
`execute()` during the boot keeps its behaviour (the call is forwarded) and
57+
now sees it reported. A host that did neither sees no change at all — no
58+
disarm note is emitted when nothing was refused and no raw command went
59+
through.
60+
61+
Boundaries stated rather than hidden: `execute()` is reported, never refused
62+
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
63+
intercepted. DDL splits: `deferSchemaDdl` holds back the
64+
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
65+
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
66+
deferral — they are gated only by `assertSchemaMutable`
67+
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
68+
Drivers the engine holds for a NON-default datasource are never published as
69+
`driver.*` (`DatasourceConnectionService.connect()` hands them to
70+
`engine.registerDriver` directly), so they are invisible to the guard's scan
71+
and objectql-mediated writes to objects bound to them would land. The guard
72+
also does not cover writes a plugin makes outside the database, or work a hook
73+
defers past the end of the bootstrap.

‎packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts‎

Lines changed: 293 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4-
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
4+
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
55
import { createRequire } from 'node:module';
66
import { tmpdir } from 'node:os';
77
import { dirname, join, resolve } from 'node:path';
@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
494494
}
495495
}, 60_000);
496496
});
497+
498+
/**
499+
* #13332 — the same guarantee, end to end, against a real SQL driver.
500+
*
501+
* The unit half
502+
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
503+
* mechanism on a recording driver. This half proves the property the operator
504+
* actually depends on: `os migrate plan`'s boot, with a host plugin that
505+
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
506+
* database whose tables already exist, which is the condition under which the
507+
* measured inserts SUCCEED instead of failing.
508+
*
509+
* The positive control comes first and is load-bearing. The identical plugin,
510+
* on a boot that composes no host stack (so no declaration composition and no
511+
* write guard), lands its rows. Without that leg the assertion below would be
512+
* green over a fixture that could not have written.
513+
*/
514+
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
515+
let dir: string;
516+
let dbFile: string;
517+
let hookLog: string;
518+
const savedEnv: Record<string, string | undefined> = {};
519+
520+
const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;
521+
522+
/**
523+
* cloud's measured shape, as a plugin this file can hand to either boot: the
524+
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
525+
* `start()` suppression never sees them, and they fire on each of the three
526+
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
527+
*
528+
* The driver is found by scanning `driver.*` — the same surface
529+
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
530+
* fixture does not depend on what the standalone stack calls its default.
531+
*/
532+
const initWritingPlugin = (tag: string): any => ({
533+
name: `com.example.writes-from-init.${tag}`,
534+
version: '1.0.0',
535+
init: async (ctx: any) => {
536+
for (const phase of PHASES) {
537+
ctx.hook(phase, async () => {
538+
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
539+
});
540+
ctx.hook(phase, async () => {
541+
const services: Map<string, any> = ctx.getServices();
542+
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
543+
if (!entry) return;
544+
await entry[1].create('sys_metadata', {
545+
id: `os13332-${tag}-${phase}`,
546+
name: `os13332-${tag}-${phase}`,
547+
type: 'os13332_probe',
548+
});
549+
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
550+
});
551+
}
552+
},
553+
});
554+
555+
const probeRows = async (driver: any): Promise<number> => {
556+
const rows: any = await driver.knex('sys_metadata')
557+
.where({ type: 'os13332_probe' })
558+
.count({ c: '*' });
559+
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
560+
};
561+
562+
beforeAll(async () => {
563+
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
564+
dbFile = join(dir, 'control.db');
565+
hookLog = join(dir, 'hooks.log');
566+
writeFileSync(hookLog, '');
567+
568+
savedEnv.NODE_ENV = process.env.NODE_ENV;
569+
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
570+
process.env.NODE_ENV = 'production';
571+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
572+
573+
// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
574+
// measured defect is precisely that on a database whose tables EXIST the
575+
// inserts succeed rather than fail, so neither case below may run against
576+
// an empty schema — and the fixture must not depend on the fix to build
577+
// itself: with the guard ablated, a writing hook against a table that does
578+
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
579+
// bootstrap dies. Setting the schema up before the writer exists keeps an
580+
// ablation landing on the assertions below instead of on this hook.
581+
const boot = await bootSchemaStack({
582+
jsonOutput: false,
583+
databaseUrl: `file:${dbFile}`,
584+
deferSchemaDdl: true,
585+
composeHostStack: false,
586+
projectRoot: dir,
587+
});
588+
try {
589+
await boot.flushSchemaDdl();
590+
} finally {
591+
await boot.shutdown();
592+
}
593+
594+
// A host config carrying the SAME plugin shape, so the composed path is
595+
// exercised as an operator would hit it — the plugin comes out of
596+
// `objectstack.config.ts`, through `composeForDeclarations`.
597+
writeFileSync(
598+
join(dir, 'objectstack.config.ts'),
599+
[
600+
"import { appendFileSync } from 'node:fs';",
601+
'',
602+
`const LOG = ${JSON.stringify(hookLog)};`,
603+
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
604+
'',
605+
'export default {',
606+
' plugins: [{',
607+
" name: 'com.example.host-writes-from-init',",
608+
" version: '1.0.0',",
609+
' init: async (ctx: any) => {',
610+
' for (const phase of PHASES) {',
611+
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
612+
' ctx.hook(phase, async () => {',
613+
' const entry = [...ctx.getServices().entries()]',
614+
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
615+
' if (!entry) return;',
616+
" await entry[1].create('sys_metadata', {",
617+
' id: `os13332-host-${phase}`,',
618+
' name: `os13332-host-${phase}`,',
619+
" type: 'os13332_probe',",
620+
' });',
621+
" appendFileSync(LOG, `host|write|${phase}\\n`);",
622+
' });',
623+
' }',
624+
' },',
625+
' }],',
626+
'};',
627+
'',
628+
].join('\n'),
629+
);
630+
631+
writeFileSync(hookLog, '');
632+
}, 60_000);
633+
634+
afterAll(() => {
635+
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
636+
else process.env.NODE_ENV = savedEnv.NODE_ENV;
637+
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
638+
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
639+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
640+
});
641+
642+
it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
643+
const stack = await bootSchemaStack({
644+
jsonOutput: false,
645+
databaseUrl: `file:${dbFile}`,
646+
deferSchemaDdl: true,
647+
// No host composition ⇒ no declaration wrapper and no write guard. This
648+
// is the leg that proves the fixture can write at all.
649+
composeHostStack: false,
650+
extraPlugins: [initWritingPlugin('control')],
651+
projectRoot: dir,
652+
});
653+
try {
654+
expect(await probeRows(stack.driver)).toBe(3);
655+
const log = readFileSync(hookLog, 'utf8');
656+
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
657+
} finally {
658+
await stack.shutdown();
659+
}
660+
}, 60_000);
661+
662+
it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
663+
const before = await (async () => {
664+
const s = await bootSchemaStack({
665+
jsonOutput: false,
666+
databaseUrl: `file:${dbFile}`,
667+
deferSchemaDdl: true,
668+
composeHostStack: false,
669+
projectRoot: dir,
670+
});
671+
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
672+
})();
673+
// The control's three rows are still there — this case measures a DELTA,
674+
// not an empty table, so a fixture that silently stopped writing cannot
675+
// pass it.
676+
expect(before).toBe(3);
677+
678+
writeFileSync(hookLog, '');
679+
const stack = await bootSchemaStack({
680+
jsonOutput: false,
681+
databaseUrl: `file:${dbFile}`,
682+
deferSchemaDdl: true,
683+
composeHostStack: true,
684+
// Both routes at once: the host config's own plugin (composed through
685+
// `composeForDeclarations`) and one handed straight to the kernel. The
686+
// guard sits at the driver, so neither reaches the database.
687+
extraPlugins: [initWritingPlugin('extra')],
688+
projectRoot: dir,
689+
});
690+
try {
691+
expect(await probeRows(stack.driver)).toBe(before);
692+
693+
const log = readFileSync(hookLog, 'utf8');
694+
695+
// The property (b) was chosen for: the hooks RAN — the log-only ones
696+
// included — on the path an operator reads before a production apply.
697+
for (const phase of PHASES) {
698+
expect(log).toContain(`host|log-only|${phase}`);
699+
expect(log).toContain(`extra|log-only|${phase}`);
700+
// …and the writing hooks got all the way to their `create()` call,
701+
// which returned instead of throwing: the line after it was reached.
702+
expect(log).toContain(`host|write|${phase}`);
703+
expect(log).toContain(`extra|write|${phase}`);
704+
}
705+
706+
// The refusals are REPORTED, not swallowed — this is the line the plan
707+
// prints and `--json` carries. No raw execute() went through on this
708+
// boot, so the outcome claim HELD and is printed with the report.
709+
const notes = stack.composition.notes.join(' ');
710+
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
711+
expect(notes).toContain('create() on sys_metadata');
712+
} finally {
713+
await stack.shutdown();
714+
}
715+
}, 60_000);
716+
717+
it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
718+
// The at-tier review's own control shape, pinned: in one guarded boot, a
719+
// hook issues a contract write (refused — the in-run control) and a raw
720+
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
721+
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
722+
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
723+
// so the row LANDS — that is the documented behaviour, not the defect.
724+
// The defect was the SILENT half: before this case's fix, the same run
725+
// printed "a plan writes nothing" and a refusal list that looked
726+
// complete. Now the notes name the forwarded call and drop the claim.
727+
const rawWritingPlugin: any = {
728+
name: 'com.example.raw-execute-from-init',
729+
version: '1.0.0',
730+
init: async (ctx: any) => {
731+
ctx.hook('kernel:ready', async () => {
732+
const entry = [...ctx.getServices().entries()]
733+
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
734+
if (!entry) return;
735+
const driver = entry[1];
736+
// In-run control: the guarded surface refuses this one.
737+
await driver.create('sys_metadata', {
738+
id: 'os14053-create-probe',
739+
name: 'os14053-create-probe',
740+
type: 'os14053_create_probe',
741+
});
742+
// The escape hatch: forwarded, so this one LANDS.
743+
await driver.execute(
744+
"INSERT INTO sys_metadata (id, name, type) VALUES "
745+
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
746+
);
747+
});
748+
},
749+
};
750+
751+
const stack = await bootSchemaStack({
752+
jsonOutput: false,
753+
databaseUrl: `file:${dbFile}`,
754+
deferSchemaDdl: true,
755+
composeHostStack: true,
756+
extraPlugins: [rawWritingPlugin],
757+
projectRoot: dir,
758+
});
759+
try {
760+
const countByType = async (type: string) => {
761+
const rows: any = await (stack.driver as any).knex('sys_metadata')
762+
.where({ type }).count({ c: '*' });
763+
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
764+
};
765+
// The control half: the contract write was refused.
766+
expect(await countByType('os14053_create_probe')).toBe(0);
767+
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
768+
expect(await countByType('os14053_exec_probe')).toBe(1);
769+
770+
// …and the run SAYS so. The refusal line drops the flat claim (the
771+
// colon directly after "boot" is the dropped phrase), the forwarded
772+
// call is named with its count, and no note in the run claims the
773+
// plan wrote nothing. 4 refusals: the host config's plugin on three
774+
// phases, plus this fixture's in-run control.
775+
const notes = stack.composition.notes.join(' ');
776+
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
777+
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
778+
expect(notes).not.toContain('a plan writes nothing');
779+
780+
// The guard's structural surface carries it too, for `--json` consumers.
781+
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
782+
expect.objectContaining({ count: 1 }),
783+
]);
784+
} finally {
785+
await stack.shutdown();
786+
}
787+
}, 60_000);
788+
});

‎packages/cli/src/utils/schema-migrate.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,18 @@ export async function bootSchemaStack(
350350
}
351351
await runtime.start();
352352

353+
// #13332 — the kernel bootstrap is over, and with it the window the
354+
// declaration boot's write guard covers. `composeForDeclarations` suppresses
355+
// a host plugin's `start()`, but `kernel.ts` fires `kernel:ready`,
356+
// `kernel:bootstrapped` and `kernel:listening` unconditionally afterwards, so
357+
// a hook REGISTERED from `init()` runs on a plan; the guard refuses those
358+
// writes at the driver instead of at a list of phase names. Everything from
359+
// this line on is work the command was ASKED for — `apply`'s confirmed DDL
360+
// flush, the #13028 coverage pass — so the guard comes off here and reports
361+
// whatever it refused, which the plan prints and `--json` carries.
362+
const refusalNote = composition.writeGuard?.disarm() ?? null;
363+
if (refusalNote) composition.notes.push(refusalNote);
364+
353365
const driver = findSqlDriver(kernel);
354366

355367
// #13028 — the composed host declared its objects in `init()`; the pass that

0 commit comments

Comments
 (0)