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,255 @@
# Voice-Signaling-Protokoll
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Wie sieht das flüchtige Signaling-Format aus, mit dem Clients einen Voice-Room betreten,
sich gegenseitig finden und SDP-/ICE-Daten austauschen?
Das ist die eine Stelle, an der echte Nutzlast über den Socket geht — der Dumb Pipe
trägt es nicht, weil ein SDP-Angebot flüchtig ist und in keiner Tabelle liegt.
**Entblockt am 14.08.2026.** Was aus [*Voice-Topologie*](04-voice-topologie.md) feststeht und
hier nicht neu aufgemacht wird:
- **Mesh, harte Obergrenze sechs Teilnehmer**, serverseitig durchgesetzt — der Beitritt wird
abgelehnt, nicht nur in der UI verhindert. Kein SFU-Vorbau; ein SFU wäre ein bewusster
Protokollbruch in einem eigenen Effort.
- **Eine Datenschutz-Politik je Instanz** (streng: `iceTransportPolicy: "relay"` + Opus
`cbr=1`/`usedtx=0`; offen: Standard-ICE + Standard-Opus). Sie muss über das Signaling an den
Client übermittelt werden, damit er sie umsetzen **und anzeigen** kann.
- Im strengen Modus sind **TURN-Zugangsdaten zwingend** — ihre Übermittlung gehört damit in
dieses Ticket.
### Zu entscheiden
- **Room-Modell.** Ist ein Voice-Room an eine Conversation gebunden („Sprachkanal dieses
Channels"), oder eine eigenständige Entität mit eigener Mitgliedschaft? Das erste ist
einfacher und passt zum bestehenden Datenmodell; das zweite erlaubt Ad-hoc-Anrufe
zwischen Leuten, die keine gemeinsame Conversation haben.
- **Persistenz.** Ist der Room-Zustand rein im Speicher (weg beim Neustart), oder liegt
er in der DB? In-Memory ist einfacher und passt zur Flüchtigkeit — aber beim Neustart
fallen alle Gespräche, und bei Mehr-Instanz-Betrieb später ist In-Memory ein Problem.
- **Teilnehmerliste.** Das ist die **einzige** Presence-Form im Scope dieses Efforts
(allgemeine Chat-Presence liegt in der Fog). Wie erfährt ein Beitretender, wer schon
drin ist? Wie erfahren die anderen von ihm? Was passiert bei einem
Verbindungsabbruch — ab wann gilt jemand als weg?
- **Nachrichtentypen.** Minimal nötig: `join`, `leave`, `participants`, `offer`,
`answer`, `ice-candidate`. Braucht es mehr — `mute`, `speaking`, `renegotiate`?
- **Adressierung.** SDP und ICE-Kandidaten gehen an einen *bestimmten* Peer, nicht an
den Raum. Wie werden Peers identifiziert — User-ID reicht nicht, weil Multi-Device
gefordert ist. Also eine Verbindungs- oder Session-ID.
- **Wer bietet an?** Bei n Teilnehmern muss geregelt sein, wer bei einem Beitritt das
Angebot macht, sonst gibt es Glare (beide bieten gleichzeitig). Deterministische Regel
festlegen.
- **TURN-Credentials.** Wenn TURN konfiguriert ist: wie kommen zeitbeschränkte
Credentials zum Client? Gehört das in dieses Protokoll oder in einen REST-Endpunkt?
Und was ist das Verhalten, wenn **kein** TURN konfiguriert ist — der Nutzer muss früh
und ehrlich erfahren, dass die Verbindung scheitern kann, statt in einen Timeout zu laufen.
- **Transport.** Derselbe WebSocket wie der Dumb Pipe, oder ein zweiter Endpunkt? Ein
Socket spart Auth- und Reconnect-Verwaltung; zwei trennen die Belange sauberer. Hängt
auch an *Wo lebt die WS-Verbindung im Client* — falls Chat in Rust und Voice in JS
liegt, ist die Frage bereits beantwortet.
### Harte Randbedingung: DTLS-Fingerprint-Pinning
Aus [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) §7.4,
und für dieses Projekt der wichtigste Sicherheitsbefund überhaupt:
**RFC 8827 benennt den Angriff wörtlich** — ein On-Path-Angreifer kann die DTLS-SRTP-Fingerprints
im Handshake austauschen und die Identität beider Endpunkte ersetzen. Der Signaling-Server
relayed die SDP, kann also `a=fingerprint:` tauschen und zwei separate DTLS-Sitzungen
terminieren: vollständiges MITM auf dem Audio, ohne dass irgendein WebRTC-Mechanismus anschlägt.
Bei einem Bedrohungsmodell, in dem der Betreiber der Angreifer ist, ist das kein Randfall,
sondern der Hauptfall.
**Der spezifizierte Gegenmechanismus existiert nicht.** W3C *Identity for WebRTC 1.0* steht
seit 2018 unverändert als Candidate Recommendation; `setIdentityProvider` / `peerIdentity`
sind in Chrome, Safari und Android WebView **nicht implementiert** — also in keiner der vier
Ziel-Engines.
Was das Protokoll deshalb leisten muss:
1. Fingerprint deterministisch vorab bestimmen (`RTCPeerConnection.generateCertificate()` +
`getFingerprints()`), statt ihn aus `localDescription.sdp` zu lesen.
2. Ihn **durch einen E2EE-authentifizierten Kanal** übertragen, signiert mit dem
Langzeit-Identity-Key — nicht im Klartext-SDP, das der Server relayed.
3. Beim Empfang gegen den Fingerprint im relayed SDP vergleichen. Mismatch ⇒ Call abbrechen
und als Sicherheitsereignis anzeigen, nicht still weiterlaufen lassen.
4. **Key Continuity** (RFC 8827): Fingerprints pro Gerät über Calls hinweg cachen, bei
Änderung warnen.
5. Optional eine **SAS** (Emoji-/Wortliste aus dem Hash beider Fingerprints, à la ZRTP)
für den paranoiden Pfad — verteidigt zusätzlich gegen einen kompromittierten Identity-Key.
Genau so löst es Matrix in MSC3401/Element Call: Call-SDP läuft über verschlüsselte
To-Device-Messages, der Homeserver sieht den Fingerprint nie im Klartext.
**Konsequenz für die Effort-Grenzen:** Punkt 2 braucht einen E2EE-Kanal, den es heute nicht
gibt — das volle E2EE-Design ist ein Folge-Effort. Dieses Ticket muss die **Naht** festlegen
(wo im Signaling-Fluss der signierte Fingerprint sitzt, wie ein Mismatch behandelt wird),
kann sie aber nicht einlösen. Sicheres Voice ist damit von der Fertigstellung des
E2EE-Efforts abhängig — das explizit festhalten, statt es implizit zu lassen.
### Randbedingung aus *Voice auf Linux*
Es gibt **zwei Medienpfade**: `RTCPeerConnection` im WebView auf Windows, Android und iOS,
und `livekit/rust-sdks` im Rust-Layer auf Linux. Das Signaling-Protokoll muss beide bedienen.
Konkret für das Fingerprint-Pinning oben: der signierte DTLS-Fingerprint muss auf **beiden**
Pfaden erzeugbar sein — im WebView über `RTCPeerConnection.generateCertificate()`, nativ über
die Zertifikats-API von libwebrtc. Die Naht darf also nicht an einer WebView-API hängen.
### Randbedingung
**Kein Audio.** Codecs, Echo-Cancellation, Audio-Pipeline und SFU-Betrieb sind out of
scope. Dieses Ticket endet bei „zwei Clients haben genug Information, um eine
`RTCPeerConnection` aufzubauen".
---
## Answer
Entschieden am 14.08.2026.
### 1. Der Voice-Room ist eine eigenständige Entität mit zwei möglichen Eltern
**Discord-Modell:** ein Sprachkanal hängt am **Workspace** und ist per Default für alle
Workspace-Mitglieder sichtbar und betretbar. Zusätzlich kann ein Raum an einer **Conversation**
hängen — das ist der Direktanruf aus einer DM oder Gruppe heraus.
```sql
CREATE TABLE voice_rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
name TEXT,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK ((workspace_id IS NULL) <> (conversation_id IS NULL))
);
```
Genau eines der beiden Elternfelder ist gesetzt — dasselbe Muster, das `conversations` heute
schon benutzt (dort ist `workspace_id` für DMs null). **Eine Entität, ein Signaling-Protokoll,
eine Prüfstelle für die Autorisierung**, die nur danach verzweigt, welcher Elternteil gesetzt
ist.
**Lebenszyklus:** Workspace-Räume sind dauerhaft (ein Ort, den man betritt und verlässt).
Conversation-Räume entstehen mit dem Anruf und werden beim Auflegen abgeräumt.
⚠️ **Die Autorisierung muss durch genau eine Funktion laufen.** Später sollen Rollen
*Conversations und Voice-Rooms gleichermaßen* einschränken — es darf deshalb **kein
raumspezifisches Berechtigungsmodell** entstehen, sonst gibt es zwei Systeme, die auseinander
laufen. Heute prüft diese Funktion Workspace- bzw. Conversation-Mitgliedschaft; das
Rollenmodell setzt später an derselben Stelle an.
### 2. Raumzustand in Postgres, aufgeräumt über den bestehenden Heartbeat
```sql
CREATE TABLE voice_participants (
room_id UUID NOT NULL REFERENCES voice_rooms(id) ON DELETE CASCADE,
session_id UUID NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (room_id, session_id)
);
```
Über Instanzen hinweg konsistent per Konstruktion — nötig, weil das Fan-out seit
[*Fan-out-Architektur*](08-fan-out-architektur.md) immer über `NOTIFY` läuft und
Mehr-Instanz-Betrieb damit ab Tag eins möglich ist. Die Teilnehmerliste, die ein Beitretender
für seine Angebote braucht, ist eine simple Abfrage.
**Geisterzeilen** nach einem Instanz-Absturz fängt `last_seen` ab, das der bestehende
15-Sekunden-Heartbeat aus [*Verbindungsaufbau & Auth*](07-verbindungsaufbau-und-auth.md)
gratis mitführt: wer zwei Intervalle fehlt, wird entfernt. Bei sauberem Socket-Schluss geht
der Abgang sofort.
Verworfen: In-Memory je Instanz (`NOTIFY` hat keine Historie — nach einem Listener-Reconnect
fehlt einer Instanz die Sicht, und abgestürzte Instanzen hinterlassen unaufräumbare Geister)
und Raum-zu-Instanz-Bindung (bräuchte selbst wieder geteilten Zustand plus eine Umleitung, und
ein Instanz-Absturz nähme den ganzen Raum mit statt ihn nur auszudünnen).
### 3. Adressierung über `session_id`, nicht `user_id`
Multi-Device ist gefordert — derselbe Nutzer kann mit Desktop und Handy im selben Raum sein.
SDP und ICE-Kandidaten gehen deshalb an eine **Verbindung**, nicht an ein Konto. Jede
WebSocket-Verbindung bekommt beim Aufbau eine `session_id`; sie ist die Peer-Identität im
Signaling.
### 4. Der Beitretende bietet an
Wer beitritt, erhält die Teilnehmerliste und sendet n−1 Angebote; die Anwesenden antworten
nur. Eine Regel statt einer Aushandlung darüber, wer aushandelt — und der Beitretende ist der
Einzige, der in diesem Moment etwas Neues weiß.
Kollisionen können praktisch nicht entstehen, weil es reines Audio ohne mitten im Gespräch
wechselnde Spuren gibt. Der einzige verbleibende Fall ist ein ICE-Neustart: **wer neu startet,
bietet an.**
⚠️ Das ist eine **Konvention, kein Mechanismus**. Kämen später Bildschirmfreigabe oder Video
hinzu, muss sie durch Perfect Negotiation (höflich/unhöflich per `session_id`-Vergleich)
ersetzt werden. Als Notiz festgehalten, nicht heute gebaut.
### 5. Fingerprint-Pinning: Naht jetzt, Füllung durch den E2EE-Effort
Das Protokoll sieht das Feld für den signierten DTLS-Fingerprint und den Abgleich gegen das
relayte SDP **von Anfang an** vor. Solange kein Identitätsschlüssel existiert, bleibt es leer,
und der Client zeigt **dauerhaft und nicht wegklickbar** an, dass das Gespräch nicht
verifiziert ist.
Begründung gegen die verlockende Alternative (eine kleine Identitätsschicht nur für Voice):
der Identitätsschlüssel ist das Kernstück des E2EE-Efforts. Ihn separat für Voice zu erzeugen
hieße, dass Nutzer später zwei Identitäten und zwei Verifikationsrituale haben — oder dass
eine davon migriert werden muss, und das ist bei Schlüsseln der unangenehmste aller
Migrationsfälle.
**Bewusst in Kauf genommen und in der Map vermerkt:** bis der E2EE-Effort steht, ist Voice
gegen den Betreiber ungeschützt. Sichtbar, nicht stillschweigend.
Beide Medienpfade aus [*Voice auf Linux*](11-voice-auf-linux.md) müssen den Fingerprint
deterministisch vorab erzeugen können — im WebView über
`RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die
Naht darf nicht an einer WebView-API hängen.
### 6. Nachrichtensatz (abgeleitet)
Client → Server: `room.join` · `room.leave` · `peer.signal` (trägt Offer, Answer oder
ICE-Kandidat, adressiert an eine `session_id`)
Server → Client: `room.joined` (Antwort auf `join`: Teilnehmerliste, Politik,
TURN-Zugangsdaten) · `room.participant_added` · `room.participant_removed` · `peer.signal`
(weitergereicht) · `room.rejected` (Raum voll, nicht berechtigt)
`mute` und `speaking` sind **nicht** enthalten: Stummschaltung ist lokal, und
Sprechaktivität würde die Information über den Server leiten, die
[*Voice-Topologie*](04-voice-topologie.md) im strengen Modus gerade per Opus-CBR versteckt.
Beides gehört, falls gewünscht, über die Peer-Verbindung statt über das Signaling.
### 7. Politik und TURN-Zugangsdaten in der Beitrittsantwort (abgeleitet)
`room.joined` trägt die Instanz-Politik aus [*Voice-Topologie*](04-voice-topologie.md)
(*streng* → `iceTransportPolicy: "relay"` und Opus `cbr=1`/`usedtx=0`; *offen* → Standard) —
der Client setzt sie um **und zeigt sie an**.
Im strengen Modus sind TURN-Zugangsdaten zwingend und reisen mit: **zeitbeschränkte
HMAC-Credentials** nach dem üblichen coturn-Muster, damit nichts Dauerhaftes im Client liegt.
Erneuerung erfolgt durch erneuten Beitritt, nicht durch einen eigenen Mechanismus.
### 8. Raumgröße serverseitig durchgesetzt
Die Obergrenze von sechs wird beim `room.join` geprüft und mit `room.rejected` abgelehnt —
**nicht** nur in der Oberfläche verhindert.
### Offene Kopplung zum E2EE-Effort
`voice_rooms.name` ist nutzererzeugter Inhalt und fiele damit unter die Blob-Regel aus
[*Dumb-Pipe-Event & Backfill*](06-dumb-pipe-event-und-backfill.md). Der Server braucht ihn
nicht — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne vorher
Schlüsselmaterial zu haben. Das ist ein Schlüsselverwaltungsproblem und gehört dem
E2EE-Effort; hier nur festgehalten, damit es dort nicht übersehen wird.