diff --git a/.changeset/arm-platform-migrations-self-hosted.md b/.changeset/arm-platform-migrations-self-hosted.md
new file mode 100644
index 0000000000..75662186f7
--- /dev/null
+++ b/.changeset/arm-platform-migrations-self-hosted.md
@@ -0,0 +1,69 @@
+---
+"@objectstack/metadata-protocol": minor
+"@objectstack/objectql": minor
+"@objectstack/runtime": minor
+"@objectstack/cli": patch
+---
+
+fix(metadata-protocol): arm the three `kernel:ready` platform-table migrations on a self-hosted boot, and keep the read-only CLI commands read-only (#9380)
+
+
+
+`assembleMetadataProtocol` arms three `kernel:ready` migrations — #5839's
+`sys_view_definition` active-row index, #8629's `sys_setting` row-identity
+index, and #8686's seed/API tenancy backfill — behind one gate whose own comment
+states the intent: *"platform / standalone kernels own their local sys_metadata;
+per-project (cloud) kernels source metadata from the control plane and must NOT
+provision these tables locally."* So standalone was always meant to be on the
+INSIDE of that gate.
+
+It never was. The gate **deduced** ownership from `environmentId === undefined`,
+and `runtime/src/standalone-stack.ts` stamps `'proj_local'` on every boot — so
+the block never ran on a self-hosted install at all. #8686's own header calls
+its `kernel:ready` half the one that "repairs an install that is ALREADY in that
+state, which covers every existing deployment"; on self-hosted it covered none,
+and those installs kept minting duplicate business identifiers.
+
+**The fix is a declaration, not a wider deduction.** `environmentId` is a
+row-scoping key, not a topology signal — the same lesson `authoringChannel`
+already records one field above it in the same options bag. A new optional
+`runPlatformMigrations` is threaded from the host that knows the answer down to
+the one assembly both protocol mounts share:
+
+- `AssembleMetadataProtocolOptions` / `MetadataProtocolPluginOptions` /
+ `ObjectQLPluginOptions` gain `runPlatformMigrations?: boolean`;
+- `createStandaloneStack` gains the same key and **defaults it to `true`** — a
+ standalone kernel owns its local platform tables, whatever environment id it
+ stamps rows with;
+- the predicate is exported as `shouldRunPlatformMigrations(environmentId,
+ declared)` so the default lives in exactly one place.
+
+**Undeclared means unchanged.** The default is `environmentId === undefined`,
+the historical deduction, so every caller that does not declare — including
+cloud's per-project kernels (`createMetadataProtocolPlugin({ environmentId })`)
+and the control-plane assembly (`createMetadataProtocolPlugin()`) — keeps
+today's behaviour exactly.
+
+**The read-only contract is preserved, and not by keying on deferral.** The
+CLI's one-shot boot funnel (`bootSchemaStack`) declares
+`runPlatformMigrations: false` for every `os migrate *` / `os meta *` command.
+Keying it on `deferSchemaDdl` would have covered only `os migrate plan` and
+`os migrate duplicates`; `os migrate summary-nulls`, `value-shapes`,
+`recorded-by`, `resume`, `files-to-references` and `os migrate meta` all boot
+**non-deferred** and are still dry-run-by-default ("a dry run writes NOTHING"),
+so that half would have quietly repaired rows behind a report. The serving boots
+— `os dev`, `os serve`, `os start` — do not come through that funnel and take
+the default, which is where an install now gets repaired.
+
+Proven on real kernels over a real SQLite file carrying the real #8686 damage,
+not on the predicate: the serving boot merges the split counter and adopts the
+movable seed row while leaving the colliding one reported-not-renumbered; the
+deferred and non-deferred one-shot boots both leave the data untouched; and a
+per-project kernel assembled cloud's way still repairs nothing.
+`os migrate duplicates`' own byte-identical-after-run pin
+(`duplicates.integration.test.ts`) still passes unchanged.
diff --git a/content/docs/deployment/seed-tenancy-repair.mdx b/content/docs/deployment/seed-tenancy-repair.mdx
index bf21fd6f76..f2e039ab7f 100644
--- a/content/docs/deployment/seed-tenancy-repair.mdx
+++ b/content/docs/deployment/seed-tenancy-repair.mdx
@@ -66,7 +66,7 @@ Two triggers, not one. Both call the same repair with the same guards.
| Trigger | Where | When it fires |
| :--- | :--- | :--- |
-| `kernel:ready` boot hook | `@objectstack/metadata-protocol` | Every boot of a kernel that has no environment id — that is, an ordinary self-hosted server. Per-environment (cloud) kernels do not provision these tables locally and are skipped. |
+| `kernel:ready` boot hook | `@objectstack/metadata-protocol` | Every **serving** boot of a self-hosted kernel — `os dev`, `os serve`, `os start`. Per-environment (cloud) kernels do not own these tables locally and are skipped, and so are the one-shot `os migrate` / `os meta` boots (see below). |
| First-organization handoff | `@objectstack/runtime` | After any successful `create` on `sys_organization`. On a fresh install this is the **first admin sign-up**. |
The second trigger is the one most operators will meet first, and it is not a
@@ -77,6 +77,15 @@ the ruling they are not the platform's to renumber. `sys_organization` gaining
its first row is exactly the event that makes the answer derivable, so the
repair runs there too.
+
+`os migrate *` and `os meta *` boot the same stack, but they declare
+themselves out of this repair (`runPlatformMigrations: false`) — so a dry-run
+report cannot destroy the evidence it was run to collect. Only a boot that
+STARTS THE SERVER repairs. Before #9380 no self-hosted boot ran the repair at
+all: the hook was gated on the kernel having no environment id, and the
+standalone stack stamps `proj_local` on every boot, so the gate never opened.
+
+
The repair is idempotent. Once the rows carry an organization and the
`__global__` counter is gone, the detection probe finds nothing and every later
@@ -257,12 +266,14 @@ The live condition is the only forward-looking line in the report, and it is the
perishable one. On a deployment you have not yet restarted since discovering the
problem, run the report first.
-`os migrate duplicates` writes nothing: it boots read-only, issues `SELECT`s
-only, and emits JSON to stdout for you to archive.
+`os migrate duplicates` writes nothing: it boots read-only, declares itself out
+of the boot repair, issues `SELECT`s only, and emits JSON to stdout for you to
+archive.
-The repair fires at `kernel:ready`. Restarting the server to "have a look" is
-enough to consume the evidence.
+The repair fires at `kernel:ready` on a serving boot. Restarting the server to
+"have a look" is enough to consume the evidence — running `os migrate
+duplicates` is not.
---
diff --git a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts
new file mode 100644
index 0000000000..41e6bad173
--- /dev/null
+++ b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts
@@ -0,0 +1,306 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #9380 — the three `kernel:ready` platform-table migrations never armed on a
+ * self-hosted boot, and arming them must not make a read-only command write.
+ *
+ * `assembleMetadataProtocol` arms #5839 (the `sys_view_definition` active-row
+ * index), #8629 (`sys_setting`'s row-identity index) and #8686 (the seed/API
+ * tenancy backfill) behind one gate whose own comment says standalone belongs
+ * on the INSIDE of it: "platform / standalone kernels own their local
+ * sys_metadata; per-project (cloud) kernels source metadata from the control
+ * plane and must NOT provision these tables locally."
+ *
+ * The gate deduced that from `environmentId === undefined`, and
+ * `runtime/src/standalone-stack.ts` stamps `'proj_local'` on every boot. So the
+ * block never ran on `os dev` / `os serve` / `os start` at all, and #8686's own
+ * header — "repairs an install that is ALREADY in that state, which covers
+ * every existing deployment" — covered no self-hosted deployment.
+ *
+ * ## Why this file boots real kernels rather than testing the predicate
+ *
+ * The defect was entirely in the WIRING: every component worked. The card's
+ * measurement carried two controls proving it — calling `backfillSeedTenancy`
+ * by hand on the same booted engine returned `applied`, and a probe plugin
+ * registering its own `kernel:ready` handler on the same boot fired and
+ * repaired. Only the shipped registration was missing. A test on the predicate
+ * would therefore have passed against the broken build: the predicate was never
+ * the unobservable part, the ARMING was. So every case here boots a real kernel
+ * over a real SQLite file carrying the real #8686 damage and asserts on the
+ * DATABASE, read back through a connection of its own.
+ *
+ * ## Why it lives in the CLI package
+ *
+ * Because two of the three sides are the CLI's: cases 2a/2b drive the REAL
+ * `bootSchemaStack` funnel rather than re-passing its flag by hand, which is
+ * the only way to pin that the funnel actually declares what it claims to.
+ * `duplicates.integration.test.ts` next door pins the same contract from the
+ * report command's end; this file pins the boot policy itself, including the
+ * NON-deferred one-shot boots that file never exercises.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { GLOBAL_TENANT, SEQUENCES_TABLE, ORGANIZATION_TABLE } from '@objectstack/metadata-protocol';
+import { bootSchemaStack } from './schema-migrate.js';
+
+const ORG_ID = 'org_x';
+/** The `__global__` counter the seed loader ran ahead to before the org existed. */
+const SEEDED_LAST_VALUE = 38;
+
+let dir: string;
+let dbFile: string;
+const savedEnv: Record = {};
+
+/**
+ * Read the install with a connection of OUR OWN — never the booted stack's.
+ *
+ * The whole question is what the boot did to the file, so the observation has
+ * to outlive the boot's teardown and must not share its pool.
+ */
+async function readState(): Promise<{ data: unknown; schema: unknown }> {
+ const probe = new SqlDriver({
+ client: 'better-sqlite3',
+ connection: { filename: dbFile },
+ useNullAsDefault: true,
+ });
+ try {
+ const k = (probe as any).knex;
+ return {
+ // Everything the three migrations could move. Column-projected, not
+ // `select('*')`: a boot that is ALLOWED to run schema-sync DDL adds
+ // audit/ownership columns to `crm_case`, and that is not what any of
+ // these cases is about (see the non-deferred case below).
+ data: {
+ cases: await k('crm_case')
+ .select('id', 'organization_id', 'case_number', 'subject')
+ .orderBy('id'),
+ sequences: await k(SEQUENCES_TABLE)
+ .select('object', 'tenant_id', 'field', 'last_value')
+ .orderBy(['object', 'tenant_id']),
+ },
+ // The physical schema, so a case asserting "this boot changed nothing"
+ // also covers the two INDEX migrations (#5839, #8629). Without this the
+ // only migration a green run could speak for would be #8686's, and a
+ // read-only boot that quietly created an index would pass.
+ schema: await k('sqlite_master').select('type', 'name', 'tbl_name', 'sql').orderBy(['type', 'name']),
+ };
+ } finally {
+ await probe.disconnect();
+ }
+}
+
+/** The install as #8686 leaves it: two counters, and one number minted on both sides. */
+async function writeDamagedInstall(): Promise {
+ const seed = new SqlDriver({
+ client: 'better-sqlite3',
+ connection: { filename: dbFile },
+ useNullAsDefault: true,
+ });
+ const k = (seed as any).knex;
+ await k.schema.createTable('crm_case', (t: any) => {
+ t.string('id').primary();
+ t.timestamp('created_at');
+ t.timestamp('updated_at');
+ t.string('organization_id');
+ t.string('subject');
+ t.string('case_number');
+ });
+ await k('crm_case').insert([
+ { id: 's1', created_at: '2026-01-01T00:00:00.000Z', organization_id: null, subject: 'seeded', case_number: 'CASE-00001' },
+ { id: 's2', created_at: '2026-01-02T00:00:00.000Z', organization_id: null, subject: 'seeded two', case_number: 'CASE-00002' },
+ { id: 'a1', created_at: '2026-02-01T00:00:00.000Z', organization_id: ORG_ID, subject: 'api', case_number: 'CASE-00001' },
+ ]);
+ await k.schema.createTable(ORGANIZATION_TABLE, (t: any) => {
+ t.string('id').primary();
+ t.string('name');
+ });
+ await k(ORGANIZATION_TABLE).insert([{ id: ORG_ID, name: 'Acme' }]);
+ await k.schema.createTable(SEQUENCES_TABLE, (t: any) => {
+ t.string('key_hash', 64).notNullable().primary();
+ t.string('object').notNullable();
+ t.string('tenant_id').notNullable();
+ t.string('field').notNullable();
+ t.string('scope', 1024).notNullable().defaultTo('');
+ t.bigInteger('last_value').notNullable().defaultTo(0);
+ t.timestamp('updated_at');
+ });
+ await k(SEQUENCES_TABLE).insert([
+ { key_hash: 'h1', object: 'crm_case', tenant_id: GLOBAL_TENANT, field: 'case_number', scope: '', last_value: SEEDED_LAST_VALUE },
+ { key_hash: 'h2', object: 'crm_case', tenant_id: ORG_ID, field: 'case_number', scope: '', last_value: 1 },
+ ]);
+ await seed.disconnect();
+}
+
+/**
+ * The self-hosted SERVING boot — `os dev` / `os serve` / `os start`.
+ *
+ * These do not go through `bootSchemaStack`; they build the standalone stack
+ * and run it, which is exactly what this does. `plugins` + `Runtime` + `start`
+ * is the same sequence `bootSchemaStack` performs, minus the one-shot policy —
+ * so the ONLY difference between this and case 2b is the declaration under
+ * test.
+ */
+async function bootServingStack(): Promise {
+ const { createStandaloneStack, Runtime } = await import('@objectstack/runtime');
+ const stack = await createStandaloneStack({
+ projectRoot: dir,
+ databaseUrl: `file:${dbFile}`,
+ });
+ const runtime = new Runtime({ cluster: false });
+ const kernel = runtime.getKernel();
+ for (const plugin of stack.plugins) await kernel.use(plugin as any);
+ await runtime.start();
+ await kernel.shutdown();
+}
+
+beforeEach(async () => {
+ dir = mkdtempSync(join(tmpdir(), 'os-9380-'));
+ mkdirSync(join(dir, 'dist'), { recursive: true });
+ mkdirSync(join(dir, 'data'), { recursive: true });
+ dbFile = join(dir, 'data', 'app.db');
+
+ writeFileSync(
+ join(dir, 'dist', 'objectstack.json'),
+ JSON.stringify({
+ manifest: { id: 'os_9380', name: 'Platform Migration Arming', version: '0.0.0', type: 'app' },
+ objects: [
+ {
+ name: 'crm_case',
+ fields: {
+ subject: { type: 'text' },
+ case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: 'organization' },
+ },
+ },
+ ],
+ }),
+ );
+
+ savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
+ savedEnv.NODE_ENV = process.env.NODE_ENV;
+ savedEnv.OS_ENVIRONMENT_ID = process.env.OS_ENVIRONMENT_ID;
+ process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
+ process.env.NODE_ENV = 'production'; // no dev-time auto-reconcile
+ delete process.env.OS_ENVIRONMENT_ID;
+
+ await writeDamagedInstall();
+}, 180_000);
+
+afterEach(() => {
+ process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
+ process.env.NODE_ENV = savedEnv.NODE_ENV;
+ if (savedEnv.OS_ENVIRONMENT_ID === undefined) delete process.env.OS_ENVIRONMENT_ID;
+ else process.env.OS_ENVIRONMENT_ID = savedEnv.OS_ENVIRONMENT_ID;
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
+});
+
+describe('#9380 the kernel:ready platform migrations, on the boots that reach them', () => {
+ it('[the card] a self-hosted SERVING boot repairs the #8686 damage', async () => {
+ const before: any = (await readState()).data;
+ // Non-vacuity: the fixture really is damaged, or a green run below would
+ // say nothing. Two counters for one object IS the split.
+ expect(before.sequences).toHaveLength(2);
+ expect(before.cases.filter((c: any) => c.organization_id === null)).toHaveLength(2);
+
+ await bootServingStack();
+
+ const after: any = (await readState()).data;
+
+ // The `__global__` counter is gone and its high-water mark was merged into
+ // the organization's — the seed's 38 wins over the org side's 1, which is
+ // what stops the next API create from re-minting a number already used.
+ expect(after.sequences).toEqual([
+ expect.objectContaining({
+ object: 'crm_case',
+ tenant_id: ORG_ID,
+ field: 'case_number',
+ last_value: SEEDED_LAST_VALUE,
+ }),
+ ]);
+
+ // The MOVABLE seed row was adopted into the one organization. `s1` collides
+ // with the org-side `CASE-00001`, so it is reported and left where it is —
+ // never renumbered (2026-08-15 ruling). That asymmetry is the proof the
+ // real migration ran and not something that merely stamped every row.
+ expect(after.cases.filter((c: any) => c.organization_id === null).map((c: any) => c.id)).toEqual(['s1']);
+ expect(after.cases.find((c: any) => c.id === 's2').organization_id).toBe(ORG_ID);
+ }, 180_000);
+
+ it('[read-only contract] a DEFERRED one-shot CLI boot leaves the install byte-identical', async () => {
+ const before = await readState();
+
+ // `os migrate plan` / `os migrate duplicates` — declared dry runs. Nothing
+ // moves and no index appears: `deferSchemaDdl` holds the DDL back, and the
+ // #9380 declaration holds the three repairs back.
+ const stack = await bootSchemaStack({
+ jsonOutput: false,
+ databaseUrl: `file:${dbFile}`,
+ deferSchemaDdl: true,
+ readOnlyProbe: true,
+ projectRoot: dir,
+ });
+ await stack.shutdown();
+
+ expect(await readState()).toEqual(before);
+ }, 180_000);
+
+ it('[read-only contract] a NON-deferred one-shot CLI boot also leaves it byte-identical', async () => {
+ const before = await readState();
+
+ // The half that a `deferSchemaDdl`-keyed policy would have missed, and the
+ // more dangerous one: `os migrate summary-nulls`, `value-shapes`,
+ // `recorded-by`, `resume`, `files-to-references` and `os migrate meta` all
+ // boot WITHOUT `deferSchemaDdl` and are still dry-run-by-default ("a dry
+ // run writes NOTHING"). If the arming were gated on deferral instead of on
+ // being a one-shot boot, every one of them would silently repair rows
+ // behind a report.
+ const stack = await bootSchemaStack({
+ jsonOutput: false,
+ databaseUrl: `file:${dbFile}`,
+ projectRoot: dir,
+ });
+ await stack.shutdown();
+
+ // DATA only, deliberately. A non-deferred boot is allowed to run schema
+ // sync, and it does — `crm_case` gains its audit/ownership columns
+ // (`created_by`, `updated_by`, `owner_id`, `owning_business_unit_id`).
+ // That is the boot doing its declared job and predates this card; what
+ // those commands promise is that they do not MOVE ROWS, and that is what
+ // is asserted. Comparing the physical schema here would pin a fact about
+ // schema sync in a file about migration arming.
+ expect((await readState()).data).toEqual(before.data);
+ }, 180_000);
+
+ it('[the other half of the invariant] a per-project (cloud) kernel repairs nothing', async () => {
+ const before = await readState();
+
+ // Cloud's own assembly shape, verbatim from
+ // `cloud/packages/objectos-runtime/src/artifact-kernel-factory.ts`: an
+ // environment-scoped engine with the protocol delegated, declaring NO
+ // `runPlatformMigrations`. This case goes red the moment the default stops
+ // excluding a per-project kernel — which is the easy thing to break while
+ // fixing the standalone half.
+ const { Runtime, DefaultDatasourcePlugin } = await import('@objectstack/runtime');
+ const { ObjectQLPlugin } = await import('@objectstack/objectql');
+ const { createMetadataProtocolPlugin } = await import('@objectstack/metadata-protocol');
+
+ const runtime = new Runtime({ cluster: false });
+ const kernel = runtime.getKernel();
+ await kernel.use(new (DefaultDatasourcePlugin as any)(
+ { driver: 'sqlite', config: { filename: dbFile } },
+ { dev: false },
+ ));
+ await kernel.use(new ObjectQLPlugin({
+ environmentId: 'env_proj_1',
+ registerProtocol: false,
+ }) as any);
+ await kernel.use(createMetadataProtocolPlugin({ environmentId: 'env_proj_1' }) as any);
+ await runtime.start();
+ await kernel.shutdown();
+
+ expect(await readState()).toEqual(before);
+ }, 180_000);
+});
diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts
index 5afa8d31df..18a38d7c79 100644
--- a/packages/cli/src/utils/schema-migrate.ts
+++ b/packages/cli/src/utils/schema-migrate.ts
@@ -254,6 +254,32 @@ export async function bootSchemaStack(
...(opts.databaseUrl ? { databaseUrl: opts.databaseUrl } : {}),
...(defer ? { skipSeedData: true } : {}),
...(opts.readOnlyProbe ? { sqliteAbsentFile: 'empty-in-memory' as const } : {}),
+ // [#9380] No boot repair migrations on a one-shot CLI boot — unconditional,
+ // and NOT keyed on `deferSchemaDdl`.
+ //
+ // #9380 armed the three `kernel:ready` platform-table migrations on the
+ // standalone stack (they had never run on a self-hosted install, because
+ // the assembly deduced "cloud per-project kernel" from the `'proj_local'`
+ // the stack stamps). Every boot through THIS function inherits that default
+ // unless it is turned off here, and every one of them is a command that
+ // reports or applies exactly what the operator asked for:
+ //
+ // • `os migrate plan` / `os migrate duplicates` boot deferred + read-only
+ // and are declared dry runs;
+ // • `os migrate meta` / `value-shapes` / `recorded-by` / `resume` /
+ // `summary-nulls` / `files-to-references` boot NOT deferred and are
+ // STILL dry-run-by-default ("a dry run writes NOTHING"). Keying this off
+ // `defer` would have left that whole second group repairing rows behind
+ // a report — the more dangerous half, and the quieter one;
+ // • `os migrate apply` / `os meta resync` do write, but only the change
+ // the operator confirmed. A repair riding along is a change they never
+ // saw in the plan (which is #8725's separate complaint).
+ //
+ // The serving boots — `os dev`, `os serve`, `os start` — do not come
+ // through here and take the default, which is where an install gets
+ // repaired. `duplicates.integration.test.ts` pins this end of it: boot
+ // included, the run must leave the database byte-identical.
+ runPlatformMigrations: false,
});
// No HTTP, no cluster — this is a one-shot schema operation.
diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts
index fda41d661f..571ec939af 100644
--- a/packages/metadata-protocol/src/index.ts
+++ b/packages/metadata-protocol/src/index.ts
@@ -23,7 +23,7 @@ export {
omitInternalFieldsFromWriteResponse,
collectInternalWriteResponseFields,
} from './write-response-internal-fields.js';
-export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
+export { createMetadataProtocolPlugin, assembleMetadataProtocol, shouldRunPlatformMigrations } from './plugin.js';
export type { MetadataProtocolPluginOptions, AssembleMetadataProtocolOptions } from './plugin.js';
// [#6710] The declared authoring channel — the explicit expression of ADR-0005's
// "package author's own bootstrap channel", replacing the `environmentId ===
diff --git a/packages/metadata-protocol/src/plugin.ts b/packages/metadata-protocol/src/plugin.ts
index da3e9d9a0d..250c42ef47 100644
--- a/packages/metadata-protocol/src/plugin.ts
+++ b/packages/metadata-protocol/src/plugin.ts
@@ -75,10 +75,16 @@ export interface MetadataProtocolPluginOptions {
* refusal to a loud log instead of silencing it.
*/
authoringChannel?: MetadataAuthoringChannel;
+ /**
+ * [#9380] See {@link AssembleMetadataProtocolOptions.runPlatformMigrations}.
+ * Forwarded unchanged to the assembly; omitted ⇒ the historical
+ * `environmentId === undefined` deduction.
+ */
+ runPlatformMigrations?: boolean;
}
export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOptions = {}): Plugin {
- const { environmentId, authoringChannel } = options;
+ const { environmentId, authoringChannel, runPlatformMigrations } = options;
return {
name: 'com.objectstack.metadata.protocol',
version: '1.0.0',
@@ -100,7 +106,7 @@ export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOpti
);
}
- assembleMetadataProtocol(ctx, ql, environmentId, { authoringChannel });
+ assembleMetadataProtocol(ctx, ql, environmentId, { authoringChannel, runPlatformMigrations });
},
};
}
@@ -112,6 +118,56 @@ export interface AssembleMetadataProtocolOptions {
* Omitted ⇒ `'environment'` ⇒ the #4463 runtime authoring gate is active.
*/
authoringChannel?: MetadataAuthoringChannel;
+ /**
+ * [#9380] Does THIS BOOT arm the `kernel:ready` platform-table repair
+ * migrations (#5839 / #8629 / #8686)?
+ *
+ * Declared, not deduced — the same lesson `authoringChannel` records one
+ * field above. The gate used to read `environmentId === undefined`, and
+ * `environmentId` is a ROW-SCOPING KEY, not a topology signal: the
+ * standalone stack stamps `'proj_local'` on every `os dev` / `os serve` /
+ * `os start` / `os migrate` boot (`runtime/src/standalone-stack.ts`), so
+ * the block the gate guards never ran on a self-hosted install at all —
+ * #8686's "repairs an install that is ALREADY in that state, which covers
+ * every existing deployment" covered none of them.
+ *
+ * TWO independent facts make this false, and each is known only by the
+ * host that assembles the kernel:
+ *
+ * 1. **This kernel does not own its local platform tables.** A
+ * per-project (cloud) kernel sources metadata from the control plane
+ * and must NOT provision or repair these tables locally. Cloud passes
+ * no value here, so the default below keeps it excluded exactly as
+ * before.
+ * 2. **This boot must not write.** A one-shot CLI boot
+ * (`bootSchemaStack`) is an inspect-or-apply-exactly-what-was-asked
+ * run: `os migrate plan`, `os migrate duplicates`, and every
+ * dry-run-by-default `os migrate *` command are declared read-only,
+ * and a repair that fires behind them destroys the very evidence they
+ * were run to collect. Those boots pass `false`.
+ *
+ * Omitted ⇒ `environmentId === undefined`, the historical deduction, so
+ * every caller that does not declare keeps today's behaviour byte for
+ * byte.
+ */
+ runPlatformMigrations?: boolean;
+}
+
+/**
+ * [#9380] The arming predicate, pure and exported so it can be pinned without
+ * booting a kernel — and so there is exactly ONE place the default lives.
+ *
+ * `undefined` is not "false": it means the caller did not declare, and an
+ * undeclared caller gets the historical `environmentId === undefined`
+ * deduction. Making the default `false` instead would silently disarm the
+ * control-plane assembly (`createMetadataProtocolPlugin()` with no options),
+ * which is the one caller that has always been on the inside of this gate.
+ */
+export function shouldRunPlatformMigrations(
+ environmentId: string | undefined,
+ declared: boolean | undefined,
+): boolean {
+ return declared ?? environmentId === undefined;
}
/**
@@ -145,6 +201,21 @@ export function assembleMetadataProtocol(
// metadata from the control plane and must NOT provision these
// tables locally. registerApp is idempotent — a MetadataPlugin that
// also registers them is harmless.
+ //
+ // [#9380] Deliberately NOT switched to the declared
+ // `runPlatformMigrations` gate below, even though the two blocks
+ // share a comment and a history. Measured: `MetadataPlugin` already
+ // registers `com.objectstack.metadata-objects` with EXACTLY these
+ // five objects (`queryableMetadataObjects` in
+ // `packages/metadata/src/plugin.ts`, gated on
+ // `registerSystemObjects !== false`, whose own note says it is
+ // registered there "not only in the ObjectQLPlugin
+ // `environmentId === undefined` standalone path"). So on a
+ // standalone boot this block is redundant, not missing — flipping
+ // it on would put five more registrations into a registry that
+ // already has them, and would show up as new pending schema work in
+ // `os migrate plan`'s output for no gain. Arming the migrations is
+ // this card's surface; provisioning is not.
if (environmentId === undefined) {
ql.registerApp({
id: 'com.objectstack.metadata-objects',
@@ -193,10 +264,17 @@ export function assembleMetadataProtocol(
// `registerProtocol !== false` convenience mode) — a hook on the
// delegated plugin alone would miss the default mount entirely.
//
- // Gated on `environmentId === undefined` for exactly the reason the
- // registerApp block above is: per-project (cloud) kernels do not
- // provision these tables locally, so there is no index of ours to
- // tighten there.
+ // Gated for exactly the reason the registerApp block above is:
+ // per-project (cloud) kernels do not provision these tables
+ // locally, so there is no index of ours to tighten there.
+ //
+ // [#9380] The gate is now DECLARED (`runPlatformMigrations`) rather
+ // than deduced from `environmentId === undefined`. The deduction was
+ // wrong in the direction that mattered: the standalone stack stamps
+ // `'proj_local'`, so this whole block never armed on a self-hosted
+ // boot and the three migrations below reached no self-hosted
+ // install. The registerApp block above keeps the old predicate on
+ // purpose — see the note there.
//
// Deferred to `kernel:ready` because the table has to EXIST first —
// ObjectQLPlugin creates it in `start()` via `syncRegisteredSchemas`,
@@ -224,7 +302,7 @@ export function assembleMetadataProtocol(
// Separate try/catch per migration, deliberately: they protect
// different tables and one that could not be armed must not skip the
// other.
- if (environmentId === undefined) {
+ if (shouldRunPlatformMigrations(environmentId, options.runPlatformMigrations)) {
(ctx as any)?.hook?.('kernel:ready', async () => {
try {
await ensureViewDefinitionActiveIndex(resolveIndexExec(ql), ctx.logger);
diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts
index b42abd43c3..74804ffdba 100644
--- a/packages/objectql/src/plugin.ts
+++ b/packages/objectql/src/plugin.ts
@@ -85,6 +85,19 @@ export interface ObjectQLPluginOptions {
* less — which is the whole point of declaring it rather than deducing it.
*/
authoringChannel?: MetadataAuthoringChannel;
+ /**
+ * [#9380] Does this boot arm the `kernel:ready` platform-table repair
+ * migrations (#5839 / #8629 / #8686)?
+ *
+ * Same lesson as `authoringChannel` right above: the gate used to be deduced
+ * from `environmentId === undefined`, which is a row-scoping key and not a
+ * topology signal — the standalone stack stamps `'proj_local'`, so the three
+ * migrations never armed on a self-hosted boot at all. Forwarded verbatim to
+ * `assembleMetadataProtocol`; see
+ * `AssembleMetadataProtocolOptions.runPlatformMigrations` for the two facts
+ * that make it false. Omitted ⇒ the historical deduction, unchanged.
+ */
+ runPlatformMigrations?: boolean;
/**
* Override the kernel's default plugin-start timeout for this plugin.
* Defaults to 120000 (120s). Schema sync to a remote SQL backend
@@ -191,6 +204,15 @@ export class ObjectQLPlugin implements Plugin {
* resolves it to `'environment'`, the gated channel.
*/
private authoringChannel?: MetadataAuthoringChannel;
+ /**
+ * [#9380] Declared arming of the `kernel:ready` platform-table repair
+ * migrations, forwarded to the ONE protocol assembly. `undefined` here is
+ * not "off" — it means "not declared", and the assembly resolves it to the
+ * historical `environmentId === undefined` deduction. The legacy positional
+ * `(ObjectQL, hostContext)` constructor returns before any option is read,
+ * so it lands here undefined and keeps that deduction too.
+ */
+ private runPlatformMigrations?: boolean;
private skipSchemaSync = false;
/** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */
private reloadSchemaSync: Promise = Promise.resolve();
@@ -231,6 +253,7 @@ export class ObjectQLPlugin implements Plugin {
this.hostContext = opts.hostContext ?? hostContext;
this.environmentId = opts.environmentId;
this.authoringChannel = opts.authoringChannel;
+ this.runPlatformMigrations = opts.runPlatformMigrations;
if (typeof opts.startupTimeout === 'number' && opts.startupTimeout > 0) {
this.startupTimeout = opts.startupTimeout;
}
@@ -325,6 +348,11 @@ export class ObjectQLPlugin implements Plugin {
// constructor path, which returns before any option is read.
const protocolShim = assembleMetadataProtocol(ctx, this.ql, this.environmentId, {
authoringChannel: this.authoringChannel,
+ // [#9380] Rides the same seam, for the same reason: an undeclared value
+ // resolves to the historical `environmentId === undefined` deduction
+ // inside the assembly, so this mount and the delegated
+ // MetadataProtocolPlugin cannot disagree about when the migrations arm.
+ runPlatformMigrations: this.runPlatformMigrations,
});
this.subscribeMetadataRebind(ctx, protocolShim);
} else {
diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts
index fd834a1c44..8f6d1e9e11 100644
--- a/packages/runtime/src/standalone-stack.ts
+++ b/packages/runtime/src/standalone-stack.ts
@@ -214,6 +214,30 @@ export const StandaloneStackConfigSchema = z.object({
* exists for.
*/
sqliteAbsentFile: z.enum(['create', 'empty-in-memory']).optional(),
+ /**
+ * [#9380] Does this boot arm the `kernel:ready` platform-table repair
+ * migrations (#5839's `sys_view_definition` active-row index, #8629's
+ * `sys_setting` row-identity index, #8686's seed/API tenancy backfill)?
+ *
+ * Defaults to `true`, and that default is the fix: a standalone kernel
+ * OWNS its local platform tables, which is what the gate in
+ * `assembleMetadataProtocol` always meant to say. It used to deduce that
+ * from `environmentId === undefined`, and line ~515 below stamps
+ * `'proj_local'` on every boot — so the block never ran and #8686's
+ * "covers every existing deployment" half covered no self-hosted install
+ * at all.
+ *
+ * Set `false` for a boot that must not repair anything behind the
+ * operator's back. `bootSchemaStack` (the CLI's ONE one-shot boot funnel)
+ * passes `false` for every `os migrate *` / `os meta *` command: those are
+ * dry-run-by-default report commands, and a repair that fires under them
+ * destroys the very evidence they were run to collect
+ * (`packages/cli/src/commands/migrate/duplicates.integration.test.ts`
+ * pins byte-identical-after-run). The serving boots — `os dev`,
+ * `os serve`, `os start` — take the default and repair, which is the one
+ * boot an operator starts in order to RUN the install.
+ */
+ runPlatformMigrations: z.boolean().optional(),
});
export type StandaloneStackConfig = z.input;
@@ -695,7 +719,14 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
// when unset so the plugin keeps its own cwd default.
...(cfg.projectRoot ? { rootDir: cfg.projectRoot } : {}),
}),
- new ObjectQLPlugin({ environmentId }),
+ // [#9380] `runPlatformMigrations` is declared here, not deduced from
+ // `environmentId`: this stack stamps `'proj_local'` above, and the
+ // assembly's old `environmentId === undefined` gate read that as "a
+ // per-project cloud kernel" and disarmed the three boot repairs on
+ // every self-hosted install. A standalone kernel owns its local
+ // platform tables — say so — and let a read-only one-shot boot turn
+ // it off explicitly.
+ new ObjectQLPlugin({ environmentId, runPlatformMigrations: cfg.runPlatformMigrations ?? true }),
];
if (artifactBundle) {
plugins.push(new AppPlugin(artifactBundle, undefined, { skipSeedData: cfg.skipSeedData ?? false }));