Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

// #13028 — the composed host declared its objects in `init()`; the pass that
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

// #13028 — the composed host declared its objects in `init()`; the pass that
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

// #13028 — the composed host declared its objects in `init()`; the pass that
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

// #13028 — the composed host declared its objects in `init()`; the pass that
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

// #13028 — the composed host declared its objects in `init()`; the pass that
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

// #13028 — the composed host declared its objects in `init()`; the pass that
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

// #13028 — the composed host declared its objects in `init()`; the pass that
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .changeset/declaration-boot-write-suppression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/cli": minor
---

fix(cli): make `os migrate plan`'s "writes nothing" a property of the mechanism, not of an unwritten host convention (#13332)

`composeForDeclarations` documented the plan path's guarantee in its own words —
*"(init runs, start does not — a plan writes nothing)"* — and implemented it as a
Proxy whose only override is `start`. `packages/core/src/kernel.ts` then fires
three phases unconditionally after the suppressed start pass: `kernel:ready`
(Phase 3), `kernel:bootstrapped` (Phase 3.5) and `kernel:listening` (Phase 4). A
writing hook **registered from `init()`** survives the suppression and executes
on all three. The guarantee was therefore a property of plugins that happen to
seed from `start()` — the shape of the one plugin that had been measured — and
not of the plan path.

Measured, twice. The module header records 14 `Insert operation failed` rows
against `sys_permission_set` from a deferred `plan` boot, and notes that on a
database whose tables already exist those inserts **succeed**: a command
documented as writing nothing seeds rows into an operator's production control
plane. Downstream, a control plane hit exactly this on the `apply=false` run
that is its mandatory human review gate before a production schema apply
(`driver.create` / `driver.update` on `sys_ai_model`, from an
`init()`-registered `kernel:ready` hook).

