Skip to content

fix(cli): os migrate plan/apply compose the deployment's own object set - #12952

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-12938-migrate-composes-host-objects
Aug 28, 2026
Merged

fix(cli): os migrate plan/apply compose the deployment's own object set#12952
hotlong merged 4 commits into
mainfrom
claude/issue-12938-migrate-composes-host-objects

Conversation

@hotlong

@hotlong hotlong commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #12938
Part of objectstack-ai/cloud#1653

os migrate plan / os migrate apply booted createStandaloneStack and nothing else, so on any real deployment they diffed a five-table subset and reported 0 drift 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 ends run "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 at e5f66214a:

managedTables: 5
total:         0
pending (5):   sys_metadata, sys_metadata_audit, sys_metadata_commit,
               sys_metadata_history, sys_view_definition

Byte-for-byte the card's reading. Confirmed before a line was written.

What is composed, derived from serve's own assembly

composed serve's step condition
the host config's plugins plugins = config.plugins an objectstack.config.{ts,js,mjs} is present in cwd
AppPlugin(config) step 3, same isAppPluginLike presence test the config carries top-level metadata and brings no app plugin of its own
PlatformObjectsPlugin step 5c absent from the composed set

⚠️ One correction to the dispatch's parenthetical, measured rather than assumed: plugin-security is NOT something serve composes unconditionally. Enumerated from serve.ts, the auth family — AuthPlugin, the Setup/Account app plugins, OrganizationsPlugin, SecurityPlugin, AuditPlugin — sits inside if (!hasAuthPlugin && tierEnabled('auth')) at serve.ts:3275, i.e. behind a tier and behind "the config brought no AuthPlugin". PlatformObjectsPlugin (5c) is the only thing beyond the data stack that serve composes 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 where plugin-security reaches a real deployment, ObjectStack Cloud's control plane included.

Both commands pass composeHostStack: true at the call site, explicitly, rather than having it deduced inside bootSchemaStack (AGENTS.md → Route & surface ownership §2). The DATA subcommands keep their own narrower set through buildDataMigrationPlugins, 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. SecurityPlugin composed into a deferred plan boot, measured 2026-08-28:

full start        : managedTables 16 · 14 × `Insert operation failed {"object":"sys_permission_set"}`
declarations only : managedTables 16 · 0 insert attempts

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 in start(); SecurityPlugin is the reference — init() hands securityObjects to the manifest service, and every seeding path is registered inside start(). So a host plugin's init() runs and its start() is suppressed. This is the same discipline bootSchemaStack already applies with runPlatformMigrations: false ("a repair that fires under them destroys the very evidence they were run to collect"), reaching one layer further out.

PlatformObjectsPlugin is 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 than init() 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

  • A project with neither a config nor a compiled artifact is unchanged. buildSchemaMigrationPlugins returns 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 --json key set (database, managedTables, total, changes, pending, duration), no composition key.
  • A host config that exists but fails to load is loud and non-fatal. A missing env var is the common case (Cloud's own config throws without 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 a plan that stops working is a worse regression than a reduced one. plan --json then carries composition.hostConfigLoaded: false.

Consumer note — objectstack-ai/cloud#1705 / #1707

cloud's migrate-control-db.yml carries a coverage gate that refuses when managedTables <= 5. After this fix plus a pin bump, its stage-2 acceptance should read, against the staging control plane and post-cloud#1701:

  • managedTables far above five — the control plane's real object set (createCloudStack()'s plugins, sys_position / sys_permission_set and 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").
  • no legacy-unique findings. cloud#1701 already applied the replace_unique_index half against staging, so those ten are gone; a replace_unique_index reappearing on staging would mean the remediation did not hold, not that this fix mis-detects.
  • an additive remainder is expected and is not a regression. The composed plan now sees ~80 tables it never diffed before, so create_index / add_columns / create_table entries for objects the control DB predates are the normal first reading. Read the classes: safe and needs_confirm are what apply covers; anything destructive is not applied by that workflow and belongs in a maintainer's window.

⚠️ One thing the <= 5 gate 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, managedTables becomes 9), so the gate passes over a plan that covers none of the control plane. The discriminator is in the payload — fail the workflow when composition.hostConfigLoaded is not true, or when composition is absent entirely. The stderr line [migrate] ⚠ Host config … could not be loaded says 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.sh could not take the shared verify lock on this host: no usable flock. The shared verify lock is declared Linux-only (flock is 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 of origin/main b9dd923b9).

Testspnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over the whole migrate surface (19 files: the two new ones, the six existing schema-migrate.* suites, and all of src/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 --listFiles counts 2, so "typecheck clean" is a statement about them).

Ablation, disk-proven, with an EXIT INT TERM trap and absolute paths. The seam in schema-migrate.ts was disarmed (opts.composeHostStack === truefalse):

BEFORE_HASH = 878ac9202be5da25c14cb466520356387e83971f   (= HEAD blob)
removed-text occurrences BEFORE / AFTER : 1 / 0
injected-text occurrences AFTER         : 1
AFTER_HASH  = 266db883a3050d244ddc834a4d8a33036cd3b79f
MUTATION CONFIRMED ON DISK
→ Tests  4 failed | 14 passed (18)

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.hostConfigLoaded false, second is DROP 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>, then git diff HEAD --stat empty, git status --porcelain empty, and the file's hash back to 878ac920…. 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, plus check-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 no major bump."

Repo-wide pnpm lint was 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 test reports 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 spawn os serve / os login and reach none of the changed modules.

Generated by Claude Code

hotlong and others added 4 commits August 28, 2026 18:21
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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 18 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via os migrate apply (command))
  • content/docs/data-modeling/indexing.mdx (via os migrate plan (command))
  • content/docs/deployment/cli.mdx (via objectstack.config.js (literal), objectstack.config.mjs (literal), os migrate apply (command), os migrate plan (command))
  • content/docs/deployment/index.mdx (via os migrate apply (command), os migrate plan (command))
  • content/docs/deployment/self-hosting.mdx (via os migrate apply (command))
  • content/docs/kernel/services-checklist.mdx (via os migrate apply (command), os migrate plan (command))
  • content/docs/protocol/kernel/lifecycle.mdx (via os migrate apply (command), os migrate plan (command))
  • content/docs/upgrading.mdx (via os migrate apply (command), os migrate plan (command))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via os migrate plan (command))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectstack.config.ts (literal, 32 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b9dd923b9965e6ce6453c32ea3c9d7ad8a2ebfa3packageMentionDocs.

Which tree this was computed on

This run read content/docs from f330af3534cccc11f2c3866fe70d57aa17779bed — the merge of head a06a8431c68b5f1970b70b390a113f9b2b952f84 into base b9dd923b9965e6ce6453c32ea3c9d7ad8a2ebfa3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b9dd923b9965e6ce6453c32ea3c9d7ad8a2ebfa3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant