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.
14 KiB
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 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"+ Opuscbr=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) §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:
- Fingerprint deterministisch vorab bestimmen (
RTCPeerConnection.generateCertificate()+getFingerprints()), statt ihn auslocalDescription.sdpzu lesen. - Ihn durch einen E2EE-authentifizierten Kanal übertragen, signiert mit dem Langzeit-Identity-Key — nicht im Klartext-SDP, das der Server relayed.
- Beim Empfang gegen den Fingerprint im relayed SDP vergleichen. Mismatch ⇒ Call abbrechen und als Sicherheitsereignis anzeigen, nicht still weiterlaufen lassen.
- Key Continuity (RFC 8827): Fingerprints pro Gerät über Calls hinweg cachen, bei Änderung warnen.
- 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.
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
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 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
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 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 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
(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. 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.