**What changed.** For the length of the kernel bootstrap, `os migrate plan` /
`os migrate apply` now refuse the row-write members of the data-driver contract
(`IDataDriver`: `create`, `update`, `upsert`, `delete`, `bulkCreate`,
`bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) on every `driver.*`
instance the kernel publishes. The refusal sits at the driver, not at a list of
lifecycle phase names: it is phase-agnostic (a phase added tomorrow is covered
on the day it ships), it covers writes that arrive through the ObjectQL engine
as well as direct `driver.*` calls (the engine holds the same instance), and
read/log-only hooks still run — which is what an operator reading a plan before
a production apply needs them to do. A refused write returns a contract-shaped
value rather than throwing (boot hooks dispatch propagating, so throwing would
abort the bootstrap and leave the operator with no plan at all), and every
refusal is reported: one warning on stderr per driver/method/object triple,
plus a line in the composition notes the plan prints and `--json` carries.

The contract's raw-execution escape hatch — `IDataDriver.execute()`, a required
member on every driver — is FORWARDED and REPORTED rather than refused: a raw
command is `unknown` by contract ("SQL string, shell command, or API payload"),
and SQL text cannot be classified as read-vs-write reliably, so refusing would
break boot-legitimate reads and the framework's own index DDL on a guess. A
boot-window `execute()` is counted per driver, warned once per driver on
stderr, and named in the composition notes — and on such a run the notes do
NOT claim the plan wrote nothing, because the guard cannot verify it.

The guard is disarmed the moment the bootstrap returns, so `os migrate apply`'s
confirmed DDL flush and the coverage measurement are untouched.

**Who this affects.** A host whose plugins write during a `plan`/`apply` boot
from anywhere other than a suppressed `start()`. Contract row writes previously
landed and now do not; the run says so. A host whose plugins call raw
`execute()` during the boot keeps its behaviour (the call is forwarded) and
now sees it reported. A host that did neither sees no change at all — no
disarm note is emitted when nothing was refused and no raw command went
through.

Boundaries stated rather than hidden: `execute()` is reported, never refused
(above); `getKnex()` (a driver-sql extension, genuinely off-contract) is not
intercepted. DDL splits: `deferSchemaDdl` holds back the
`initObjects`/`syncSchema` path (flushed on purpose by `apply` once the
operator confirms), while `dropTable`/`rotateShards` are NOT held back by that
deferral — they are gated only by `assertSchemaMutable`
(schemaMode/dialect) and stay a genuinely open boundary during the boot.
Drivers the engine holds for a NON-default datasource are never published as
`driver.*` (`DatasourceConnectionService.connect()` hands them to
`engine.registerDriver` directly), so they are invisible to the guard's scan
and objectql-mediated writes to objects bound to them would land. The guard
also does not cover writes a plugin makes outside the database, or work a hook
defers past the end of the bootstrap.
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
Expand DownExpand Up@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
}
}, 60_000);
});

/**
* #13332 — the same guarantee, end to end, against a real SQL driver.
*
* The unit half
* (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the
* mechanism on a recording driver. This half proves the property the operator
* actually depends on: `os migrate plan`'s boot, with a host plugin that
* registers a writing hook from `init()`, leaves the DATABASE unchanged — on a
* database whose tables already exist, which is the condition under which the
* measured inserts SUCCEED instead of failing.
*
* The positive control comes first and is load-bearing. The identical plugin,
* on a boot that composes no host stack (so no declaration composition and no
* write guard), lands its rows. Without that leg the assertion below would be
* green over a fixture that could not have written.
*/
describe('a plan writes nothing even when the host writes from init() (#13332)', () => {
let dir: string;
let dbFile: string;
let hookLog: string;
const savedEnv: Record<string, string | undefined> = {};

const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* cloud's measured shape, as a plugin this file can hand to either boot: the
* writing hooks are registered from `init()`, so `composeForDeclarations`'s
* `start()` suppression never sees them, and they fire on each of the three
* phases `kernel.ts` triggers unconditionally after the suppressed pass.
*
* The driver is found by scanning `driver.*` — the same surface
* `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the
* fixture does not depend on what the standalone stack calls its default.
*/
const initWritingPlugin = (tag: string): any => ({
name: `com.example.writes-from-init.${tag}`,
version: '1.0.0',
init: async (ctx: any) => {
for (const phase of PHASES) {
ctx.hook(phase, async () => {
appendFileSync(hookLog, `${tag}|log-only|${phase}\n`);
});
ctx.hook(phase, async () => {
const services: Map<string, any> = ctx.getServices();
const entry = [...services.entries()].find(([n]) => n.startsWith('driver.'));
if (!entry) return;
await entry[1].create('sys_metadata', {
id: `os13332-${tag}-${phase}`,
name: `os13332-${tag}-${phase}`,
type: 'os13332_probe',
});
appendFileSync(hookLog, `${tag}|write|${phase}\n`);
});
}
},
});

const probeRows = async (driver: any): Promise<number> => {
const rows: any = await driver.knex('sys_metadata')
.where({ type: 'os13332_probe' })
.count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13332-'));
dbFile = join(dir, 'control.db');
hookLog = join(dir, 'hooks.log');
writeFileSync(hookLog, '');

savedEnv.NODE_ENV = process.env.NODE_ENV;
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
process.env.NODE_ENV = 'production';
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');

// Materialize `sys_metadata` FIRST, with no host config on disk yet. The
// measured defect is precisely that on a database whose tables EXIST the
// inserts succeed rather than fail, so neither case below may run against
// an empty schema — and the fixture must not depend on the fix to build
// itself: with the guard ablated, a writing hook against a table that does
// not exist yet THROWS, and boot hooks dispatch propagating, so the whole
// bootstrap dies. Setting the schema up before the writer exists keeps an
// ablation landing on the assertions below instead of on this hook.
const boot = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try {
await boot.flushSchemaDdl();
} finally {
await boot.shutdown();
}

// A host config carrying the SAME plugin shape, so the composed path is
// exercised as an operator would hit it — the plugin comes out of
// `objectstack.config.ts`, through `composeForDeclarations`.
writeFileSync(
join(dir, 'objectstack.config.ts'),
[
"import { appendFileSync } from 'node:fs';",
'',
`const LOG = ${JSON.stringify(hookLog)};`,
"const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];",
'',
'export default {',
' plugins: [{',
" name: 'com.example.host-writes-from-init',",
" version: '1.0.0',",
' init: async (ctx: any) => {',
' for (const phase of PHASES) {',
" ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });",
' ctx.hook(phase, async () => {',
' const entry = [...ctx.getServices().entries()]',
" .find(([n]: [string, unknown]) => n.startsWith('driver.'));",
' if (!entry) return;',
" await entry[1].create('sys_metadata', {",
' id: `os13332-host-${phase}`,',
' name: `os13332-host-${phase}`,',
" type: 'os13332_probe',",
' });',
" appendFileSync(LOG, `host|write|${phase}\\n`);",
' });',
' }',
' },',
' }],',
'};',
'',
].join('\n'),
);

writeFileSync(hookLog, '');
}, 60_000);

