fix(cli): os migrate plan/apply compose the deployment's own object set - #12952
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ngeset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…grate-composes-host-objects
📓 Docs Drift CheckThis PR changes 1 package(s): 8 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 — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f330af3534cccc11f2c3866fe70d57aa17779bed && git checkout f330af3534cccc11f2c3866fe70d57aa17779bed
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b9dd923b9965e6ce6453c32ea3c9d7ad8a2ebfa3 a06a8431c68b5f1970b70b390a113f9b2b952f84 && git checkout -B drift-repro b9dd923b9965e6ce6453c32ea3c9d7ad8a2ebfa3 && git merge --no-ff a06a8431c68b5f1970b70b390a113f9b2b952f84
node scripts/docs-audit/affected-docs.mjs --json b9dd923b9965e6ce6453c32ea3c9d7ad8a2ebfa3
|
Fixes #12938
Part of objectstack-ai/cloud#1653
os migrate plan/os migrate applybootedcreateStandaloneStackand nothing else, so on any real deployment they diffed a five-table subset and reported0drift over it — while the driver's own boot-time detector, running with the full registered object set on the same database, emitted findings whose message endsrun "os migrate apply". This restores the behaviour that message already promises. Nothing about what counts as drift changed.The premise, re-measured on this branch's base
os migrate plan --json, cwd an empty directory, fresh SQLite target, CLI ate5f66214a:Byte-for-byte the card's reading. Confirmed before a line was written.
What is composed, derived from
serve's own assemblyserve's steppluginsplugins = config.pluginsobjectstack.config.{ts,js,mjs}is present in cwdAppPlugin(config)isAppPluginLikepresence testPlatformObjectsPluginplugin-securityis NOT somethingservecomposes unconditionally. Enumerated fromserve.ts, the auth family —AuthPlugin, the Setup/Account app plugins,OrganizationsPlugin,SecurityPlugin,AuditPlugin— sits insideif (!hasAuthPlugin && tierEnabled('auth'))atserve.ts:3275, i.e. behind a tier and behind "the config brought noAuthPlugin".PlatformObjectsPlugin(5c) is the only thing beyond the data stack thatservecomposes with no tier, env var or capability in front of it. Composing a tier-gated plugin here would invent an object set no boot of the deployment has, so the composition takes the config's own plugins — which is whereplugin-securityreaches a real deployment, ObjectStack Cloud's control plane included.Both commands pass
composeHostStack: trueat the call site, explicitly, rather than having it deduced insidebootSchemaStack(AGENTS.md → Route & surface ownership §2). The DATA subcommands keep their own narrower set throughbuildDataMigrationPlugins, untouched.Host plugins are composed for their DECLARATIONS only — measured, not chosen for taste
Composing a host config means composing arbitrary code, and the shipped code writes.
SecurityPlugincomposed into a deferredplanboot, measured 2026-08-28:Those inserts fail only because the deferred boot has not created the tables. On a database whose tables exist — a production control plane — they succeed: a command documented as writing nothing would seed rows. The kernel contract puts object declarations in
init()("register services, schemas, routes") and side-effecting work instart();SecurityPluginis the reference —init()handssecurityObjectsto themanifestservice, and every seeding path is registered insidestart(). So a host plugin'sinit()runs and itsstart()is suppressed. This is the same disciplinebootSchemaStackalready applies withrunPlatformMigrations: false("a repair that fires under them destroys the very evidence they were run to collect"), reaching one layer further out.PlatformObjectsPluginis deliberately not suppressed: the sibling DATA subcommands already boot it fully and they are dry-run-by-default too. The line is between plugins this repo owns and has measured, and host code it cannot know.Named residue: a host plugin that registers its objects in
start()rather thaninit()is invisible to this composition — its tables stay out of the plan. Accepted because the alternative measured strictly worse (a dry run that writes), and made diagnosable rather than silent: the composition prints what it did.Two behaviours worth reviewing closely
buildSchemaMigrationPluginsreturns early — not even the platform floor — because there is no deployment there to mirror. Verified post-fix: same five tables,total: 0, and the same--jsonkey set (database, managedTables, total, changes, pending, duration), nocompositionkey.AUTH_SECRET). It warns on stderr naming the config, the error and the consequence — "an empty plan here is UNMEASURED, not 'in sync'" — and continues, because aplanthat stops working is a worse regression than a reduced one.plan --jsonthen carriescomposition.hostConfigLoaded: false.Consumer note — objectstack-ai/cloud#1705 / #1707
cloud's
migrate-control-db.ymlcarries a coverage gate that refuses whenmanagedTables <= 5. After this fix plus a pin bump, its stage-2 acceptance should read, against the staging control plane and post-cloud#1701:managedTablesfar above five — the control plane's real object set (createCloudStack()'s plugins,sys_position/sys_permission_setand the rest included). The gate passes silently and untouched; its own header already anticipates exactly this ("the day the framework closes that gap the count jumps to the real object set").replace_unique_indexhalf against staging, so those ten are gone; areplace_unique_indexreappearing on staging would mean the remediation did not hold, not that this fix mis-detects.create_index/add_columns/create_tableentries for objects the control DB predates are the normal first reading. Read the classes:safeandneeds_confirmare whatapplycovers; anythingdestructiveis not applied by that workflow and belongs in a maintainer's window.<= 5gate does not catch, and cloud should close on its side: a host config that fails to load still raises the count (the platform floor lands,managedTablesbecomes 9), so the gate passes over a plan that covers none of the control plane. The discriminator is in the payload — fail the workflow whencomposition.hostConfigLoadedis nottrue, or whencompositionis absent entirely. The stderr line[migrate] ⚠ Host config … could not be loadedsays the same thing in the log. Filing this against cloud is left to the PM; it is a consumer-side gate change, not framework scope.Verification
Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usableflock. The shared verify lock is declared Linux-only (flockis util-linux, and a stock macOS does not ship it), so the commands below were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.All exit codes captured before any pipe; every result quotes the gate's own verdict line. Final head
a06a8431c(merge oforigin/mainb9dd923b9).Tests —
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2over the whole migrate surface (19 files: the two new ones, the six existingschema-migrate.*suites, and all ofsrc/commands/migrate):Test Files 19 passed (19),Tests 104 passed (104).pnpm --filter @objectstack/cli typecheck: exit 0 — and both new test files are really in that program (tsc --listFilescounts 2, so "typecheck clean" is a statement about them).Ablation, disk-proven, with an
EXIT INT TERMtrap and absolute paths. The seam inschema-migrate.tswas disarmed (opts.composeHostStack === true→false):The four host-composition cases go red; the artifact-less baseline pin stays green, which is the point — it must not depend on the fix. First failure is
composition.hostConfigLoadedfalse, second isDROP INDEX uniq_sys_position_organization_id_name - no such index(the table is not even registered pre-fix). Restore leg:git checkout HEAD -- <abs path>, thengit diff HEAD --statempty,git status --porcelainempty, and the file's hash back to878ac920…. No build step is involved on either leg — vitest resolves these workspace imports to source, so both legs measure the tree on disk.Gates, derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-written path list), all exit 0:check:changeset-gate-self-tests,check:cross-package-test-inputs,check:i18n,check:i18n-coverage,check:objectql-double-limit,check:objectui-changeset,check:page-declaration-shape,check:pm-half-states,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:nul-bytes, pluscheck-adr-0087-registration,check-changeset-no-major,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-empty-changeset,check-plugin-teardown-shape,docs-audit/check-affected-docs. Sample verdict lines: "OK: 23 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob." · "check-nul-bytes: OK (scanned 7184 text file(s) … no raw ASCII control bytes)." · "✓ This diff introduces nomajorbump."Repo-wide
pnpm lintwas NOT run — a declared narrowing, not a measurement. The three-part evidence a narrowing owes (population read from eslint's own config, file count from--format json, an invariance claim over untouched files) was not collected, so this is recorded as "not run" and left to CI, which runs the farm exactly once.Pre-existing failures, proven not mine. A full
pnpm --filter @objectstack/cli testreports 5 unrelated e2e files failing on this host (test/{cloud-,}login-json-ndjson.e2e.test.ts,test/serve-port-drift-notice.e2e.test.ts,test/serve-port-readback.e2e.test.ts,test/serve-process-child-env.e2e.test.ts). Measured rather than asserted: with this branch's three changed source files reverted to the merge base on disk (each revert hash-verified, restored under a trap), the same five files fail with byte-identical counts —Test Files 5 failed (5),Tests 3 failed | 28 passed | 14 skipped (45). They spawnos serve/os loginand reach none of the changed modules.Generated by Claude Code