Skip to content

fix: pin dev app-schema migrations to their schema and self-heal drift#120

Merged
I-am-nothing merged 1 commit into
mainfrom
fix/dev-appschema-migration-searchpath
Jun 13, 2026
Merged

fix: pin dev app-schema migrations to their schema and self-heal drift#120
I-am-nothing merged 1 commit into
mainfrom
fix/dev-appschema-migration-searchpath

Conversation

@I-am-nothing

Copy link
Copy Markdown
Contributor

Summary

Hardens the SDK dev per-app schema migration path so module DDL lands in the right app_<uuid> schema instead of leaking into public, and so two modules can share one app schema without their migrations colliding. This is the follow-on hardening for the per-app isolation work in docs-temp/dev-app-isolation/design.md.

Three changes in internal/core/dev_migrate.go:

  • Module-scoped tracking PK. The per-schema schema_migrations table PK becomes (module_id, scope, version). A legacy table (PK (scope, version), no module_id) is upgraded in place — ADD COLUMN IF NOT EXISTS + an idempotent DO block that rebuilds the PK only when module_id is missing from it. Each app's app-scope history stays independent, and a second module in the same app schema no longer gets skipped as "already applied" on the shared (scope, version) key.
  • pinnedRunTx defense-in-depth. The lifecycle TxRunner is wrapped to run SET LOCAL search_path TO <schema> inside each migration transaction (cleared on COMMIT/ROLLBACK, so it can't leak to a pooled connection). This sits on top of the existing db.WithSchema context path: even if the context-based search_path ever regresses (the exact failure that leaked module tables into public and broke platform login), the DDL still lands in the app schema.
  • Drift self-heal. A version recorded as applied but whose tables are absent from the schema (the prior public leak) is detected via the module's table-name prefix, forgotten, and re-applied into the correct schema. Module migrations are CREATE TABLE IF NOT EXISTS, so re-apply is clean. Drift detection is prefix-based, so it generalizes to any module / any app schema rather than special-casing one module.

New regression test internal/core/dev_migrate_drift_test.go (integration, against the live module Postgres):

  • two modules with the same version "0001" both get their tables in one shared app schema, with no leak into public;
  • drop a recorded migration's table, re-provision, and the self-heal log fires and re-creates the table.

Verification

  • go build ./..., go vet ./internal/core ./db, gofmt -l all clean.
  • Full ./internal/core suite passes; new integration tests pass against the live module Postgres (MS_LOCAL_DB_URL overrides the default OrbStack container address).
  • Pre-existing TestDevAppSchema_PerAppIsolation_Integration skips (it hardcodes :5433, which is closed) — left untouched to keep the diff focused.
  • /simplify was run: the diff was clean across reuse / simplification / efficiency / altitude; the only applied changes were a gofmt-stability fix in a doc comment.

Deferred / follow-up

  • Manual backfill for the 3 existing dev app schemas. The runner self-heals app migrations on the next mirrorstack dev run for app_4dd3cb9d… and app_63b23941… (oauth-google tables missing -> drift detected -> re-applied into the app schema). app_f64152df… was already hand-backfilled and is left as-is. The runner deliberately does NOT touch public, so the only manual step is dropping the leaked tables that broke platform login:

    -- ms-app-modules Postgres (container ms-app-modules-postgres-1, db ms_app_modules)
    DROP TABLE IF EXISTS public.m9238a60fa3c943039c28252b454b8071_assertion_nonces;
    DROP TABLE IF EXISTS public.m9238a60fa3c943039c28252b454b8071_settings;

    Then start the dev stack normally; oauth-google's settings / assertion_nonces tables are (re)created inside app_4dd3cb9d… and app_63b23941… by the self-heal. Verify:

    SELECT table_schema, table_name FROM information_schema.tables
     WHERE table_name LIKE 'm9238a60%' ORDER BY 1;
    -- expect rows under each app_<id> schema, none under public
  • Production lifecycle path not covered (out of scope). The prod path (system.InstallHandler via /platform/lifecycle/app/install in internal/core/module.go) relies on the platform sending db.WithSchema in the request context and does NOT get pinnedRunTx's defense-in-depth. Applying the same explicit SET LOCAL pin there would close a prod regression of the same shape, but that needs the platform-side context contract reviewed and is a separate unit.

Generated with Claude Code (https://claude.com/claude-code)

Dev per-app schema migrations could leak DDL into public when the
context-based search_path was unset, and the (scope, version) tracking PK
collided when two modules shared one app schema (a second module's
migrations were skipped as already-applied).

This hardens the SDK dev migration path:

- Tracking table PK becomes (module_id, scope, version); a legacy table is
  upgraded in place (ADD COLUMN + DROP/ADD PK, idempotent). Two modules can
  now share one app schema without colliding, and each app's history stays
  independent.
- pinnedRunTx wraps the lifecycle TxRunner with SET LOCAL search_path to the
  target schema for the duration of each migration transaction. This is
  defense-in-depth on top of db.WithSchema: even an empty migration context
  lands the DDL in the app schema instead of public (the exact failure that
  leaked module tables into public and broke platform login).
- Drift self-heal: a version recorded as applied but whose tables are absent
  from the schema (the prior public leak) is forgotten and re-applied into the
  correct schema; module migrations are CREATE TABLE IF NOT EXISTS so re-apply
  is clean. Drift is detected by the module's table-name prefix, so it
  generalizes to any module/app schema.

Adds dev_migrate_drift_test.go covering two modules sharing one app schema
(no public leak) and the drop-table -> re-provision self-heal path.

Design: docs-temp/dev-app-isolation/design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@I-am-nothing I-am-nothing merged commit 0a7a24a into main Jun 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant