ProofedMesh/docs/adr/0008-dummy-sink-and-verification-chain.md

236 lines
11 KiB
Markdown
Raw Normal View History

# 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/<id>/deliver`** mit folgendem Body akzeptieren:
```json
{
"decision_id": "<string>",
"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:<id>"}` 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:/<buchungsnr>`, `db:/<schema>/<id>`) 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