diff --git a/internal/storage/mysql/migrate.go b/internal/storage/mysql/migrate.go index f3e8898..103865d 100644 --- a/internal/storage/mysql/migrate.go +++ b/internal/storage/mysql/migrate.go @@ -176,6 +176,9 @@ func Migrate(ctx context.Context, db *sql.DB) error { if err != nil { return fmt.Errorf("migration %d: %w", m.Version, err) } + if err := validateNoReferenceDDL(m.Version, statements); err != nil { + return err + } if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil { return err } @@ -191,6 +194,24 @@ func Migrate(ctx context.Context, db *sql.DB) error { return nil } +// validateNoReferenceDDL protects the post-removal migration line at runtime. +// Versions 1-8 are immutable compatibility history: version 8 removes the +// legacy constraints from databases that were created before the no-FK policy. +// Every later migration must be incapable of creating or retaining a database +// reference constraint, even if a future author forgets the static test. +func validateNoReferenceDDL(version uint64, statements []string) error { + if version <= 8 { + return nil + } + for _, statement := range statements { + upper := strings.ToUpper(statement) + if strings.Contains(upper, "FOREIGN KEY") || strings.Contains(upper, " REFERENCES ") { + return fmt.Errorf("migration %d contains forbidden foreign-key DDL", version) + } + } + return nil +} + // applyFreshInstallBaseline creates a new database directly at the current // schema version. It records every historical migration as dirty before DDL so // an interrupted baseline remains blocked for operator reconciliation, just diff --git a/internal/storage/mysql/no_foreign_keys_test.go b/internal/storage/mysql/no_foreign_keys_test.go index 9dfc0b6..bbab21a 100644 --- a/internal/storage/mysql/no_foreign_keys_test.go +++ b/internal/storage/mysql/no_foreign_keys_test.go @@ -51,3 +51,15 @@ func TestFreshInstallBaselineContainsNoReferenceDDL(t *testing.T) { t.Fatalf("baseline version=%d, migrations=%d", freshInstallBaselineVersion, len(migrations)) } } + +func TestPostRemovalMigrationGuard(t *testing.T) { + if err := validateNoReferenceDDL(9, []string{"ALTER TABLE pulse_tasks ADD CONSTRAINT fk FOREIGN KEY (queue_id) REFERENCES pulse_queues(id)"}); err == nil { + t.Fatal("post-removal migration accepted foreign-key DDL") + } + if err := validateNoReferenceDDL(9, []string{"ALTER TABLE pulse_tasks ADD INDEX ix_queue (queue_id)"}); err != nil { + t.Fatalf("valid post-removal migration rejected: %v", err) + } + if err := validateNoReferenceDDL(8, []string{"ALTER TABLE pulse_tasks DROP FOREIGN KEY legacy_fk"}); err != nil { + t.Fatalf("immutable removal migration rejected: %v", err) + } +}