From d56f918c6969f7b8a3f4cef63acb16d16fa47cc64d518bfc33b68ed21423403a Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Sun, 25 Oct 2026 17:28:00 +0000 Subject: [PATCH] db: add ADR-0004 (golang-migrate), db/ layout, 0001_per_diem_rates migration (up+down) with EXCLUDE constraint and immutability trigger --- db/README.md | 59 ++++++++++++ .../migrations/0001_per_diem_rates.down.sql | 12 +++ .../migrations/0001_per_diem_rates.up.sql | 95 +++++++++++++++++++ docs/adr/0004-db-migrations.md | 82 ++++++++++++++++ docs/adr/README.md | 1 + 5 files changed, 249 insertions(+) create mode 100644 db/README.md create mode 100644 db/trust/migrations/0001_per_diem_rates.down.sql create mode 100644 db/trust/migrations/0001_per_diem_rates.up.sql create mode 100644 docs/adr/0004-db-migrations.md diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..3f31997 --- /dev/null +++ b/db/README.md @@ -0,0 +1,59 @@ +# ProofedMesh Database Layer + +Alle Postgres-Schemas von ProofedMesh werden mit **[golang-migrate](https://github.com/golang-migrate/migrate)** versioniert. Entscheidungsgrundlage: siehe [ADR-0004](../docs/adr/0004-db-migrations.md). + +## Layout + +``` +db/ +├── README.md — diese Datei +├── trust/ +│ └── migrations/ +│ ├── 0001_per_diem_rates.up.sql +│ └── 0001_per_diem_rates.down.sql +└── expense/ + └── migrations/ — kommt im Phase-4-Rollout der DB-App +``` + +Jede logische Datenbank bekommt ein eigenes `migrations/`-Verzeichnis. Die Migrationstabelle (`schema_migrations`) wird je Datenbank separat gepflegt. Pro Postgres-Instanz können mehrere logische Schemas liegen — die Migrationen schreiben SQL mit `CREATE SCHEMA IF NOT EXISTS` und `SET search_path`. + +## Konventionen + +- Dateinamen: `NNNN_description.up.sql` + `.down.sql` (vierstellige Nummer, Snake-Case). +- Nummerierung streng monoton. Fehlerhafte Migrationen werden nie umnummeriert — immer durch eine neue Migration korrigiert. +- Immutable Business-Felder werden per `BEFORE UPDATE`-Trigger gesichert (siehe `per_diem_rates`). +- Postgres-Extensions werden am Anfang der Migration eingezogen (`CREATE EXTENSION IF NOT EXISTS …`). +- Jede `up.sql` MUSS ein `down.sql` haben; die `down.sql` DARF im Business-Fall unvollständig oder destruktiv sein, muss das dann aber im Kommentar sagen. + +## Lokal ausführen + +Voraussetzung: eine lokale Postgres (z.B. `docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16`) und die `migrate`-CLI (`brew install golang-migrate` / `apt install migrate` / `go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest`). + +```bash +# Up (alle noch nicht angewandten Migrationen) +migrate -path db/trust/migrations \ + -database "postgres://postgres:postgres@localhost:5432/trust?sslmode=disable" up + +# Down um eine Version +migrate -path db/trust/migrations \ + -database "postgres://postgres:postgres@localhost:5432/trust?sslmode=disable" down 1 + +# Aktuelle Version anzeigen +migrate -path db/trust/migrations \ + -database "postgres://postgres:postgres@localhost:5432/trust?sslmode=disable" version +``` + +## In Prod + +- Rollout als dedizierter Kubernetes-Job (`job-migrate-trust`) VOR dem Rolling-Update der trust-api-Pods. Siehe [ADR-0004 → Ausführungsmodell](../docs/adr/0004-db-migrations.md#ausführungsmodell). +- Trust-API startet nur, wenn `schema_migrations.dirty = false` und die höchste Version die vom Code erwartete erreicht. +- Rollback ist ein manueller Prozess mit CTO-Freigabe. + +## CI + +Ein eigener CI-Job `db` (noch nicht committed) macht: +1. **Lint** (`sqlfluff` oder `pg_format`). +2. **Dry-run** auf einer wegwerfbaren Postgres-Instanz: `up` → `down` → `up`, muss grün sein. +3. **Keine History-Rewrites:** vorhandene Dateien dürfen in einem PR nicht geändert werden. + +Kommt in einem separaten Heartbeat. diff --git a/db/trust/migrations/0001_per_diem_rates.down.sql b/db/trust/migrations/0001_per_diem_rates.down.sql new file mode 100644 index 0000000..6ca5e65 --- /dev/null +++ b/db/trust/migrations/0001_per_diem_rates.down.sql @@ -0,0 +1,12 @@ +-- Rollback for 0001_per_diem_rates. +-- DESTRUKTIV: diese Migration zurückzurollen entfernt per_diem_rates und +-- per_diem_rate_changes vollständig. Vorhandene Rate-Change-Attestations +-- im Transparency Log bleiben; die lokale Spiegelung ist verloren. In Prod +-- NUR mit CTO-Freigabe und vorherigem Backup des trust-Postgres anwenden. + +DROP TABLE IF EXISTS per_diem_rate_changes; +DROP TRIGGER IF EXISTS per_diem_rates_prevent_rewrite_trg ON per_diem_rates; +DROP FUNCTION IF EXISTS per_diem_rates_prevent_rewrite(); +DROP TABLE IF EXISTS per_diem_rates; +DROP SEQUENCE IF EXISTS per_diem_rates_seq; +-- btree_gist bleibt stehen; andere Migrationen dürfen die Extension weiternutzen. diff --git a/db/trust/migrations/0001_per_diem_rates.up.sql b/db/trust/migrations/0001_per_diem_rates.up.sql new file mode 100644 index 0000000..20cae58 --- /dev/null +++ b/db/trust/migrations/0001_per_diem_rates.up.sql @@ -0,0 +1,95 @@ +-- Migration: 0001_per_diem_rates +-- Legt die Tabelle für Verpflegungsmehraufwands-Pauschalen (BMF-Sätze etc.) an. +-- Siehe docs/architecture/v1.1.md §6.2 und schemas/ds/per_diem/v1.0.0.json. +-- +-- Design-Entscheidungen: +-- * rate_id ist eine stabile textuelle ID "PDR-", vergeben über eine Sequence. +-- * Kernfelder (Beträge, country, valid_from, source_regulation) sind unveränderlich, +-- erzwungen per BEFORE UPDATE Trigger. Nur valid_to und rate_change_attestation_ref +-- dürfen nachträglich gesetzt werden (für kontrolliertes Ablaufen bzw. das Nachtragen +-- der Attestations-Referenz). +-- * Keine Überlappung von Gültigkeitsbereichen pro (country, city) — EXCLUDE-Constraint +-- mit GiST + daterange, benötigt die btree_gist Extension. +-- * Betrage als NUMERIC(10,2) — Geld, keine Floats. + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE SEQUENCE IF NOT EXISTS per_diem_rates_seq START 1; + +CREATE TABLE IF NOT EXISTS per_diem_rates ( + rate_id TEXT PRIMARY KEY + DEFAULT ('PDR-' || nextval('per_diem_rates_seq')), + country TEXT NOT NULL CHECK (country ~ '^[A-Z]{2}$'), + city TEXT, + currency TEXT NOT NULL CHECK (currency ~ '^[A-Z]{3}$'), + full_day NUMERIC(10, 2) NOT NULL CHECK (full_day >= 0), + partial_day_min8h NUMERIC(10, 2) NOT NULL CHECK (partial_day_min8h >= 0), + arrival_day NUMERIC(10, 2) NOT NULL CHECK (arrival_day >= 0), + departure_day NUMERIC(10, 2) NOT NULL CHECK (departure_day >= 0), + valid_from DATE NOT NULL, + valid_to DATE, + source_regulation TEXT NOT NULL CHECK (length(source_regulation) BETWEEN 1 AND 500), + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + rate_change_attestation_ref TEXT CHECK (rate_change_attestation_ref ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT per_diem_rates_valid_range CHECK (valid_to IS NULL OR valid_to >= valid_from) +); + +CREATE INDEX idx_pdr_country_valid_from + ON per_diem_rates (country, valid_from); + +CREATE INDEX idx_pdr_lookup_active + ON per_diem_rates (country, valid_from) + WHERE valid_to IS NULL; + +-- Prevent overlapping validity windows per (country, city). +-- COALESCE(city, '') normalises NULL to a sentinel so NULL-city overlaps are also caught. +ALTER TABLE per_diem_rates + ADD CONSTRAINT per_diem_rates_no_overlap EXCLUDE USING gist ( + country WITH =, + COALESCE(city, '') WITH =, + daterange(valid_from, COALESCE(valid_to, 'infinity'::date), '[]') WITH && + ); + +-- Immutability trigger: only valid_to and rate_change_attestation_ref may be updated. +CREATE OR REPLACE FUNCTION per_diem_rates_prevent_rewrite() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.rate_id IS DISTINCT FROM OLD.rate_id + OR NEW.country IS DISTINCT FROM OLD.country + OR NEW.city IS DISTINCT FROM OLD.city + OR NEW.currency IS DISTINCT FROM OLD.currency + OR NEW.full_day IS DISTINCT FROM OLD.full_day + OR NEW.partial_day_min8h IS DISTINCT FROM OLD.partial_day_min8h + OR NEW.arrival_day IS DISTINCT FROM OLD.arrival_day + OR NEW.departure_day IS DISTINCT FROM OLD.departure_day + OR NEW.valid_from IS DISTINCT FROM OLD.valid_from + OR NEW.source_regulation IS DISTINCT FROM OLD.source_regulation + OR NEW.created_by IS DISTINCT FROM OLD.created_by + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'per_diem_rates: only valid_to and rate_change_attestation_ref may be updated (rate_id=%)', OLD.rate_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER per_diem_rates_prevent_rewrite_trg + BEFORE UPDATE ON per_diem_rates + FOR EACH ROW + EXECUTE FUNCTION per_diem_rates_prevent_rewrite(); + +-- Audit log for admin actions on per_diem_rates. The authoritative audit lives +-- in the Transparency Log (rate_change attestations); this table is a local +-- convenience mirror for operators to query without hitting Trillian. +CREATE TABLE IF NOT EXISTS per_diem_rate_changes ( + id BIGSERIAL PRIMARY KEY, + rate_id TEXT NOT NULL REFERENCES per_diem_rates(rate_id), + change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'retire')), + previous_valid_to DATE, + new_valid_to DATE, + performed_by TEXT NOT NULL, + performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + rate_change_attestation_ref TEXT NOT NULL CHECK (rate_change_attestation_ref ~ '^sha256:[0-9a-f]{64}$') +); + +CREATE INDEX idx_pdr_changes_rate_id ON per_diem_rate_changes (rate_id, performed_at DESC); diff --git a/docs/adr/0004-db-migrations.md b/docs/adr/0004-db-migrations.md new file mode 100644 index 0000000..cd9dfeb --- /dev/null +++ b/docs/adr/0004-db-migrations.md @@ -0,0 +1,82 @@ +# 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 diff --git a/docs/adr/README.md b/docs/adr/README.md index 887ab5b..ab6f065 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,5 +16,6 @@ Dieses Verzeichnis dokumentiert durchlebte Architekturentscheidungen in [MADR-Fo | 0001 | [Erster produktiver Rollout: eigene DB-App als Senke und Datenquelle](0001-first-rollout-db-app-scope.md) | accepted | | 0002 | [Session-Scope-Token-Format](0002-session-scope-token-format.md) | accepted | | 0003 | [PII-Retention-Richtlinie](0003-pii-retention-policy.md) | accepted | +| 0004 | [Datenbank-Migrationsframework (golang-migrate)](0004-db-migrations.md) | accepted | Vorlage: [`0000-template.md`](0000-template.md).