# ADR-0008: Dummy-Sink und zweistufige Verification-Chain - **Status:** accepted - **Datum:** 2026-10-25 - **Entscheider:** CTO - **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.4 (Verification), §4.5 (Replay-Schutz), §7 (Senken-Vertrag), `services/dummy-sink/`, `services/verification-service/`, `db/trust/migrations/0005_verification_decisions.*.sql` ## Kontext und Problemstellung Phase 1 muss die Vertrauenskette *zweistufig* schließen können (Approval → Transform → Sink), ohne auf eine echte Ziel-Senke (ERP, DB-App) zu warten. Gleichzeitig muss der Vertrag so sitzen, dass die Phase-2-Senken (SAP-ABAP, DB-App, HTTP-Webhook) **nicht umgeschrieben werden müssen** — die generische Senke muss Phase-1 bereits erfüllen können. Zwei Fragen sind hier zu entscheiden: 1. **Was ist die Mindest-Pflicht eines Senken-Adapters?** Der Senken-Vertrag aus `docs/architecture/v1.1.md §7` ist konzeptionell klar, aber in Code musste das für Phase 1 exakt gezogen werden. 2. **Wo lebt die Verifikation der Kette, und wo lebt der Replay-Schutz?** Die naive Lösung wäre, jede Senke die DSSE-Prüfung + Replay-Tabelle selbst machen zu lassen — das macht die Kette so stark wie die schwächste Senke und verteilt die Verifikationslogik. ## Entscheidungsoptionen - **Option A: Verifikation + Replay-Schutz in jeder Senke.** Jeder Adapter prüft DSSE, lädt Inclusion-Proofs vom TLog, hält eigenen `decision_id`-Replay-Store. - **Option B: Zentraler verification-service, Senken rufen ihn vor jeder Zustellung auf; Replay-Schutz lebt im verification-service.** - **Option C: Zentraler verification-service, Senken halten zusätzlich eigenen `decision_id`-Store.** ## Entscheidung Wir wählen **Option B**. ### Senken-Vertrag (Phase 1, mindestens erforderlich) Ein Senken-Adapter MUSS: 1. **`POST /api/v1/sink//deliver`** mit folgendem Body akzeptieren: ```json { "decision_id": "", "payload_hash": "sha256:<64-hex>", "payload": { ... }, "approval_ref": "sha256:<64-hex>", "transform_ref": "sha256:<64-hex>" } ``` 2. **Payload-Hash gegen Payload prüfen.** `payload_hash` muss zu `sha256(raw payload bytes)` *oder* `sha256(canonical(payload))` passen — der Adapter rechnet beide Formen nach, weil Agenten-Implementierungen je nach Umfeld das eine oder andere schicken. Mismatch → `400`. 3. **`POST {VERIFY_URL}/api/v1/verify/chain`** aufrufen mit `{decision_id, approval_ref, transform_ref, payload_hash, sink_target: "sink:"}` und das Verdict verarbeiten: - `valid: true` → Zustellung ausführen. - `valid: false, replay: true` → `409 Conflict`. - `valid: false` → `403 Forbidden` mit `error.reasons: [...]`. 4. **Handover-Attestation signieren.** Nach erfolgreicher Zustellung DSSE-signiert eine Statement mit `predicateType = https://basicstack.de/attestations/handover/v1` publizieren und ins TLog eintragen, die `decision_id`, `payload_hash`, `approval_ref`, `transform_ref` und einen senken-spezifischen `sink_record_ref` (z.B. `file://…`, `sap:/`, `db://`) materialisiert. `materials[]` referenziert `approval_ref` und `transform_ref` zurück — so bleibt die Vertrauenskette im TLog traversierbar. 5. **Keinen lokalen Replay-Store führen.** Der Adapter sieht `decision_id` nur als Pass-Through-Feld; die Entscheidung "schon gesehen oder neu?" trifft ausschließlich der verification-service. Was ein Senken-Adapter NICHT muss: - DSSE-Signaturen selbst verifizieren. - Inclusion-Proofs vom TLog einholen. - Policy-Regeln (`allowed_transformations`, `allowed_sinks`) interpretieren. - `approval_ref` / `transform_ref` auflösen oder dereferenzieren. Diese Zentralisierung ist der Kern des Vertrages: eine neue Senke in Phase 2 (z.B. SAP) braucht nichts Kryptografisches zu lernen — sie implementiert nur den `deliver`-Handler, delegiert an verification-service, signiert ihre Handover und schreibt ins TLog. ### Zweistufige Verification `POST /api/v1/verify/chain` auf dem verification-service führt in dieser Reihenfolge aus (erster Fehler gewinnt, Begründung in `reasons[]`): 1. **DSSE-Signaturen prüfen** — Approval gegen `trust-api/approval-signer-2026-q4`, Transform gegen `trust-api/agent-testagent-transform-2026-q4` (konfigurierbar via `APPROVAL_SIGNER_KEY_ID` / `TRANSFORM_SIGNER_KEY_ID`, Public-Keys aus `VERIFICATION_SIGNER_KEYS`). 2. **Transparency-Log-Inclusion-Proofs** gegen den TLog-`tree_root_hash` schließen (RFC 6962 §2.1.1) — beide Envelopes müssen tatsächlich im TLog stehen, nicht nur signiert sein. 3. **Content-Binding-Hash-Gleichheit**: `approval.subject.digest.sha256` == `approval.predicate.content_hash`, `transform.subject.digest.sha256` == `request.payload_hash`. 4. **`allowed_transformations`** auf dem Approval-Predicate deckt die `operation` des Transform-Predicates ab. 5. **`allowed_sinks`** auf dem Approval-Predicate enthält `request.sink_target`. 6. **Approval-Expiry**: `approval.predicate.expires_at` (RFC 3339) liegt in der Zukunft; sonst `approval_expired`. 7. **Replay-Guard**: `decision_id` wird in der `verification_decisions`-Tabelle *atomar reserviert*. Ein zweites `verify/chain` mit derselben `decision_id` schlägt die Reservierung fehl und liefert `replay: true, valid: false, reasons: ["replay_decision_id"]`. Bemerkenswert: Replay-Schutz wird **erst nach allen anderen Prüfungen** geschrieben. Eine Chain, die wegen `approval_expired` scheitert, darf später — nach Erneuerung — doch noch mit *neuer* `decision_id` laufen; die alte `decision_id` wird in diesem Fall nicht reserviert und wäre ebenfalls wiederverwendbar (soll aber laut Operations-Policy nicht wiederverwendet werden, da sie das Audit erschwert). Error-Semantik (konsistent mit `services/verification-service/README.md`): - Transport zum TLog kaputt → HTTP `502`. - Body malformed → `400`. - Sonst immer `200` + `valid`/`replay`/`reasons`-JSON. Die Senke entscheidet daraus `201`/`403`/`409`. ### Replay-Schutz-Semantik — bewusst zentral - **Pro-Decision-ID-Reservierung**: `decision_id` ist der einzige Dedup-Schlüssel. Senken dürfen nicht selbst dedupieren (`sink_record_ref` kennt der Verifier nicht, umgekehrt kennt die Senke `approval_ref`/`transform_ref` nicht in Reservierungs-Form). - **`cancelled` ist kein Terminalzustand des Replay-Guards.** Eine einmal reservierte `decision_id` bleibt reserviert; auch wenn der Flow danach abbricht, kann die Senke die gleiche Entscheidung nicht nochmal ausliefern. Rückabwickeln passiert auf Geschäftsebene mit einer neuen `decision_id`. - **Transaktionsgrenze**: Die Reservierung ist atomar relativ zum Verifier (Postgres `INSERT … ON CONFLICT DO NOTHING RETURNING`, Phase 2 Prod; Phase 1 sync-Map, Mutex-geschützt). Die Senke führt erst zu, nachdem der Verifier `valid: true` zurückgegeben hat — wenn die Senken-Zustellung danach noch failt, bleibt die `decision_id` dennoch reserviert. Das ist beabsichtigt: eine halbgescheiterte Zustellung darf nicht lautlos zweimal passieren; Operator entscheidet, ob eine neue `decision_id` ausgestellt wird. ## Begründung - **Ein Verifier, viele Senken.** Alle Senken-Adapter stimmen über denselben Verifier ab; neue Signaturregeln oder Content-Binding- Fehlermodi müssen nicht in n Codebasen nachgezogen werden. - **Senken-Code bleibt dumm.** Für Phase-2-SAP-Adapter (ABAP) ist das entscheidend: dort hätten wir mit "DSSE in jeder Senke" gar nicht erst anfangen können. - **Replay-Schutz kann nicht gegabelt werden.** Zwei Replay-Stores (zentraler + lokaler, Option C) öffnen genau die Lücke, die Replay-Schutz vermeiden soll: Sync-Drift zwischen beiden, Race zwischen beiden, Doppelzustellung während Store-Resync. - **Reasons-Codes sind stabil.** Dashboards und Operator-Playbooks haben feste Strings (`approval_expired`, `sink_not_in_allowed_sinks` usw.) zum Grepen (`services/verification-service/README.md`). ### Alternativen bewusst nicht gewählt - **Option A (Verifikation in jeder Senke)**: hätte die Senken-Barriere für Phase 2 unakzeptabel hochgezogen und die Verifikationslogik mehrfach ausgeliefert. - **Option C (Verifier + lokaler Senken-Store)**: siehe oben, Dedup-Kollision und Race-Fenster. Wenn ein Compliance-Audit später einen senkenlokalen "we actually delivered this" Log fordert, lässt er sich aus der `handover`-Attestation im TLog ableiten; der ist signiert und zeitstempel-ordnend. ## Konsequenzen ### Positive - Phase-2-Senken (SAP, DB-App, Webhook, S3) implementieren denselben flachen Vertrag; die Vertrauenskette-Codepfade sind bereits getestet. - Audit-Spur ist einheitlich im TLog: Approval, Transform, Handover (pro Zustellung eine) stehen hintereinander mit stabilen Hashes. - `verification_decisions`-Tabelle (ADR-0004-Migrations) ist der einzige Punkt, an dem "Replay-Status" zustandsbehaftet lebt. Backup/ Restore / Dis-aster-Recovery hat genau ein Objekt zu beachten. ### Negative / Trade-offs - Jeder Delivery kostet einen Verifier-Roundtrip, bevor die Senke schreibt. Latenz-Budget ca. 50–150 ms (DSSE-Verify + TLog-GET + Replay-INSERT). Für Agenten-Flows akzeptabel. - Senken müssen Fehlerfälle `403` / `409` aus Verifier-Verdicten verlässlich propagieren; sonst sieht der Agent eine fehlgeschlagene Zustellung nicht korrekt. - Verifier-Ausfall blockiert alle Senken. Das ist ein akzeptabler single-point-of-failure für Phase 1 — Prod-Härtung in Phase 2 (HA, Postgres-Backend, read-only standby). ### Folgearbeiten / Open Points - **Postgres-Backend** für `verification_decisions` (Phase 2). Phase 1 nutzt In-Memory + Mutex, Schema ist bereits authoritativ unter `db/trust/migrations/0005_verification_decisions.{up,down}.sql`. - **Verifier-Policy nach OPA** verschieben (statt hartcodierter Chain-Regeln in `internal/chain/chain.go`). Phase 2. - **Agent-Registry-Lookup** für Transform-Signer-Keys (statt statischem `VERIFICATION_SIGNER_KEYS`). Phase 2. - **N-stufige Chains** (mehrere Transform-Agenten hintereinander). Die Chain-Logik ist auf zweistufig hartcodiert; der nächste Transform würde `materials[]` auf vorhergehende Transforms setzen. Phase 2. - **Senken-spezifische `sink_record_ref`-Formate** sauber in einer Mini-Spezifikation festhalten (DB-Senke, SAP-Senke, Webhook-Senke). ## Links - `services/dummy-sink/README.md` — Phase-1-Senken-Adapter - `services/verification-service/README.md` — Chain-Regeln, Reason-Codes - `services/verification-service/internal/chain/chain.go` — Chain-Validator - `db/trust/migrations/0005_verification_decisions.{up,down}.sql` — Replay-Tabelle - `docs/architecture/v1.1.md` §4.4, §4.5, §7 - RFC 6962 §2.1.1 (Merkle Audit Path) - [ADR-0005](0005-signing-service-and-transparency-log-api.md) — DSSE/TLog-Konsument - [ADR-0006](0006-approval-service-webauthn-content-binding.md) — Erzeuger der Approval-Attestation - [ADR-0007](0007-signing-service-dual-identity-check.md) — Erzeuger der Transform-Signatur