Found while implementing #8928 (the duplicate-identifier report), where the question "does running this command repair the install first?" had to be answered by measurement rather than by reading. The answer turned out to be no, and the reason is this defect rather than anything about that command.
The gate, and what stands on it
assembleMetadataProtocol (packages/metadata-protocol/src/plugin.ts) arms three migrations on kernel:ready, all inside one if (environmentId === undefined) block:
The gate's own comment says what it is for: "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 is meant to be on the inside of this gate.
Measured: standalone is on the outside
packages/runtime/src/standalone-stack.ts line 515:
constenvironmentId=cfg.environmentId??process.env.OS_ENVIRONMENT_ID??'proj_local';
Every boot that goes through createStandaloneStack — os dev, os serve, os start, and the whole os migrate family — therefore reaches assembleMetadataProtocol with environmentId = 'proj_local', not undefined, and the block never runs.
Measured on origin/main @ 90c5285cb, by instrumenting the built @objectstack/metadata-protocol and booting the real standalone stack against a SQLite fixture carrying the #8686 damage (a __global__ counter at 38 beside an org_x counter at 4, untenanted seed rows, one organization row):
INFO Protocol service registered [MP-JS] envId="proj_local" hookType=function
BEFORE sequences: [{crm_case,__global__,38},{crm_case,org_x,4}]
AFTER sequences: [{crm_case,__global__,38},{crm_case,org_x,4}]
VERDICT: boot mutated the pre-repair evidence = false
Two controls, so this is not a fixture that simply cannot be repaired:
- calling
backfillSeedTenancy(resolveSeedTenancyExec(ql)) by hand on the same booted engine returns status: 'applied', stamps the movable rows, merges the counter to 38 and deletes the __global__ row; - a probe plugin registering its own
kernel:ready handler on the same boot fires and repairs — so ctx.hook works, kernel:ready is triggered by kernel.bootstrap(), and the seam is usable at that moment. Only the shipped registration is missing.
Why it matters
For #8686 specifically: its own header calls the kernel:ready migration the half that "repairs an install that is ALREADY in that state, which covers every existing deployment". On a self-hosted install it covers none. The only surviving path is the sys_organization-create handoff in runtime/src/app-plugin.ts, which fires when an organization is first created — i.e. the fresh-install half. An install that already had its organization when it upgraded gets neither, and keeps minting duplicates.
The same reasoning applies to the two index migrations beside it.
Not fixed here, deliberately
#8928's file surface is packages/cli/src/commands/**, and the fix is a decision about a gate two packages away — whether the standalone default should be undefined, whether the gate should test something other than environmentId (what it actually wants is "is this kernel the owner of its local platform tables"), or whether the three migrations should be armed from a different seam. Worth noting that flipping this on changes what a plain os migrate plan does to a database: those three migrations would then run on every one-shot CLI boot, so whichever way it is fixed, the read-only commands need to be considered in the same breath.
Verification hooks that already exist: packages/cli/src/commands/migrate/duplicates.integration.test.ts (from #8928) boots the real stack over a fixture carrying this exact damage and asserts the database is unchanged afterwards. When this gate is fixed, that assertion is where the change in behaviour will surface first.
Found while implementing #8928 (the duplicate-identifier report), where the question "does running this command repair the install first?" had to be answered by measurement rather than by reading. The answer turned out to be no, and the reason is this defect rather than anything about that command.
The gate, and what stands on it
assembleMetadataProtocol(packages/metadata-protocol/src/plugin.ts) arms three migrations onkernel:ready, all inside oneif (environmentId === undefined)block:ensureViewDefinitionActiveIndex— [metadata-core]sys_view_definition的"活跃行唯一"从未生效——归档视图仍占用 (name, organization_id, owner) 名额 #5839, the "unique among ACTIVE rows" index onsys_view_definition;ensureSysSettingIdentityIndex—sys_setting's declared row identity is unenforced on everytenantandglobalrow —user_idis NULL there and SQL UNIQUE is NULL-distinct #8629,sys_setting's row-identity index;backfillSeedTenancy— Seed loader writes untenanted rows while the REST path stamps an organization — one single-tenant install runs two autonumber scopes and mints duplicate business identifiers, silently (17.0.0 GA) #8686, the seed/API tenancy split repair that moves stored rows and merges the split autonumber counters.The gate's own comment says what it is for: "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 is meant to be on the inside of this gate.
Measured: standalone is on the outside
packages/runtime/src/standalone-stack.tsline 515:Every boot that goes through
createStandaloneStack—os dev,os serve,os start, and the wholeos migratefamily — therefore reachesassembleMetadataProtocolwithenvironmentId = 'proj_local', notundefined, and the block never runs.Measured on
origin/main@90c5285cb, by instrumenting the built@objectstack/metadata-protocoland booting the real standalone stack against a SQLite fixture carrying the #8686 damage (a__global__counter at 38 beside anorg_xcounter at 4, untenanted seed rows, one organization row):Two controls, so this is not a fixture that simply cannot be repaired:
backfillSeedTenancy(resolveSeedTenancyExec(ql))by hand on the same booted engine returnsstatus: 'applied', stamps the movable rows, merges the counter to 38 and deletes the__global__row;kernel:readyhandler on the same boot fires and repairs — soctx.hookworks,kernel:readyis triggered bykernel.bootstrap(), and the seam is usable at that moment. Only the shipped registration is missing.Why it matters
For #8686 specifically: its own header calls the
kernel:readymigration the half that "repairs an install that is ALREADY in that state, which covers every existing deployment". On a self-hosted install it covers none. The only surviving path is thesys_organization-create handoff inruntime/src/app-plugin.ts, which fires when an organization is first created — i.e. the fresh-install half. An install that already had its organization when it upgraded gets neither, and keeps minting duplicates.The same reasoning applies to the two index migrations beside it.
Not fixed here, deliberately
#8928's file surface is
packages/cli/src/commands/**, and the fix is a decision about a gate two packages away — whether the standalone default should beundefined, whether the gate should test something other thanenvironmentId(what it actually wants is "is this kernel the owner of its local platform tables"), or whether the three migrations should be armed from a different seam. Worth noting that flipping this on changes what a plainos migrate plandoes to a database: those three migrations would then run on every one-shot CLI boot, so whichever way it is fixed, the read-only commands need to be considered in the same breath.Verification hooks that already exist:
packages/cli/src/commands/migrate/duplicates.integration.test.ts(from #8928) boots the real stack over a fixture carrying this exact damage and asserts the database is unchanged afterwards. When this gate is fixed, that assertion is where the change in behaviour will surface first.