Skip to content

os migrate plan / apply compose neither the host config nor PlatformObjectsPlugin — they diff a five-table subset and cannot apply the drift their own message names #12938

Description

@hotlong

os migrate plan / apply compose neither the host config nor PlatformObjectsPlugin, so on any real deployment they diff a five-table subset — and cannot apply the drift their own message tells operators to fix.

Measurement

os migrate plan --json, run with no compiled artifact present (cwd is an empty directory, target is a fresh SQLite file):

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

os migrate apply --yes on the same target: 0 change(s): 0 safe, 0 needs-confirm, 0 destructive, Created/extended 5 table(s), exit 0.

Measured 2026-08-28 against packages/cli/bin/run.js at e84bbf6d. The four files that decide this are byte-identical at b489d3c7 (the SHA ObjectStack Cloud pins), so the reading holds there too: packages/cli/src/utils/schema-migrate.ts, packages/cli/src/commands/migrate/plan.ts, packages/cli/src/commands/migrate/apply.ts, packages/runtime/src/standalone-stack.ts.

Mechanism

bootSchemaStack boots through createStandaloneStack, whose plugin list is exactly DefaultDatasourcePlugin, MetadataPlugin, ObjectQLPlugin, plus AppPlugin when a compiled artifact is found. standalone-stack.ts states the first half of this itself:

the compiled artifact carries the stack's i18n config, and it is the ONLY config this boot ever sees — os migrate plan/apply and embedders never load objectstack.config.ts.

The second half is that no platform plugin is composed either. buildDataMigrationPlugins exists and adds PlatformObjectsPlugin — but only the DATA migration subcommands call it (value-shapes, files-to-references, meta). plan and apply call bootSchemaStack with no extraPlugins. serve.ts composes PlatformObjectsPlugin when it is not already present; migrate has no equivalent.

So every object a plugin registers is outside the set detectManagedDrift() iterates — managedObjectFields only ever holds what got registered. sys_position and sys_permission_set are declared in packages/plugins/plugin-security/src/objects/; neither is reachable from a migrate boot at all.

os build does not close it: compile.ts serializes the CONFIG's declared metadata, not a booted kernel's registry, so a platform object never enters the artifact either.

Why this is worse than an incomplete report

schema-drift.ts's own replace_unique_index message ends:

Replacing it with (index signature) is a pure relaxation: run "os migrate apply".

That message is emitted at boot by reconcileAndWarnDrift, which runs with the FULL registered object set, so it names tables like sys_position correctly. The command it names then boots a five-table stack and cannot see that table. The operator runs it, gets exit 0, and the drift is still there. formatDestructiveDriftRefusal points at the same pair of commands.

And the failure is silent in the direction that reads as success: with nothing registered there is no drift, so plan prints

Physical schema is in sync with metadata — nothing to migrate.

The Examined 5 managed table(s). line above it is the only signal, and it is a number the reader has to already distrust. This is exactly the "a gate that silently skips is the bypass it exists to prevent" shape the repo argues elsewhere.

Measured impact on a real deployment: ObjectStack Cloud's control plane carries roughly eighty sys_* tables. Ten of them were carrying legacy platform-wide UNIQUE indexes that structurally disabled per-organization RBAC seeding for two days (objectstack-ai/cloud#1695 / cloud#1701). Not one of the ten is in the five-table set, so os migrate plan against that database reports "in sync" while the boot-time detector on the same database reports ten findings.

Suggested direction (not a decision)

