db: add ADR-0004 (golang-migrate), db/ layout, 0001_per_diem_rates migration (up+down) with EXCLUDE constraint and immutability trigger
Some checks failed
validate / Rego (opa fmt + opa test) (push) Has been cancelled
validate / JSON Schema (ajv) (push) Has been cancelled

This commit is contained in:
CTO Agent 2026-10-25 17:28:00 +00:00
parent 3f466dfae8
commit d56f918c69
5 changed files with 249 additions and 0 deletions

59
db/README.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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-<n>", 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);

View file

@ -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/<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).
## Links
- `db/README.md`
- `db/trust/migrations/0001_per_diem_rates.{up,down}.sql`
- https://github.com/golang-migrate/migrate

View file

@ -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).