fix: pin dev app-schema migrations to their schema and self-heal drift#120
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens the SDK dev per-app schema migration path so module DDL lands in the right
app_<uuid>schema instead of leaking intopublic, 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 indocs-temp/dev-app-isolation/design.md.Three changes in
internal/core/dev_migrate.go:schema_migrationstable PK becomes(module_id, scope, version). A legacy table (PK(scope, version), nomodule_id) is upgraded in place —ADD COLUMN IF NOT EXISTS+ an idempotentDOblock that rebuilds the PK only whenmodule_idis 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.pinnedRunTxdefense-in-depth. The lifecycleTxRunneris wrapped to runSET 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 existingdb.WithSchemacontext path: even if the context-based search_path ever regresses (the exact failure that leaked module tables intopublicand broke platform login), the DDL still lands in the app schema.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):"0001"both get their tables in one shared app schema, with no leak intopublic;Verification
go build ./...,go vet ./internal/core ./db,gofmt -lall clean../internal/coresuite passes; new integration tests pass against the live module Postgres (MS_LOCAL_DB_URLoverrides the default OrbStack container address).TestDevAppSchema_PerAppIsolation_Integrationskips (it hardcodes:5433, which is closed) — left untouched to keep the diff focused./simplifywas 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 devrun forapp_4dd3cb9d…andapp_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 touchpublic, so the only manual step is dropping the leaked tables that broke platform login:Then start the dev stack normally; oauth-google's
settings/assertion_noncestables are (re)created insideapp_4dd3cb9d…andapp_63b23941…by the self-heal. Verify:Production lifecycle path not covered (out of scope). The prod path (
system.InstallHandlervia/platform/lifecycle/app/installininternal/core/module.go) relies on the platform sendingdb.WithSchemain the request context and does NOT getpinnedRunTx's defense-in-depth. Applying the same explicitSET LOCALpin 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)