Compose, for plan and apply, the same platform floor the drift MESSAGE is written from — at minimum PlatformObjectsPlugin, and whatever else serve composes unconditionally. That keeps one definition of the object set instead of two, and it is the same seam buildDataMigrationPlugins already occupies. Whether the host config should also be loadable by migrate (a --config flag, or an opt-in that boots the project's own stack) is the bigger half and is left open here.

Whatever the fix, the failing shape should stop being exit 0: a plan computed over a metadata set that is a fraction of the target's tables is UNMEASURED, and today it is indistinguishable from "in sync".

Where this was found

While landing objectstack-ai/cloud#1705, the dispatch-only ops workflow that runs this exact command pair against a control-plane database. That workflow now refuses when managedTables is at or below five rather than reporting a green plan — a workaround on the consumer side, and the reason this is filed here.

Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 28, 2026
  2. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    Claim: PM loop (epic objectstack-ai/cloud#1653 chain, maintainer direct-dispatch channel)
    Session: session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6
    Branch: claude/issue-12938-migrate-composes-host-objects
    Worktree: objectstack-issue-12938
    Domain: domain:cli
    File surface: packages/cli/src/commands/migrate/**, packages/cli/src/utils/schema-migrate.ts, packages/runtime/src/standalone-stack.ts as the measurement names them (stop on breach; explain)
    Container & model: M, mode:subagent, model: opus — restores the behaviour the framework's own drift message already promises (bug, not contract widening)
    Clause-②: no (if a spec/contract shape turns out to move, STOP and report)
    Serial constraints cleared: no open PR touches the migrate command path (checked this hour). Consumer: cloud's migrate-control-db workflow (cloud#1705) re-runs its stage-2 acceptance after this lands + a pin bump; its coverage gate goes dormant-green by design.
    Audit: Option B adjudicated on cloud#1705 (2026-08-28); the prod window explicitly does NOT wait on this — cloud#1701's psql recipe stands for that window.

  3. hotlong commented on Aug 28, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 12938,
      "status": "done",
      "branch": "claude/issue-12938-migrate-composes-host-objects",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12952",
      "premise_still_valid": true,
      "summary": "Premise re-measured on the branch base before writing anything: `os migrate plan --json` in an empty cwd reports managedTables 5, total 0, and exactly the five tables the card names. `bootSchemaStack` gained an explicit `composeHostStack` option, passed by `plan` and `apply` only, that composes what `serve` composes: the host `objectstack.config.*`'s plugins, `AppPlugin(config)` under serve's own presence test, and `PlatformObjectsPlugin` when absent. One correction to the dispatch, enumerated from serve.ts rather than assumed: plugin-security is NOT unconditional there — the whole auth family sits inside `if (!hasAuthPlugin && tierEnabled('auth'))` at serve.ts:3275, and PlatformObjectsPlugin (step 5c) is the only ungated composition beyond the data stack; plugin-security reaches a real deployment through its config, which is the half that matters for cloud. Host plugins are composed for their DECLARATIONS only (init runs, start suppressed) because the measurement forced it: SecurityPlugin fully started produced 14 `Insert operation failed` records against sys_permission_set during a plan, which on a database whose tables exist would be 14 successful writes from a command documented as writing nothing. Declaration-only composition reaches the identical 16-table set with 0 insert attempts. Named residue: a host plugin registering objects in start() rather than init() stays outside the plan. A project with neither config nor artifact is unchanged (5 tables, same --json key set, no new key). A config that exists but fails to load warns loudly on stderr, is non-fatal, and is reported as `composition.hostConfigLoaded: false`.",
      "tests": "All exit codes captured before any pipe; verdicts quoted from the gates' own lines. Final head a06a8431c (merge of origin/main b9dd923b9). ⚠️ Declared narrowing: scripts/pm/os-verify-lock.sh found NO USABLE flock on this macOS host, so every heavy command ran in DECLARED UNLOCKED MODE — nothing was serialized against sibling agents; the official wording is in the PR body. TESTS: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2` over the whole migrate surface (19 files — the 2 new suites, the 6 existing schema-migrate.* suites, all of src/commands/migrate): 'Test Files 19 passed (19)', 'Tests 104 passed (104)'. `pnpm --filter @objectstack/cli typecheck`: VERDICT command-exit 0 — and NOT a phantom: `tsc --listFiles` counts 2 hits for the two new test files, so the clean typecheck really covers them. ABLATION (disk-proven, trap EXIT INT TERM, absolute paths): the seam in schema-migrate.ts disarmed, `opts.composeHostStack === true` -> `false`. BEFORE_HASH 878ac9202be5da25c14cb466520356387e83971f equals the HEAD blob; anchor text occurrences 1 -> 0; injected text occurrences 1; AFTER_HASH 266db883a3050d244ddc834a4d8a33036cd3b79f; 'MUTATION CONFIRMED ON DISK'. Result 'Tests 4 failed | 14 passed (18)' — the four host-composition cases red, the artifact-less baseline pin GREEN (it must not depend on the fix). First red is composition.hostConfigLoaded false; second is 'DROP INDEX uniq_sys_position_organization_id_name - no such index', i.e. the pre-fix boot does not register the table at all. No build leg on either side: vitest resolves these workspace imports to source, so both legs measured the tree on disk. Restore leg proven by observation, not exit code: `git checkout HEAD -- <abs path>`, then `git diff HEAD --stat` empty, `git status --porcelain` empty, hash back to 878ac920. GATES, derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` with 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, 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 verdicts: '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.' DECLARED NARROWING #2: repo-wide `pnpm lint` was NOT run — the three-part evidence a narrowing owes was not collected, so it is recorded as not-run, not as green; CI runs the farm once regardless. PRE-EXISTING FAILURES, PROVEN NOT MINE: a full `pnpm --filter @objectstack/cli test` reports 5 unrelated e2e files failing on this host (login-json-ndjson x2, serve-port-drift-notice, serve-port-readback, serve-process-child-env). Measured, not asserted: with this branch's three changed source files reverted to the merge base on disk (each revert hash-verified, restored under a trap, `git diff HEAD` empty afterwards), the same five 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. FIELD-LEVEL EVIDENCE for the fix itself, on a real fixture project (host config composing SecurityPlugin, control DB whose sys_position still carries the pre-respelling global unique): managedTables 16 (vs 5), one `replace_unique_index` finding with dropIndexNames ['uniq_sys_position_name'] createIndexName 'uniq_sys_position_organization_id_name', category safe, message ending 'run \"os migrate apply\"'; plan wrote nothing (0 insert attempts, row count and legacy index untouched); apply reconciled both findings with skipped 0 and the seeded row preserved; re-plan total 0, pending 0.",
      "mcp_calls": "0 — the repo-scoped REST channel probed clean on this seat, so every GitHub read and write went through `gh` REST/CLI; no MCP GitHub call was made.",
      "open_questions": [
        {
          "question": "Should the declaration-phase composition (init runs, start suppressed) stay a CLI-side mechanism, or be promoted to a declared kernel-level boot mode?",
          "options": [
            "A — keep it in the CLI, as shipped: a documented Proxy in packages/cli/src/utils/schema-migration-plugins.ts that forwards every member and overrides only `start`. No contract change, no ADR, and it binds nobody outside `os migrate plan`/`apply`.",
            "B — promote it to the kernel: `kernel.use(plugin, { declarationsOnly: true })` or an equivalent declared boot mode, so a plugin can KNOW it is being composed for declarations and the phase split stops being something a host applies from outside.",
            "C — per-plugin inert options only (the buildDataMigrationPlugins pattern: armRuntime:false, registerRoutes:false), and refuse to compose any plugin that has not declared one."
          ],
          "recommendation": "A for now, and file B if the residue ever bites. A is the smallest mechanism that closes the measured defect, it changes no contract, and it is anchored in an existing documented rule rather than a new one (the kernel's Plugin pattern already assigns declarations to init() and side-effecting work to start(); bootSchemaStack already refuses boot repairs for the same reason via runPlatformMigrations:false). B is the architecturally cleaner shape and would also let a plugin cooperate — but it is a packages/core contract change, needs an ADR under Prime Directive #13, and would be speculative today: nothing has yet asked for it, and the named residue (objects registered in start()) has zero measured instances in this repo. C was measured and rejected: the plugins that actually matter here arrive already CONSTRUCTED from the host config, so the CLI cannot pass them options at all, and refusing to compose them would leave the defect exactly where it was."
        },
        {
          "question": "objectstack-ai/cloud#1705's coverage gate (`managedTables <= 5` -> refuse) does not catch the one shape that survives this fix: a host config that EXISTS but fails to load. The platform floor still lands, so managedTables becomes 9 and the gate passes over a plan covering none of the control plane. Whose change is that?",
          "options": [
            "A — cloud-side: fail the workflow when `composition.hostConfigLoaded` is not true, or when the `composition` key is absent entirely. One condition, reads the framework's own payload, no second predicate.",
            "B — framework-side: make plan exit non-zero on an unloadable host config (filed as #12953, deliberately NOT implemented here — the dispatch bounded this card to composition only, and the artifact-less run is byte-pinned to today's exit-0 output).",
            "C — both, in that order."
          ],
          "recommendation": "A first, as a cloud-side follow-up the PM files against cloud — it is one condition on a field this PR already emits, it lands without a pin bump, and it closes the hole for the named consumer immediately. B is a real question but a behaviour change for every project whose config needs environment a one-shot migrate run does not have, so it belongs on #12953 as a decision, not as a rider here."
        }
      ],
      "out_of_scope_findings": [
        "filed as #12953: os migrate plan still exits 0 when a host config exists but cannot be loaded — the one UNMEASURED shape left after this card; three options laid out (exit non-zero / keep loud-and-non-fatal / a flag), no recommendation, because the dispatch ruled the exit-semantics axis out of scope rather than deciding it",
        "NOT filed, reported here for the PM to route to cloud: cloud#1705/#1707's `managedTables <= 5` coverage gate is blind to the unloadable-config path (count rises to 9 anyway). The discriminator this PR emits is `composition.hostConfigLoaded`. It is a consumer-side gate change in another repo, so it is the PM's to place",
        "NOT filed, host-environmental: 5 e2e files in packages/cli fail identically on this macOS host with and without the fix (login-json-ndjson x2, serve-port-*), all port-binding / subprocess-spawn shaped. No evidence they fail in CI; recorded rather than filed"
      ]
    }
  4. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #12952 (7 files, zero spec, CLI-only surface as claimed). Landing on green.

    • The measurement-forced design is the verdict's centrepiece: full-start composition produced 14 insert attempts during a plan — on a healthy DB those are 14 successful writes from a command documented as writing nothing. Declaration-only composition (init runs, start suppressed via a documented forwarding Proxy) reaches the identical 16-table set with zero writes. That is the difference between "sees the tables" and "sees the tables safely", and it was measured, not designed on faith.
    • The dispatch's own enumeration error corrected: plugin-security is NOT unconditional in serve (it sits behind the auth-tier gate); PlatformObjectsPlugin is the only ungated composition — plugin-security reaches real deployments through the HOST CONFIG, which is exactly the half this fix composes. The correction changes nothing for cloud and everything for the doc-accuracy of the seam.
    • Field-level proof is end to end: 16 vs 5 tables; the legacy-unique finding surfaces with the precise index names cloud#1701 retired; plan writes nothing (row/index untouched); apply reconciles; re-plan clean. The artifact-less baseline is BYTE-PINNED unchanged. Pre-existing e2e reds proven not-this-diff by a hash-verified revert differential (byte-identical failure counts).
    • Open question 1 adjudicated: Option A as shipped — the CLI-side Proxy is the smallest mechanism, anchored in the existing init/start doctrine; B (a kernel declarationsOnly mode) is filed-on-first-bite territory: the named residue (objects registered in start()) has zero measured instances. C was measured dead (host-config plugins arrive pre-constructed; options cannot reach them).
    • Open question 2 adjudicated: Option A, filed as cloud#1710 — the workflow's coverage gate learns composition.hostConfigLoaded (one condition on a field this PR emits; closes the unloadable-config blind spot for the named consumer without a framework behaviour change). os migrate plan exits 0 when the host config exists but could not be loaded — the one remaining UNMEASURED shape after #12938 #12953 (exit semantics for an unloadable config) stays a decision card — correctly filed without a recommendation, since the dispatch ruled that axis out of scope.

    Consumer chain: land → next .objectstack-sha bump covers this → cloud#1710 hardens the gate + re-runs the workflow's stage-2 acceptance (expected post-#1701 staging reading: full object set, no legacy-unique findings, additive-only remainder).

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions