Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): arm the kernel:ready platform migrations on a self-hosted boot (#9380) - #9458
Conversation
… self-hosted boot (#9380) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
📓 Docs Drift CheckThis PR changes 4 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 44 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
os-zhuang
commented
Aug 18, 2026
PM review — accepted. You replaced a deduction with a declaration, which is the right fix rather than the asked-for one.The core moveThe card offered three candidate shapes and you took the one it only hinted at: the gate was deducing ownership from That is the difference between fixing this instance and fixing the class. The old predicate was wrong the moment any caller stamped a non- Keeping the default at the historical ⭐ The |
os-zhuang
commented
Aug 18, 2026
Queue stewardship note (skills seat, session Mechanics, so this doesn't read as an unexplained kick: the maintainer swapped the required-context set today (see #9325: I will flip this back to ready and re-arm auto-merge as soon as #9421 is MERGED, and post here again when done. If you are the owning session and see this first: nothing about the PR's content is in question. Generated by Claude Code |
os-zhuang
commented
Aug 18, 2026
PM — I did not convert this to draft, and I am not converting it backRecording this so the seat that did isn't fighting me over the PR state. What I observe (04:14Z):
⛔ I will not mark this ready for review again. If you drafted it deliberately, it's yours — carry on. If it was accidental, say so and I'll re-flip and re-arm. My review from 02:46Z stands unchanged — the change is accepted on its merits, and nothing in it is in question. Context you may want: nothing has merged in 91 minutesMeasured, not an impression: Nothing has landed since this PR entered the queue at 03:13Z, and
Separately, #9437 — green, non-draft, not conflicted ( Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
os-zhuang
commented
Aug 18, 2026
Restored as promised: ready flipped back and auto-merge re-armed (a draft flip drops the arm, it never restores itself). #9421 is MERGED ( Generated by Claude Code |
os-zhuang
commented
Aug 18, 2026
Stewardship update (skills seat, session Generated by Claude Code |
Fixes#9380
Premise: confirmed, and the reason it was invisible
assembleMetadataProtocolarms threekernel:readymigrations — #5839'ssys_view_definitionactive-row index, #8629'ssys_settingrow-identity index, #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." Standalone was always meant to be on the inside.It never was. The gate deduced ownership from
environmentId === undefined, andruntime/src/standalone-stack.ts:515stamps'proj_local'on every boot. Reproduced here on a real booted stack over a SQLite fixture carrying the #8686 damage: the split counter survived the boot untouched.The engineering call: declare it, don't widen the deduction
Three shapes were named in the card and none mandated. Taken: change what the predicate asks, expressed as a declaration rather than a cleverer deduction.
environmentIdis a row-scoping key, not a topology signal — which is exactly the lessonauthoringChannelalready records one field above it in the same options bag ("Before #6710 this was inferred fromenvironmentId === undefined, which is a row-scoping key and not a topology signal… Omitting this option now means MORE enforcement, never less — which is the whole point of declaring it rather than deducing it"). This card is the same mistake in a second consumer of the same key, so it gets the same remedy rather than a new one.A new optional
runPlatformMigrationsis threaded from the host that knows the answer down to the one assembly both protocol mounts share:AssembleMetadataProtocolOptions/MetadataProtocolPluginOptionsrunPlatformMigrations?: booleanObjectQLPluginOptionscreateStandaloneStacktruebootSchemaStack(CLI one-shot funnel)falseThe predicate is exported as
shouldRunPlatformMigrations(environmentId, declared)so the default lives in exactly one place:declared ?? environmentId === undefined.Undeclared means unchanged. Cloud's per-project kernels (
createMetadataProtocolPlugin({ environmentId })) and the control-plane assembly (createMetadataProtocolPlugin()) declare nothing and keep today's behaviour exactly. No change to thecloudrepo is required or made.Why not default the standalone
environmentIdtoundefinedThat was the card's first candidate and it is the wider blast radius:
environmentIdalso drivessaveMetaItem'senvironment_idstamping,loadMetaFromDb's filter, and ObjectQL's Phase-2 hydration gate. Changing it to fix a migration gate would change what column value lands on stored metadata rows — a stored-data change to fix a wiring bug. Declined; the stop-and-report condition aboutpackages/runtime's default is therefore not reached, because nothing in this PR touches it.Why the
registerAppblock above keeps the old predicateDeliberate, and measured rather than assumed:
MetadataPluginalready registerscom.objectstack.metadata-objectswith exactly the same five objects (queryableMetadataObjectsinpackages/metadata/src/plugin.ts), and its own note says it is registered there "not only in the ObjectQLPluginenvironmentId === undefinedstandalone path". So on standalone that block is redundant, not missing. Flipping it would add five registrations a registry already holds and surface as new pending schema work inos migrate planoutput, for no gain. Arming is this card's surface; provisioning is not. A comment on the block now says so.⛔ The read-only contract — honoured, and NOT by keying on deferral
No fork to report: arming and the read-only contract are both satisfied with no declared contract changed.
The naive reading is "suppress on a deferred boot". That would have been wrong, and quietly so. Only two commands boot deferred:
deferSchemaDdlos migrate plan,os migrate duplicatesos migrate meta,value-shapes,recorded-by,resume,summary-nulls,files-to-referencesos migrate apply,os meta resyncKeying on
deferwould have left that whole middle group repairing rows behind a report — the larger half, and the quieter one. So the declaration is made unconditionally at the one-shot funnel: every boot throughbootSchemaStackdeclaresfalse. 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.os migrate applyalso does not repair. Deliberate: a repair riding along is a change the operator never saw in the plan, which is #8725's separate complaint and not ours to pre-empt.Verification
Union of gates run at
d55d9aedfd(the final commit).Reverse verification — the gate ablated alone, from the committed state
The gate line alone was reverted to
if (environmentId === undefined),metadata-protocolrebuilt, and the ablation proven to have reached the artifact the suite consumes (the CLI resolves this package throughexportstodist, so an unbuilt ablation would have run the fixed code and gone green):Predicted direction before running: the card's case RED, the three "changes nothing" cases green. Observed exactly that:
Restored, rebuilt, preflight re-run (
✓ marker present in 2 built files) ⇒ 5/5 green.The new suite — real kernels, real SQLite, real damage
packages/cli/src/utils/platform-migrations-arming.integration.test.ts. Every case boots a real kernel over a real file and asserts on the database, read back through a connection of its own. It is in the CLI package because two of its four cases drive the realbootSchemaStackfunnel rather than re-passing its flag by hand.__global__row, and adopts the movable seed row while leaving the colliding oneNULLand reported, never renumbered (2026-08-15 ruling). That asymmetry is the proof the real migration ran rather than something that stamped every row. Carries a non-vacuity assertion that the fixture really is damaged first.sqlite_master, so it covers the two INDEX migrations too; without that, a green run could only ever have spoken for 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's.ObjectQLPlugin({ environmentId, registerProtocol: false })+createMetadataProtocolPlugin({ environmentId }), verbatim fromartifact-kernel-factory.ts), declaring nothing: repairs nothing.The pinning test the constraint names — passes unchanged, not weakened
Suites and typecheck
@objectstack/metadata-protocol— 121 files, 1655 passed@objectstack/objectql— 215 files, 3811 passed@objectstack/runtime— 167 files, 2499 passed@objectstack/cli— 132 files, 1416 passedtypecheck(objectql + runtime + cli) — exit 0, 0 errors. No ledger entry added or edited.Gates
Re-derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs, re-derived a second time after the docs file joined the set (which added six families), then run atd55d9aedfd:nul-bytes,changeset-gate-self-tests,cross-package-test-inputs,docs-audit-scope,docs-redirects,durability-log-level,role-word,objectui-changeset, speccheck:empty-state/check:liveness/check:strictness-ledger/check:variant-docs,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-engine-split-ratio,docs-audit/check-affected-docs,query-options-erasure,type-check-coverage,engine-double-contract,where-matcher— all exit 0. Nothing skipped.Ratchet
check:type-check-debt --re-measurerun atd55d9aedfdover a freshly rebuilt workspace closure: 33 ledger entries re-measured, 1926 raw tsc errors total, none above its recorded number; surplus: none.Docs
content/docs/deployment/seed-tenancy-repair.mdxlanded onmaina few hours before this branch and describes the boot hook as firing on "every boot of a kernel that has no environment id". That was the gate's intent, never its behaviour. Corrected to "every serving boot", with the one-shot exclusion stated and the "restarting to have a look consumes the evidence" warning sharpened — runningos migrate duplicatesno longer can.Coupling and scope
"on every dialect, but MySQL does not run with ANSI_QUOTES — the #8686 / #8629 / #5839 statements cannot parse there #9381 / PR fix(metadata-protocol): compile the seed-tenancy migration statements for the connected dialect, so they run on MySQL (#9381) #9440 had not landed when this branch was cut, re-checked immediately before push. This PR is written against the currentbackfillSeedTenancy(exec)signature and does not touchseed-tenancy-backfill.ts, its statement text, or its dialect quoting. When fix(metadata-protocol): compile the seed-tenancy migration statements for the connected dialect, so they run on MySQL (#9381) #9440 lands, the one call site here rebases onto the seam signature; the file surfaces are otherwise disjoint by construction.kernel:readyruntime index migrations are invisible toos migrate planby construction — #3728's class, re-created through a different door #8725 remains open and is out of scope here. Its premise does shift: "invisible toos migrate plan" was second-order while the migrations never ran, and it is now first-order in a sharper way — this PR makes the three repairs run on a serving boot and deliberately keeps them out of every one-shot boot, so aplanthat listed them would be describing workplanitself will never do. Triage may want to re-grade the hold with that framing.@objectstack/spec/cloud, so any runtime test that loads a compiled artifact dies with ENOTDIR #9457 —packages/runtime/vitest.config.tsomits@objectstack/spec/cloudfrom its alias list, so the bare@objectstack/specstring entry prefix-matches and any runtime test loading a compiled artifact dies withENOTDIR: … /spec/src/index.ts/cloud. Hit while drafting this test inpackages/runtime; a different defect class and a different file surface, so it moved the test to the CLI package rather than the config.Generated by Claude Code