docs(realtime): record the design decisions behind the realtime layer

Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
This commit is contained in:
2026-08-14 19:04:41 +02:00
parent 099315a538
commit 55a2af4756
14 changed files with 4470 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
# Voice-Topologie: Mesh oder SFU
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Läuft Audio direkt zwischen den Clients (P2P-Mesh) oder über einen serverseitigen
Medien-Router (SFU) — und was davon legt das Signaling-Protokoll heute schon fest?
Diese Frage wurde beim Charting bewusst offengelassen (Q6). **Entblockt am 14.08.2026:**
[*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) hat
bestätigt, dass Android, iOS und Windows WebRTC im WebView tragen. Linux nicht — das ist
eine eigene Frage in [*Voice auf Linux*](11-voice-auf-linux.md), deren Antwort auf diese
hier zurückwirken kann.
### Spannungsfeld
- **Mesh** passt zum Bedrohungsmodell: der Server sieht nie Klartext-Audio, DTLS-SRTP
bleibt zwischen den Peers. Passt außerdem zur harten Deployment-Regel — nichts
Zusätzliches zu betreiben. **Skaliert aber nicht**: bei n Teilnehmern hält jeder n−1
Verbindungen und lädt sein Audio n−1 mal hoch. Praktische Obergrenze liegt bei etwa
vier bis sechs Teilnehmern, auf Mobile eher darunter.
- **SFU** skaliert auf Dutzende Teilnehmer, **terminiert aber DTLS** und sieht damit den
Audio-Klartext. Das widerspricht dem Bedrohungsmodell „Betreiber ist Angreifer"
frontal. E2EE über einen SFU braucht Insertable Streams / SFrame — das ist ein
eigenes Forschungsprojekt und im Rust-Ökosystem dünn besiedelt. Zusätzlich ein
weiterer zu betreibender Dienst, gegen die Simplizitätsregel.
### Zu entscheiden
- Welche **Teilnehmerzahl** muss ein Voice-Room realistisch tragen? Zwei Personen im
Gespräch, ein Team-Standup mit acht, oder ein Discord-artiger Kanal mit dreißig?
Diese Zahl entscheidet die Frage fast allein.
- Wenn Mesh: was passiert **oberhalb** der Grenze — harte Begrenzung mit klarer
Fehlermeldung, oder Degradation?
- Wenn SFU irgendwann: welche **Nähte** muss das Signaling-Protokoll heute schon haben,
damit der Wechsel kein Bruch ist? (Wer bietet an, wer antwortet, wie werden Streams
identifiziert.)
- **TURN**: P2P scheitert in restriktiven Firmennetzen ohne Relay — und Firmen sind
Zielgruppe. Bestätigt ist bereits: TURN bleibt optionale externe Abhängigkeit. Was
genau ist die Zusage an den Nutzer, wenn kein TURN konfiguriert ist? Und wie merkt
der Client das früh genug, um es ehrlich anzuzeigen statt in einen Timeout zu laufen?
- Wie werden **TURN-Credentials** übermittelt, ohne sie fest in den Client zu backen?
(Zeitbeschränkte HMAC-Credentials sind der übliche Weg — gehört das ins
Signaling-Protokoll?)
### Zwei SDP-Fragen aus der WebRTC-Recherche, die hierher gehören
Beide sind formal Codec-/Transport-Themen, aber es sind SDP- bzw. ICE-Entscheidungen und
damit Topologie-nah:
- **`iceTransportPolicy: "relay"` erzwingen?** Verhindert, dass der Peer die IP-Adresse
des Gegenübers lernt (W3C WebRTC 1.0 nennt das ausdrücklich als Zweck). Der Server
kennt die IP ohnehin aus der WebSocket-Verbindung, also kostet es nichts an Vertraulichkeit
gegenüber dem Betreiber — es reduziert strikt die Exposition gegenüber dem *Peer*. Preis:
ohne TURN gibt es dann gar keine Verbindung mehr, auch im LAN nicht.
- **Opus mit `cbr=1` (und ggf. `usedtx=0`) verhandeln?** Variable Bitrate leckt über
Paketgrößen ein Sprech-/Pausen-Muster — ein feindlicher TURN-Betreiber bekommt damit
eine Voice-Activity-Detection frei Haus, obwohl er den Klartext nicht sieht. Konstante
Bitrate schließt das gegen Bandbreite.
### Randbedingung
Diese Entscheidung darf die Grenze „dieser Effort endet beim Signaling" nicht
überschreiten. Codecs, Echo-Cancellation und Audio-Pipeline sind out of scope — die beiden
Punkte oben sind die Ausnahme, weil sie im SDP ausgehandelt werden und damit ins Protokoll
gehören.
---
## Answer
Entschieden am 14.08.2026.
### 1. Mesh mit harter Obergrenze von sechs Teilnehmern
P2P-Mesh, DTLS-SRTP bleibt zwischen den Peers, **der Server fasst nie Audio an**. Das
entspricht dem Bedrohungsmodell direkt und braucht keinen weiteren Dienst — die harte Regel
„eine Binary + Postgres" bleibt für Voice unangetastet.
Oberhalb von sechs wird der Beitritt **abgelehnt**, nicht degradiert: eine klare Meldung ist
ehrlicher als ein Gespräch, das für alle schlechter wird, ohne dass jemand versteht warum.
Bei sechs Teilnehmern lädt jeder Client fünf Ströme hoch, also grob 120 kbit/s aufwärts — die
Grenze, ab der Mesh in der Praxis kippt.
**Kein spekulativer Vorbau für einen späteren SFU.** Das ist der Kern der Entscheidung und
bewusst gegen den Reflex getroffen: ein SFU-Protokoll ist client-zu-Server adressiert, ein
Mesh-Protokoll peer-zu-peer. Eine „SFU-taugliche Naht" heute wäre Selbsttäuschung — sie würde
Komplexität kosten und im Ernstfall trotzdem nicht passen. **Ein SFU wäre ein bewusster
Protokollbruch in einem eigenen Effort**, und das steht so in der Map.
Verworfen: SFU von Anfang an. Er trüge Dutzende Teilnehmer, und SFrame/Insertable Streams
sind laut Recherche auf allen Zielplattformen verfügbar (WKWebView ab 15.4, Chromium ab 141).
Dagegen stehen drei Kosten: ein weiterer zu betreibender Dienst gegen die Deployment-Regel,
ein erheblich schwereres Krypto-Design zulasten des E2EE-Efforts, und selbst mit SFrame
behielte der SFU Zugriff auf RTP-Metadaten — die `audio-level`-Header-Erweiterung ist ein
direktes Sprechaktivitäts-Orakel und müsste eigens wegverhandelt werden.
Ebenfalls verworfen: den SFU dauerhaft auszuschließen. Das hätte das Profil geschärft, aber
die Tür für Firmenkunden mit größeren Runden zugezogen — eine Produktentscheidung, die es
ohne Not heute nicht braucht.
### 2. Eine Datenschutz-Politik je Instanz, vom Server gesetzt
Die Instanz konfiguriert **einen** Schalter, der zwei Dinge zugleich regelt und im Signaling
an den Client übermittelt wird; der Client zeigt sichtbar an, welcher Modus gilt.
| Schalterstellung | `iceTransportPolicy` | Opus |
|---|---|---|
| **streng** | `"relay"` erzwungen — der Peer lernt die IP-Adresse nie | `cbr=1`, `usedtx=0` |
| **offen** | Standard-ICE (host / srflx / relay) | Standard (VBR mit DTX) |
Die Kopplung ist nicht Bequemlichkeit, sondern folgt der Bedrohung: **beide Lecks entstehen
am selben Ort.** Läuft Audio über das Relay, sieht der Betreiber — der erklärte Angreifer —
die Paketgrößen und hat damit einen Sprechaktivitäts-Detektor, obwohl er den Inhalt nicht
lesen kann. Läuft es direkt, bekommt er die Pakete gar nicht zu sehen, dafür lernt der Peer
die IP. Ein Schalter, ein anzuzeigender Zustand.
**Warum je Instanz und nicht global:** auf einem öffentlichen Server telefonieren Fremde
miteinander, und dass zwei Unbekannte gegenseitig ihre Anschlussadresse lernen, ist für ein
Datenschutzprodukt untragbar — dort gehört „streng" hin, und TURN wird auf dieser Instanz zur
Voraussetzung. Auf einer Firmeninstanz sind die Peers Kollegen im selben Netz; dort wäre
erzwungenes Relay nur Bandbreitenverschwendung, und ein Gespräch zwischen zwei Rechnern im
selben Büro liefe ohne Not über den Server.
**Bewusst in Kauf genommen:** im offenen Modus bleibt das Sprechmuster für Netzbeobachter auf
dem Pfad sichtbar, und die Zusage an den Nutzer hängt am Betreiber — deshalb muss der Client
sie anzeigen und nicht nur intern kennen.
**Damit bleibt TURN die optionale Abhängigkeit**, die die Map festgelegt hat: ohne TURN läuft
Voice im offenen Modus eingeschränkt (Firmen-NATs), im strengen Modus gar nicht — und der
Betreiber weiß, worauf er sich einlässt, weil er den Schalter selbst umlegt.
### 3. Beide Medienpfade müssen die Politik umsetzen
Aus [*Voice auf Linux*](11-voice-auf-linux.md): `RTCPeerConnection` im WebView auf drei
Plattformen, `livekit/rust-sdks` auf Linux. Sowohl `iceTransportPolicy` als auch die
Opus-`fmtp`-Parameter müssen auf beiden Wegen gesetzt werden können — beides ist
Standard-WebRTC und in beiden Stacks vorhanden, gehört aber in die Spec, damit es nicht auf
einem der Pfade vergessen wird.
### Folgen
[*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md) ist damit entblockt. Zwei
Dinge gehen von hier dorthin: die **Raumgröße** muss serverseitig durchgesetzt werden (der
Beitritt wird abgelehnt, nicht nur in der UI verhindert), und die **Politik-Übermittlung**
gehört ins Signaling — zusammen mit den TURN-Zugangsdaten, die im strengen Modus zwingend
sind.