# Smart Watch Recorder — Projektplan

## Überblick

Medizinisches Diktiersystem für Ärzte. Aufnahmen werden per Pixel Watch (primäres Erfassungsgerät, MVP-Ziel) oder weiteren Clients (Linux-Desktop bereits in Betrieb, Android-Handy geplant, Windows geplant, iOS optional) erstellt, automatisch transkribiert, durch ein LLM aufbereitet und über ein schlichtes Webinterface abgerufen. Alle Clients — einschließlich Browser, Watch und jedes native App — sprechen dieselbe Server-HTTP-Schnittstelle; ihre Rolle ergibt sich allein daraus, *welchen Teil* der API sie nutzen. Das System unterstützt mehrere Ärzte mit vollständig getrennten Daten.

**Datenphilosophie:** Das System ist kein Langzeitspeicher. Daten sollen so schnell wie möglich durch die Pipeline fließen und nach Übernahme ins Praxissystem vom Arzt zum Entfernen markiert werden. Je weniger Daten in der Pipeline verbleiben, desto besser. Die Watch löscht Aufnahmen erst nach Server-Bestätigung — so gehen keine Daten verloren, solange die Watch funktioniert. Serverseitig besteht bewusst kein Backup-Konzept — bei Datenverlust auf dem Server vor Übernahme ins Praxissystem gehen Daten verloren. Dieses Risiko wird den Nutzern kommuniziert.

**Timestamps:** Alle Zeitstempel im gesamten System (Dateinamen, API-Kommunikation, Logs) sind **UTC**. Die Anzeige in der lokalen Zeitzone erfolgt ausschließlich in der UI-Schicht (Watch-App, Webinterface). "Heute" wird systemweit als aktueller UTC-Tag definiert.

---

## Architektur

```
Clients (alle sprechen dieselbe HTTPS-API)
  ┌────────────────────────────────────────────────────────────┐
  │ Pixel Watch (Wear OS) — primär: Aufnahme + Oneliner-Polling│
  │ Linux-Desktop (gebaut) — Aufnahme + Oneliner-Poll + Review │
  │ Windows-Desktop (geplant) — gleiche Rolle wie Linux        │
  │ Android-Handy (geplant) — Aufnahme + optional Review       │
  │ Browser — Review, Audio-Streaming                          │
  │ iOS (optional, später) — dieselbe Rolle                    │
  └────────────────────────────────────────────────────────────┘
                │
                │  HTTPS (einheitliche Server-API, keine
                │  geräte-spezifischen Endpoints)
                │  Watch ohne eigene Connectivity:
                │  transparent via Wear OS Proxy über Phone
                ↓
Unraid Server
    └── nginx (reverse proxy, TLS)
            ↓ lokales Netz
Ubuntu Server (RTX 3060, 12 GB VRAM)
    ├── Docker: Axum (Webserver, API, Worker)
    │     Empfang → Transkriptions-Queue (mpsc, sequentiell)
    │                             + separater Analyse-Worker
    │           ↓ HTTP (localhost)
    ├── Docker: faster-whisper (STT, CTranslate2, large-v3)
    ├── Ollama (Gemma 3 4B, Oneliner)
    │         ↓ HTTPS
    └── externer LLM-Provider (Ionos als Default, OpenAI-API-kompatibel, austauschbar)
```

---

## Design-Prinzipien

### Filesystem ist Source of Truth (SoT)

Der Zustand eines Falls wird **ausschließlich aus dem Dateisystem** abgeleitet, nicht aus einer parallelen Sidecar- oder Metadaten-Datei. Das Vorhandensein bestimmter Dateimarker (`<stem>.m4a`, `<stem>.m4a.failed`, `<stem>.json` (Per-Recording-Metadaten — Transcript + Duration), `oneliner.json`, `analysis_input.json`, `document.md`, `.closed`) plus die `WorkerBusy`-Flags bestimmen jederzeit eindeutig, was als Nächstes zu tun ist.

**Konsequenzen**:
- Keine State-Duplizierung (kein `state.json`, keine DB). Der zu synchronisierende Zweitstand fehlt ersatzlos — also kann er auch nicht drift.
- Recovery-Scan nach Restart ist trivial: finde `.m4a` ohne `<stem>.json` → transkribieren. Natürliche Idempotenz, kein Crash-Recovery-Protokoll.
- Crash mitten in Whisper = `<stem>.json` fehlt = nächster Lauf macht's nochmal. Keine Intermediate-States, die rückwärts abgewickelt werden müssten. Single atomic write (`tmp` + `rename`) am Ende der Pipeline garantiert: entweder gibt es ein vollständiges JSON oder keines — nie ein halbes.
- Manuelle Reparatur möglich: Datei löschen, neu triggern. Kein „state irgendwie auf Received setzen".

**Bewusst verzichtet** auf: retry-Budget, Failed-Kategorie mit Error-Details. Wenn ein Audio dauerhaft an Whisper scheitert, loggt der Server im Crash-Loop — das fällt sofort auf und wird manuell entfernt. Diese Kategorie wird erst eingeführt, wenn ein realer Bedarf entsteht (z.B. hochvolumiger Betrieb).

### Client-Prinzip

Clients sind flüchtige Zugriffs- und Erfassungsstellen. Der Server ist die einzige autoritative Datenquelle; jeder Client nutzt dieselbe HTTP-API, aber nur so viel davon, wie für seine Form und sein Use-Case sinnvoll ist.

- **Pixel Watch (primär):** minimaler API-Ausschnitt — Upload + Oneliner-Polling. Kein Playback, kein Review, kein lokaler Archivspeicher.
- **Linux-Desktop (gebaut, produktiv):** Upload + Oneliner-Polling + Review-Absprung per Magic-Link. Tech-Stack entschieden: `eframe`/`egui` + `tokio` + `reqwest` + `ffmpeg`-Subprozess; siehe Komponenten-Kapitel.
- **Windows-Desktop (geplant):** gleiche Rolle wie Linux, derselbe `clients/desktop/`-Crate (`doctate-desktop`) mit `#[cfg(target_os)]`-Gates.
- **Android-Handy (geplant):** identische Erfassung wie die Watch; darf zusätzlich Review-Endpoints (`/web/...`) nutzen, weil Display und Eingabe das sinnvoll machen.
- **Browser:** klassisches Review-Frontend via `/web/...` + Audio-Streaming.
- **iOS (optional, später):** gleiche Invariante, SwiftUI-UI; würde die Client-Logik aus `clients/desktop/src/` (Case-Store, Server-Sync, Upload, Snapshot-Cache, Cleanup) über UniFFI-Bindings konsumieren — sobald ein zweiter Rust-Konsument das rechtfertigt, wandern diese Module in eine eigene Lib zurück.

#### Invariante (fixiert)

> **Alle Clients sprechen ausschließlich die einheitliche Server-HTTP-Schnittstelle.** Es gibt keine gerätespezifische API und keinen geräteeigenen Backchannel. Die Rolle eines Clients ergibt sich allein daraus, *welchen Teil* der API er nutzt.
>
> **Brisante medizinische Daten bleiben auf dem Server.** Brisant sind Audioaufnahmen (`.m4a`), Transkripte (`<stem>.json`) und die daraus generierten Dokumente (`document.md`) — also alles, was Anamnese, Diagnose, Medikation oder Patientenstimme direkt enthält. Clients dürfen sie nur so lange lokal halten, wie der laufende Upload- oder Render-Vorgang es erzwingt: die Pending-Queue beim Recorder bis zum ACK, temporäre Render-Puffer beim Audio-Playback. Danach werden sie auf dem Client gelöscht — der Server ist die einzige dauerhafte Wahrheit.
>
> **Informelle Navigationsdaten dürfen lokal gecacht werden.** Informell sind Fall-IDs, Zeitstempel, Fall-Listen und der Oneliner — kurze Orientierungsdaten, die dem Arzt zeigen, *welche* Fälle existieren, ohne deren medizinischen Inhalt preiszugeben. Der Server bleibt auch hier die autoritative Quelle; der Client-Cache ist verwerfbar und wird beim nächsten erfolgreichen Poll überschrieben. Beispiel: `doctate-desktop::snapshot_cache` persistiert die Oneliner-/Fall-Liste, damit der Desktop-Client beim Launch keinen Flash-Fehlzustand zeigt.

**Lackmustest für neue Features:** *„Erzwingt das Feature, dass brisante Daten (Audio, Transkript, Dokument) länger lokal gehalten werden als der aktuelle Upload- oder Render-Vorgang?"* Wenn ja, gehört das Feature ins Web-UI / auf den Server, nicht in den Client. Informelle Navigationsdaten dürfen persistiert werden, solange sie durch den nächsten Server-Poll überschreibbar bleiben.

**Konsequenzen:**
- Server-Code bleibt client-agnostisch: ein neuer Client-Typ erfordert keinen neuen Endpoint.
- Clients sind austauschbar und können unterschiedlich mächtig sein (Watch = 2 Endpoints, Browser ≈ 15, Handy dazwischen).
- Review-Funktionen sind automatisch überall konsistent — ein nativer Client kann die Web-UI als WebView einbetten und ist damit sofort synchron mit dem Browser.

---

## Komponenten

### 1. Clients (Erfassung + Review)