afterAll(() => {
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedEnv.NODE_ENV;
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
// No host composition ⇒ no declaration wrapper and no write guard. This
// is the leg that proves the fixture can write at all.
composeHostStack: false,
extraPlugins: [initWritingPlugin('control')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(3);
const log = readFileSync(hookLog, 'utf8');
for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`);
} finally {
await stack.shutdown();
}
}, 60_000);

it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => {
const before = await (async () => {
const s = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: false,
projectRoot: dir,
});
try { return await probeRows(s.driver); } finally { await s.shutdown(); }
})();
// The control's three rows are still there — this case measures a DELTA,
// not an empty table, so a fixture that silently stopped writing cannot
// pass it.
expect(before).toBe(3);

writeFileSync(hookLog, '');
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
// Both routes at once: the host config's own plugin (composed through
// `composeForDeclarations`) and one handed straight to the kernel. The
// guard sits at the driver, so neither reaches the database.
extraPlugins: [initWritingPlugin('extra')],
projectRoot: dir,
});
try {
expect(await probeRows(stack.driver)).toBe(before);

const log = readFileSync(hookLog, 'utf8');

// The property (b) was chosen for: the hooks RAN — the log-only ones
// included — on the path an operator reads before a production apply.
for (const phase of PHASES) {
expect(log).toContain(`host|log-only|${phase}`);
expect(log).toContain(`extra|log-only|${phase}`);
// …and the writing hooks got all the way to their `create()` call,
// which returned instead of throwing: the line after it was reached.
expect(log).toContain(`host|write|${phase}`);
expect(log).toContain(`extra|write|${phase}`);
}

// The refusals are REPORTED, not swallowed — this is the line the plan
// prints and `--json` carries. No raw execute() went through on this
// boot, so the outcome claim HELD and is printed with the report.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
expect(notes).toContain('create() on sys_metadata');
} finally {
await stack.shutdown();
}
}, 60_000);

it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => {
// The at-tier review's own control shape, pinned: in one guarded boot, a
// hook issues a contract write (refused — the in-run control) and a raw
// `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver`
// (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape
// Hatch)"), and the guard cannot classify a raw command as read-vs-write,
// so the row LANDS — that is the documented behaviour, not the defect.
// The defect was the SILENT half: before this case's fix, the same run
// printed "a plan writes nothing" and a refusal list that looked
// complete. Now the notes name the forwarded call and drop the claim.
const rawWritingPlugin: any = {
name: 'com.example.raw-execute-from-init',
version: '1.0.0',
init: async (ctx: any) => {
ctx.hook('kernel:ready', async () => {
const entry = [...ctx.getServices().entries()]
.find(([n]: [string, unknown]) => n.startsWith('driver.'));
if (!entry) return;
const driver = entry[1];
// In-run control: the guarded surface refuses this one.
await driver.create('sys_metadata', {
id: 'os14053-create-probe',
name: 'os14053-create-probe',
type: 'os14053_create_probe',
});
// The escape hatch: forwarded, so this one LANDS.
await driver.execute(
"INSERT INTO sys_metadata (id, name, type) VALUES "
+ "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')",
);
});
},
};

const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
deferSchemaDdl: true,
composeHostStack: true,
extraPlugins: [rawWritingPlugin],
projectRoot: dir,
});
try {
const countByType = async (type: string) => {
const rows: any = await (stack.driver as any).knex('sys_metadata')
.where({ type }).count({ c: '*' });
return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1);
};
// The control half: the contract write was refused.
expect(await countByType('os14053_create_probe')).toBe(0);
// The escape hatch half: the raw INSERT landed — forwarded on purpose.
expect(await countByType('os14053_exec_probe')).toBe(1);

// …and the run SAYS so. The refusal line drops the flat claim (the
// colon directly after "boot" is the dropped phrase), the forwarded
// call is named with its count, and no note in the run claims the
// plan wrote nothing. 4 refusals: the host config's plugin on three
// phases, plus this fixture's in-run control.
const notes = stack.composition.notes.join(' ');
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
expect(notes).not.toContain('a plan writes nothing');

// The guard's structural surface carries it too, for `--json` consumers.
expect(stack.composition.writeGuard?.rawExecutions).toEqual([
expect.objectContaining({ count: 1 }),
]);
} finally {
await stack.shutdown();
}
}, 60_000);
});
12 changes: 12 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,18 @@ export async function bootSchemaStack(
}
await runtime.start();

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

const driver = findSqlDriver(kernel);

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