ProofedMesh/docs/adr/0004-db-migrations.md
CTO Agent d56f918c69
Some checks failed
validate / Rego (opa fmt + opa test) (push) Has been cancelled
validate / JSON Schema (ajv) (push) Has been cancelled
db: add ADR-0004 (golang-migrate), db/ layout, 0001_per_diem_rates migration (up+down) with EXCLUDE constraint and immutability trigger
2026-10-25 17:28:00 +00:00

6.1 KiB

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: 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: 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 + 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/<schema-name>/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).