Alle Clients folgen dem [Client-Prinzip](#client-prinzip): einheitliche Server-API, flüchtige lokale Datenhaltung, Rolle ergibt sich aus genutztem API-Teil.

**Entwicklungs-Reihenfolge:**
1. **Pixel Watch (primäres Entwicklungsziel, MVP-relevant)** — siehe unten.
2. **Android-Handy** — geplant direkt nach der Watch, teilt Core-Module.
3. **Linux / Windows / iOS** — optional, frühestens nach Handy-Abschluss; Tech-Stack pro Plattform zu entscheiden.

**Gemeinsame Code-Basis (Android-Clients):**

Die Watch-App und die spätere Handy-App werden als **Multi-Modul-Gradle-Projekt** aufgesetzt, sodass UI-unabhängige Business-Logik von Anfang an in geteilten Modulen liegt. Diese Struktur ist **nicht optional** — sie wird bereits beim Bau der Watch-App eingehalten, damit die Handy-App später ohne Refactoring andocken kann.

```
doctate-android/
├── :core-domain    ← case_id, Modelle, State-Machine (reines Kotlin/JVM)
├── :core-audio     ← MediaRecorder-Wrapper, Bluetooth-Headset-Routing
├── :core-sync      ← Queue, WorkManager-Jobs, Retry-Logik
├── :core-http      ← API-Client, API-Key-Auth, Endpoints
├── :core-storage   ← Marker-Dateien, Room/DataStore, Lazy Cleanup
│
├── :app-wear       ← Watch-UI (Compose for Wear OS)
└── :app-mobile     ← Handy-UI (Compose Material 3) — später
```

UI-unabhängige Tests laufen auf der JVM (keine Emulator-Runtime). Importe von `android.*`/`androidx.compose.*` sind in `:core-*` strukturell verboten — Verstöße schlagen beim Kompilieren fehl.

#### 1a. Pixel Watch App (Wear OS / Kotlin) — primäres Entwicklungsziel

**Ist-Stand (2026-04-24):** Vertikaler PoC end-to-end lauffähig auf Wear-OS-34-AVD **und auf realer Pixel Watch 2** (Wear OS 5, ADB-over-WiFi-Pairing). Record-Button → 5 s MediaRecorder (MPEG-4/AAC-LC 16 kHz 64 kbps mono) → Multipart-`POST /api/upload` mit `X-API-Key` → Server schreibt `.m4a`, Whisper transkribiert, Ollama klassifiziert, `document.md` entsteht. Manuell verifiziert mit "Hallo, hallo" → korrekte Transkription + `oneliner.kind = empty` (Silence-Rule). Architektur-Unbekannte aus der vertikalen Scheibe (Audio-Codec-Kompatibilität, Cleartext-HTTP-Policy, Emulator-Mic, Wire-Vertrag) sind **gemessen**, nicht mehr spekulativ. Toolchain unterstützt beide Targets parallel: `build.gradle.kts` liest Gradle-CLI-Property `-Pdoctate.serverUrl=…` vor `local.properties` (damit Emulator auf `10.0.2.2:3000` und Watch auf `http://<laptop-lan-ip>:3000` ohne Dateitausch wechseln); `network_security_config.xml` whitelistet die LAN-IP zusätzlich zu `10.0.2.2` für Cleartext (Produktions-HTTPS bleibt via `base-config` gesperrt); `run.sh` fordert jetzt ein explizites `watch`/`emulator`-Target-Prefix (früherer stiller `(none)`-Fallback auf das erste Device entfernt), exportiert `ANDROID_SERIAL` zusätzlich zu `android.injected.device.serial` für exakte ADB-Selektion, cacht die zuletzt gepairte Watch-Serial in `.watch_serial` und ergänzt `connect`/`devices`-Subkommandos.

Seit dem 2026-04-24 gebaut (alles noch auf `CaseStoreStub`-Basis — in-memory Singleton mit Demo-Seed, keine Marker-Dateien): **Fallliste als `ScalingLazyColumn`** (`CaseListScreen`, reverse-chronologisch, Auto-Scroll zum neuesten Eintrag, Auto-Centering deaktiviert, `CaseRow` mit minHeight/Padding/Text-Ellipsis), **Tile-Service** (`DoctateTileService` mit drei Tap-Regionen), **Complication-Service** (`DoctateComplicationService`, simple SHORT_TEXT), **Navigation** (`AppNav` mit `NavCommand`, `MainActivity` auf `singleTask` für Tile/Complication-Intents), **Recording-Flow aufgetrennt** (`CaseListScreen → CaseDetailScreen → RecordingScreen`; „● Neu" überspringt Detail, Tap auf Liste öffnet Detail, Tap auf Detail-Record öffnet Recording), **Tap-to-edit-Oneliner** im CaseDetailScreen (Wear-OS-System-Input-Picker, de-DE-gepinnt, Manual-Flag latcht gegen simulierten LLM-Burst), **Zeit-Formatierung** (`TimeFormat.kt`: „Gerade eben" / „Vor X Minuten" / „Heute HH:mm" / „Gestern HH:mm" / „dd.MM.yy HH:mm" via `java.time`). Der PoC hat aber **bewusst** weiterhin nicht: Marker-Dateien + `unsynced/`-Queue (persistente Quelle der Fallliste), Foreground Service, Retry-Backoff, Server-seitiges Oneliner-Polling, Bluetooth-Headset, Neu/Fortsetzen-Flow über echten `CaseStore` — diese Features bleiben laut Plan zu bauen.

**Funktion:**
- Audioaufnahme direkt auf der Watch (MediaRecorder, AAC/m4a)
- Optionale Aufnahme über gekoppeltes Bluetooth-Headset (automatisch bevorzugt)
- case_id (UUIDv4) Generierung lokal auf der Watch: "Neu" erzeugt eine neue case_id + Marker-Datei (`/recordings/cases/{case_id}.json`), "Fortsetzen" verwendet die bestehende case_id des gewählten Falls
- Ein Fall = 1–n Aufnahmen, identifiziert durch case_id + Aufnahme-Zeitstempel
- Aufnahmen persistent ins lokale Dateisystem (überlebt Neustarts, Akku leer)
- Nach Aufnahme: Stop → direkt in Sync-Queue, Korrekturen per Folge-Diktat
- Vollautomatischer Sync im Hintergrund (Foreground Service)
- Kein Review, kein Playback, kein Archiv — konsequent nach Client-Prinzip
- Kein eigener Code auf dem Phone nötig (Wear OS Network Proxy tunnelt transparent, wenn Watch keine eigene Connectivity hat)

**Sync-Service (Foreground Service):**
- Läuft permanent im Hintergrund, überlebt Doze-Modus
- Bei Netzwerk-Verfügbarkeit: alle ungesyncten Aufnahmen hochladen
- Upload → Server-Bestätigung abwarten → bei "received" oder "gone": lokal löschen
- Bei Fehler / kein ACK: exponentieller Backoff, automatischer Retry
- Bei Watch-Neustart: Service startet automatisch, prüft Dateisystem auf ungesyncte Aufnahmen
- Kein manuelles Eingreifen des Arztes nötig — es muss einfach funktionieren

**Netzwerk-Verhalten:**
```
Watch mit LTE/WiFi  → direkt an Server
Watch ohne LTE/WiFi → Wear OS Proxy → Phone → Server (transparent)
Kein Netzwerk       → Aufnahmen bleiben lokal, Sync bei Wiederherstellung
```

---

**UI — drei native Wear-OS-Surfaces:**

Die Watch-App exponiert ihre Funktion über die drei kanonischen Wear-OS-Surfaces — jede in ihrer eigentlichen Rolle. Ein früherer Plan („ein Screen pro Eintrag, Swipe hoch/runter zwischen Fällen auf einem Tile") ist **verworfen**: Tiles sind per Design nicht scrollbar (ProtoLayout rendert einen statischen Snapshot), und Long-Press / vertikaler Swipe sind System-Gesten (Tile-Edit-Karussell bzw. Quick-Panel), die Apps nicht abfangen können. Die einzige App-seitige Interaktion auf einem Tile sind **Tap-Regionen** (`Clickable`-Modifier auf ProtoLayout-Elementen) — mehrere pro Tile sind HIG-konform.

| Surface      | Rolle                          | Entry-Point |
|---|---|---|
| Activity     | Fallliste + Recording-Screen   | App-Launcher, Complication-Tap, Tile-Tap |
| Tile         | Glance: aktueller Fall         | Swipe links vom Watchface |
| Complication | Fast-Launch zur Fallliste      | Tap auf Watchface (optional, vom Arzt platziert) |

---

**Activity (Hauptscreen) — funktional-analog zum Desktop-Client:**

```
┌─────────────────────┐
│  ☁↑ 2   🟢          │ ← Header: Sync-Indikator + Online-Status
│  ───────────────     │
│  10:43               │
│  Kniegelenk re.,     │ ← Listeneintrag (Tap → Fortsetzen)
│  V.a. Meniskus       │
│                      │
│  10:32               │
│  ⏳                  │ ← Fall ohne Oneliner (Polling aktiv)
│                      │
│       [ ● Neu ]      │ ← EdgeButton (Material-3 Expressive, primäre Aktion)
└─────────────────────┘
```

- Fallliste als `ScalingLazyColumn`, gespeist aus dem Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore` in `doctate-desktop::case_store`; strukturell 1:1, kein UniFFI-Binding bis zum Handy-App-Start — siehe Abweichungen → Client-Architektur)
- EdgeButton „● Neu" → neue `case_id` + Marker + direkter Recording-Screen
- Tap auf Listeneintrag → „Fortsetzen" (neue Aufnahme mit bestehender `case_id`)
- Nach Stop: Screen bleibt auf dem aktiven Fall, Foreground Service startet aggressives Oneliner-Polling (Post-Stop-Burst, siehe unten)
- Einträge ohne Oneliner zeigen ⏳; sobald das Polling den Oneliner eingetragen hat, wechselt die Anzeige automatisch (Snapshot-Flow)

**Tile (Glance: aktueller Fall):**

```
┌──────────────────────┐
│   10:43    ☰ Fälle   │ ← Zeit + kleine zweite Tap-Region (→ Activity auf Liste)
│                      │
│   Kniegelenk re.,    │ ← große mittige Tap-Region (→ Fortsetzen)
│   V.a. Meniskus      │
│                      │
│       [ ● Neu ]      │ ← EdgeButton (→ Activity mit new=true)
└──────────────────────┘
```

- „Aktueller Fall" = laufende Aufnahme, sonst Fall mit maximalem `last_activity_at` im Snapshot
- Drei Tap-Regionen — nicht drei Buttons. In ProtoLayout ist `Clickable` ein Modifier auf beliebige Elemente (Box/Row/Text), mehrere pro Tile sind von System-Tiles (Fitness, Wetter) etabliert
- Datenquelle: derselbe Singleton-`CaseStore`-Snapshot wie die Activity — Tile und Activity divergieren nie
- Tile-Refresh wird explizit getriggert nach jedem `CaseStore`-Merge via `TileService.getUpdater(ctx).requestUpdate(DoctateTileService::class.java)`. Tiles pollen nicht selbst; wer Daten ändert, ruft `requestUpdate()`

**Complication (optional, vom Arzt auf sein Watchface platziert):**

- Slot-Typ: `SHORT_TEXT` oder `SMALL_IMAGE` — minimal, damit jedes Watchface sie unterstützt. Kein OneLiner-Text (`LONG_TEXT` wird von weniger Watchfaces akzeptiert)
- Tap → `PendingIntent` auf `MainActivity` mit Extra `open=list` → Fallliste
- Rolle: Fast-Launch vom Watchface, spart den Zwischenschritt über App-Launcher oder Tile-Karussell
- Ist rein *optional* — Arzt platziert sie selbst in den Watchface-Einstellungen; die App funktioniert ohne Complication vollständig

---

**Post-Stop-Burst (aggressives Polling):**
Nach `Stop` einer Aufnahme pollt der Foreground Service den frisch erzeugten Oneliner **aggressiv mit Timeout** (z.B. 2 s Intervall, 60 s Budget), damit der OneLiner möglichst schon sichtbar ist, wenn der Arzt das nächste Mal auf die Watch blickt. Nach Ablauf oder Treffer fällt das Polling auf das reguläre (lazy, sichtbarkeits-getriebene) Intervall zurück. Begründung: der gefühlte „Time-to-Oneliner" nach einer Aufnahme bestimmt, ob der Arzt der Automatik vertraut — hier lohnt sich eine kurze Netzwerk-Burst gegenüber dem Standard-Snapshot-Poll.

**Weitere UI-Indikatoren:**
- 🎧 Headset-Indikator wenn Bluetooth-Headset aktiv
- ☁↑ zeigt Anzahl ungesyncter Aufnahmen (dezent, verschwindet bei 0)
- Kein Verwerfen-Button — Korrekturen per Diktat („Korrektur: …")

**Oneliner-Zustände (Activity + Tile gleich):**
- Verfügbar → Uhrzeit + Oneliner (stabil, Polling beendet)
- Lädt → Uhrzeit + ⏳ (Polling aktiv)
- Kein Server → nur Uhrzeit (Polling mit Backoff)
- Keine Fälle heute → Activity zeigt Leer-Hinweis, Tile zeigt nur EdgeButton „● Neu"

---

**Lokale Datenhaltung (persistent im Dateisystem):**

Das Dateisystem ist die einzige Quelle der Wahrheit. Die Fallliste ergibt sich aus zwei Ordnern: `cases/` (welche Fälle existieren + Oneliner) und `unsynced/` (welche Aufnahmen noch auf Upload warten).

```
/recordings/
├── cases/
│   ├── {case_id}.json    ← Marker-Datei (JSON, bei Erstellung minimal, wächst bei Bedarf)
│   └── ...
└── unsynced/
    ├── {case_id}_{UTC-timestamp}.m4a    ← wartet auf Upload
    └── ...
```

- **Marker-Datei** (`cases/{case_id}.json`): wird bei "Neu" mit minimalem JSON angelegt. Oneliner wird ergänzt, sobald er vom Server empfangen wird. Format ist erweiterbar für zukünftige Felder.

```json
// Bei Erstellung ("Neu"):
{
  "created_at": "2026-04-06T09:32:00Z"
}

// Nach Oneliner-Empfang:
{
  "created_at": "2026-04-06T09:32:00Z",
  "oneliner": "Kniegelenk re., V.a. Meniskus"
}
```

- **Fallliste** = alle `.json`-Dateien in `cases/` deren `created_at` von heute (UTC) ist. Sortierung absteigend. `oneliner` fehlt oder null → nur Uhrzeit anzeigen. Vorhanden → Uhrzeit + Oneliner.
- **Sync-Indikator** (☁↑) = Anzahl Dateien in `unsynced/`
- Dateinamen-Format Aufnahmen: `{case_id}_{yyyy-MM-ddTHH:mm:ssZ}.m4a` (UTC)
- UI zeigt Uhrzeiten in lokaler Zeitzone an

**Lazy Cleanup (Watch):**
Beim App-Start / Scan: Marker-Dateien in `cases/` deren `created_at` nicht von heute (UTC) ist, werden gelöscht — sie sind rein informativ für die UI und enthalten keine Audiodaten.

Audiodateien in `unsynced/` werden **nie** durch die Lazy Cleanup gelöscht. Der Sync-Service ist der einzige Verantwortliche für das Löschen von Audiodateien — ausschließlich nach Server-Bestätigung (ACK mit `received` oder `gone`). So gehen keine Aufnahmen verloren, auch wenn die Watch tagelang offline war (z.B. Wochenende im Spind). Beim nächsten Netzwerkzugang synct der Foreground Service alle ausstehenden Aufnahmen nach.

**Speicherplatz-Warnung (optional):** Pixel Watch 2/3 hat 32 GB, eine Minute AAC/m4a ≈ 1 MB. Selbst 100 ungesyncte Aufnahmen sind unkritisch. Dennoch: bei >500 MB ungesyncten Daten dezenten Warnhinweis in der UI anzeigen (nicht löschen, nur informieren).

---

**Upload-Paket (vom Sync-Service gesendet):**

```json
{
  "case_id": "uuid",
  "recorded_at": "2026-04-06T09:32:00Z",
  "audio": "..."
}
```

**Server-Antwort (ACK — drei Zustände):**

```json
{
  "case_id": "uuid",
  "recorded_at": "2026-04-06T09:32:00Z",
  "status": "received | gone"
}
```

| Status | Bedeutung | Watch-Verhalten |
|---|---|---|
| `received` | Upload angenommen | Lokale Datei löschen |
| `gone` | Fall existiert nicht mehr (vom Arzt entfernt) | Lokale Datei löschen |
| (kein ACK / Fehler) | Server nicht erreichbar oder interner Fehler | Retry mit exponentiellem Backoff |

→ Sowohl `received` als auch `gone` führen zum Löschen der lokalen Datei. Die Watch muss nicht verstehen, *warum* der Fall weg ist — nur dass sie die Datei gefahrlos löschen kann. Das hält den Watch-Code einfach.

---

**Oneliner-Abfrage (Polling, informativ):**

```
GET /api/oneliner/{case_id}
→ 200: { "oneliner": "Hypertonie Grad 2..." }
→ 404: noch nicht verarbeitet (erstes Transkript noch ausstehend)
→ 503: Ollama nicht verfügbar
```

- Oneliner wird beim ersten Transkript einmalig generiert und danach nur noch gelesen
- Der Arzt kann den Oneliner beeinflussen, indem er im Diktat eine Bezeichnung nennt (z.B. „Bezeichnung: Kniegelenk")
- Timeout: ~2 Sekunden
- **Polling (zwei Modi):**
  - *Post-Stop-Burst:* direkt nach `Stop` einer Aufnahme pollt der Foreground Service aggressiv (2 s Intervall, 60 s Budget) für den gerade gesendeten Fall — Ziel: OneLiner ist da, bevor der Arzt zurück auf Activity/Tile blickt.
  - *Lazy-Regulär:* danach bzw. bei bestehenden Fällen ohne OneLiner fragt die Watch „alle paar Sekunden" nur für den aktuell sichtbaren Fall ab (Activity im Vordergrund, oder Fall = „aktueller Fall" auf dem Tile).
- Bei 200: Oneliner in Marker-Datei `/recordings/cases/{case_id}.json` schreiben → Polling für diesen Fall beenden
- Bei 404/503/Timeout → weiter pollen (Watch funktioniert normal, Marker-Datei bleibt ohne Oneliner, UI zeigt ⏳)
- Keine Blockierung des Aufnahme-Workflows — Polling läuft unabhängig vom Recording

---

**Sicherheit Clients (API-Key-Auth für `/api/...`):**
- API-Key pro Arzt im HTTPS-Header
- Server mappt API-Key → Arzt-Identität (bestimmt Speicherpfad `/data/{arzt}/`)
- Einmalig pro Client-Gerät konfiguriert (Watch und Handy verwenden denselben Key pro Arzt; verschiedene Geräte desselben Arztes teilen sich den Key)

---

#### 1b. Android-Handy-App — sekundäres Erfassungsgerät (geplant)

Wird nach Abschluss der Watch-App gebaut, andockt an dieselben `:core-*`-Module. Funktional-Set in zwei Blöcken:

**Erfassungs-Features (identisch zur Watch):**
- Audioaufnahme, case_id-Verwaltung, Marker-Dateien, Sync-Queue, ACK-Protokoll, Oneliner-Polling, exponentieller Backoff, Lazy Cleanup — nichts davon wird neu geschrieben, alles kommt aus `:core-*`.
- UI ist eigenständig (Compose Material 3, Bottom-Nav, größeres Display), folgt aber demselben „flüchtiges Erfassungsgerät"-Ansatz.

**Review-Features (nur Handy, nutzen bestehende Web-Endpoints):**
- Zugriff auf `/web/cases`, `/web/cases/{case_id}`, `/web/cases/{case_id}/document`, `/web/audio/{user}/{case_id}/{filename}` etc.
- **Keine neuen Server-APIs nötig** — das Handy ist aus Server-Sicht ein weiterer Web-Client.
- Erste Ausbaustufe kann als **WebView auf `/web/...`** umgesetzt werden (kein doppelter UI-Code, automatisch synchron zum Browser-UI); spätere Ausbaustufen dürfen selektiv Teile nativ ersetzen.

**Invariante bleibt gewahrt:** Das Handy zeigt mehr als die Watch, hält aber keinen lokalen Archiv-Cache — Review-Daten werden on-demand vom Server gerendert.

**Data Layer Sync (optional, später):**
Wenn Watch und Handy gekoppelt sind (klassisches Wear-OS-Pairing), *könnten* beide Geräte die heutige Fallliste via `DataClient` (Wear OS Data Layer API) replizieren — nur Marker-Dateien, keine Audios. Das ist eine reine UX-Verbesserung (beide Geräte sehen denselben Fall), nicht MVP-relevant und wird erst nach Handy-App-Grundfunktion evaluiert.

#### 1c. Desktop-Clients (Linux / Windows) — erster nativer Client

**Ist-Stand:** Der Linux-Desktop-Client ist gebaut und funktioniert (`clients/desktop/`, Crate `doctate-desktop`, Bin-Name `doctate`). Er ist der **erste echte native Client überhaupt** — wurde vor der Watch-App gebaut, weil zu dem Zeitpunkt noch keine Pixel-Watch-Hardware verfügbar war und ein realer Client für API-Ergonomie-Tests (Retry, Content-Types, Auth-Fehlerpfade) nötig war. (Pixel Watch 2 Hardware eingetroffen am 2026-04-23.)

**Features (MVP):**
- Config-Panel beim Erststart (Server-URL + API-Key, TOML unter `~/.config/doctate/client.toml`)
- Ein-Klick-Aufnahme („● Neu") + Stop-Button mit Live-Timer
- Persistente Pending-Queue (`~/.local/share/doctate/pending/`, m4a + `{stem}.meta.json`-Sidecar)
- Uploader mit exponentieller Retry (2/4/8/16/32 s, Cap 60 s, unbegrenzt für transiente Fehler, sofortiger Abbruch bei 400/401/403/413)
- Startup-Recovery: Pending-Dir-Scan beim Launch → Crash-Residuen automatisch retried
- State-Machine: `NotConfigured` → `Idle` → `Recording` → `FinalizingRecording` → `Uploading` → `Idle` | `Error`
- Single-Instance-Lock (PID-Lock, verhindert konkurrierende Instanzen, die dieselbe Pending-Queue racen würden)
- Fall-Liste + Oneliner-Poller im Client: zeigt eigene Fälle der letzten Stunden, pollt `/api/oneliners` per ETag (304 bei unverändertem FS-Fingerprint)
- Snapshot-Cache-Persistenz (Poller-State überlebt Neustart, kein Flash-Fehlzustand beim Launch)
- Pending-Upload-Indicator + Last-Failure-Anzeige im Footer-Status
- Startup-Cleanup für stale Markers / Orphans (Pending-Dateien ohne Sidecar, verwaiste meta.json)
- Magic-Link-Login: „Im Browser öffnen"-Button tauscht den API-Key gegen einen Kurz-Token (`POST /api/auth/magic-link`) und öffnet den System-Browser auf `/web/magic?token=…`. Damit landet der Arzt ohne Passworteingabe in seiner Web-Review.

**Tech-Stack (Linux):** `eframe`/`egui` (Immediate-Mode-UI, natives Fenster, keine WebView) + `tokio` (Background-Runtime für Recorder + Uploader + Poller) + `reqwest` (Multipart-Upload + JSON-GET, rustls) + `ffmpeg`-Subprozess (m4a/AAC direkt; Graceful-Stop via `SIGINT` über `libc::kill`). Die gesamte Client-Business-Logik (Case-Store, Poller, Upload-Queue, Cleanup, Server-Sync) lebt als reguläre Module direkt in `clients/desktop/src/` — UI-Schale, OS-spezifischer Recorder und Headless-Logik teilen sich heute genau einen Crate. (Bis zum 2026-05-02 lebte die Headless-Logik in einer separaten Lib `doctate-client-core`; die Lib wurde aufgelöst, weil es nur einen Rust-Konsumenten gab — siehe Abweichungen → Client-Architektur.)

**Windows-Desktop:** geplant „bald" — derselbe `clients/desktop/`-Crate (`doctate-desktop`) mit `#[cfg(target_os = "windows")]`-Gates, vor allem für `audio_input_args` (dshow statt PulseAudio) und Graceful-Stop (`q`-Stdin als Best-Effort, weil `tokio::process::Child` keine einfache SIGINT-Entsprechung auf Windows bietet).

**iOS:** SwiftUI, Ausblick — nicht in Arbeit. Geplante Arbeitsaufteilung: Swift nur für UI + OS-Recording, alles andere konsumiert die Headless-Module aus `clients/desktop/src/` über eine C-ABI-/UniFFI-Bindings-Schicht. Mit dem Start von iOS oder Handy-Rust-Konsum wandern die UI-freien Module wieder in eine eigene Lib (z.B. `clients/core/`) — die Auflösung 2026-05-02 war bewusst umkehrbar, alle Module sind weiterhin als `pub mod` aus `clients/desktop/src/lib.rs` re-exportiert.

**Zurückgestellt (nicht MVP):** System-Tray-Icon (Design war fertig, dann `tray-icon`-Crate verworfen wegen System-Dep-Schwergewicht mit `libxdo`/`gtk3`/`libappindicator`; `ksni` als pure-Rust-dbus-Alternative für später dokumentiert), Autostart, Keyboard-Shortcuts.

---

### 2. Server (Axum / Rust — Docker Container auf Ubuntu Server)

Axum ist der zentrale Koordinator. Er empfängt Uploads, startet die sequentiellen Worker (Transcribe + Analyze) und bedient das Webinterface. STT und Preprocessing laufen als externe Services (faster-whisper, Ollama) — Axum selbst braucht keinen GPU-Zugriff.

**Endpunkte (Ist-Stand):**

```
# Erfassungs-API (API-Key-Auth; Watch, Handy, künftige Erfassungs-Clients)
POST /api/upload                              → Aufnahme empfangen, ACK zurück
GET  /api/health                              → Liveness-Probe für Deployments
GET  /api/debug/whoami                        → API-Key → slug (Entwicklungshilfe)
GET  /api/oneliners                           → kollektive Fall-/Oneliner-Liste (ETag-basiert,
                                                 Fensterweite via users.toml->window_hours
                                                 pro User, Default 72 h; Client-Polling)

# API: Magic-Link (API-Key → Browser-Session ohne Passwort)
POST /api/auth/magic-link                     → Einmal-Token ausstellen (X-API-Key, TTL 60 s,
                                                 optional return_to unter /web/)
GET  /web/magic?token=...                     → Token konsumieren, Session-Cookie setzen,
                                                 303-Redirect auf return_to

# Web: Login / Session
GET  /web/login                               → Login-Seite
POST /web/login                               → Login-Formular absenden
POST /web/logout                              → Session zerstören

# Web: Arzt-UI (Session-gebunden, kein {slug} in URL, IDOR-geschützt)
GET  /web/cases                               → eigene Fallübersicht
GET  /web/cases/{case_id}                    → Fall-Übersicht (case_page: Oneliner, Aktionen,
                                                 gerendertes Dokument inline)
GET  /web/cases/{case_id}/recordings          → Einzel-Transkripte + Audio-Player (case_recordings)
POST /web/cases/{case_id}/analyze             → Analyse starten / neu anstoßen
POST /web/cases/{case_id}/reset               → Analyse/Transkripte verwerfen, alles neu
                                                 transkribieren (admin-only)
POST /web/cases/{case_id}/delete              → Soft-Delete (Batch-Marker)
POST /web/cases/{case_id}/recordings/delete   → Einzel-Aufnahme hart löschen (m4a + Sidecars),
                                                 derived artefacts (oneliner.json, document.md,
                                                 analysis_input.json) invalidieren — Auto-Trigger
                                                 regeneriert beim nächsten View-Load
POST /web/cases/undo-delete                   → letzte Lösch-Batch wiederherstellen
POST /web/cases/bulk                          → Bulk-Aktionen (analyze / delete, admin-only,
                                                 Check am Entry-Handler)
POST /web/cases/purge-closed                  → Geschlossene Fälle hart löschen (admin-only,
                                                 `confirm=yes` Pflicht, emittiert CasePurged)
GET  /web/audio/{user}/{case_id}/{filename}   → Audio-Streaming (Cookie-Auth; Arzt oder Admin;
                                                 HTTP-Range-Support, `Accept-Ranges: bytes`,
                                                 206 Partial Content für HTML5-Seeking)
GET  /web/events                              → SSE-Stream für Live-UI-Updates (Cookie-Auth;
                                                 Non-Admins: eigene Slug-gefilterte Events;
                                                 Admins: alle Slugs; 15 s Keep-Alive)
```

**Template-Split (Ist-Stand seit 2026-04-19):** Die bisherigen Templates `case_detail.html`, `document.html`, `cases.html` sind entfernt. Stattdessen zwei dezidierte Seiten: `case_page.html` (Übersicht + Aktionen + gerendertes Dokument) und `case_recordings.html` (einzelne Transkripte + Audio). Das Admin-Log unter `GET /web/` existiert damit in der Routing-Tabelle nicht mehr als eigenständige Seite — die Admin-Sichtbarkeit wird über den `is_admin`-Flag in den ViewModels und Templates an den regulären `/web/cases`-Views aufgehängt.

**Geplant, noch nicht implementiert:** Preset- und Undo-Endpoints für Dokument-Versionen. Siehe Phase 4 weiter unten.

**Kein `{arzt}` in URLs (IDOR-Prävention):**
Die Arzt-Identität wird ausschließlich aus dem Session-Cookie abgeleitet, nie aus der URL. Ein Axum-Extractor (`AuthenticatedArzt`) liest das Session-Token, schlägt den zugehörigen Arzt nach und gibt ihn als typisierte Struct zurück. Alle `/web/`-Handler erhalten den Arzt nur über diesen Extractor — der Dateisystempfad `/data/{arzt}/` wird serverseitig aus der Session konstruiert. Dadurch kann ein eingeloggter Arzt prinzipbedingt nicht auf Daten eines anderen Arztes zugreifen, selbst wenn er URLs manuell ändert. `case_id` wird zusätzlich als UUIDv4 validiert (`uuid::Uuid::parse_str`), um Path-Traversal über manipulierte IDs auszuschließen.

**Fall-States:**

```
┌────────────┐     sofort      ┌───────────────┐   auto-trigger    ┌─────────────┐
│  Empfangen │ ──────────────→ │ Transkribiert │ ──────────────→  │ Ausgewertet │
│  (queued)  │  Transkription  │  (einsehbar)  │  oder manuell    │ (Dokument)  │
└────────────┘                 └───────────────┘  "Analysieren"   └─────────────┘
```

- **Empfangen:** Mindestens eine Aufnahme ist noch in der Transkriptions-Queue oder wird gerade verarbeitet
- **Transkribiert:** Alle Aufnahmen dieses Falls sind transkribiert. Arzt kann jedes Transkript einzeln einsehen
- **Ausgewertet:** LLM hat aus allen Transkripten ein Dokument generiert

Ein Fall wechselt erst zu "Transkribiert", wenn *alle* zugehörigen Aufnahmen transkribiert sind. Solange eine Aufnahme noch in der Queue ist, bleibt der Fall im State "Empfangen".

"Analysieren" wird **automatisch ausgelöst** (`analyze::auto_trigger`), sobald ein Fall im State "Transkribiert" ist und noch kein `document.md` existiert (oder das Dokument älter ist als die jüngste Aufnahme). Der Trigger läuft auf jedem `/web/cases`- und `/web/cases/{id}`-Handler-Aufruf; SSE-getriggerte Reloads stellen den Puls, ein Background-Timer ist nicht nötig. Der manuelle „Analysieren"-Button bleibt für erzwungenes Re-Analyze (z.B. nach Prompt-Änderung) erhalten und ist nur im State "Transkribiert" sowie bei konfiguriertem LLM-Provider sichtbar.

---

**Pipeline nach Aufnahme-Eingang (Ist-Stand):**

```
1. API-Key → User-Slug auflösen (HashMap aus users.toml, kein {slug} in URL)
2. case_id validieren (UUIDv4)
3. Case-Verzeichnis bestimmen: /data/{slug}/{case_id}/
     ├─ Verzeichnis fehlt → anlegen (erster Upload einer neuen case_id)
     ├─ .deleted-Marker vorhanden → Marker entfernen (verspäteter Upload für gelöschten Fall)
     └─ sonst → Nachtrag zu bestehendem Fall
4. Audio speichern: {case_dir}/{UTC-ISO-Zeitstempel}.m4a
5. Transcribe-Job in mpsc-Queue einreihen
6. ACK mit status "received" an Watch senden
```

Das ursprünglich geplante `open/`/`done/`-Split wurde verworfen — siehe Abweichungen-Sektion. Es gibt nur noch eine flache Struktur pro User.

**Nachträgliche Aufnahmen nach Fallabschluss:**
Wenn eine Aufnahme für einen Fall mit bereits vorhandenem `document.md` eintrifft, wird sie angenommen, gespeichert und transkribiert. Sobald das neue Transkript fertig ist, erkennt der Auto-Trigger, dass das Dokument älter ist als die jüngste Aufnahme, löscht `document.md` und reiht den Fall neu ein — der Arzt sieht nach dem SSE-Reload das aktualisierte Dokument. Der geplante UI-Hinweis („⚠ 1 neue Aufnahme seit Abschluss") ist damit in der Sache bereits umgesetzt, als sichtbare Banner-Variante aber noch offen. Der manuelle „Neu analysieren"-Button bleibt für erzwungene Re-Runs (z.B. nach Prompt-Änderung).

**Verspätete Uploads für gelöschte Fälle (Ist-Stand):**
Trifft ein Upload für einen Fall mit `.deleted`-Marker ein, wird der Marker entfernt und die Aufnahme normal als Nachtrag behandelt. Der ursprünglich geplante `"gone"`-ACK-Status ist nicht implementiert — der Upload-Handler antwortet immer mit `"received"`. Grund: Sonst würde der allererste Upload einer neuen case_id unnötig abgelehnt. Details in der Abweichungen-Sektion.

**Gazetteer (Post-AI-Terminologie-Normalisierung):**

```
Whisper-Output  ──┐
Oneliner (Ollama) ├──→ gazetteer::replace() ──→ Persistenz (`<stem>.json`, oneliner.json, document.md)
Analyse-LLM ──────┘
```

Der Gazetteer ist ein deterministischer Filter, der jede KI-Ausgabe passiert, bevor sie aufs Dateisystem geschrieben wird. Er korrigiert zwei typische Fehlerklassen:

1. **Whisper-Typos** an Fachtermini (z.B. `Zerebrum` → `Cerebrum`, `Pantoprasol` → `Pantoprazol`).
2. **LLM-Drift** zurück zu englischem/anglisiertem Wortlaut (`Amiodarone` → `Amiodaron`), den ein Pre-LLM-Hint-Kanal nicht einfangen könnte, weil das Drift im Output entsteht.

**Matching-Logik:**
- Kuratiertes Vokabular in `server/vocab/*.txt`, eine Phrase pro Zeile, Leerzeilen und `#`-Kommentare erlaubt, non-recursive, case-insensitive.
- Min. Tokenlänge 5 Zeichen (`MIN_TOKEN_LEN`) — schützt gegen falsche Kollisionen bei kurzen Alltagswörtern (`Bein` ↔ `Behn`).
- Damerau-Levenshtein-Distanz ≤ 2 (`MAX_EDIT_DISTANCE`) gegen das Vocab. Exakte Treffer werden per `HashSet`-Shortcut gehandhabt.
- **Per-Entry edit distance** (Commit 1fe7c27, 2026-04-30): Einträge dürfen ihre eigene Edit-Distanz übersteuern (z.B. extrem lange Termini erlauben DL=3, sehr kurze auf DL=1 herunter), für phonetisch verzwickte Fälle ohne globalen Default zu lockern.
- **Bypass-Aliases** (Commit 9c452e8, 2026-04-30): Pro Vocab-Eintrag dürfen mit `-`-Prefix Aliases gelistet werden (Beispiel: `Enoxaparin -Inoxaparin`), die das Hunspell-Dict-Veto explizit überstimmen — nötig, wenn Hunspell ein Compound fälschlich als gültig akzeptiert (z.B. `Inoxaparin` als deutsches Wort) und damit den Rewrite blockiert. Malformed Lines (Sekundär-Tokens ohne `-`-Prefix) sind ein **Hard-Error** und brechen den Server-Start mit `io::ErrorKind::InvalidData` — Drift zwischen Vocab und Code wird sofort sichtbar statt im Stillen verlorengehen.
- `==text==`-Markierungen aus dem LLM-Prompt werden **nicht angetastet**.

**Dict-Veto gegen False Positives:**
`Kaktus` (DL=2 zu `Lantus`) darf nicht zu einem Medikamentennamen umgeschrieben werden. Deswegen prüft der Gazetteer vor jedem Commit, ob das Input-Token selbst ein gültiges deutsches Wort ist — über einen pluggable `DictChecker`-Trait. Produktiv liefert `SpellbookDict` (pure-Rust Hunspell-Parser) diese Prüfung, inklusive Flexions-Expansion: `Kakteen` wird aus `Kaktus` via `.aff`-Affix-Regeln anerkannt, ohne dass wir Flexionen explizit im Dict haben müssten. Der Dict-Check läuft lazy — nur wenn es überhaupt einen DL-Kandidaten gibt (99% der Tokens fallen nicht in dieses Fenster). Das Veto wird nur dann übersteuert, wenn der Kandidat auf der Bypass-Alias-Liste seines Canonical-Eintrags steht (siehe oben).

**Konfiguration:** `VOCAB_DIR` (Vokabular-Verzeichnis) und `HUNSPELL_DICT` (Stamm-Pfad zu `.aff`/`.dic`). Beide sind optional — ohne Vokabular läuft die Pipeline ungefiltert, ohne Dict-Veto erhöht sich die False-Positive-Rate (aktuelle Fälle zeigen das deutlich).

**Wichtige Eigenschaft:** Das Vokabular ist manuell kuratiert (ca. 200 Einträge, Medikamente + Substanzen + Anatomie). Keine Web-Crawls. Das hält die Qualität hoch, das Repository klein und das Urheberrecht sauber.

---

**Worker-Zyklus (sequentielle Tokio-Worker):**

**Ist-Stand:** Zwei unabhängige `tokio::spawn`-Worker, je mit eigener `mpsc`-Queue:

| Worker | Eingang | Externer Call | Ausgang |
|---|---|---|---|
| `transcribe::worker` | `TranscribeSender` (unbounded) | ffmpeg remux → `WHISPER_URL/asr` | `{ts}.json` (single atomic write: transcript + duration), anschließend Oneliner via Ollama |
| `analyze::worker` | `AnalyzeSender` (unbounded) | `LLM_URL/v1/chat/completions` | `document.md` |

Jeder Worker arbeitet sequentiell (ein Job nach dem anderen). Parallelität innerhalb eines Workers ist bewusst ausgeschlossen — die GPU auf der Whisper-Seite kann nur eine Aufgabe gleichzeitig sinnvoll bedienen, und der Analyse-LLM profitiert nicht von Burst-Lasten. Beide Worker laufen aber **zueinander parallel**: während Whisper noch transkribiert, kann der Analyse-Worker bereits einen anderen Fall abschließen.

**Kein striktes GPU-Phasen-Modell mehr.** Ein früher Entwurf wechselte explizit zwischen Whisper- und Ollama-Phase (mit `keep_alive=0`), um VRAM zu sparen. Der aktuelle Whisper-Wrapper (`large-v3-turbo`, ~1.6 GB) und Ollama (`gemma4:latest`, ~9 GB) passen gleichzeitig in die 12 GB der RTX 3060, das Phasen-Modell ist daher nicht mehr nötig. Ollama nutzt `OLLAMA_KEEP_ALIVE=300` statt 0 — spart das Modell-Reload pro Oneliner. Siehe Abweichungs-Sektion.

**Live-Flag pro Worker:** `WorkerBusy = Arc<AtomicBool>` + `BusyGuard` (RAII). Der Worker setzt `true` bei Job-Start, `false` bei Job-Ende (Drop-safe). Das UI nutzt das Flag, um zwischen echter In-flight-Aufgabe und orphaned On-disk-Markern (Crash-Residuen) zu unterscheiden. Es ersetzt den ursprünglich geplanten `RwLock<HashMap<CaseId, CaseState>>` — aufgrund der sequentiellen Worker-Semantik genügt ein einfaches Flag.

**Concurrency-Schutz aktuell:**
- Upload-Handler und Worker teilen sich keinen In-Memory-State; Synchronisation läuft ausschließlich über das Dateisystem (z.B. `has_pending_recordings(case_dir)` scannt nach `.m4a` ohne passendes `<stem>.json`).
- Race zwischen Upload-Write und Worker-Scan: in der Praxis unkritisch, weil nachfolgende Uploads erneut in die Queue wandern und ein weiterer Recovery-Scan offene Stellen findet. Im Plan als Phase-3-TODO markiert, bei Bedarf auf explizites Locking nachrüstbar.

**Latenz-Profil (3 Ärzte, ~10 Min. pro Patient, Turbo-Modell):**
Eine Aufnahme trifft alle ~3 Min. ein. faster-whisper-turbo verarbeitet 60 s Audio in ~2–4 s. Typischer Ablauf bei einem Burst:

```
00:00  5 Aufnahmen in Queue
00:00  Transcribe-Worker: sequentielle Abarbeitung
00:00   ├─ ffmpeg faststart remux (~0.5 s/Datei)
00:00   ├─ Whisper call (~3 s/Datei)
00:00   └─ Gazetteer replace (<1 ms)
00:18  Alle 5 Transkripte geschrieben. Weil keine .m4a mehr pending:
00:18  Oneliner wird aus allen 5 Transkripten EINMAL regeneriert (~1 s)
00:19  Worker idle, wartet auf nächsten Job
```

Mehrere Ärzte teilen sich die Queue fair (FIFO).

**Oneliner-Generierung (aktuelle Semantik):**
Der Oneliner wird am Ende jedes Batches aus **allen** nicht-leeren Transkripten des Falls neu generiert (`update_oneliner`). „Batch-Ende" = `has_pending_recordings(case_dir) == false`. Das hat zwei Gründe:

1. **Spätere Aufnahmen dürfen korrigieren.** Sagt der Arzt „Korrektur: das Mittel heißt Vomex", soll der Oneliner diese Korrektur reflektieren. Wäre der Oneliner nach dem ersten Transkript eingefroren, bliebe der ursprüngliche Begriff (z.B. `Womax`) sichtbar.
2. **Keine überflüssigen LLM-Calls.** Während ein Batch noch läuft (mehrere Uploads hintereinander), wird der Oneliner nicht nach jedem einzelnen Transkript, sondern genau einmal am Ende regeneriert.

**Persistenz:** `oneliner.json` hält das Resultat als internally-tagged Enum `OnelinerState::{Ready{text,generated_at}, Empty{generated_at}, Error{generated_at}}` (siehe `doctate-common::oneliners`). Drei disjunkte Ausgänge des LLM-Calls:
- `Ready` → brauchbarer Einzeiler.
- `Empty` → `OllamaError::EmptyResponse` (Modell folgte der Silence-Rule: kein medizinischer Schlüsselbegriff). **Valides Endergebnis, kein Retry.**
- `Error` → transient gescheitert (Timeout, HTTP, Parse). Startup-Recovery retryt **nur** diese Variante.

Silent-Case-Handling: bei `Error` bleibt ein evtl. bereits existierender `Ready`-Zustand unangetastet, der nächste Transkript-Write versucht es erneut. Startup-Recovery (`recovery::regenerate_missing_oneliners`) fängt Fälle ein, bei denen die Datei fehlt oder `Error` enthält.

Der Arzt kann den Oneliner weiterhin durch eine explizite Bezeichnung im Diktat beeinflussen („Bezeichnung: Kniegelenk"). Die Erkennung läuft vollständig über den Ollama-Prompt, keine deterministische Keyword-Suche.

**Queue-Recovery bei Serverstart:**
- `transcribe::recovery::scan_and_enqueue`: findet alle `.m4a` ohne passendes `<stem>.json` (und ohne `.m4a.failed`) und schiebt sie in die Transcribe-Queue.
- `transcribe::recovery::regenerate_missing_oneliners`: für Fälle mit Transkripten aber ohne `oneliner.json` (oder mit `OnelinerState::Error`) wird der Oneliner einmalig erzeugt. `Empty` gilt als Endzustand und wird **nicht** retryed.
- `analyze::recovery::scan_and_enqueue`: findet `analysis_input.json` ohne `document.md` und reiht sie in die Analyze-Queue ein.
- Kein Datenverlust bei Server-Neustart; alle drei Scans laufen bei Boot parallel.

---

**Pipeline bei „Fall abschließen" (Ist-Stand):**

```
1. Handler serialisiert alle Transkripte chronologisch als AnalysisInput-JSON
   → schreibt `analysis_input.json` (persistent, überlebt Crash)
2. Job in AnalyzeSender-Queue einreihen (mpsc::unbounded)
3. Analyze-Worker liest `analysis_input.json`, rendert Prompt, ruft LLM
   (Temperatur 0, System-Prompt aus Config):
     - "bereinigen und strukturieren, nichts hinzufügen"
     - "spätere Aufnahmen haben Vorrang"
     - ASR-Fehler aktiv korrigieren, unsichere Stellen mit ==text== markieren
4. Gazetteer::replace() auf der LLM-Antwort (post-AI-Normalisierung)
5. Atomic write (`.tmp` → rename) → `document.md`
6. `analysis_input.json` löschen (Queue-Marker weg, recovery kein Duplikat)
```

Crash zwischen LLM-Antwort und Dokument-Write: akzeptiert. Recovery-Scan enqueued den Job erneut, ein LLM-Call wird wiederholt — seltenes Ereignis, kleiner Preis.

Silent-Case (keine verwertbaren Transkripte): Worker schreibt eine Stub-Datei (`_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._`) statt LLM-Call.

---

**Pipeline bei „Neu analysieren" (Ist-Stand):**

```
1. Handler löscht bestehendes `document.md`
2. Pipeline wie oben neu ausführen
```

Keine Versionierung. Eine neue Analyse überschreibt die alte in-place.

---

**Auto-Trigger für LLM-Analyse (`analyze::auto_trigger`, Ist-Stand):**

Die LLM-Analyse wird opportunistisch gestartet, sobald ein Fall transkriptionsvollständig ist — ohne Nutzeraktion. Ausgelöst wird der Trigger in den `/web/cases`- und `/web/cases/{id}`-Handlern; jeder SSE-getriggerte Reload wird damit zum natürlichen Polling-Puls, ein Background-Timer ist nicht nötig.

```
evaluate_case(case_dir) → AutoDecision::{Enqueue, Skip(reason)}
  Skip-Gründe (reine FS-Stats, keine Mutation, keine Channels):
    • keine Aufnahmen
    • nicht alle Aufnahmen transkribiert
    • analysis_input.json vorhanden → Job schon in Queue / in-flight
    • .analysis_failed.json vorhanden mit identischer last_recording_mtime
    • document.md aktueller als jüngste Aufnahme → nichts zu tun

try_enqueue(case_dir, …):
  1. config.llm_configured() ? sonst false
  2. evaluate_case → Enqueue ? sonst false
  3. build_analysis_input() aus allen Transkripten
  4. (re-analyse) document.md löschen, damit Worker-Guard nicht short-circuitet
  5. analysis_input.json überschreiben
  6. Job an AnalyzeSender; CaseEventKind::AnalysisQueued emittieren
```

**Retry-Gate `.analysis_failed.json`:** Bei LLM-Fehler schreibt der Worker einen Marker mit `{last_recording_mtime, reason, failed_at}`. `evaluate_case` überspringt Fälle mit passender `last_recording_mtime` — kein Retry-Loop, bis das Input-Signal (neue Aufnahme oder Reset) sich ändert. Der Worker löscht den Marker bei erfolgreichem Analyse-Abschluss.

**Re-Analyse-Pfad:** Trifft eine neue Aufnahme nach Fallabschluss ein, wird das Transkript geschrieben → Oneliner regeneriert → beim nächsten UI-Render erkennt `evaluate_case` das veraltete `document.md`, `try_enqueue` löscht es und reiht den Fall neu ein. Aus Arztsicht „passiert das von selbst".

`try_enqueue_all_for_user(user_root, …)` iteriert über alle UUID-Verzeichnisse und darf auf jedem Fall-Listen-Render laufen — Skips sind billig (nur `stat`).

---

**Geplant für Phase 4 (noch nicht implementiert):**

- **Preset-basiertes „Neu generieren"**: Preset-Prompt + optionaler Freitext kombinieren, LLM erneut aufrufen, vorige Version erhalten.
- **Dokument-Versionierung** (`document_v{N}.md` oder Alternative) als Voraussetzung für Undo. Noch offen, ob via Filename-Suffix + Max-Scan oder Content-Hash.
- **Undo-Button**: Rückkehr zur vorherigen Version.

Diese drei Features hängen zusammen — erst mit Versionierung ist Undo sinnvoll.

---

**Background Tasks (tokio::spawn in main.rs):**

- **Transcribe-Worker**: sequentielle mpsc-Abarbeitung (ffmpeg remux → Whisper → Gazetteer → Transkript-Write → Oneliner am Batch-Ende). Emittiert `RecordingUploaded`, `RecordingDeleted`, `TranscriptReady`/`TranscriptFailed`, `OnelinerUpdated` auf den Event-Bus.
- **Analyze-Worker**: sequentielle mpsc-Abarbeitung (LLM → Gazetteer → atomic rename auf `document.md`). Emittiert `AnalysisQueued`, `DocumentReady`; schreibt bei Fehler `.analysis_failed.json` (siehe Auto-Trigger).
- **Event-Bus** (`events::channel`, `tokio::sync::broadcast`, Kapazität 256): multi-producer/multi-consumer. Langsame Subscriber erhalten `Lagged(n)` und resynchronisieren sich beim nächsten Reload; FS bleibt SoT, Events sind reine Trigger.
- **SSE-Route** `GET /web/events`: eine Verbindung pro Browser-Tab, Non-Admins slug-gefiltert, Admins ungefiltert, 15 s Keep-Alive-Kommentar gegen Reverse-Proxy-Timeouts. Client-Side debounced `location.reload()` in den Templates.
- **Recovery-Scans bei Boot** (siehe Worker-Zyklus): fehlende Transkripte, fehlende Oneliner, fehlende Dokumente.
- **Rolling-File-Logger** (`tracing_appender::rolling::daily`) für `recorder.{datum}.log`.

**Geplant, noch nicht implementiert:**
- Health-Checks / Backoff für faster-whisper und Ollama bei Ausfall.
- Retention-Prüfung: lazy bei Zugriff — Audio/Transkripte älter als `RETENTION_*_DAYS` löschen, wenn der Ordner ohnehin gelesen wird.
- Lazy Cleanup für soft-deleted Fälle (`.deleted`-Marker älter als ein Tag → physisch entfernen). Aktuell bleibt der Ordner bestehen.

---

**Service-Ausfall (Ist-Stand):**

| Situation | Verhalten |
|---|---|
| faster-whisper nicht erreichbar | Transcribe-Worker loggt Fehler, markiert Aufnahme als `.m4a.failed`. Recovery-Scan ignoriert sie. |
| Ollama nicht erreichbar | Oneliner-Generierung loggt Fehler, bestehender Oneliner bleibt erhalten. Nächster Transkript-Write versucht es erneut. |
| LLM-Provider nicht erreichbar | Analyze-Worker loggt Fehler, `analysis_input.json` bleibt → Recovery-Scan enqueued beim nächsten Start. |
| Server-Neustart | Queue-Recovery aus Dateisystem (alle drei Recovery-Scans). |

**Geplant:** Automatische Re-Try-Policy statt `.m4a.failed`-Rename nach einmaligem Whisper-Fehler; Admin-Warnung im UI bei > 30 Min. Ausfall.

---

**Webinterface (askama Templates, Vanilla-Forms):**

Serverseitig gerendertes HTML + Formular-Submits. Live-Updates via SSE sind implementiert: der Client hält eine `EventSource`-Verbindung zu `GET /web/events`, filtert eingehende `CaseEvent`s nach der aktuellen Sicht (Fall-Übersicht, Fall-Detail, Recordings) und löst einen debounced `location.reload()` aus — der Server rendert mit aktuellem FS-Stand neu, ohne dass das HTML Teilzustände diffen müsste.

**UI-Helfer:**
- `partials/oneliner.html` definiert ein Askama-Macro `render`, das alle fünf `OnelinerDisplay`-Zustände (`Ready`, `Empty`, `Error`, `Pending` = Transkription läuft, `Generating` = Transkription fertig, LLM-Call noch offen) einheitlich rendert. Sowohl `case_page.html` als auch `my_cases.html` konsumieren es — kein divergentes Markup pro View.
- `partials/time_format.js` ist ein gemeinsames Client-Script, das UTC-ISO-Timestamps in lokaler Zeitzone formatiert (bewusst JS, nicht Rust: Browser kennt TZ des Nutzers, Server nicht — siehe Memory „JS-OK, Logic-Rust"). Wird u.a. für `recorded_at_iso` (letzte Aufnahme) und Gruppen-Überschriften genutzt.
- **Post-Action-Redirect:** `POST`-Handler für Aktionen auf einem Fall (analyze, reset, delete-recording) nutzen `resolve_return_path(Referer)` — wenn der Referer **gleich-origin** auf `/web/...` zeigt, landet der Arzt wieder genau dort, sonst Fallback `/web/cases`. Schema und Host werden bewusst weggeworfen, damit ein feindlicher Referer keinen Open-Redirect triggern kann.

**Ist-Stand UI (vereinfacht):**

```
Meine Fälle — dr_mueller
────────────────────────────────────────────
Heute (2/5)                                       ← open_count / total_count
  08:14  │  ⏳ Empfangen (transkribiere ...)
  09:32  │  „Kniegelenk, re." (3 Aufnahmen)       ← Titel-Link → Fall-Übersicht
           Zusammenfassung: Röntgen rechtes …       Recordings-Link → case_recordings
           (Analyse-HTML, CSS-line-clamp auf
            preview_lines, click → expand)
           [Analysieren]
  ...
  [Bulk: alle markierten analysieren]             ← nur sichtbar für Admins
  [Bulk: alle markierten löschen]

Gestern (0/2)
  2026-04-20  „Hypertonie Grad 2"                 [Öffnen] [Löschen]

Abgeschlossen ausblenden/anzeigen (Toggle)
[Alte abgeschlossene Fälle endgültig löschen]     ← purge-closed-Form, admin-only

Zuletzt gelöscht   [Undo letzten Batch]
```

**Gruppenzählung:** Pro Datum-Gruppe zeigen wir `open_count/total_count`. Bei `show_closed=false` kommen bereits geschlossene Fälle nicht als Zeilen vor, werden aber in `total_count` über eine separate `closed_extra`-Map eingerechnet (`count_closed_by_date` läuft einmal über die Case-Verzeichnisse). Im `show_closed=true`-Modus sind geschlossene Fälle als Zeilen sichtbar — `closed_extra` ist dann leer, die Summen stimmen über den regulären Iterations-Pfad. Ergebnis: konsistente Anzeige beider Modi, ohne doppelte Zählpfade.

**Analyse-Preview in der Liste:** Wenn ein Fall ein `document.md` hat, rendert der Server das gesamte Markdown durch dieselbe `md_to_html`-Pipeline wie die Fall-Übersicht (`analysis_html`). Das gerenderte HTML wird direkt unter der Case-Zeile eingehängt; CSS-`line-clamp` (`preview_lines` aus `users.toml`) klemmt es auf N Zeilen. Ein Klick auf die Zusammenfassung togglet eine `.expanded`-Klasse, die den Clamp aufhebt — kein separater Server-Roundtrip, keine zweite gerenderte Variante. Entscheidung: **kein** server-seitiges Truncaten, damit es keine Diskrepanz zwischen Preview und Full-View gibt.

Fall-Übersicht (case_page, Ist-Stand seit 2026-04-19):
```
Fall 09:32 — „Kniegelenk, re."               ← Oneliner als Titel
────────────────────────────────────────────
Dokument (Markdown → HTML, single version)   ← inline gerendert, wenn vorhanden
... Inhalt ...

[Analysieren / Neu analysieren]   [Löschen]  [Reset]*   [Aufnahmen ansehen →]
                                              * admin-only
```

Einzel-Transkripte (case_recordings):
```
Fall 09:32 — 3 Aufnahmen
────────────────────────────────────────────
09:32  „Patient klagt über Schmerzen ..."    ← Transkript 1 (Audio-Link)
09:45  „Röntgenbild zeigt ..."               ← Transkript 2
10:02  „Diagnose: ..."                       ← Transkript 3

[← Zurück zur Übersicht]
```

**Admin-spezifische Aktionen:** `Reset`, die Bulk-Aktionen und `purge-closed` sind serverseitig auf `role = "admin"` eingegrenzt. Der Admin-Check sitzt jeweils am *Entry-Handler* (`bulk.rs::handle_bulk` + `case_actions.rs::handle_purge_closed`), nicht pro Sub-Action — so kann kein späterer Branch die Prüfung vergessen. Die Templates erhalten `is_admin: bool` aus dem ViewModel und blenden Nicht-Admin-User die entsprechenden Buttons und die Purge-Form aus (Defense-in-Depth).

„Löschen" legt einen `.deleted`-Batch-Marker an. Der Fall verschwindet aus den Listen, kann aber über „Zuletzt gelöscht → Undo" en bloc wiederhergestellt werden. Physisches Entfernen (Cleanup) ist geplant, aber noch nicht implementiert — der Ordner bleibt bis auf Weiteres bestehen.

**Phase-4-Entwurf (noch nicht gebaut):**
- Nachtrag-Hinweis-Banner („⚠ 1 neue Aufnahme seit Abschluss — neu analysieren?"): funktional durch den Auto-Trigger abgedeckt (Doku oben), als sichtbarer Banner aber noch offen.
- Preset-System + Undo für Dokument-Versionen (Arztbrief / Kürzer / Formeller / Diagnosen / Medikamente).
- Service-Ausfall-Warnung im UI (> 30 Min. Ausfall).

**Phase-4-Entwurf (inzwischen umgesetzt):**
- CSRF-Tokens in allen state-changing POST-Formularen — implementiert 2026-04-23 (`CsrfForm<T>`-Extractor + `partials/csrf_field.html`-Macro). Details siehe Abschnitt „6. Sicherheit".

---

### 3. Filesystem-Struktur

```
/data/
└── {slug}/                                      ← User-Slug aus users.toml (kein open/done-Split)
    └── {case_id}/
        ├── {UTC-timestamp}.m4a                 ← Aufnahme (unveränderlich)
        ├── {UTC-timestamp}.m4a.failed          ← optional: dauerhaft gescheiterte Aufnahme
        ├── {UTC-timestamp}.json                ← `RecordingMeta` (Transcript + duration_seconds + optional `loudness`), single atomic write am Ende des Worker-Pipelines
        ├── oneliner.json                       ← `OnelinerState` (Ready/Empty/Error), aus allen Transkripten regeneriert
        ├── analysis_input.json                 ← nur während Analyse-Lauf (wird nach Erfolg gelöscht)
        ├── .analysis_failed.json               ← Auto-Trigger-Retry-Gate (JSON: last_recording_mtime, reason, failed_at)
        ├── document.md                         ← nach LLM-Analyse (Gazetteer-normalisiert)
        └── .deleted                            ← Soft-Delete-Marker (JSON mit Batch-UUID + deleted_at)

/var/log/recorder/
└── recorder.{datum}.log
```

**Keine Versionierung im Code (Ist-Stand).** `document.md` und `analysis_input.json` existieren genau einmal pro Fall. Re-Analyze überschreibt `document.md` in-place (Handler löscht vorher). Dokument-Versionierung (`document_v{N}.md`) und „Undo" sind als Phase-4-Feature geplant — siehe Abweichungen-Sektion.

**Soft-Delete (`.deleted`-Marker):**

```json
{ "batch": "7f3a...uuid", "deleted_at": "2026-04-17T10:20:00Z" }
```

- Marker wird beim Klick auf „Entfernen" geschrieben. Alle in einem UI-Klick gelöschten Fälle teilen sich die `batch`-UUID, damit `POST /web/cases/undo-delete` gezielt den letzten Batch wiederherstellen kann.
- Ordner bleibt physisch bestehen, ist nur in den Listen ausgeblendet. Lazy-Cleanup älter als ein Tag ist geplant, aber aktuell **nicht implementiert**.

**Marker-Semantik im Überblick:**

| Datei | Bedeutung |
|---|---|
| `{ts}.m4a` ohne `{ts}.json` | Transkriptions-Auftrag offen |
| `{ts}.m4a.failed` | Dauerhaft gescheitert, Recovery-Scan ignoriert, UI zeigt „failed" |
| `{ts}.json` | `RecordingMeta` mit `transcript` (`Silent` oder `Content`), `duration_seconds` und optional `loudness` (`{mean_db, max_db, gain_db}` für Replay-Gain im Browser; `#[serde(default)]`-rückwärtskompatibel). Single atomic write — Existenz = transcribiert |
| `oneliner.json` | `OnelinerState` (Ready/Empty/Error). Fehlt oder `Error` → Recovery-Retry; `Empty`/`Ready` sind Endzustände |
| `analysis_input.json` vorhanden | Analyse-Job in Queue / in-flight |
| `.analysis_failed.json` vorhanden | Auto-Trigger-Retry-Gate; Auto-Analyse übersprungen, solange `last_recording_mtime` unverändert |
| `document.md` vorhanden | Fall gilt als „ausgewertet" |
| `.deleted` vorhanden | Fall ist soft-deleted (per Undo-Batch wiederherstellbar) |

---

### 4. Konfiguration

**System-Konfiguration (.env):**

```
# Server (required)
SERVER_PORT=3000
DATA_PATH=/data
USERS_FILE=users.toml

# Logging
LOG_LEVEL=info
LOG_PATH=/var/log/recorder
LOG_MAX_DAYS=90

# Retention (noch nicht durchgesetzt, siehe Phase 2-Checkliste)
RETENTION_AUDIO_DAYS=30
RETENTION_TRANSCRIPT_DAYS=30
RETENTION_DOCUMENT_DAYS=0        # 0 = permanent

# faster-whisper (eigener FastAPI-Wrapper, siehe Phase 2b.5)
WHISPER_URL=http://localhost:9001
WHISPER_TIMEOUT_SECONDS=120

# Ollama (Oneliner)
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=gemma3:4b
OLLAMA_KEEP_ALIVE=0

# LLM Provider (OpenAI-kompatibel, z.B. Ionos) — optional:
# Fehlt einer dieser Werte, versteckt das UI den "Analysieren"-Button.
LLM_URL=https://openai.inference.de-txl.ionos.com
LLM_API_KEY=...
LLM_MODEL=...
LLM_TEMPERATURE=0
LLM_TIMEOUT_SECONDS=180
LLM_REASONING_EFFORT=medium                  # optional, default "medium". Wirkt nur auf
                                             # GPT-OSS-Reasoning-Modelle (Ionos); andere
                                             # OpenAI-kompatible Modelle ignorieren das Feld.
                                             # Trade-off Halluzinations-Risiko ↔ Latenz/Token-Kosten.

# Consolidation system prompt — optional override. Leer → Default aus
# analyze/prompt.rs. Runtime-konfigurierbar, damit Admins iterieren und
# per "Neu analysieren" bestehende Fälle re-runnen können.
LLM_SYSTEM_PROMPT="..."

# Gazetteer (Post-AI-Normalisierung)
VOCAB_DIR=./vocab                          # Verzeichnis mit *.txt (eine Phrase pro Zeile)
HUNSPELL_DICT=/usr/share/hunspell/de_DE    # Stamm-Pfad (ohne .aff/.dic), Dict-Veto

# Session
SESSION_TIMEOUT_HOURS=8
COOKIE_SECURE=true                         # false nur für Plain-HTTP-Dev
```

**Verhalten bei fehlenden optionalen Blöcken:**

| Fehlt | Konsequenz |
|---|---|
| `LLM_URL` / `LLM_API_KEY` / `LLM_MODEL` | UI versteckt „Abschließen", Handler antwortet 503. Pipeline startet ohne Fehler (`config.llm_configured()` entscheidet). |
| `VOCAB_DIR` oder kein passendes `*.txt` darin | `warn!` beim Start, Pipeline läuft **ohne** Gazetteer-Normalisierung. |
| `HUNSPELL_DICT` bzw. `.aff`/`.dic` nicht lesbar | `warn!` beim Start, Gazetteer läuft **ohne** Dict-Veto (potentiell mehr False-Positive-Rewrites). |
| `LLM_SYSTEM_PROMPT` leer | Fallback auf hardcoded Default aus `analyze/prompt.rs` (inkl. `==text==`-Markierungen). |

**User-Verwaltung (users.toml):**

User-Daten (API-Keys, Passwörter, Rollen, Per-User-Whisper-Settings) werden separat in `users.toml` verwaltet statt in .env. So können neue User hinzugefügt werden, ohne die System-Konfiguration anzufassen. Das `role`-Feld ermöglicht neben `doctor` auch andere Rollen (z.B. `mta`, `admin`).

```toml
[[user]]
slug = "dr_mueller"
api_key = "..."
web_password = "$2b$12$..."
role = "doctor"
preview_lines = 2                   # optional; CSS line-clamp für die Analyse-Preview
                                    # in der Fall-Liste. Default 2. Wirkt nur visuell;
                                    # der Server rendert immer das vollständige HTML.

  [user.whisper]                    # optional; missing block = Service-Defaults
  language = "de"
  hotwords = "HOCM Valsalva"        # technisch durchverdrahtet, wird nicht beworben
  initial_prompt = "Kardiologie"

[[user]]
slug = "dr_schmidt"
api_key = "..."
web_password = "$2b$12$..."
role = "doctor"
```

Der Transkriptions-Worker reicht `[user.whisper]` pro Upload an den Whisper-Service durch. Siehe Abweichungen-Eintrag „Hotwords".

---

### 5. Prompts (Ist-Stand: hardcoded)

**Aktuell keine `prompts.toml`.** Die zwei aktiven Prompts leben direkt im Rust-Code:

| Prompt | Ort | Override |
|---|---|---|
| Consolidation-Prompt (Fallabschluss-LLM) | `server/src/analyze/prompt.rs::SYSTEM_PROMPT` | env `LLM_SYSTEM_PROMPT` |
| Oneliner-Prompt (Ollama) | `server/src/transcribe/ollama.rs` | bisher kein Override |

**Semantik des Consolidation-Prompts:**
- „Bereinigen und strukturieren, nichts hinzufügen, keine Diagnose, keine Arztbrief-Struktur."
- „Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen."
- ASR-typische Fehler (phonetische Verwechslungen, zerschnittene Komposita) aktiv korrigieren. Bei Unsicherheit Original behalten.
- Unsichere Stellen / korrigierte Tokens / unklare Zahlen mit `==text==` umschließen. Diese Markierungen werden später vom Gazetteer nicht mehr angefasst. Keine anderen Annotation-Formen erlaubt.

**Semantik des Oneliner-Prompts (Priority-Order, strikt top-down):**
1. **Explizites Label:** Enthält das Transkript `Bezeichnung: X` oder `Fall-ID: X`, wird `X` verbatim als Label übernommen.
2. **Medizin-Entity:** Diagnose, Körperregion, Organ, Bildgebungs-Modalität oder zentraler Befund aus dem Transkript.
3. **Fallback:** Markantestes Substantiv / Ereignis / Kontext — **nur** wenn keine Medizin-Entity vorhanden ist.

**Silence-Rule (eng gefasst):** Nur wenn das Transkript komplett leer ist oder keine verwertbaren Wörter enthält, gibt das Modell null Zeichen aus. Platzhalter wie „empty", „nothing", „-", „N/A" sind verboten. Frühere Versionen erlaubten die Silence-Rule als Default-Eskape für „nicht-medizinische Transkripte" — das führte zu still aussehenden Fällen, obwohl Wörter vorhanden waren. Die Fallback-Regel fängt diesen Fall jetzt ab, die Silence-Rule feuert nur noch bei echt leerem Input.

**Geplant für Phase 4 (noch nicht implementiert):** Preset-System für „Neu generieren" (Arztbrief / Kürzer / Formeller / Diagnosen / Medikamente). Konfigurierbar vermutlich via `prompts.toml` — Format ist noch nicht festgelegt. Bis dahin gibt es nur den einen Default-Prompt.

**Entwurf (für spätere Umsetzung):**

```toml
[[preset]]
label = "Arztbrief"
prompt = "Formatiere das Dokument als formellen Arztbrief mit Anrede und Grußformel."

[[preset]]
label = "Kürzer fassen"
prompt = "Fasse das Dokument kürzer zusammen ohne inhaltliche Verluste."

# ... weitere Presets
```

Ziel: neue Presets ohne Code-Änderung hinzufügbar.

---

### 6. Sicherheit

| Verbindung | Methode |
|---|---|
| Watch → nginx | HTTPS TLS 1.3 + API-Key im Header |
| Browser → nginx | HTTPS TLS 1.3 + Session-Cookie (`Secure`, `HttpOnly`, `SameSite=Strict`) |
| nginx → Axum | HTTP lokal (Unraid → Ubuntu, internes Netz) |
| Axum → faster-whisper | HTTP localhost (gleicher Server) |
| Axum → Ollama | HTTP localhost (gleicher Server) |
| Axum → Ionos | HTTPS + API-Key |
| TLS-Zertifikat | Let's Encrypt (certbot, auto-renewal) |
| Fail2Ban | Zu viele Fehlversuche → IP geblockt |
| Rate Limiting | Max 10 Requests/Minute pro IP |
| Ports | Nur 443 offen (Unraid), SSH nur lokal |
| Datenhaltung | /data/ nur root lesbar |
| IDOR-Prävention | Arzt-Identität kommt ausschließlich aus der Session, nie aus der URL. `AuthenticatedArzt`-Extractor leitet den Dateisystempfad serverseitig ab. |
| Input-Validierung | **Zweistufig:** strukturelle Validierung via Serde (`Json<T>`/`Form<T>`/`Query<T>`-Extraktoren), darüber das zentrale `server/src/validate.rs`-Modul mit pure Boundary-Validatoren — Slug-Shape (Login), Magic-Link-Token-Shape (Consume) und strikter `recorded_at`-RFC3339 (Upload, exakt 20 Bytes `YYYY-MM-DDTHH:MM:SSZ`, kein Subsekunden-Anteil). Info-Leak-sensitive Routen (Login → „Login fehlgeschlagen", Magic-Link → `/web/login`-Redirect) folden Shape-Fehler in den Standard-Fehlerpfad; Upload bouncet 400 mit Field-Name. `case_id` weiterhin inline UUIDv4 (`uuid::Uuid::parse_str`); `ValidCaseId`-Extractor bleibt Phase-4-TODO. Verhindert Path-Traversal und Filename-Drift (siehe Abweichungen → Sicherheit / Hardening). |
| CSRF | **Implementiert (2026-04-23):** `CsrfForm<T>`-Extractor mit constant-time-compare (`subtle`), pro-Session-Token (Login + Magic-Link minten, nicht rotiert), Askama-Macro rendert Hidden Field in allen state-changing `/web/`-POST-Formularen. `SameSite=Strict`-Cookie bleibt als zweite Ebene. Login und `/api/...` sind exempt. |
| Security Headers | **Zweischichtig:** Axum-Defense-in-Depth via `tower-http::SetResponseHeaderLayer` (in `create_router_with_state`, `if_not_present`-Mode erlaubt Per-Route-Overrides) — aktiv auch ohne nginx. Zusätzlich nginx-Layer bei TLS-Terminierung (geplant). Gesetzt: `Content-Security-Policy` (enthält `'unsafe-inline'`, weil Templates Inline-JS haben — revisiten, sobald User-Input unescaped gerendert wird), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy`. **HSTS bewusst ausgelassen**, bis TLS-Terminierung steht (gecachter `max-age` auf Plain-HTTP-Deployment ist irreversibel). |

---

### 7. Logging

**Setup:** `tracing` + `tracing-subscriber` mit zwei Sinks — stdout (Container-Logs) und `tracing_appender::rolling::daily` für `{LOG_PATH}/recorder.{datum}.log`. Log-Level via `LOG_LEVEL` env (Default `info`). Keine ANSI-Farben im File-Sink.

Typische Log-Linien im Regelbetrieb (strukturiert, key=value):

```
INFO  Transcription worker started
INFO  Analyze worker started                 vocab_entries=187
INFO  Transcribing                           audio=/data/dr_mueller/<uuid>/2026-04-17T10-15-00Z.m4a user=dr_mueller
INFO  Transcript written                    audio=... bytes=1423
INFO  gazetteer replaced                     token="Pantoprasol" canonical="Pantoprazol" distance=1
INFO  gazetteer blocked by dict veto         token="Kaktus" candidate="Lantus" distance=2
INFO  Oneliner updated                      path=/data/.../oneliner.json kind=ready chars=23
INFO  sending to llm                         case=/data/.../<uuid> recording_count=3 total_chars=1840
INFO  analysis done                         case=... bytes=2105
WARN  Gazetteer not available; running without proper-name correction   dir=... error=...
WARN  Hunspell dict unavailable; gazetteer running without dict veto    stem=... error=...
ERROR whisper call failed                   audio=... error=...
ERROR llm call failed                       case=... error=...
```

Wichtig: LLM-Antworten werden **nicht** geloggt (potenziell patientenbezogene Daten). Der `LlmError::Display` ist bereits redigiert.

---

## Tech Stack

| Schicht | Technologie |
|---|---|
| Android-Clients (Watch primär, Handy geplant) | Kotlin, Multi-Modul-Gradle. UI: Compose for Wear OS (`:app-wear`) bzw. Jetpack Compose Material 3 (`:app-mobile`). Geteilte Core-Module (`:core-*`) sind UI-unabhängig und JVM-testbar. |
| Desktop-Client (Linux gebaut, Windows geplant) | Rust, Standalone-Cargo-Workspace `clients/desktop/` (Crate `doctate-desktop`, Bin-Name `doctate`). `eframe`/`egui` (Immediate-Mode-UI), `tokio` (async I/O), `reqwest` (Multipart-Upload + Polling), `ffmpeg`-Subprozess (m4a-Recording, SIGINT-Stop). Teilt `doctate-common` (API-Typen) als `path = "../../common"`-Dependency mit dem Server. Headless-Logik (Case-Store, Sync, Poller, Cleanup) lebt als reguläre Module im selben Crate. |
| iOS-Gerät (optional, später) | SwiftUI oder Compose Multiplatform. Entscheidung nach Watch/Handy-Erfahrung. |
| Server | Rust (edition 2024), Axum 0.8, askama, reqwest, tracing, `strsim` + `spellbook` (Gazetteer); SSE-Live-Updates via `tokio::sync::broadcast` + `axum::response::sse` produktiv |
| STT | faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API) |
| Preprocessing | Ollama, Gemma 3 4B (keep_alive: 0 für VRAM-Freigabe nach Request) |
| Dokument-LLM | Ionos (OpenAI-API-kompatibel, Temperatur 0) |
| Deployment | Docker auf Ubuntu Server (NVIDIA Container Toolkit für faster-whisper + Ollama), nginx reverse proxy auf Unraid |
| TLS | Let's Encrypt / certbot |

### Rust Crates (Server)

Axum selbst benötigt keinen GPU-Zugriff — STT und Preprocessing laufen als externe Services (faster-whisper, Ollama). Der Axum-Container ist daher ein schlankes Image ohne NVIDIA-Abhängigkeiten. Edition ist `2024`, was axum `0.8`, tower-http `0.6` und axum-extra `0.12` voraussetzt (native async fn in Traits).

```toml
[package]
edition = "2024"

[dependencies]
# Web
axum = { version = "0.8", features = ["multipart"] }
axum-extra = { version = "0.12", features = ["cookie", "form"] }
tower-http = { version = "0.6", features = ["limit", "trace"] }

# Async runtime + HTTP client
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["sync"] }   # BroadcastStream → SSE-Endpoint
futures-util = "0.3"                                      # Stream-Kombinatoren für SSE
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }

# Templates + Rendering
askama = "0.12"
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }  # document.md → HTML

# Auth / Session
bcrypt = "0.15"
rand = "0.8"                                              # Session-Token (OsRng)
rpassword = "7.4"                                         # hash-password CLI (keine Passwort-Echos)

# Config / Daten
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
toml_edit = "0.25"                                        # User-CRUD ohne Kommentarverlust
dotenvy = "0.15"
uuid = { version = "1", features = ["v4", "serde"] }
time = { version = "0.3", features = ["local-offset", "parsing", "formatting", "macros"] }

# Observability
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"

# Pipeline-Utilities
tempfile = "3"                                            # ffmpeg remux temp files

# Gazetteer (Post-AI-Normalisierung)
strsim = "0.11"                                           # Damerau-Levenshtein
spellbook = "0.4"                                         # pure-Rust Hunspell-compat, Dict-Veto

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
wiremock = "0.6"
```

**Gazetteer-Stack separat begründet:** `strsim` liefert die Kernmetrik (Damerau-Levenshtein ≤ 2 über N-Gramme gegen kuratiertes Vocab). `spellbook` ist ein pure-Rust Hunspell-kompatibler Parser, der `.aff`-Affix-Regeln live anwendet — Tokens, die als gültige deutsche Alltagswörter erkannt werden (inkl. Flexionen wie *Kakteen* aus *Kaktus*), werden vom Rewrite ausgenommen (Dict-Veto). Siehe Abschnitt „Gazetteer" weiter oben.

### Cargo-Workspace-Layout

Das Repository ist seit dem Stage-Split (2026-05-02) **kein** monolithischer Cargo-Workspace mehr, sondern besteht aus **drei isolierten Build-Welten** mit eigenen `Cargo.lock`-Files plus einer reinen Source-Lib `common/` und dem Wear-OS-Gradle-Projekt:

```
doctate/
├── server/                 Standalone-Workspace (eigener Cargo.lock + target/)
│                             Crate doctate-server, Bin doctate-server, Axum
├── common/                 Source-Lib (kein Workspace, kein Cargo.lock)
│                             Crate doctate-common, runtime-agnostische API-Typen
├── clients/
│   ├── desktop/            Standalone-Workspace (eigener Cargo.lock + target/)
│   │                         Crate doctate-desktop, Bin doctate, eframe + ffmpeg
│   └── wearos/             Gradle (Kotlin), kein Cargo
├── experiments/            Standalone-Workspace (eigener Cargo.lock + target/)
│                             Sandbox: Prompt-Iteration, Sweep-Tools
└── (kein Cargo.toml im Root, kein Cargo.lock im Root)
```

**Drei Build-Welten, drei Tests-Befehle.** `cargo test --workspace` aus dem Root ist ohne Wirkung (kein Workspace-Root). Volle Test-Coverage erfordert vier separate Befehle:

| Welt | Build | Test |
|------|-------|------|
| server | `cd server && cargo build [--release --locked]` | `cd server && cargo test -j 4` |
| clients/desktop | `cd clients/desktop && cargo build` | `cd clients/desktop && cargo test -j 4` |
| common | (von Konsumenten gebaut) | `cd common && cargo test -j 4` |
| experiments | `cd experiments && cargo build` | `cd experiments && cargo test -j 4` |
| clients/wearos | `cd clients/wearos && JAVA_HOME=/opt/android-studio/jbr ./gradlew :app:assembleDebug` | `cd clients/wearos && JAVA_HOME=/opt/android-studio/jbr ./gradlew :app:testDebugUnitTest` |

Test-Coverage (Stand 2026-05-02): server 385 + clients/desktop 89 + common 34 = **508 Cargo-Tests**, plus Wear-OS-JVM-Tests.

**Warum drei Welten statt einem Root-Workspace?** Vor dem Split kontaminierte ein gemeinsamer `Cargo.lock` Server-Container-Builds (`cargo build --release --locked`) mit GUI-Lock-Drift: jeder eframe/egui/winit-Bump invalidierte den Server-Build-Cache. Heute hat `server/Cargo.lock` exakt **0** GUI-Deps. Der Container-Build (`cd server && cargo build --release --locked`) bleibt unangetastet, wenn der Desktop-Client iteriert.

**`doctate-common`** ist die Quelle der API-Wahrheit zwischen Server und Clients:
- `ack::AckResponse`, `ack::AckStatus` — ACK-Typen für `POST /api/upload`
- `oneliners::OnelinersResponse`, `OnelinerEntry`, `ONELINER_FILENAME` — Wire-Format + kanonischer Dateiname für `GET /api/oneliners`
- `timestamp::now_rfc3339()`, `filename_stem_to_recorded_at()`, `recorded_at_to_filename_stem()` — bijektive Umwandlung UTC-RFC3339 ↔ Filesystem-safe-Name (Colon ↔ Hyphen im Zeit-Teil)
- `bulk::BulkAction` — getyptes Enum für `close`/`analyze`/`reset` im Bulk-Handler, `FromStr` mit stabiler Fehlermeldung. Ersetzt die String-Literale, die vorher zwischen Handler und drei Testdateien dupliziert waren; Handler-Match ist exhaustive.
- `url::join_url` — Helper zum sauberen Zusammensetzen von `base_url` + `path` (trimmt Trailing-Slash + vorangestellte Slashes einheitlich); konsumiert von `clients/desktop/src/{server_sync,upload,case_update}`, `server/transcribe::{ollama,whisper}` und `server/analyze::llm`.
- `constants::API_KEY_HEADER` (= `"X-API-Key"`), `UPLOAD_PATH`, `FIELD_CASE_ID`, `FIELD_RECORDED_AT`, `FIELD_AUDIO`, `CONTENT_TYPE_AUDIO_MP4`

Deps von `doctate-common` sind minimal: nur `serde`, `uuid`, `time` — **kein** `tokio`, `reqwest`, `axum`. `common/Cargo.toml` pinnt **konkrete Versionen** statt `workspace = true`, weil common keinen Workspace-Root mehr hat und gleichzeitig von drei Konsumenten (server, clients/desktop, experiments) per `path = "..."`-Dependency gepullt wird. Bei einem Versions-Bump (z.B. `tokio` 1.42 → 1.43) müssen vier Stellen gepflegt werden: `server/[workspace.dependencies]`, `clients/desktop/[workspace.dependencies]`, `common/[dependencies]` (konkrete Version), und `experiments/[dependencies]`.

**Headless Client-Logik** lebt seit dem 2026-05-02 als reguläre Module direkt in `clients/desktop/src/`:
- `case_store` — lokaler Fall-Index mit Unterstützung für Soft-Deletions (Delete-Watermark)
- `case_update` — `PUT /api/oneliner/{case_id}` (Manual-Override-Pfad)
- `server_sync` — ETag-basiertes Polling gegen `/api/oneliners`, `last_failure`-Tracking
- `upload` — persistente Pending-Queue mit Retry-Ladder und Sidecar-Metadaten
- `snapshot_cache` — serialisierter Poller-Stand auf Disk, damit der Launch keinen Flash-Fehlzustand zeigt
- `startup` + `pending_cleanup` — Konsistenz-Scans beim App-Start (stale Markers / Orphans)
- `footer_status` — konsolidierter UI-Status (Server-Erreichbarkeit, pending-count, last_error)
- `config` — TOML-Schema für Client-Konfiguration (`config_path` ist der OS-Pfad-Wrapper)

Diese Module sind in `clients/desktop/src/lib.rs` als `pub mod` re-exportiert, damit Wear-OS-Doku-Anker und künftige UniFFI-Bindings auf canonical Crate-Pfade (`doctate-desktop::case_store`, `doctate-desktop::server_sync`, …) zeigen können statt auf interne `crate::`-Pfade. Vor dem 2026-05-02 lebten diese Module in einer separaten Lib `doctate-client-core`; sie wurde aufgelöst, weil es nur einen Rust-Konsumenten (Desktop) gab. Mit Start eines zweiten Rust-Konsumenten (z.B. iOS via UniFFI oder ein Rust-Handy-Client) wandern sie zurück in eine eigene Lib — die Auflösung war bewusst umkehrbar, kein Modul wurde am Inhalt verändert.

### Rust Crates (Client-Desktop)

`clients/desktop/Cargo.toml` ist ein 1-Member-Workspace mit eigenem `[workspace.dependencies]`-Block:

```toml
[workspace]
resolver = "3"

[workspace.dependencies]
serde, serde_json, uuid, time, tokio, reqwest, tracing, toml   # heute identisch zu server/

[package]
name = "doctate-desktop"
edition = "2024"

[[bin]]
name = "doctate"               # nicht doctate-desktop
path = "src/main.rs"

[lib]
# implizit src/lib.rs — re-exportiert alle headless Module als pub mod

[dependencies]
doctate-common = { path = "../../common" }   # path-dep, KEIN workspace-dep (common ist nicht Member)
eframe = "0.28"                              # egui + winit
reqwest, tokio, time, serde, serde_json, toml, uuid, tracing   # workspace-geerbt
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"                            # OS-konforme Config-/Data-Pfade
thiserror = "1"
webbrowser = "1"                             # Magic-Link: System-Browser öffnen
which = "6"                                  # ffmpeg-PATH-Check
filetime = "0.2"                             # für mtime-basierte Cleanup-Heuristiken

[target.'cfg(unix)'.dependencies]
libc = "0.2"                                 # SIGINT via libc::kill

[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }   # für start_paused in Retry-Tests
wiremock = "0.6"
```

**Bin-Output:** `clients/desktop/target/{debug,release}/doctate` — nicht `doctate-desktop`. `cargo run -p doctate-desktop` aus dem Repo-Root funktioniert nicht mehr (kein Root-Workspace); stattdessen `cd clients/desktop && cargo run`.

**Bewusst NICHT verwendet:**
- `ffmpeg-next`/`libav-sys`: Build-Komplexität (system-ffmpeg-Headers) + GPL/LGPL-Lizenzfragen. Subprozess-Aufruf hat keine Linkage-Probleme.
- `cpal` + `fdk-aac-sys` + `mp4`: wäre ~500 LOC Reimplementierung von ffmpeg für marginalen Benefit.
- `tray-icon`: zieht `libxdo`/`gtk3`/`libappindicator` als System-Deps. Für MVP zu viel Friktion; `ksni` als Pure-Rust-dbus-Alternative dokumentiert für später.

---

## Hardware

| Gerät | Rolle |
|---|---|
| Pixel Watch 2 oder 3 (LTE empfohlen) | Primäres Erfassungsgerät (Aufnahme + Upload + Oneliner-Polling) |
| Bluetooth-Headset (optional) | Bessere Aufnahmequalität |
| Android Phone | Sekundäres Erfassungsgerät (eigene App, geplant — Erfassung + Review) **und** Wear OS Companion. Für LTE-lose Watches bleibt der Wear-OS-Network-Proxy als Framework-Mechanismus erhalten — kein eigener Code auf der Companion-Ebene nötig. |
| Linux-Desktop (Arzt-PC, CachyOS/Arch) | Erster nativer Client — Aufnahme + Upload. Tray-los, Fenster bleibt sichtbar. |
| Windows-Desktop | Geplant „bald"; gleicher Crate via `#[cfg(target_os = "windows")]`-Gates |
| iOS-Gerät (optional, später) | Weitere Client-Plattform — spricht dieselbe Server-API wie alle anderen |
| Unraid Server | nginx reverse proxy, TLS-Terminierung |
| Ubuntu Server (RTX 3060, 12 GB VRAM) | Docker: Axum (kein GPU), faster-whisper (GPU), Ollama (GPU) |

---

## Entwicklungsreihenfolge

### Phase 1 — Server-Grundgerüst
- [x] Axum Setup, Routing, Authentifizierung (API-Key → User-Mapping via `users.toml`)
- [x] Filesystem-Struktur anlegen
- [x] .env Konfiguration (dotenvy)
- [x] Logging (tracing + tracing-appender)
- [x] Upload-Endpunkt mit ACK-Response
- [x] Docker Container für Axum auf Ubuntu Server (kein GPU nötig)
- [x] faster-whisper Container mit HTTP-API (NVIDIA Container Toolkit) — jetzt eigener `whisper/`-Service, siehe Phase 2b.5
- [x] Ollama einrichten, Modell gepullt (`gemma4:latest` statt `gemma3:4b`, siehe Abweichungen)
- [ ] nginx auf Unraid + Let's Encrypt + TLS-Terminierung. **HSTS (`Strict-Transport-Security`) zieht nginx** (nicht Axum), sobald TLS stabil läuft — vorher irreversibel. Die übrigen Security-Header (`CSP`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`) kommen schon aus dem Axum-Layer (siehe nächster Punkt); nginx darf sie duplizieren, muss aber nicht.
- [ ] HTTP-Compression (gzip/brotli): Policy-Entscheidung nginx vs. `tower-http::CompressionLayer` (Axum) — bei direkter Axum-Exposition im Dev-Setup muss die Compression dort laufen, hinter nginx ist `gzip on` meist einfacher. Vorsicht bei SSE: `text/event-stream` darf **nicht** komprimiert werden (buffering bricht Live-Updates)
- [x] Axum-seitige Security-Header als Defense-in-Depth (`tower-http::SetResponseHeaderLayer` in `create_router_with_state`, `if_not_present`-Modus — nicht in `main.rs`, damit Integrationstests dieselbe Response-Shape wie Prod sehen): `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy` (enthält `'unsafe-inline'` für Script/Style, weil Templates Inline-JS nutzen), `Referrer-Policy: no-referrer`, `Permissions-Policy`. HSTS bewusst **weggelassen**, bis TLS-Terminierung steht — ein gecachter `max-age` auf Plain-HTTP-Deployment ist irreversibel. `if_not_present` respektiert Per-Route-Overrides (z.B. Magic-Link-Handler setzt eine eigene `Referrer-Policy`).

### Phase 2 — Transkriptions-Pipeline
- [x] Transkriptions-Queue (tokio mpsc channel)
- [~] GPU-Phasen-Worker (Greedy-Zyklus Whisper↔Ollama) — **nicht nötig** im aktuellen Setup (Turbo + Ollama passen gleichzeitig in VRAM, OLLAMA_KEEP_ALIVE=300). Wird erst wieder relevant, wenn größere Modelle zurückkehren — siehe Abweichungen
- [x] faster-whisper HTTP-Client (reqwest, POST `/asr`, multipart Audio)
- [x] Ollama HTTP-Client (reqwest, POST /api/chat) — `keep_alive=300` statt `0`, siehe Abweichungen
- [x] Oneliner-Generierung (einmalig, erst geschrieben wenn ein sinnvoller Oneliner vorliegt; sonst Retry beim nächsten Transkript)
- [x] Queue-Recovery bei Serverstart (Filesystem-Scan)
- [ ] faster-whisper Health-Check + Retry
- [ ] Ollama Health-Check + Retry
- [ ] Lazy Cleanup bei Falllisten-Zugriff (zum Entfernen markierte Fälle vom Vortag löschen)
- [ ] Retention-Prüfung bei Zugriff (Audio/Transkripte nach RETENTION_*_DAYS)
- [x] Per-User Whisper-Settings in `users.toml` (`[user.whisper]` mit `language`, `hotwords`, `initial_prompt`) — Worker reicht sie pro Upload an den `whisper/`-Service durch. **Hotwords sind technisch verfügbar, werden aber nicht als Feature angeboten** (siehe Abweichungen).

### Phase 2b.5 — Eigener faster-whisper-Service (`whisper/`)
- [x] FastAPI-Wrapper um `faster-whisper` mit fest verdrahteten Anti-Halluzinations-Params (`condition_on_previous_text=False`, `temperature=0.0`, `vad_filter=True`)
- [x] Endpoints: `POST /asr` (txt/json), `GET /health`, `GET /info`
- [x] Modell `large-v3-turbo` via `WHISPER_MODEL` umschaltbar, Modelle persistent in Docker-Volume
- [x] Hotwords-Support (faster-whisper 1.2.1) als Form-Feld durchgereicht
- [x] Dockerfile (CUDA 12.1 + cuDNN), `docker-compose.yml`, `deploy.sh` (rsync + `/health`-Polling)
- [x] README mit Begründung (Halluzinations-Befund), Build- und Deploy-Anleitung
- [x] Deployed auf minerva:9001, `WHISPER_URL` in `server/.env` umgestellt

### Phase 2b.6 — Alternative ASR-Modelle: Canary-Sweep (`canary/` + `experiments/`)
- [x] Standalone-Service `canary/` (FastAPI auf NVIDIA Canary 1B v2, port 9002 auf minerva). Eigener Docker-Build, deploy-Skript pre-pullt das Base-Image gegen TLS-Cache-Fehler.
- [x] Long-Form-Strategie: buffered inference mit chunk/overlap (siehe `canary/CHUNKING.md`)
- [x] Sweep-Skripte (`canary/sweep.py`, `canary/sweep_chunklen.py`, `canary/sweep_threshold.py`) für single-shot vs. buffered, greedy vs. beam, chunk_len/overlap/batch/precision-Grid
- [x] Probes für Konfidenz und Time-Stamps (`probe_attn_conf.py`, `probe_canary_ts.py`, `probe_ctc_standalone.py`, `probe_tdt.py`); Befund festgehalten in Memory `feedback_asr_confidence_advisory_only`: weder per-token-Confidence noch `dur=0` sind als Halluzinations-Marker brauchbar (Precision ~2.5% auf Real-Korpus)
- [x] Dokumentations-Stub: Suppress-Tokens-Sektion (Memory `project_whisper_suppress_tokens_dead_end`: `\d+` produziert Halluzinations-Loops, `\d` löscht Sequenzen — nicht erneut versuchen ohne anderes ASR-Backend)
- **Status:** Sweep-Track, **kein** Pipeline-Switch. Live-Server fährt weiterhin Whisper (`WHISPER_URL=…:9001`). Canary bleibt Sandbox bis Qualitäts-/Latenz-Vergleich gegenüber Whisper-Turbo eindeutig ist.
- **VRAM-Konflikt** mit Ollama auf minerva (Memory `project_canary_ollama_vram_conflict`): Vor Canary-Start Ollama explizit unloaden — beide passen nicht gleichzeitig in die 12 GB; Whisper + Ollama dagegen koexistieren.

### Phase 2b.7 — Experiments-Sandbox (`experiments/`)
- [x] Standalone-Cargo-Workspace, `path = "../server"`-Dependency (reproduziert Production-Pipeline 1:1 durch direkte Wiederverwendung der Server-Module — kein eigenes HTTP-Re-Implementieren, kein Drift-Risiko zur Live-Pipeline).
- [x] Drei Bin-Targets: `run_full_case` (volle Pipeline ffmpeg → whisper → pre-gazetteer → llm → post-gazetteer, n Runs einer Variante), `run_llm_only` (schneller LLM-Iterations-Pfad auf gazetteer-bereinigten Transkripten — spart Whisper-Calls), `diff_runs` (qualitativer Vergleich zwischen zwei Run-Verzeichnissen).
- [x] Pre-LLM-Gazetteer-Pass auch in `run_llm_only` (Commit ff53d8b) — damit die Sandbox die Production-Pipeline nicht stillschweigend unterschätzt (Memory `feedback_replicate_full_pipeline_in_sandbox`).
- [x] Versionierte Prompt-Iteration unter `experiments/prompts/llm/` (z.B. `v3_treue_konsolidiert.txt` als gewinnende Variante, `v4_treue_kompakt.txt` als verworfen erhalten — `baseline.txt` spiegelt den aktuellen Server-Prompt für Drift-Detection).
- [x] Repo-Hygiene: `case_*/` (Patientendaten, Symlinks zu `tmpdata/`) und `_data/` (Run-Outputs) komplett gitignored. Nur Code und fall-übergreifende Prompt-Hypothesen werden versioniert.

### Phase 3 — Fallverwaltung
- [x] Drei States: Empfangen → Transkribiert → Ausgewertet — rein aus FS abgeleitet (`compute_flags` in `user_web.rs`, kein State-Sidecar)
- [x] State-Übergang: erst "Transkribiert" wenn alle `.m4a` ein passendes `<stem>.json` haben
- [x] "Analysieren" nur möglich, wenn LLM konfiguriert und Fall transkribiert — `handle_analyze_case` in `routes/case_actions.rs`
- [x] Aufnahmen nach case_id zusammenführen — `analysis_input.json` (single, nicht versioniert) enthält alle Transkripte
- [x] Chronologische Sortierung nach UTC-Zeitstempel — lexikographische Filename-Sortierung
- [x] Externes LLM → Dokument generieren — `analyze/`-Modul, OpenAI-kompatibel (`llm.rs`)
- [x] Fall soft-löschen — `.deleted`-Marker (JSON mit Batch-UUID), Undo letzter Batch via `POST /web/cases/undo-delete`
- [x] Einzel-Aufnahme hart löschen — `POST /web/cases/{case_id}/recordings/delete` entfernt `.m4a` + `<stem>.json`, invalidiert `oneliner.json`/`document.md`/`analysis_input.json`; Auto-Trigger regeneriert beim nächsten View-Load (siehe Phase 4)
- [x] Bulk-Aktionen (analyze/delete auf mehrere Fälle gleichzeitig) — `POST /web/cases/bulk`
- [x] Reset-Endpoint — löscht Transkripte/Analyse/Document, re-enqueued alle `.m4a` (inkl. `.m4a.failed` → zurück auf `.m4a`)
- [x] Upload verspätet für gelöschten Fall → `.deleted`-Marker entfernen, Nachtrag normal behandeln
- [x] Auto-Trigger LLM-Analyse (`analyze::auto_trigger`) — Handler-getriggert auf `/web/cases[...]`, Retry-Gate via `.analysis_failed.json` mit `last_recording_mtime`-Signatur, Re-Analyse bei veraltetem `document.md`
- [ ] Concurrency-Schutz: optional Pending-Zähler / Lock — aktuell genügt sequenzieller Worker + WorkerBusy-Flag
- [ ] Upload-Deduplizierung (gleiche case_id + Timestamp → ignorieren)
- [ ] Nachtrag-Erkennung im UI (Transkript neuer als `document.md` → Hinweis im Fall-Detail)
- [ ] Dokument-Versionierung (`document_v{N}.md` o.ä.) — Voraussetzung für Undo
- [ ] Undo-Logik für Dokument-Versionen
- [ ] Lazy Cleanup: `.deleted`-Marker älter als N Tage → Ordner physisch entfernen
- [ ] Retention-Prüfung: `RETENTION_*_DAYS` durchsetzen (derzeit nur in Config)
- [ ] Upload für gelöschte Fälle: explizit ACK `"gone"` statt Marker-entfernen (Rollback vs. Auferstehung — Policy-Entscheidung offen)

### Phase 4 — Webinterface
- [x] Session-Management: kryptographisches Token (256-Bit über 43 Alphanumeric-Zeichen, `OsRng`), Cookie (`HttpOnly`, `SameSite=Strict`, `Secure` via `COOKIE_SECURE`-ENV schaltbar für Dev), serverseitiger `RwLock<HashMap<Token, Session>>`, Ablauf nach 8 h
- [x] `AuthenticatedWebUser`-Extractor: Session-Token → User auflösen, bei ungültiger/abgelaufener Session → Redirect `/web/login`
- [x] Login-Seite (`GET /web/login`, `POST /web/login`), Logout (`POST /web/logout`)
- [x] askama Templates (seit Split 2026-04-19): `login.html`, `my_cases.html`, `case_page.html` (Fall-Übersicht + inline gerendertes Dokument), `case_recordings.html` (Einzel-Transkripte + Audio). `case_detail.html`, `cases.html`, `document.html` sind entfernt.
- [x] Shared Partials: `partials/oneliner.html` (Askama-Macro für alle `OnelinerDisplay`-Zustände, konsumiert von `case_page.html` und `my_cases.html`) und `partials/time_format.js` (Client-TZ-Rendering für UTC-Timestamps).
- [x] Übersicht für Arzt: zwei Sektionen (Offen / Abgeschlossen), plus "Zuletzt gelöscht" mit Undo-Batch
- [x] Fall-Übersicht: `GET /web/cases/{case_id}` rendert `case_page.html` mit Oneliner + Aktionen + inline-gerendertem Dokument (via `pulldown-cmark`); IDOR-geschützt via Session-Slug.
- [x] Einzel-Transkripte: `GET /web/cases/{case_id}/recordings` rendert `case_recordings.html` (ein Eintrag pro Aufnahme, Audio-Link pro Transkript).
- [x] Audio-Streaming: `GET /web/audio/{user}/{case_id}/{filename}` (Cookie-Auth, Arzt eigene Dateien oder Admin) — HTTP-Range-Requests, `Accept-Ranges: bytes`, 206 Partial Content, Duration aus `{ts}.json` (`duration_seconds`-Feld) für HTML5-Player mit Seeking
- [x] Replay-Gain-Normalisierung für Wiedergabe (nicht destruktiv) — verschiedene Erfassungsgeräte liefern stark unterschiedliche Pegel (Watch `MIC` ~-38 dB mean, Desktop ~-27 dB mean). **Umgesetzt 2026-04-27** (Commits 427a28a, 4a7f84d, 710b60b, 99f77b6): `Loudness { mean_db, max_db, gain_db }` lebt in `RecordingMeta.loudness` (`#[serde(default)]` für Backward-Compat) — single atomic write zusammen mit `transcript` + `duration_seconds` ins `<stem>.json`, **kein** separates Sidecar. Audio-Analyse (`server/src/loudness.rs` + `server/src/transcribe/ffmpeg.rs`) läuft via `ffmpeg -af volumedetect` und ist **concurrent** zum Whisper-Call (`tokio::join!`), damit die Pipeline-Latenz nicht steigt. `analyze::analysis_to_meta_fields` ist als pure Funktion extrahiert (handhabt finite/silent/error). Browser appliziert den Gain über Web Audio API `GainNode` (kein Disk-Rewrite, Original + Whisper unberührt); `RecordingView.gain_db` wird im `case_recordings.html`-Template via `data-gain-db`-Attribut nach unten gereicht, gemeinsame `AudioContext`-Instanz spart Ressourcen. Ziel −16 dB mean, Peak-Cap −1 dB. Kombiniert mit `AudioSource.VOICE_RECOGNITION` auf der Watch (besseres SNR). Verworfene Alternativen: `ffmpeg loudnorm` (pumpt + Artefakte), fixes `volume=+XdB` (client-abhängig). Kern-Einsicht: SNR > Loudness an der Source, solange Post-Gain verfügbar ist.
- [x] Fall analysieren — Button in der Fall-Übersicht
- [x] Bulk-Aktionen (alle markierten analysieren / löschen) über `POST /web/cases/bulk` — **admin-only** (`AuthenticatedUser::is_admin()` auf `role == "admin"`, Check am Entry-Handler).
- [x] Purge-Closed (`POST /web/cases/purge-closed`, `confirm=yes` Pflicht) — **admin-only**, entfernt geschlossene Fälle hart, emittiert `CaseEventKind::CasePurged` pro entferntem Case.
- [x] Soft-Delete mit Undo letzter Batch (ohne Bestätigungsdialog)
- [ ] `ValidCaseId`-Extractor: UUID-Validierung als Axum-Extractor (aktuell inline in Handlern)
- [x] Boundary-Input-Validatoren (`server/src/validate.rs`, 2026-04-27): pure Funktionen für Slug-, Magic-Link-Token- und strikten `recorded_at`-RFC3339-Shape; integriert in `handle_login_submit`, `handle_consume`, `handle_upload`. Info-Leak-sensitive Endpunkte folden Shape-Fehler in den vorhandenen Fehlerpfad, Upload reicht 400 mit Field-Name durch. Integration-Test in `server/tests/validate_boundary_test.rs` (167 Zeilen).
- [x] SSE-Endpunkt (`GET /web/events`) — `events`-Modul + `routes::events`, 15 s Keep-Alive, Non-Admins slug-gefiltert, Admins ungefiltert, `Lagged`-Recovery durch Reload
- [x] Vanilla-JS EventSource-Client — debounced `location.reload()` in `my_cases.html`, `case_page.html`, `case_recordings.html`
- [~] Nachtrag-Hinweis bei Aufnahmen nach Abschluss — funktional durch Auto-Trigger abgedeckt (re-analysiert automatisch), sichtbarer „⚠ N neue Aufnahmen"-Banner noch offen
- [ ] Preset-basiertes „Neu generieren" (Preset + Freitext, Freitext max. 500 Zeichen) — hängt an Dokument-Versionierung
- [ ] Undo-Button für Dokument-Versionen — hängt an Versionierung
- [ ] Service-Ausfall-Warnung (faster-whisper/Ollama/LLM > 30 Min.)
- [x] CSRF-Token pro Session: `CsrfForm<T>`-Extractor (`server/src/csrf.rs`) als Drop-in-Ersatz für `Form<T>` auf jeder state-changing `/web/`-POST-Route (bulk, purge-closed, reset, close, reopen, analyze, delete-recording, logout). Constant-Time-Compare via `subtle::ConstantTimeEq` (Timing-Oracle-Schutz). Token wird bei Login **und** Magic-Link-Konsum einmalig gemintet und bleibt für die Session stabil — **keine Per-Request-Rotation** (bricht Multi-Tab-Nutzung, `SameSite=Strict` deckt das übrige Angriffsprofil). Templates binden via `partials/csrf_field.html`-Macro (Hidden Input); `AuthenticatedWebUser` trägt das Token, damit Render-Handler keinen zweiten Store-Lookup brauchen. Login-Route bleibt unbeschützt (Forced-Login-CSRF hat auf diese Codebasis keinen Impact); `/api/...` ist header-auth und exempt. Fehlerpfad: fehlende/abgelaufene Session → `/web/login`-Redirect, malformed body → 400, Token-Mismatch → 403.

**Admin-Features (integriert in die Arzt-UI)**
- [x] `hash-password` CLI (`cargo run --bin hash-password`) — erzeugt bcrypt-Hashes für `users.toml`, mit `toml_edit`-Schreibzugriff ohne Kommentarverlust.
- [x] Admin-Gate auf destruktiven Aktionen: `POST /web/cases/{id}/reset`, `POST /web/cases/bulk` und `POST /web/cases/purge-closed` prüfen `AuthenticatedUser::is_admin()` (`role == "admin"`). Check sitzt jeweils am Entry-Handler (statt pro Sub-Action in `bulk.rs`), sodass neue Bulk-Operationen automatisch geschützt sind. Templates erhalten `is_admin: bool` aus dem ViewModel und blenden Buttons + Purge-Form für Nicht-Admins aus (Defense-in-Depth).
- [~] Das frühere separate Admin-Log (`GET /web/` als flache Cross-User-Liste) ist mit dem Case-Pages-Refactor (2026-04-19) entfernt worden. Admin-übergreifende Sichten sind aktuell nicht implementiert; bei Bedarf als Phase-4-TODO wieder einziehen.

### Phase 5 — Clients (Erfassung + Review)

**Reihenfolge:** 5a (gemeinsame Code-Basis) → 5b (Watch, primäres Entwicklungsziel) → 5c (Handy) → 5d (optional: Data Layer Sync) → 5e (Ausblick: weitere Plattformen).

**Hinweis:** Ein früher Proof-of-Concept (minimale Aufnahme + Upload auf echter Pixel Watch) sollte parallel zu Phase 2–3 stattfinden, um Wear-OS-spezifische Einschränkungen (Doze-Mode, Foreground-Service-Limits, Battery-Optimization) frühzeitig aufzudecken.

**Schneller Server-Test ohne Client:** `scripts/dictate.sh` simuliert den Client-Flow via ffmpeg-PulseAudio-Aufnahme + Upload gegen den laufenden Server (Modi: neuer Fall / aktuellen Fall fortsetzen / refresh). State in `/tmp/doctate-current-case`. Ursprünglich als Hardware-Stand-in entstanden; bleibt nützlich für Iterationen am Server, die keinen Watch-/Desktop-Build-Zyklus rechtfertigen.

#### 5a — Gemeinsame Code-Basis (vor jeder UI-Arbeit)
- [ ] Gradle-Multi-Modul-Projekt aufsetzen (`:core-domain`, `:core-audio`, `:core-sync`, `:core-http`, `:core-storage`, `:app-wear`, `:app-mobile` als Platzhalter) — PoC (2026-04-23) läuft vorerst paket-basiert in einem `:app`-Modul; Split wird spätestens beim Start der Handy-App fällig (siehe Abweichungen → Client-Architektur).
- [ ] `:core-*`-Module strikt UI-frei halten (kein Import von `android.*`/`androidx.compose.*` erlaubt — Build schlägt sonst fehl)
- [~] JVM-Unit-Tests für `:core-domain` und `:core-sync` (case_id-Generierung, ACK-Zustandsautomat, Backoff-Kurve) — Teil-abgedeckt im `:app`-Modul: `CaseIdTest` (UUID-v4-Vertrag) und `UiStateReducerTest` (10 Übergänge inkl. Recorder-Failure). Backoff-Kurve fehlt noch, weil keine Retry-Queue existiert.
- [x] API-Client (`:core-http`): Endpoints typisiert, API-Key im Header, Reqwest-Äquivalent (Ktor oder OkHttp) — implementiert als `net/UploadClient` mit **OkHttp 4.12.0**, Wire-Protocol 1:1 zu `doctate-desktop::server_sync` (heute `clients/desktop/src/server_sync.rs`). Siehe Abweichungen.

#### 5b — Pixel Watch App (primäres Entwicklungsziel)
- [x] Wear OS Projekt in Android Studio (`:app-wear`) — `clients/wearos/` Gradle-Projekt (AGP 9.2, Kotlin 2.2.10, Compose BOM 2024.09, minSdk 30, targetSdk 36). **Single `:app` statt `:app-wear` + `:core-*`.**
- [x] Jetpack Compose UI — PoC-UI umfasst jetzt `CaseListScreen` + `CaseDetailScreen` + `RecordingScreen` plus `AppNav`/`NavCommand`-Navigation. Nur die Datenquelle ist noch Stub (siehe unten).
- [~] Activity-Hauptscreen: Fallliste als `ScalingLazyColumn` + EdgeButton „● Neu" (funktional-analog zum Desktop-Client). Tap auf Eintrag → Fortsetzen; Tap auf EdgeButton → neuer Fall. — UI-Scaffold gebaut (reverse-chronologisch, Auto-Scroll zum neuesten, Auto-Centering deaktiviert, `CaseRow` mit minHeight/Padding/Ellipsis); Tap auf Eintrag öffnet jetzt erst `CaseDetailScreen` (siehe Client-Architektur → Watch-Recording-Flow), „● Neu" überspringt den Detail-Schritt. Datenquelle noch `CaseStoreStub`.
- [~] Singleton-`CaseStore` in Kotlin (Pendant zum Rust-`CaseStore` in `doctate-desktop::case_store`): Snapshot-Flow, `create_local` / `mark_activity` / `merge_server_snapshot` / `reconcile_with_server_snapshot`. Gemeinsame Datenquelle für Activity + TileService. — **PoC: `CaseStoreStub`** (in-memory Singleton, Demo-Seed) speist Activity + Tile + Complication aus derselben Quelle. Snapshot-Flow + Merge-Asymmetrie zum Rust-Pendant stehen noch aus, ebenso die Bindung an Marker-Dateien.
- [~] Tile-Service (`DoctateTileService` via androidx.wear.tiles): ProtoLayout mit drei Tap-Regionen — Mitte (OneLiner → Fortsetzen), oben-rechts (☰ Fälle → Activity/Liste), EdgeButton (● Neu → Activity/Recording). Refresh via `getUpdater().requestUpdate(...)` nach jedem `CaseStore`-Merge. — PoC gebaut (Tap-Regionen: continue-case / list / new-case). Refresh-Trigger nach Merge fehlt, solange der echte `CaseStore` fehlt.
- [~] Complication-Service (`ComplicationDataSourceService`): `SHORT_TEXT` oder `SMALL_IMAGE`, Tap → MainActivity mit `open=list`. Kein OneLiner-Text im Slot. — PoC: `DoctateComplicationService` (SHORT_TEXT „Doctate", Tap → Fallliste).
- [ ] Nach Stop: Screen bleibt auf aktuellem Fall (→ „Fortsetzen" direkt sichtbar) — aktuell: Stop poppt `RecordingScreen` zurück auf `CaseDetailScreen` (oder auf die Liste, wenn „● Neu" den Detail-Schritt übersprungen hat). Der „Fortsetzen"-EdgeButton sitzt dort, kostet aber einen Zusatz-Tap gegenüber der ursprünglichen Plan-Skizze.
- [ ] Post-Stop-Burst-Polling: 2 s Intervall, 60 s Budget, dann Abstieg ins reguläre Intervall; triggert Tile-Refresh bei Oneliner-Treffer
- [x] MediaRecorder-Integration via `:core-audio` — in `audio/AudioRecorder.kt` (MPEG-4/AAC-LC, 16 kHz mono, 64 kbps, `context.cacheDir`). Landet bei künftigem Multi-Modul-Split in `:core-audio`.
- [x] `AudioSource.MIC` → `VOICE_RECOGNITION` umgestellt (2026-04-27, Commit 99f77b6) — Wear-OS-DSP (Noise-Suppression, AGC) liefert saubereres Signal bei niedrigerem Rohpegel. Funktioniert nur zusammen mit serverseitigem Replay-Gain (siehe Phase 4), weil VR allein ~9 dB leiser ist als MIC. Validierung: VR + Replay-Gain klingt deutlich besser als MIC + Replay-Gain (letzteres verstärkt auch Rauschen mit).
- [ ] Bluetooth-Headset Erkennung + Indikator
- [~] case_id (UUIDv4) Generierung: Neu → neue case_id, Fortsetzen → bestehende — PoC erzeugt `CaseId.new()` pro Aufnahme (`domain/CaseId.kt`). Neu/Fortsetzen-Dualität fehlt, weil keine Fallliste-UI existiert.
- [~] Persistente Speicherung im lokalen Dateisystem (UTC-Timestamps in Dateinamen) — PoC speichert in `cacheDir` und **löscht nach Upload**; Datei lebt also nur während der laufenden Pipeline. `unsynced/`-Pattern (überlebt Neustarts) fehlt.
- [x] RFC3339-Timestamp-Erzeugung mit Subsekunden-Strip (`PendingStore.nowRfc3339()`, 2026-04-27): `Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()` als zentrale Quelle für `recordedAt` (`RecordingViewModel.startRecordingFlow` + `finalizeAndUpload`) und `setAt` (`OnelinerState.Manual` in `CaseDetailScreen`). Spiegelt `doctate-common::timestamp::now_rfc3339` 1:1. Regression-Unit-Test `nowRfc3339_has_no_subsecond_component` guard'et gegen erneuten Mikrosekunden-Leak. Hintergrund: `Instant.now().toString()` lieferte plattformabhängig Mikrosekunden, der Server-Filename-Parser fiel dadurch auf `today` zurück → doppelte „Heute"-Header in der Web-UI.
- [ ] Marker-Datei pro Fall (`/recordings/cases/{case_id}.json`): minimales JSON bei Erstellung, Oneliner ergänzen nach Empfang
- [ ] Fallliste aus Marker-Dateien ableiten (`created_at` von heute + optionaler Oneliner)
- [ ] Lazy Cleanup: nur Marker-Dateien (`cases/`) von gestrigen/älteren UTC-Tagen beim Scan löschen — Audiodateien in `unsynced/` werden nie durch Cleanup gelöscht
- [ ] Foreground Sync Service (automatischer Upload) — einziger Verantwortlicher für Audio-Löschung (nur nach ACK)
- [~] ACK-Protokoll: "received" → lokal löschen, "gone" → lokal löschen, kein ACK → Retry — PoC parst die Server-ACK-Response (`UploadResult.Success/Transient/Terminal`), löscht die Datei aber **vor** persistenter Queue. Retry/Backoff fehlt.
- [ ] Exponentieller Backoff bei Fehlern
- [ ] Doze-Modus-Kompatibilität (Netzwerk-Wiederherstellung)
- [ ] Sync-Indikator (☁↑ Anzahl ungesyncter Aufnahmen)
- [ ] (optional) Speicherplatz-Warnung bei >500 MB ungesyncten Daten
- [ ] Stop → direkt in Sync-Queue, ✓-Feedback, Screen bleibt auf aktuellem Fall
- [ ] Oneliner-Polling (alle paar Sekunden für den sichtbaren Fall, bis Oneliner empfangen)
- [~] Emulator-Tests + Pixel Watch Hardware-Test — vertikaler PoC im Emulator **und auf realer Pixel Watch 2** (ADB-over-WiFi, LAN-Dev-Server) manuell verifiziert (Aufnahme → Upload → Whisper → Ollama → `document.md`). Instrumented MockWebServer-Test (`UploadClientTest`) hängt auf Wear-OS-34-AVD (siehe Abweichungen). Vollständige Feldtests (LTE-Modus, Akku-Lauf, Doze-Modus, Langzeit-Foreground-Service) stehen weiter aus.

#### 5c — Android-Handy-App (nach Watch-Abschluss)
- [ ] `:app-mobile`-Modul auf bestehende `:core-*`-Basis aufsetzen
- [ ] Erfassungs-UI (Compose Material 3): Fallliste, Neu/Fortsetzen, Recording-Screen — funktional-identisch zur Watch, aber an Handy-Ergonomie angepasst
- [ ] Alle Erfassungs-Features via `:core-*` (keine Duplizierung von Sync/Queue/ACK/Marker-Logik)
- [ ] Review-UI: Erste Ausbaustufe als WebView auf `/web/...`-Endpoints (schneller, kein doppelter Code, automatisch synchron zum Browser-UI)
- [ ] Auth-Integration für `/web/...` (siehe „Offene Entscheidungen": Handy-App-Auth)
- [ ] **Invariante prüfen:** kein lokaler Archiv-Cache für Transkripte/Audios außerhalb des aktiven Upload-Vorgangs
- [ ] End-to-End-Test: Erfassung Handy → Server → Review in Handy-App

#### 5d — Data Layer Sync zwischen Watch und Handy (optional, später)
- [ ] `DataClient` (Wear OS Data Layer API) in `:core-storage` einbetten
- [ ] Nur Marker-Dateien syncen (keine Audios — Audio-Upload bleibt beim Aufnahmegerät)
- [ ] Tombstone-Strategie für „gestern gelöscht" (TTL ~25 h, um Auferstehung zu vermeiden)
- [ ] Test: Watch + Handy offline → beide nehmen auf → bei erneuter Bluetooth-Verbindung gleicht sich die Liste ab

#### 5e — Desktop-Clients (in Arbeit, nativer Rust-Stack)
- [x] Tech-Stack entschieden: `eframe`/`egui` + `tokio` + `reqwest` + `ffmpeg`-Subprozess (nicht Tauri/Compose)
- [x] Cargo-Topologie: drei isolierte Build-Welten (`server/`, `clients/desktop/`, `experiments/`) + lib-only `common/`. Bis 2026-05-02 ein gemeinsamer Root-Workspace; aufgelöst zur Lock-Isolation Server vs. Client-GUI (Container-Build sieht keine eframe/egui/winit-Drift mehr). Siehe Abweichungen → Client-Architektur.
- [x] Headless Client-Logik (Case-Store, Server-Sync, Upload, Poller, Cleanup) — bis 2026-05-02 in der Lib `doctate-client-core`, seitdem als reguläre Module direkt in `clients/desktop/src/` (in `lib.rs` als `pub mod` re-exportiert)
- [x] Linux-Desktop: Config, Recorder (SIGINT-Stop), Uploader mit Retry + persistenter Queue, State-Machine, UI mit Fehlerpfad
- [x] Single-Instance-Lock (PID-File, verhindert konkurrierende Pending-Queue-Zugriffe)
- [x] Snapshot-Cache-Persistenz (Poller-State überlebt Neustart, kein Flash-Fehlzustand)
- [x] Oneliner-Poller + Fall-Liste im Client (ETag-basiert gegen `/api/oneliners`; Delete-Watermark respektiert)
- [x] Startup-Cleanup für stale Markers / Orphans (`pending_cleanup`)
- [x] Pending-Upload-Indicator + `last_failure`-Anzeige im Footer-Status
- [x] Magic-Link-Login („Im Browser öffnen"-Button tauscht API-Key gegen Einmal-Token)
- [x] Integration-Test für Recorder (`lavfi`-Silence, `#[ignore]`, verifiziert SIGINT-Graceful-Stop + `ftyp`-Magic)
- [x] Integration-Tests für Magic-Link-Flow und Single-Instance-Lock
- [ ] Windows-Desktop: `dshow`-Device-Enumeration; ffmpeg-Installationspfad (winget vs. Bundle)
- [ ] Tray-Icon (zurückgestellt; später via `ksni`, weil `tray-icon` zu viele System-Deps mitzieht)
- [ ] iOS-App (SwiftUI, Ausblick; würde die Headless-Module aus `clients/desktop/src/` via UniFFI konsumieren — bei zweitem Rust-Konsumenten wandern die Module in eine separate Lib zurück)
- [x] Invariante gewahrt: jeder Client = weiterer HTTP-Client, kein Server-Code-Ausbau nötig

### Phase 6 — Integration & Testing
- [~] End-to-End Test (Watch → Server → Webinterface) — PoC-Pfad auf realer Pixel Watch 2 (2026-04-23, ADB-over-WiFi, LAN-Server) einmalig durchgelaufen; automatisierter Testlauf steht aus.
- [~] End-to-End Test (Linux-Desktop → Server → Webinterface inkl. Oneliner-Poll-Refresh und Magic-Link-Handoff in den Browser) — im Alltagsbetrieb validiert, aber kein dedizierter automatisierter E2E-Testlauf
- [~] SSE Live-Updates testen — Unit-Tests im `events`-Modul (Capacity, Lagged, Subscriber-Fanout), `server/tests/sse_integration.rs` + `server/tests/sse_cleanup_test.rs` (Connection-Pool-Leak)
- [~] Pixel Watch Hardware-Test — erster Hardware-Lauf am 2026-04-23 erfolgreich (Pairing, Install, Aufnahme, Upload, Transkript). Ausstehend: LTE-Modus, Doze-Modus, Langzeit-Foreground-Service.
- [ ] LTE-Modus testen
- [ ] Bluetooth-Headset testen
- [ ] Sync-Service testen (Netzwerkausfall, Neustart, Doze)
- [ ] Stop-und-Sync-Flow testen
- [ ] Korrektur-Diktate testen (LLM löst Widersprüche korrekt auf)
- [ ] faster-whisper-Ausfall simulieren + Warnung prüfen
- [ ] Ollama-Ausfall simulieren + Warnung prüfen
- [~] GPU-Phasen-Wechsel testen (Whisper → Ollama → Whisper, VRAM-Freigabe) — obsolet, Phasen-Modell existiert nicht mehr; nur relevant beim Zurückkehren zu großen Modellen
- [ ] Queue-Recovery nach Server-Neustart testen
- [ ] Upload-Deduplizierung testen
- [ ] Concurrent Uploads: mehrere Aufnahmen gleichzeitig für denselben Fall → State-Übergang erst nach letzter Transkription
- [ ] Mehrere Ärzte testen
- [ ] IDOR-Test: eingeloggter Arzt A versucht `case_id` von Arzt B aufzurufen → 404, kein Datenleck
- [ ] Session-Ablauf testen: nach 8 h → Redirect zu Login, SSE-Stream geschlossen
- [x] CSRF-Test: POST ohne gültiges CSRF-Token → 403 (`server/tests/csrf_attack_test.rs`). Zusätzlich: Safety-Net-Test „jede gerenderte Seite enthält das aktive Session-Token als Hidden Input" (fängt Formulare, die das Macro vergessen), und Happy-Path-Roundtrip (Seite abrufen → Token aus HTML parsen → POST → 303) fängt Drift zwischen gerendertem und akzeptiertem Token-Format. Security-Header-Layer hat eigene Regressionstests in `server/tests/security_headers_test.rs` (10 Attack-Confirming + 2 Anchor-Tests).
- [ ] Path-Traversal-Test: `case_id` mit `../` → 400
- [ ] Ungültige `case_id` (kein UUID) → 400
- [ ] Lazy Cleanup testen (Watch: nur Marker-Dateien von gestern gelöscht, Audiodateien in unsynced/ bleiben)
- [ ] Lazy Cleanup testen (Server: markierte Fälle am Folgetag entfernt?)
- [ ] Retention-Prüfung testen (Audio/Transkripte nach RETENTION_*_DAYS)
- [ ] Undo/Regenerate testen
- [ ] Fall zum Entfernen markieren → am Folgetag gelöscht
- [ ] Nachträglicher Upload nach Fallabschluss → Nachtrag-Hinweis im Webinterface
- [ ] Upload für gelöschten Fall → ACK "gone", Watch löscht lokal
- [ ] Verspäteter Upload für soft-deleted Fall → `.deleted`-Marker entfernt, Nachtrag verarbeitet (Logik implementiert, End-to-End-Test offen)
- [ ] Edge-Case: Watch über Nacht/Wochenende offline → Aufnahmen bleiben in unsynced/, Sync bei Wiederherstellung

---

## Offene Entscheidungen

| Thema | Status |
|---|---|
| Whisper Modell | **Entschieden:** `large-v3-turbo` via eigener `whisper/`-Service, float16, optional Hotwords per Request. Qualitativer Vergleich gegen `large-v3` zeigte: gleichwertig bei Fachvokabular, nur Hotwords bringen echte Fehlerreduktion. Deutsches Fine-Tuning (primeline) getestet, lieferte schlechtere Ergebnisse. Umschaltbar via `WHISPER_MODEL`. |
| Alternative ASR | **Sandbox-Track:** `canary/`-Service auf NVIDIA Canary 1B v2 (port 9002) inkl. Sweep-Skripten für single-shot/buffered + chunk/overlap/precision-Grid. Probes für token-Confidence und `dur=0`-Halluzinations-Marker liefen, Befund: weder Confidence noch `dur=0` ist als Halluzinations-Filter brauchbar. Live-Pipeline bleibt auf Whisper, bis Canary-Vergleich qualitativ entscheidet. VRAM-Konflikt mit Ollama: Canary-Start setzt Ollama-Unload voraus. |
| Ollama Modell | **Entschieden (vorläufig):** `gemma4:latest` (läuft). Llama 3.1 8B als Option, wenn Oneliner-Qualität nicht reicht. |
| Ionos Modell | Noch zu evaluieren |
| Watch Hardware | Pixel Watch 2 oder 3 (LTE empfohlen) |
| Authentifizierung Browser | Entschieden: Serverseitiges Session-Token (256-Bit, Cookie mit `Secure`/`HttpOnly`/`SameSite=Strict`), Ablauf 8 h. User-Identität ausschließlich aus Session, nie aus URL (IDOR-Prävention). Login gegen User-Passwort in `users.toml`, **oder** passwortlos per Magic-Link (API-Key → Einmal-Token via `POST /api/auth/magic-link` → `GET /web/magic?token=…`, TTL 60 s, one-time-use, `return_to` per Whitelist auf `/web/`-Pfade beschränkt). |
| Prompt-Qualität | Erfordert Testing mit echten Diktaten |
| Transkript editierbar? | Read-only oder editierbar vor Abschluss — offen |
| Sicheres Löschen | Reicht rm oder Overwrite nötig? — offen |
| faster-whisper HTTP-API | **Entschieden:** eigener FastAPI-Wrapper (`whisper/`). Grund siehe Phase 2b.5 / Abweichungen. |
| Recovery-UI (Webinterface) | Dedizierte Ansicht für verspätet eingetroffene Aufnahmen (Anhören / Ins Dokument / Verwerfen) — optional, Bedarf im Echtbetrieb evaluieren |
| Handy-App-Authentifizierung | Aktuell drei Mechanismen verfügbar: API-Key für `/api/...` (Erfassung), Cookie-Session für `/web/...` (Review), Magic-Link (API-Key → Browser-Session ohne Passwort). Varianten für die Handy-App: (a) alle parallel, (b) API-Key für alles vereinheitlichen (Cookie-Pfad erweitern oder Token-basiert), (c) WebView mit Cookie-Login für Review + API-Key für Upload, (d) Magic-Link aus der App heraus → eingebetteter WebView übernimmt Session. Entscheidung zu Beginn von Phase 5c. |
| Weitere Client-Plattformen (Linux/Windows/iOS) | Tech-Stack-Kandidaten: Compose Multiplatform (Code-Sharing über Android hinaus), Tauri + Rust (Desktop, kleine Binaries), nativ (SwiftUI). Entscheidung nach Handy-App-Erfahrung; Server-seitig keine Änderungen nötig (Client-Invariante). |

---

## Nicht im Scope (vorerst)

- Apple Watch (iOS-App für iPhone/iPad ist in Phase 5e als optionaler Ausblick enthalten)
- Praxissoftware-Integration (Medical Office / GDT)
- Multi-Tenant / Cloud-Hosting
- Echtzeit-Transkription
- Automatische Patientenzuordnung

**Hinweis zu Client-Plattformen:** Android-Handy, Linux-/Windows-Desktop und iOS sind nach dem [Client-Prinzip](#client-prinzip) explizit *erlaubt* — ihre Umsetzung ist in Phase 5c bzw. 5e geplant, aber zeitlich nach der Watch-App priorisiert.

---

## Abweichungen von der Originalplanung

Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche Idee bleibt erhalten, damit spätere Entscheidungen nachvollziehbar bleiben.

### Infrastruktur / Toolchain

| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| Rust-Edition + Crate-Versionen | edition 2021, axum 0.7, tower-http 0.5, axum-extra 0.9 | edition 2024, axum 0.8, tower-http 0.6, axum-extra 0.12 | Edition 2024 erlaubt native `async fn` in Traits; axum 0.7 nutzt `#[async_trait]`, inkompatibel |
| STT-Service | `lscr.io/linuxserver/faster-whisper` (Wyoming-Protokoll) → `onerahmet/openai-whisper-asr-webservice` → **eigener `whisper/`-FastAPI-Wrapper** | Wyoming ist binäres TCP, ungeeignet. Kein verfügbarer HTTP-Wrapper reicht `condition_on_previous_text=False` + feste `temperature=0.0` durch; Tests zeigten 2/13 Runs mit katastrophalen Halluzinationen (russisch/chinesisch). Eigener Service hardcodet die Anti-Halluzinations-Params. | Hardware-Tests auf echter Pipeline |
| Whisper-Port | 8100 (Plan) → 10300 (ahmetoner-Container) → **9001** (eigener Service) | freier Port auf minerva; alter Container optional parallel belassen |
| Whisper-Modell | `large-v3` int8 | `large-v3-turbo` float16 | Gleiche Qualität auf europäischen Sprachen, ~1.6 GB statt ~3 GB VRAM — ermöglicht Koexistenz mit Ollama im 12-GB-VRAM |
| Ollama-Modell | `gemma3:4b` | `gemma4:latest` | Neuere Version beim Aufsetzen verfügbar; Llama 3.1 8B als Fallback, falls Oneliner-Qualität nicht reicht |
| m4a-Preprocessing | nicht erwähnt | Server remuxt m4a mit `-movflags faststart` vor Whisper-Aufruf | Bekannter Bug ([Issue #97](https://github.com/ahmetoner/whisper-asr-webservice/issues/97)): m4a > 20 s liefern leere Transkripte, weil Android `MediaRecorder` das `moov atom` ans Dateiende schreibt. `faststart`-Remuxing (verlustfrei, < 1 s) behebt es. ffmpeg wird im Axum-Container mitgeliefert. |
| GPU-Phasen-Worker | Strikter Whisper↔Ollama-Wechsel, Ollama `keep_alive=0` | Zwei unabhängige sequentielle Worker (transcribe + analyze), beide Modelle parallel im VRAM, `OLLAMA_KEEP_ALIVE=300` | Dank Turbo-VRAM-Footprint passen beide gleichzeitig in die GPU. Spart Modell-Reload (~2 s pro Oneliner). Phasen-Wechsel bleibt als Fallback für größere Modelle (`large-v3`, Llama 3.1 8B) dokumentiert. |
| Analyse-Queue | `mpsc::channel(depth=32)` (bounded) | `mpsc::unbounded_channel` | `AnalyzeJob` ist ~110 Bytes; selbst 100 000 Jobs ≈ 11 MB. Persistenz lebt in `analysis_input.json` auf Platte, nicht in der Queue. Unbounded spart den 503-Pfad. |
| LLM-Provider-Naming | `ionos.rs`, `IonosError`, `IonosSettings` | `llm.rs`, `LlmError`, `LlmSettings` | OpenAI-API-kompatibel — Ionos ist durch Azure/OpenAI/Together.ai ersetzbar. Provider lebt nur in `.env`. |

### Daten-Modell und State

| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| Filesystem-Layout | `/data/{arzt}/open/{case_id}/` bzw. `done/{case_id}/` | Flach: `/data/{slug}/{case_id}/` | State aus Dateimarkern ableiten statt aus Ordnerlage. Migration zwischen `open`/`done` war ein zusätzlicher atomarer Schritt mit eigenem Crash-Pfad. |
| Terminologie | "Arzt/Ärzte" | "User/slug" | Rollen-System: auch MTAs und Admins (`role`-Feld in `users.toml`) |
| Case-State-Machine | Nicht vorgesehen | **Verworfen** (nach Live-Test): explizite State-Machine + `state.json`-Sidecar war kurzzeitig gebaut, erzeugte aber sofort Datendrift bei Schema-/Code-Änderungen. FS bleibt einzige Wahrheitsquelle. | Recovery hätte Self-Healing-Checks gegen die FS-Invariante gebraucht — `state.json` wäre dann eine überflüssige Cache-Kopie. Siehe „Design-Prinzipien: Filesystem ist SoT". |
| Soft-Delete | `.remove`-Marker (leere Datei) + Lazy Cleanup am Folgetag | `.deleted`-JSON-Marker `{batch, deleted_at}` + explizites „Undo letzter Batch" via `POST /web/cases/undo-delete` | Mehrere in einem UI-Klick gelöschte Fälle bilden eine Batch, dezidiertes Undo ist besser testbar als Zeit-Fenster. Physisches Cleanup älter als N Tage bleibt als TODO. |
| Erster Upload neue case_id | ACK „gone" | Neuer Fall anlegen, ACK „received" | Sonst würde der allererste Upload einer neuen case_id immer abgelehnt. „gone" aktuell gar nicht implementiert — auch Uploads für soft-deleted Fälle reaktivieren den Fall (Marker entfernen + Nachtrag). |
| Dokument-Versionierung | `document_v{N}.md` + `current`-Symlink | **Single-Version** `document.md`; Re-Analyze überschreibt in-place | Versionierung + Undo sind aktuell nicht benötigt — Re-Analyze ist selten, der Arzt hat den Markdown-Export in der Hand. Als Phase-4-TODO behalten (Voraussetzung für Preset-System). |
| Analyse-Input-Format | Zusammengeführter Markdown-Text als Prompt | Single `analysis_input.json` mit `{last_recording_mtime, recordings[{recorded_at, text}]}` | Persistenz und LLM-Prompt entkoppelt. Prompt wird zur Call-Zeit aus dem JSON gerendert. Schema-Evolution bleibt billig, ohne Prompt-Format zu brechen. Keine Versionierung der Input-Datei — sie ist ephemeral und wird nach erfolgreichem Dokument-Write entfernt. |
| Oneliner-Persistenz | Plain-Text `oneliner.txt` (Sentinel: Datei fehlt/leer → nicht generiert) | `oneliner.json` mit internally-tagged Enum `OnelinerState::{Ready, Empty, Error}` + `generated_at`-Timestamp | Silence-Rule des Ollama-Prompts macht „leer" zu einem **gültigen** Ergebnis (kein medizinisches Schlüsselwort im Transkript). Vorher war nicht unterscheidbar, ob „leer" bedeutet „noch nie versucht", „gerade gescheitert" oder „bewusst leer" — Recovery hätte endlos retryed. `OllamaError::EmptyResponse` persistiert als `Empty` und bricht den Retry-Loop sauber ab; UI rendert dafür einen eigenen Zustand („kein medizinischer Inhalt"), während `Error` eine Fehleranzeige zeigt. |
| Audio-Loudness-Persistenz | Geplant als separates Sidecar `<stem>.loudness.json` (eigener atomic write, Lazy-Backfill in `scan_recordings`-JoinSet) | **In `<stem>.json` integriert:** Field `loudness: { mean_db, max_db, gain_db }` als optionaler Teil von `RecordingMeta` mit `#[serde(default)]`. Single atomic write zusammen mit Transcript + Duration. Audio-Analyse (`server/src/loudness.rs` + `transcribe/ffmpeg.rs::probe_loudness`) läuft via `tokio::join!` parallel zu Whisper. Mapping pure: `analyze::analysis_to_meta_fields`. | Zwei Writes für ein Recording sind zwei Failure-Modi und zwei Recovery-Pfade — `RecordingMeta` ist sowieso Single-Write am Pipeline-Ende, also kommt Loudness ins gleiche JSON. `tokio::join!` verbindet zwei externe Calls, deren dominanter (Whisper) die Latenz definiert: Loudness ist bei jedem Audio kostenlos zu haben. `#[serde(default)]` hält ältere `<stem>.json` ohne Loudness lesbar — Pre-Production-Migration ist trotzdem überflüssig (Memory `project_pre_production_no_migration`). Browser appliziert `gain_db` über Web-Audio-API `GainNode`; Originaldatei bleibt unverändert (Whisper-Input und Replay sind getrennt). |

### Pipeline und LLM-Nutzung

| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| Gazetteer (Post-AI) | Nicht vorgesehen | Deterministische Terminologie-Normalisierung auf **jeder** KI-Ausgabe (Whisper, Oneliner, Analyse). Damerau-Levenshtein ≤ 2 über kuratiertes Vokabular | LLMs haben starke Training-Priors, die Kontext-Hints überstimmen (z.B. `Amiodarone` statt `Amiodaron`). Ein Pre-LLM-Hint-Kanal kann das nicht fangen, weil der Drift im Output passiert. Post-AI fängt Whisper-Typos und LLM-Drift in einem Mechanismus. |
| Gazetteer-Dict-Veto | Nicht vorgesehen | Hunspell-kompatibler `spellbook`-Crate veto'ed Rewrite, wenn Token ein gültiges deutsches Wort ist | `Kaktus` (DL=2 zu `Lantus`) darf nicht zu Medikamentennamen umgeschrieben werden. Flexions-Expansion via `.aff` fängt `Kakteen` ohne expliziten Eintrag. Lazy Lookup (nur bei DL-Kandidaten) → vernachlässigbarer Overhead. |
| LLM-Annotation `==text==` | Nicht vorgesehen | Analyse-Prompt fordert, unsichere/korrigierte Stellen mit `==text==` zu umschließen | Dem Arzt expliziten „bitte prüfen"-Hinweis geben. Der Gazetteer respektiert diese Markierungen und normalisiert Inhalt darin nicht. |
| Oneliner-Semantik | „Einmalig beim ersten Transkript, danach unveränderlich" | Regeneriert am **Batch-Ende** aus **allen** Transkripten (`has_pending_recordings == false`). Spätere Aufnahmen dürfen korrigieren (symmetrisch zur Analyse-LLM-Regel). | Der Arzt hält den Oneliner für mutierbar: „Korrektur: das Mittel heißt Vomex" soll auch im Oneliner landen. Batch-Ende verhindert, dass wir mitten in einem Upload-Burst LLM-Calls verbrennen, die der nächste Job wieder überschreibt. |
| Recovery-Scans | Nur „fehlende Transkripte bei Start" | Drei parallele Scans bei Boot: Transkripte, **Oneliner**, Dokumente | Ein früherer Lauf kann zwischen Transkript-Write und Oneliner-Write crashen → Startup-Scan holt das auf, bevor der Arzt die Watch öffnet. |
| Analyse-Trigger | Arzt klickt „Analysieren" | `analyze::auto_trigger` läuft auf jedem `/web/cases[...]`-Handler-Aufruf; SSE-Reloads sind der natürliche Puls. Manueller Button bleibt als „erzwingen". | Jeder Reload liefert aktuellen FS-Stand → warum den Arzt einen Knopf drücken lassen, wenn der Server anhand reiner FS-Stats entscheiden kann? Retry-Gate `.analysis_failed.json` (mit `last_recording_mtime`-Signatur) verhindert Retry-Loops bei fehlgeschlagenen LLM-Calls, bis neue Eingaben eintreffen. |
| Event-Bus + SSE | Nicht vorgesehen | `events::channel` (tokio `broadcast`, Cap 256), Worker und Route-Handler emittieren `CaseEvent`s; SSE-Route streamt an Browser-Tabs; Client macht debounced `location.reload()` | FS bleibt SoT — Events sind **Trigger**, nicht State. Browser-Diffing entfällt komplett: Re-Render vom aktuellen FS-Stand ist billiger und trivial korrekt. `Lagged(n)` für langsame Subscriber wird durch den Reload automatisch resynchronisiert. |

### UI / Entwickler-Tools

| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| User-Verwaltung | `API_KEY_*` / `WEB_PASSWORD_*` in `.env` | Separate `users.toml` + `hash-password`-CLI mit `toml_edit` | Neue User ohne `.env`-Änderung; CLI aktualisiert `users.toml` kommentar-erhaltend; Rollen-System (doctor, mta, admin) |
| LLM-Gate im UI | Nicht vorgesehen | „Analysieren"-Button nur sichtbar, wenn `LLM_URL`/`LLM_API_KEY`/`LLM_MODEL` alle gesetzt; Handler hat denselben Guard (503 Defense-in-Depth) | Ohne Provider würde der Close zur Laufzeit an einem `reqwest`-Fehler scheitern. `Config::llm_configured()` macht das Gate explizit. |
| Bulk-Aktionen | Nur „Alle abschließen" im UI angedacht | `POST /web/cases/bulk` mit mehreren markierten Fällen, Aktion `analyze` oder `delete` | Realer Workflow: Arzt räumt am Tagesende mehrere Fälle gleichzeitig ab. |
| Reset-Endpoint | Nicht vorgesehen | `POST /web/cases/{id}/reset` löscht Transkripte/Oneliner/Analyse/Document und re-enqueued alle `.m4a` (inkl. `.m4a.failed` → zurück auf `.m4a`) | Debug-Tool während der Entwicklung; hilft bei Prompt-Iteration und Gazetteer-Tuning, ohne den Case neu aufzunehmen. |
| Audio-Streaming-Route | Nicht vorgesehen | `GET /web/audio/{user}/{case_id}/{filename}` mit Cookie-Auth (Arzt eigene Audios, Admin alle) | Ermöglicht das direkte Anhören im Browser — unverzichtbar für Plausibilitätsprüfung bei Gazetteer/LLM-Fehlern. |
| Audio-Seeking | Nicht vorgesehen | HTTP-Range-Requests in `handle_audio` (`parse_range` + `serve_range`), `Accept-Ranges: bytes`, 206 Partial Content; Duration aus `{ts}.json` (`duration_seconds`-Feld, ffprobe auf der remuxten Kopie, ~ms) für Player-Rendering ohne HEAD-Roundtrips | HTML5-`<audio>`-Player brauchen Range für Seek ohne Re-Download. Single-write der Recording-Metadaten (transcript + duration in einem atomaren JSON) spart den Extra-HEAD pro Zeile; während des Whisper-Fensters füllt ein read-only `ffprobe`-Backfill in `scan_recordings` die Lücke. |
| Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `GET /web/` (flache Liste aller Fälle) | Gebaut, bevor Session/States/Fall-Detail existierten, um die Pipeline während Entwicklung inspizieren zu können. Soll später hinter `role = "admin"` geschützt werden. |
| Test-Client für Watch-Flow | Erst ab Phase 5 mit Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + c/n/r/q-Loop) | End-to-End-Tests ohne Pixel-Watch-Hardware. |
| Hotwords (Whisper) | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` **im Code**, aber nicht als Feature angeboten | Regress-Lauf über 10 Fixtures zeigt **keinen** Vorteil (ohne 12/257 Wortfehler, mit 14/257). Hotwords schluckten Funktionswörter. Leitung bleibt durchverdrahtet, bewerben wir aber nicht — re-evaluieren bei konkretem Bedarf. |
| Test-Infrastruktur | Jede Integrationstest-Datei brachte eigenen Harness mit (User-Factories, TestConfig-Builder, Login-Flow inline) | **Shared-Modul `server/tests/common/`** (2026-04-22/23): `config`, `http`, `paths`, `seed`, `session`, `users`, `artefacts`. 95 Tests migriert, ~780 Zeilen Duplikat entfernt, neue Tests starten mit drei Zeilen statt achtzig. | Beim Einführen von CSRF mussten alle POST-Tests durch `common::session` den Login→CSRF→POST-Flow abwickeln — ad-hoc-Harnesses hätten jede Testdatei gleichzeitig umgebaut. Zusätzlicher Gewinn: `health_test` nutzte vorher einen hartcodierten `/tmp/doctate-test`-Pfad, der bei parallelen `cargo test`-Läufen kollidierte; das einheitliche Unique-Tmpdir-Pattern hat diese Flake entfernt. |
| Konsolidierung wiederholter Muster | Bulk-Action-Literals, URL-Assembly, Case-Artefakt-Dateinamen als inline-Strings an 10+ Stellen | Drei Helper-Gruppen in `doctate-common` + `server/tests/common/artefacts.rs` (siehe Cargo-Workspace-Layout): `BulkAction`-Enum, `join_url`, und zentral re-exportierte Artefakt-Filenames (`ONELINER_FILENAME`, `DOCUMENT_FILE`, `ANALYSIS_INPUT_FILE`, `CLOSE_MARKER`). Zusätzlich in `clients/desktop/`: `paths::project_path`-Helper, `main::or_die`-Helper, `RecordingContext`-Struct statt `(Uuid, String)`-Tupel. | Rename oder Wire-Format-Tweak bearbeitet jetzt eine Datei statt 10+. `BulkAction`-Match ist exhaustive — neue Actions fallen beim Kompilieren auf. |

### Authentifizierung

| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| Browser-Login | Nur Passwort-Formular gegen `users.toml` | Zusätzlich Magic-Link: `POST /api/auth/magic-link` (API-Key) → `GET /web/magic?token=…` (60 s TTL, one-time-use, `return_to` auf `/web/`-Pfade whitelisted, `Referrer-Policy: no-referrer`) | Der Desktop-Client hat den API-Key ohnehin, das Passwort separat einzutippen ist Friktion ohne Sicherheitsgewinn. Flow nutzt den vorhandenen `AuthenticatedUser`-Extractor, sodass Policy an einer Stelle bleibt. |
| Admin-Gating für destruktive Aktionen | `Reset`/`Bulk` für jeden eingeloggten Arzt | `AuthenticatedUser::is_admin()` (`role == "admin"`) — Handler für `POST /web/cases/{id}/reset`, `POST /web/cases/bulk` und `POST /web/cases/purge-closed` rejecten Nicht-Admins. Der Admin-Check sitzt am *Entry-Handler* (`bulk.rs::handle_bulk`, nicht pro Sub-Action wie `bulk_analyze`/`bulk_delete`), damit eine neu hinzugefügte Bulk-Operation nicht versehentlich ungeschützt bleibt. Templates blenden Buttons und die Purge-Form via `is_admin: bool`-Feld aus (Defense-in-Depth). | Reset wirft alle Transkripte weg, Bulk kann viele Fälle löschen, Purge entfernt geschlossene Fälle hart. Admin-Gate schützt vor Fat-Finger während der Entwicklung und bleibt auf Dauer sinnvoll. Authorization am Rand (Entry-Handler statt Sub-Action) ist robuster gegen Code-Drift. |

### Sicherheit / Hardening

| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| CSRF-Schutz | Als Phase-4-TODO skizziert („Token pro Session, Hidden Field, serverseitige Validierung") | **Implementiert (2026-04-23):** `CsrfForm<T>`-Extractor mit `subtle::ConstantTimeEq`, Token bei Login + Magic-Link-Konsum gemintet und **nicht rotiert**, Askama-Macro (`partials/csrf_field.html`) für alle state-changing Forms. Login bleibt unbeschützt, `/api/...` ist exempt. | Per-Request-Rotation würde Multi-Tab-Nutzung brechen und bringt gegenüber `SameSite=Strict` keinen realen Zusatzschutz. Constant-Time-Compare schließt Timing-Oracles am Token aus. Login-Exempt: Forced-Login-CSRF hat auf diese Codebasis keinen Impact (kein Konto-Merge, keine verknüpften Aktionen). `/api/...`-Exempt: Header-Auth (`X-API-Key`) ist nicht cookie-gebunden → kein CSRF-Vektor. |
| Security-Header | Im Plan als nginx-Aufgabe ausgewiesen (Phase 1) | **Zweischichtig:** Axum-Layer (`tower-http::SetResponseHeaderLayer` in `create_router_with_state`, `if_not_present`-Mode) + geplanter nginx-Layer bei TLS-Terminierung. HSTS bewusst ausgelassen. CSP enthält `'unsafe-inline'` für Inline-JS/CSS in Templates. | Der Axum-Layer schützt auch dann, wenn nginx wegfällt (Dev-Direkt-Zugriff, Proxy-Misconfig) oder wenn Integrationstests die Response-Shape prüfen — der Layer sitzt deshalb in `create_router_with_state`, nicht in `main.rs`. `if_not_present` erlaubt Per-Route-Overrides (z.B. Magic-Link setzt eigene `Referrer-Policy`). HSTS auf Plain-HTTP-Deployment ist irreversibel (gecachter `max-age` sperrt den Browser aus, sobald TLS mal ausfällt) — erst mit stabiler TLS-Terminierung aktivieren. `'unsafe-inline'` ist eine bewusste Schuldenposition: revisiten, sobald User-Input unescaped gerendert wird. |
| Boundary-Input-Validierung | Im Plan nur als Phase-4-TODO „Input-Validierung" (case_id-UUID, inline in Handlern) skizziert | **Zentralisiert (2026-04-27):** `server/src/validate.rs` als pure-Funktion-Modul für Slug-Shape (Login), Magic-Link-Token-Shape (Consume) und strikter `recorded_at`-RFC3339 (Upload, exakt 20 Bytes, kein Subsekunden-Anteil). Info-Leak-sensitive Routen folden den Shape-Fehler in ihren Standard-Fehlerpfad (Login → „Login fehlgeschlagen", Magic-Link → `/web/login`-Redirect); Upload macht 400 mit Field-Name. `case_id`-Pfad bleibt inline (`ValidCaseId`-Extractor weiterhin Phase-4-TODO). | Zwei verzahnte Anlässe: (1) Subsekunden-Drift im `recorded_at` von Watch-Clients (`Instant.now().toString()` ohne Truncate) hat den Filename-Parser für Tag-Gruppierung gebrochen — sichtbar als doppelte „Heute"-Header. (2) Slug + Magic-Link-Token wurden bisher direkt an User-/Store-Lookup gereicht, ohne Shape-Vorprüfung — kein konkreter Bug, aber Path-Traversal- und Info-Leak-Defense-in-Depth. Pure-Funktion + zentrales Modul: Regression-Test wandert mit dem Validator, nicht mit dem Handler; folding-vs-400 entscheidet jeder Caller selbst, sodass Info-Leak-sensitive Endpunkte den Standard-Fehlerpfad behalten. Strikte `recorded_at`-Spezifikation (20 Bytes statt „RFC3339-toleriert") fängt den Watch-Drift am Server-Boundary, parallel zum Truncate-Fix in `PendingStore.nowRfc3339()` (siehe Abweichungen → Client-Architektur). |

### Client-Architektur

| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| Repository-Layout | `server/` standalone + `watch/wearos/` separat | **Drei isolierte Cargo-Build-Welten** (2026-05-02): `server/` (Standalone-Workspace, Container-Build), `clients/desktop/` (Standalone-Workspace, GUI), `experiments/` (Standalone-Workspace, Sandbox); plus reine Source-Lib `common/` (kein Workspace, von allen drei Welten als `path = "..."`-Dependency gepullt) und das Wear-OS-Gradle-Projekt unter `clients/wearos/`. Vorgeschichte: kurzzeitig (2026-04-18 – 2026-05-02) Root-Cargo-Workspace mit vier Mitgliedern (server, doctate-common, doctate-client-core, client-desktop). | **Lock-Isolation Server vs. Client-GUI.** Im Root-Workspace zog `cargo build --release --locked` für den Server-Container den vollen `eframe`/`egui`/`winit`-Stack mit ins `Cargo.lock` — jeder GUI-Bump invalidierte den Server-Build-Cache, jeder Server-Tokio-Bump zwang einen GUI-Rebuild. Mit drei `Cargo.lock`-Files iteriert jede Welt unabhängig; `server/Cargo.lock` enthält 0 GUI-Deps. Test-Coverage bleibt erhalten (508 Cargo-Tests, jetzt verteilt auf 385/89/34 zwischen den Welten). Trade-off: gemeinsame Versions-Pins (`tokio`, `serde`, `time` etc.) müssen jetzt manuell synchron in vier `[workspace.dependencies]`/`[dependencies]`-Blöcken gepflegt werden. |
| Client-Shared-Lib-Grenze | `doctate-common` enthält alles, was zwischen Server und Client geteilt wird | Zwei Schritte: (1) seit 2026-04-18 zweistufig — `doctate-common` runtime-agnostisch, `doctate-client-core` runtime-behaftete Client-Logik (tokio + reqwest). (2) Seit 2026-05-02 nur noch eine geteilte Lib (`doctate-common`); die ehemaligen `doctate-client-core`-Module (case_store, case_update, config, footer_status, pending_cleanup, server_sync, snapshot_cache, startup, upload) leben jetzt direkt in `clients/desktop/src/` und sind über `lib.rs`-Re-Exports unter `doctate-desktop::*` adressierbar. | `doctate-common` soll WASM-fähig bleiben (kein tokio, kein reqwest) — diese Grenze bleibt scharf. Eine zweite Lib hat sich aber nicht gerechnet: nur ein Rust-Konsument (Desktop) heißt, jeder Cross-Crate-Aufruf war Boilerplate ohne Code-Sharing-Gewinn. Re-Exports über `clients/desktop/src/lib.rs` halten die canonical Pfade (`doctate-desktop::case_store`) für künftige UniFFI-Bindings stabil — wenn ein zweiter Rust-Konsument auftaucht (iOS, Handy-Rust), wandern die Module ohne API-Bruch in eine eigene Lib zurück. |
| Reihenfolge der Clients | Pixel Watch zuerst, dann Handy, Desktop später (Phase 5e „Ausblick") | **Linux-Desktop zuerst** (vor Watch), Windows nächster, Watch sobald Hardware verfügbar | Nutzer hat noch keine Pixel-Watch-Hardware. Desktop-Client dient zusätzlich als API-Ergonomie-Test-Fahrzeug (Retry, Content-Types, Auth-Fehlerpfade, die Wiremock-Tests nicht zeigen) und als Arzt-Workflow-Tool am PC (nebenher diktieren beim Arztbrief-Tippen). |
| Desktop-Tech-Stack | Offen — Kandidaten Tauri / Compose Multiplatform / nativ | `eframe`/`egui` (Immediate-Mode-UI) + `tokio` + `reqwest` + `ffmpeg`-Subprozess | Kleinere Binaries als Tauri (keine WebView, kein WebKitGTK auf Linux), keine Android-Verpflichtung wie Compose, pure Rust-Toolchain. ffmpeg-Subprozess statt `libav`-FFI: Lizenz (GPL vs. LGPL), Build-Komplexität, plattformübergreifende Konsistenz. |
| Desktop-Audio-Stop | nicht spezifiziert | **Unix:** `SIGINT` via `libc::kill`. **Windows:** `q`-Stdin-Write als Best-Effort. | ffmpeg's `term_init()` prüft `isatty(stdin)`; bei Pipe-stdin wird Keyboard-Polling deaktiviert, `q` landet in der Pipe, wird aber nie gelesen. SIGINT triggert ffmpeg's Signal-Handler unabhängig vom TTY-Status. Exit-Code 255 bei SIGINT-Exit ist normal — wir prüfen Datei-Existenz + Nicht-Leer statt Exit-Code. |
| Desktop-Tray-Icon | Geplant als Schritt 4f des Client-Aufbaus | Zurückgestellt, kein Tray im MVP. Close-Button beendet App. | `tray-icon`-Crate zieht drei System-Libs (`libxdo`, `gtk3`, `libappindicator`) für triviale Funktion. `ksni` (pure-Rust via dbus/zbus) als saubere Linux-Alternative für später dokumentiert. Client läuft ohne Tray sinnvoll. |
| Watch-Modul-Struktur | Multi-Modul-Gradle-Projekt (`:core-domain`, `:core-audio`, `:core-sync`, `:core-http`, `:core-storage`, `:app-wear`, `:app-mobile`) — "nicht optional" | Single `:app`-Modul mit paket-basierter Trennung: `presentation/`, `audio/`, `net/`, `settings/`, `domain/`. Split auf Multi-Modul spätestens beim Handy-App-Start (Phase 5c). | Für den vertikalen PoC (2026-04-23) war Multi-Modul reine Build-Ergonomie-Last ohne Mehrwert — alle Schichten sind testbar getrennt, Imports zwischen `presentation` und `audio`/`net` sind manuell diszipliniert. Beim Handy-App-Ausbau zwingt das fehlende Modul zur ersten echten Code-Duplikation — das ist der natürliche Trigger-Punkt für den Split. |
| Watch-HTTP-Client | Im Plan offen als "Reqwest-Äquivalent (Ktor oder OkHttp)" | **OkHttp 4.12.0** mit `kotlinx-coroutines-android`; Multipart-API ist API-Klon zu `reqwest::multipart::Form` | API-Nähe zum bestehenden `doctate-desktop::server_sync` (heute `clients/desktop/src/server_sync.rs`, 1:1-Portierung der `post_upload`-Klassifikation), kleinere APK als Ktor (keine zusätzliche Engine + Serialization-Transitiv), MockWebServer ist im selben Maven-Artefakt für Wire-Tests. Ktor bleibt Option, falls wir später einen WASM-Browser-Client wollen. |
| Watch-Dependency-Injection | Nicht explizit im Plan (impliziert Hilt, das Android-Studio-Scaffold-Default) | **Service-Locator** in `DoctateApp : Application` mit `by lazy`-Properties | Für vier Singletons (Settings, OkHttpClient, UploadClient, AudioRecorder) rechtfertigen keinen Annotation-Processor + Compile-Time-Graph. `ViewModelProvider.Factory` via `viewModelFactory { initializer { ... } }` gibt Constructor-Injection ohne DI-Framework. Bei deutlich mehr Singletons (~10+) ist Hilt der natürliche Aufrüstpfad, weil `by lazy`-Boilerplate dann skaliert. |
| Watch-Settings-Kanal | Nicht explizit im Plan | `local.properties` (gitignored) speist `BuildConfigField("SERVER_URL", "API_KEY")`; `Settings`-Interface + `BuildConfigSettings`-Impl erlauben späteres `DataStoreSettings` ohne Konsumenten-Refactor | Watch hat kein ergonomisches Text-Input-UI; Build-Time-Konstante via `local.properties` ist die einzige pragmatische Lösung, bis ein Settings-Screen existiert. Default `"MISSING_API_KEY"` bei fehlender Property sorgt für sichtbaren 401-Fehler statt unsichtbarer Silent-Fail. |
| Watch-Case-ID-Strategie im PoC | Plan: "Neu" → neue case_id + Marker, "Fortsetzen" → bestehende | **PoC**: jede Aufnahme = neue `UUID.randomUUID()`; keine Marker-Datei, keine Fortsetzen-Logik | Solange keine Fallliste-UI existiert, gibt es keinen Mechanismus zum Auswählen eines bestehenden Cases. Neu-pro-Aufnahme matcht das Server-Verhalten (unbekannte UUID → neuer Case-Ordner) und hält den PoC-Scope klein. Umstellung auf Neu/Fortsetzen berührt nur das ViewModel (Case-Store hält die "aktuelle" ID), nicht `UploadClient` oder `AudioRecorder`. |
| Watch-Datei-Lifecycle im PoC | Plan: Persistentes `unsynced/`-Verzeichnis, Sync-Service löscht nach ACK | **PoC**: Aufnahme in `cacheDir`, im `finally`-Block des Upload-Flows gelöscht — egal ob Success, Transient oder Terminal | Für den vertikalen Beweis unnötig, Crash-Recovery zu implementieren. Volle Pending-Queue mit Marker + Retry + Backoff kommt in der Arbeitsphase nach dem PoC (entspricht den noch offenen Phase-5b-Tasks). |
| Watch-Instrumented-Test-Hang | Nicht im Plan | `UploadClientTest` (MockWebServer-basiert) **hängt in `@Before setUp()`** auf Wear-OS-34-AVD. JVM-Unit-Tests laufen. Real-Server-E2E validiert denselben Vertrag strenger. | Vermutlich Wear-SELinux-Policy gegen Loopback-Socket-Bind aus der Instrumentation-APK-Prozess. Priorität niedrig, weil der echte Server-Upload funktioniert — Mock-Test ist nur Regressions-Absicherung fürs Refactoring. `SKIP_INSTRUMENTED=1 ./run.sh test` überspringt den on-device-Tier. |
| Watch-UI-Surface-Aufteilung | Plan (alt): „ein Screen pro Eintrag, Swipe hoch/runter zwischen Fällen", konzeptionell als Tile-Paginierung gedacht | **Drei native Wear-OS-Surfaces** in getrennten Rollen: Activity (`ScalingLazyColumn` + EdgeButton „● Neu") = Fallliste + Recording; Tile (ProtoLayout mit drei Tap-Regionen: OneLiner-Mitte / ☰ Fälle / EdgeButton „● Neu") = Glance auf den aktuellen Fall; Complication (`SHORT_TEXT`/`SMALL_IMAGE`, Tap → Liste) = optionaler Fast-Launch vom Watchface | Tile-Paginierung ist technisch unmöglich: ProtoLayout ist nicht scrollbar, Long-Press und vertikaler Swipe sind System-Gesten (Tile-Edit-Karussell / Quick-Panel) und von Apps nicht abfangbar. Die einzige App-seitige Interaktion auf einem Tile ist Tap auf `Clickable`-Regionen — mehrere pro Tile sind HIG-konform und von System-Tiles etabliert. Activity + Tile + Complication teilen sich einen Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore`), damit sie nie divergieren; Tile-Refresh via `getUpdater().requestUpdate()` nach jedem Merge. |
| Watch-Core in Kotlin (nicht Rust) | „Es war geplant, die Business-Logik aller Clients in Rust zu entwickeln" (Diskussionsstand) — technisch machbar via UniFFI-Bindings auf das Rust-Pendant | **Watch-`CaseStore` in Kotlin nachgebaut**, strukturell 1:1 zum Rust-Pendant in `doctate-desktop::case_store` (Snapshot-Flow, Merge-/Reconcile-Asymmetrie, Sync-Flag-Semantik) | Für MVP (PoC → Watch-App-Abschluss) wiegt der zusätzliche Build-Stack (NDK, Cross-Compile für `aarch64-linux-android` + `armv7-linux-androideabi`, APK-Größe) schwerer als der Code-Sharing-Nutzen — es gibt genau *einen* Kotlin-Consumer. Natürlicher Einzug-Moment ist der Start der **Handy-App** (zweiter Kotlin-Consumer → erster echter Duplikations-Druck); bis dahin wird die Kotlin-Portierung diszipliniert strukturgleich zum Rust-Original geführt, damit ein späterer UniFFI-Swap kein Refactoring der Call-Sites erzwingt. |
| Watch-Recording-Flow | Plan: Einstieg über EdgeButton „● Neu" *oder* Tap auf Listen-Eintrag → direkt Aufnahme; Stop → zurück auf den aktiven Fall | **Drei separate Screens**: `CaseListScreen` → `CaseDetailScreen` (bei Tap auf einen Fall) → `RecordingScreen` (bei Tap auf den Detail-Record-Button). „● Neu" bleibt Direkt-Einstieg (überspringt Detail). `RecordingScreen` ist radikal reduziert (live `mm:ss`-Counter, Stop-EdgeButton, 300 s Safety-Cap via `elapsedSeconds`-Reducer, `FLAG_KEEP_SCREEN_ON` via `view.keepScreenOn`). Swipe-right oder Back = Discard ohne Bestätigung (`DisposableEffect.onDispose` ist der einzige Discard-Pfad). | Ein Tap auf einen Listeneintrag darf nicht stumm das Mikrofon öffnen — der Arzt soll erst den Fall (Datum + Oneliner) sehen und explizit „Record" drücken. Der State-Machine-Split kommt mit zwei neuen Invarianten: (1) `finalizeAndUpload` läuft in `applicationScope`, damit der Pop nach Stop den Upload nicht kill — `AtomicBoolean finalizationStarted` serialisiert Stop/Discard/Auto-Stop gegen Doppelspiel; (2) `formatTime` ist nach `TimeFormat.kt` extrahiert, damit Liste und Detail-Header dieselbe Relativ-/Absolut-Formatierung („Gerade eben" / „Heute HH:mm" / „dd.MM.yy HH:mm") zeigen. |
| Watch-Oneliner-Manual-Override | Plan (Zeile 1384 ff.): Oneliner wird ausschließlich serverseitig am Batch-Ende aus allen Transkripten regeneriert — der Arzt beeinflusst ihn nur über das Diktat („Bezeichnung: …") | **PoC auf der Watch**: Tap auf den Oneliner im `CaseDetailScreen` öffnet den Wear-OS-System-Input-Picker (voice/keyboard/handwriting, fest auf `de-DE` gepinnt). Ein Doctor-Edit latched ein Manual-Flag am `CaseEntry`, das den simulierten LLM-Burst blockiert; ein erneutes Manual-Edit gewinnt wieder. | Medizinische Oneliner-Typos und Fall-Bezeichnungen sollen ohne Umweg übers Diktat korrigierbar sein. „Doctor wins" ist die neue Invariante gegenüber der serverseitigen Regen-Logik. **Offene Entwurfsentscheidung für Phase 5b:** wie wird das Manual-Flag zum Server synchronisiert, damit Browser-UI, Desktop-Client und künftiger Handy-Client es respektieren (kandidierende Varianten: eigenes Upload-Feld `{oneliner, manual: true}`, oder dedizierter `PUT /api/oneliner/{case_id}` mit API-Key-Auth — konsistent zum bestehenden API-Key-Erfassungs-Vertrag). Aktuell arbeitet der PoC nur gegen den `CaseStoreStub`, das Wire-Protokoll zum Server fehlt. |
| Watch-Timestamp-Erzeugung | Plan implizit: `Instant.now().toString()` als RFC3339-Quelle (siehe ältere PoC-Beispiele) | **`PendingStore.nowRfc3339()`** als zentrale Quelle: `Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()` — spiegelt `doctate-common::timestamp::now_rfc3339()` 1:1. Konsumiert von `RecordingViewModel.startRecordingFlow`, `RecordingViewModel.finalizeAndUpload` und `CaseDetailScreen` (`OnelinerState.Manual.setAt`). Unit-Test `nowRfc3339_has_no_subsecond_component` regression-guard'et den Truncate. | `Instant.now().toString()` leakt plattformabhängig Mikro- oder Nanosekunden in den Filename. Der Server-seitige Filename-Parser (`utc_date_and_iso_of`) erwartet exakt `YYYY-MM-DDTHH-MM-SSZ` nach `:` → `-`-Substitution; bei Drift gibt das `<time datetime>` leer zurück und die Tag-Gruppierung fällt auf `today` zurück — sichtbar als doppelte „Heute"-Header in der Web-UI. Truncate auf Sekunden ist load-bearing, nicht kosmetisch. Zentraler Helper im `audio/PendingStore`-companion verhindert Drift bei künftigen neuen Call-Sites; flankiert vom strikten Server-Boundary-Validator (siehe Sicherheit / Hardening). |
