# ADR-0004: Datenbank-Migrationsframework - **Status:** accepted - **Datum:** 2026-10-25 - **Entscheider:** CTO - **Betroffene Dokumente:** `docs/architecture/v1.1.md` Abschnitt 6.2, `db/README.md`, `services/admin-api/README.md` ## Kontext und Problemstellung ProofedMesh wird mehrere PostgreSQL-Schemas betreiben: `trust-postgres` (Agent Registry, Sink Registry, Data Source Registry, Decisions, `per_diem_rates`, `rate_change`-Log, …) und `expense-postgres` (DB-App als Senke/Datenquelle). Beide müssen über Monate und Releases hinweg schema-sauber versioniert evolvieren — mit nachvollziehbarer Migrationshistorie, deterministischem Rollback und einem CI-Hook, der Breaking Changes vor dem Merge erwischt. ## Entscheidungsoptionen - **Option A — [golang-migrate](https://github.com/golang-migrate/migrate):** reine SQL-Migrations-Dateien (`NNNN_name.up.sql` + `NNNN_name.down.sql`), CLI + Go-Library, läuft sowohl als init-container als auch programmgesteuert im App-Startup, keine Build-time-Codegen. - **Option B — [atlas](https://atlasgo.io/):** deklarative Zielschema-Definition in HCL, Migrationen werden auto-generiert, Diff/Lint-Features. Mächtiger, aber vendor-lock-in auf Atlas-DSL. - **Option C — Build-in migrations im Trust-API (eigene Mini-Lösung):** rohe `database/sql`-Statements in Go-Konstanten, einfacher Zähler in `schema_migrations`-Tabelle. Null externe Dependencies. - **Option D — [sqlc](https://sqlc.dev/) + [goose](https://github.com/pressly/goose):** sqlc für typsichere Queries, goose für Migrations. ## Entscheidung **Option A — golang-migrate.** ### Begründung - **Plattform-neutral.** SQL-Migrations sind lesbar für jeden Datenbank-Admin ohne Go-Kenntnisse. Rollout kann als init-container ohne Go-Runtime laufen (`migrate/migrate:v4` Container). - **Reine SQL-Dateien** sind in Git-Diffs selbsterklärend und erzeugen keine Build-time-Abhängigkeiten in CI. - **Dual-Modus:** Dieselben Migrationen lassen sich manuell via CLI gegen die Prod-DB fahren (für kontrollierte Rollouts) oder beim App-Start automatisch anwenden (für Dev/Test). Keine zusätzlichen Werkzeuge nötig. - **Rollback:** jede `up`-Migration hat ein `down`-Pendant. Nicht alle Rollbacks sind sauber lösbar (Datenverlust bei DROP COLUMN), aber die Konvention zwingt uns, bewusst darüber nachzudenken. - Atlas (Option B) ist mächtiger, bringt aber eine zusätzliche DSL und einen Vendor-Lock-in — Overkill für unseren jetzigen Scope. - Goose/sqlc (Option D) ist eine valide Alternative; wenn wir sqlc wegen typsicherer Queries nachrüsten, bleibt golang-migrate trotzdem der sinnvolle Partner. Sqlc ersetzt es nicht. - Eigenbau (Option C) hat heute wenig Vorteile und morgen viel Wartung. ## Konventionen - **Pfad:** `db//migrations/NNNN_description.{up,down}.sql`. Erster Schema-Pfad: `db/trust/migrations/`. Später z.B. `db/expense/migrations/`. - **Dateinamen:** vierstellige Nummer, `_`, Snake-Case-Beschreibung, `.up.sql` / `.down.sql`. Beispiel: `0001_per_diem_rates.up.sql`. - **Migrationstabelle:** `schema_migrations` (default von golang-migrate), pro Datenbank. - **Nummerierung ist monoton und niemals umgenummeriert.** Eine fehlerhafte Migration wird durch eine neue Migration korrigiert, nicht durch Rewrite der History. - **Immutable Datenfelder:** wo fachlich nötig, erzwingen Trigger im Schema die Unveränderbarkeit (siehe `per_diem_rates` → `BEFORE UPDATE` Trigger, der nur `valid_to` und `rate_change_attestation_ref` ändern lässt). - **Benötigte Postgres-Extensions** (`btree_gist`, `pgcrypto`, …) werden am Anfang der jeweiligen `up.sql` per `CREATE EXTENSION IF NOT EXISTS …` eingezogen; nicht in einer separaten "setup"-Datei. ## Ausführungsmodell - **Dev/Test:** Trust-API führt beim Startup `migrate.Up()` aus (Idempotenz garantiert durch `schema_migrations`-Tabelle). Das ist schnell und fehlerresistent für die interne Entwicklung. - **Prod:** Rollout via dediziertem Kubernetes-Job (`job-migrate-trust`), der VOR dem Rolling-Update der trust-api-Pods läuft. Trust-API startet nur, wenn `schema_migrations.dirty = false` und die höchste Version mindestens die von der neuen Code-Version erwartete ist. Verhindert, dass neuer Code gegen altes Schema läuft. - **Rollback-Prod:** separater Job `job-migrate-trust-down`, manuell getriggert. CTO-Freigabe als Prozess, nicht als Code-Schranke. ## CI-Hook Eine neue CI-Job-Gruppe `db` prüft: 1. **Lint:** `sqlfluff` oder `pg_format` auf alle `.sql` (Formatierung + Basis-Syntax). 2. **Dry-run:** eine wegwerfbare Postgres-Instanz im CI-Job hochziehen, `migrate up` ausführen, `migrate down` ausführen, `migrate up` erneut — garantiert, dass Up/Down-Paare konsistent sind. 3. **Keine History-Rewrites:** CI prüft, dass vorhandene Migrations-Dateien in einem PR nicht geändert werden (nur neue Nummern hinzukommen). Konkrete Implementierung dieses CI-Jobs folgt separat — in diesem Heartbeat landet die Framework-Entscheidung + die erste Migration. Der CI-Job kommt parallel zum Go-CI-Job, den wir ohnehin brauchen. ## Konsequenzen ### Positive - SQL-first, lesbar für Nicht-Go-Entwickler. - Dual-Modus (CLI + Library) deckt Dev- und Prod-Workflows mit demselben Werkzeug. - Keine zusätzliche DSL, keine Codegen-Pipeline. - Rollback-Pendant pro Up-Migration zwingt zur Reversibilitätsüberlegung. ### Negative / Trade-offs - Keine automatische Diff-Erzeugung (im Gegensatz zu Atlas) — Migrationen schreibt man von Hand. - Keine Typsicherheit zwischen Code und Schema — mit sqlc nachrüstbar, wenn der Bedarf entsteht. - Für sehr große Teams könnte Atlas langfristig besser skalieren — rechtfertigt derzeit die Umstellung nicht. ### Folgearbeiten / Open Points - CI-Job `db` (`sqlfluff` + Up/Down-Dry-Run) als eigener Commit. - Operations-Dokumentation für Prod-Rollouts (Runbook `docs/ops/db-migration.md`), sobald der erste Rollout ansteht. - Entscheidung, ob wir Immutability-Trigger per Default im Repo versionieren oder zusätzliche Row-Level-Security einziehen. Im `per_diem_rates`-Fall klar: Triggers (siehe Migration 0001). ## Links - `db/README.md` - `db/trust/migrations/0001_per_diem_rates.{up,down}.sql` - https://github.com/golang-migrate/migrate