From 537452ef7702131666cc1da6238f2ab8e213f214 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 01:54:03 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): arm the kernel:ready platform migrations on a self-hosted boot (#9380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three kernel:ready migrations (#5839 view-definition active-row index, #8629 sys_setting row-identity index, #8686 seed/API tenancy backfill) were gated on `environmentId === undefined`, while the standalone stack stamps 'proj_local' on every boot — so none of them ever ran on a self-hosted install, and #8686's "covers every existing deployment" half covered none. Replace the deduction with a declaration: `runPlatformMigrations`, threaded from the host that knows the answer through ObjectQLPlugin into the one assembly both protocol mounts share. Undeclared falls back to the historical `environmentId === undefined`, so cloud's per-project kernels and the control-plane assembly are unchanged. The standalone stack declares `true`; the CLI's one-shot boot funnel declares `false`, which keeps every dry-run-by-default `os migrate *` command read-only — including the six that boot non-deferred and would have been missed by a defer-keyed gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj --- .../arm-platform-migrations-self-hosted.md | 69 ++++ ...form-migrations-arming.integration.test.ts | 306 ++++++++++++++++++ packages/cli/src/utils/schema-migrate.ts | 26 ++ packages/metadata-protocol/src/index.ts | 2 +- packages/metadata-protocol/src/plugin.ts | 92 +++++- packages/objectql/src/plugin.ts | 28 ++ packages/runtime/src/standalone-stack.ts | 33 +- 7 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 .changeset/arm-platform-migrations-self-hosted.md create mode 100644 packages/cli/src/utils/platform-migrations-arming.integration.test.ts 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/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 ce15630fb6..48bed467fc 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 516b061553..60104519b3 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 })); From d55d9aedfd310d67cdae1361da585d8dd67f70d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:23:30 +0000 Subject: [PATCH 2/2] docs(deployment): correct the seed-tenancy repair's trigger after #9380 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page landed hours before this fix and describes the boot hook as firing on "every boot of a kernel that has no environment id". That was the gate's INTENT and never its behaviour: the standalone stack stamps 'proj_local', so no self-hosted boot ever ran the repair. With the gate now declared rather than deduced, the honest statement is "every SERVING boot" — and the one-shot `os migrate` / `os meta` boots are explicitly out, which is what keeps `os migrate duplicates` safe to run before a restart. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj --- .../docs/deployment/seed-tenancy-repair.mdx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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. ---