Files
rustification-server/docs/realtime/map.md
MDietermann 55a2af4756 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.
2026-08-14 19:04:41 +02:00

326 lines
24 KiB
Markdown

# Map: WebSocket-Realtime-Layer + Voice-Signaling-Fundament
**Status: abgeschlossen.** Alle elf Tickets sind aufgelöst; das Ziel — eine
implementierbare Spec — ist erreicht. Dieses Verzeichnis ist die Begründungsschicht
zum Realtime-Code: *warum* etwas so gebaut ist, samt der verworfenen Alternativen.
Offene Arbeit steht nicht hier, sondern als Issue im GitLab.
Der Effort umfasst beide Repos (`rustification-server` und `rustification-client`);
die Dokumente liegen hier, weil das Gewicht der Spec serverseitig ist.
Entstanden mit `/wayfinder`; Label dort: `wayfinder:map`.
## Destination
Eine implementierbare Spec für einen WebSocket-Realtime-Layer **neben** dem bestehenden
REST-API (Nachrichten-Push für alles Persistierte) plus das **Voice-Signaling-Fundament**
(Room join/leave, Teilnehmerliste, SDP/ICE-Relay — kein Audio), zugeschnitten für den
Tauri-Client auf Desktop und Mobile im Foreground.
Fertig, wenn jemand die Spec ohne Rückfragen implementieren kann und der spätere
E2EE-Effort sie nicht ungültig macht.
**Dieser Effort plant, er baut nicht.** Jedes Ticket beantwortet eine Entscheidung.
Die Umsetzung ist ein eigener Effort.
## Notes
### Produktkontext
Rustification ist als Alternative zu Matrix gedacht, mit zwei erklärten Erfolgsfaktoren:
1. **Deployment-Simplizität** — wie leicht, schnell und reproduzierbar ist ein Server
aufgesetzt und einsatzbereit?
2. **Nutzersicherheit** — Schutz vor Angriffen und Data-Leaks.
Zielbild: Firmen, die mit sensiblen Daten umgehen (eigene Instanz), und eine öffentliche
Discord-Alternative für Privatnutzer. Jeder Nutzer soll einen eigenen Server hosten können.
**Bedrohungsmodell: der Serverbetreiber ist ein Angreifer.** Das ist der Grund, warum
echtes E2EE gerechtfertigt ist und nicht bloß Verschlüsselung at rest.
### Codebases
| | |
|---|---|
| `rustification-server` | Rust, Axum 0.8, SQLx 0.9 / PostgreSQL 17, JWT + Argon2, Tokio. Migrationen embedded. |
| `rustification-client` | Tauri v2. Frontend React 18 + TS (Vite). Client-Backend Rust, ruft REST via `reqwest`. Ziele: Windows, Linux, Android, iOS. |
Stand heute: **Polling.** `MessageList.tsx:33` fährt ein `setInterval(load, 3000)`,
zusätzlich zählt `ChatPage.tsx` nach dem Senden einen `refreshKey` hoch. Beides wird
ersetzt. (Der Client-README beschreibt darüber hinaus Member-Panel und Settings-Seite,
die es im Code noch nicht gibt.)
Weitere Bestandsfakten aus den Recherchen: `axum = "0.8.9"` ist **ohne `ws`-Feature**
eingebunden, es gibt keinen Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
Auf Client-Seite existieren `src-tauri/gen/android` und `gen/apple` noch nicht — Mobile ist
nie initialisiert worden, es gibt also nichts zu migrieren. Und es gibt weder WebRTC- noch
WebSocket-Code: grüne Wiese.
### Feststehende Randbedingungen
Diese sind **entschieden** und werden von den Tickets nicht neu aufgemacht:
| Thema | Entscheidung |
|---|---|
| REST vs. WS | REST bleibt für Auth, CRUD und History. WS macht Push + Voice-Signaling. |
| Protokollform | **Dumb Pipe** `{conversation, cursor}` für alles Persistierte; eigenes flüchtiges Format nur für Voice-Signaling. Die Naht verläuft entlang *persistiert vs. flüchtig*. |
| Deployment | **Harte Regel: eine Binary + Postgres.** TURN ist optionale, extern konfigurierbare Abhängigkeit — ohne sie läuft Chat voll, Voice eingeschränkt. |
| Zustellung | Best-effort + `?since`-Backfill nach Reconnect. Keine Server-Queue, keine ACKs — die DB *ist* die Queue. |
| Fan-out | **Revidiert am 14.08.2026 durch *Fan-out-Architektur*:** kein Trait, keine zweite Implementierung — **immer** über `pg_notify`, auch bei einer Instanz. Ein Trigger auf `conversation_events` feuert, der Server hört sich per `PgListener` selbst zu. Ein Pfad für Ein- und Mehr-Instanz-Betrieb, der getestete Pfad ist der Produktionspfad. Kein Redis. *(Ursprünglich: In-Process hinter einem Trait, `NOTIFY` als zweite Impl — formuliert, bevor feststand, dass der Event 60 Byte groß ist und der Trigger ohnehin feuert.)* |
| Server-Modell | **Keine Föderation.** Der *Client* hält N Verbindungen zu N unabhängigen Servern; kein Server-zu-Server-Protokoll. |
| Krypto | Nachrichteninhalte sind im Protokoll undurchsichtige Blobs. Metadaten darf der Server sehen. Volles E2EE-Design ist ein Folge-Effort. |
| Mobile | Foreground ja. Background-Delivery und Push out of scope. |
| Bestandsdaten | Keine. Migrationen dürfen brechen. |
| Client-Plattformen | Windows, Linux, Android, iOS — **Linux ist vollwertige Client-Plattform**, nicht nur Server-Host (bestätigt 14.08.2026). Das verbaut den billigen Ausweg bei *Voice auf Linux*. |
| **Keine fremden Origins im WebView** | wry vergibt Mikrofonrechte ohne App-Beteiligung — auf iOS in 0.55.1 bedingungslos `Grant` für jeden Origin. Solange nur eigener gebündelter Code läuft, ist das harmlos; ein `<iframe>`, eine Link-Vorschau oder eine Einbettung fremder Inhalte bekäme sofort Mikrofonzugriff, ohne dass die App gefragt wird. Ab Tauri 2.12 (`on_permission_request`) kontrollierbar — bis dahin ist diese Regel der einzige Schutz. |
### Skills pro Session
Jede Session, die ein Ticket bearbeitet: `/grilling` und `/domain-modeling`.
Recherche-Tickets: `/research` als Subagent. Prototyp-Tickets: `/prototype`.
### Council-Verdikt (14.08.2026)
Position: *Zuschnitt grundsätzlich richtig, aber zu groß und an einer Stelle sachlich
falsch — Protokoll auf einen generischen Änderungs-Event reduzieren, Mobile-WebRTC-
Recherche vor alle Voice-Entscheidungen ziehen.* Konfidenz 71 %.
Drei kritische Risiken:
1. **Cursor-Lücke** — **aufgelöst am 14.08.2026**: Zähler pro Conversation unter Zeilen-Lock,
damit ist Commit-Reihenfolge gleich Cursor-Reihenfolge. Siehe *Monotoner Cursor*.
2. **Mobile-WebRTC-Unbekannte** — **aufgelöst am 14.08.2026, positiv**: Android, iOS und
Windows tragen WebRTC im WebView. An seine Stelle tritt ein neues Risiko, das die
Recherche gefunden hat: **Linux trägt es nicht**, und Linux ist die Heimplattform der
Zielgruppe Selbsthoster. → *Voice auf Linux*.
3. **Bedrohungsmodell-Lücke** — **aufgelöst**: der Betreiber ist Angreifer (siehe oben).
Korrektur am Verdikt: das Argument „Dumb Pipe ist E2EE-agnostisch, weil kein Inhalt
durch den Socket geht" ist **falsch** — unter E2EE ist der Inhalt ohnehin ein Blob, den
der Server nicht lesen kann. Der Dumb Pipe gewinnt bei Simplizität, `NOTIFY`-Tauglichkeit
und Cache-Konsistenz, nicht bei der Sicherheit.
Minderheitsvotum (🎨 Visionary): *„Matrix ist seit zehn Jahren exakt dieses Produkt —
ein Matrix-Client in Tauri bringt dich in einem Wochenende weiter."* Bewusst verworfen:
Föderation ist der Grund für Matrix' Betriebsaufwand, und Simplizität im Deployment ist
hier das Differenzierungsmerkmal.
## Decisions so far
<!-- eine Zeile pro geschlossenem Ticket: Gist + Link -->
- [Recherche: WebRTC im Tauri-WebView (Mobile)](issues/01-research-webrtc-tauri-mobile.md) —
**iOS, Android und Windows tragen WebRTC im WebView** (iOS ab 14.3 mit
`NSMicrophoneUsageDescription`; Android ab API 21 mit `RECORD_AUDIO` **und**
`MODIFY_AUDIO_SETTINGS`; Windows ohne Konfiguration). **Linux nicht** — drei unabhängige
Gates in WebKitGTK, eines davon distro-seitig unlösbar. Council-Risiko 2 positiv
aufgelöst. Zwei Befunde mit Folgen: der Signaling-Server kann DTLS-Fingerprints tauschen
(RFC 8827) und der spezifizierte Gegenmechanismus ist nirgends implementiert; wry vergibt
Mikrofonrechte ohne App-Beteiligung.
Details: [`research/01-webrtc-tauri-mobile.md`](research/01-webrtc-tauri-mobile.md)
- [Recherche: WS-Auth, Heartbeat und Reconnect](issues/03-research-ws-auth-reconnect.md) —
Faktenlage zu allen sechs Teilfragen, ohne Empfehlung. Vier Befunde mit Folgen: **(a)** der
`Authorization`-Header ist der einzige Kanal, der in den Proxy-Defaults nicht mitgeloggt wird
(Caddy redigiert ihn aktiv, `request.uri` nicht) — und `Sec-WebSocket-Protocol` wird sehr wohl
geloggt; **(b)** die Browser-API muss laut WHATWG alle Fehlerdetails verschlucken, im WebView
ist „Token abgelaufen" nicht von „Server down" unterscheidbar, im Rust-Layer schon;
**(c)** `axum::serve(...).with_graceful_shutdown(...)` beendet WS-Verbindungen nicht und
wartet nicht auf sie — eigenes Tracking ist zwingend; **(d)** `tokio::sync::watch` passt
semantisch besser zum Dumb Pipe als `broadcast`.
Details: [`research/03-ws-auth-reconnect.md`](research/03-ws-auth-reconnect.md)
- [Monotoner Cursor](issues/02-monotoner-cursor.md) — **ein Zähler `seq` pro Conversation,
vergeben per `UPDATE conversations SET seq = seq + 1 … RETURNING` in derselben Transaktion
wie die Änderung.** Der Zeilen-Lock erzwingt Commit-Reihenfolge == `seq`-Reihenfolge; die
Korrektheit folgt aus der Konstruktion. Die Änderungsachse ist eine neue Tabelle
`conversation_events` (die Log-Zeile *ist* der Dumb-Pipe-Event), nicht eine Spalte auf
`messages` — nur so werden Edits, Soft-Deletes und Reactions vom Backfill erfasst.
`?since=N` liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events: idempotent
und koaleszierend, und das Log bleibt reine Signalisierung. Keine Retention, dafür ein
Antwortlimit mit `resync`-Antwort — derselbe Pfad bedient auch Erstverbindung und
`RecvError::Lagged`. **Nebenbefund: `created_at` verliert seine Sortierrolle** — es ist
`NOW()`, also Transaktionsstart, und die heutige Sortierung in `list_messages` ist damit
schon jetzt falsch. Council-Risiko 1 aufgelöst.
- [Wo lebt die WS-Verbindung im Client](issues/05-wo-lebt-die-ws-verbindung.md) — **eigene
Implementierung im Tauri-Rust-Layer** (`tokio-tungstenite`), Pool im `AppState`, Zustellung
per `emit`. Entscheidend waren `Authorization: Bearer` (einziger Kanal, den die
Proxy-Defaults nicht loggen) und dass `tungstenite::Error::Http` die volle Antwort trägt,
während der WebView laut WHATWG nur `1006` sehen darf. `tauri-plugin-websocket` geprüft und
verworfen (dünne API, dokumentierter Nachrichtenverlust, von den Maintainern erklärtermaßen
nicht gepflegt). **Voice-Signaling läuft ebenfalls über diesen Socket**, nur die
`RTCPeerConnection` lebt im WebView — die im Ticket vermutete Spannung löst sich auf.
**Rust macht den Backfill selbst und emittiert aufgelöste Entitäten**, die Cursor-Buchhaltung
liegt also bei Verbindung und Token statt im Frontend. Alle konfigurierten Server sind im
Vordergrund verbunden. `BASE_URL` wird zur Server-Liste im `tauri-plugin-store`;
Geheimnisse kommen hinter ein `SecretStore`-Trait, dessen Implementierung der E2EE-Effort
wählt.
- [Dumb-Pipe-Event & Backfill-Endpunkt](issues/06-dumb-pipe-event-und-backfill.md) — Event ist
**minimal `{conversation, seq}`** (~60 Byte, JSON, keine Protokollversion); `kind` und
`target_id` bewusst weggelassen, weil additiv nachrüstbar. `conversation_events.kind` bleibt
damit serverintern. Aufholen nach Reconnect läuft über ein **Manifest** (`GET /sync` liefert
`{conversation_id, seq}` für alle Mitgliedschaften — eine Abfrage) plus gezielte Backfills:
bei 40 Conversations und 3 geänderten 1+3 Requests statt 40. **`resync` ist ein
unterschiedener Antworttyp bei 200**, kein 4xx — es ist der Normalfall bei jeder
Erstverbindung und darf nicht durch Fehlerpfade laufen. Limit 500 Entitäten, Tombstones
werden mitgeliefert. **Klartext-Grenze konkret gezogen** und damit bindend für den
E2EE-Effort: Blob werden `messages.content`, `attachments.filename`, `attachments.mime_type`
und `reactions.emoji` (deterministisch, damit der `UNIQUE`-Constraint greift); Routing-,
Ordnungs- und Autorisierungsfelder plus Zeitstempel bleiben lesbar. Drei Endpunkte:
`/sync`, `/conversations/{id}/changes`, `/conversations/{id}/messages` (seitenweise).
- [Fan-out-Architektur](issues/08-fan-out-architektur.md) — Registry
`conversation_id → Verbindungen` plus eine zweite `user_id → Verbindungen` für
Mitgliedschaftswechsel (ein Beitretender steht noch nicht in der ersten). Je Verbindung ein
**Dirty-Set** `HashMap<Conversation, seq>` + `Notify` statt eines Kanals — perfektes
Zusammenfassen, kein Überlauf, kein Überlauf-Fehlerpfad; Eintrag als `max()`, weil `NOTIFY`
Reihenfolge nur je Listener garantiert. Log-Zeile schreibt der Handler, `pg_notify` feuert
ein dreizeiliger Trigger auf `conversation_events` — damit ist der Signalpfad unvergesslich.
**Der Trait ist gestrichen** (siehe Randbedingung Fan-out oben). Zwei akzeptierte Kosten:
`PgListener` belegt eine dedizierte Verbindung außerhalb des Pools, und eine volle
Notify-Queue lässt Transaktionen beim Commit scheitern. Eine eingeführte Lücke ist
geschlossen: **nach jedem Listener-Reconnect geht ein Resync-Hinweis an alle Verbindungen**,
weil `NOTIFY` keine Historie hat.
**Tragende Einsicht des Tickets:** weil nur der neueste `seq` je Conversation zählt, darf
das Fan-out zusammenfassen und sogar verlieren — Lag ist Latenz, nicht Datenverlust.
- [Verbindungsaufbau & Auth über WS](issues/07-verbindungsaufbau-und-auth.md) —
`Authorization: Bearer` im Handshake. **In-Band-Re-Auth** statt Verbindungs-TTL: der Client
schiebt das erneuerte Token über den ohnehin bidirektionalen Socket, statt bei 15 Minuten
Tokenlebensdauer zwölfmal pro Stunde neu zu verbinden; Widerruf wirkt trotzdem innerhalb der
Restlaufzeit. **Autorisierung liegt bei den REST-Endpunkten**, nicht bei der Registry — der
Event trägt keinen Inhalt, Registry-Drift leckt also höchstens Metadaten.
**Client pingt alle 15 s** (Protokoll-Ping, in Rust verfügbar; 15 s wegen des striktesten
belegten Proxy-Idle-Timeouts von 20 s), Server führt `last_seen`. **Acht Verbindungen je
Nutzer, die älteste weicht** — nicht die neue abweisen, sonst sperrt ein hängengebliebener
Socket das Gerät aus. **Eigene Close-Codes, drei Handlungsklassen** (sofort / Backoff /
gar nicht), Full Jitter beim Backoff. Graceful Shutdown wird selbst gebaut
(`CancellationToken` + `TaskTracker`), weil axum WS-Verbindungen nicht abwartet.
- [Voice auf Linux](issues/11-voice-auf-linux.md) — **Flatpak zusätzlich geprüft und als Lösung
ausgeschlossen**: das GNOME-Runtime baut WebKitGTK ohne WebRTC-Flag, und das einzige
funktionierende Rezept verlangt, WebKitGTK selbst zu kompilieren *und* wry zu patchen.
Gate 1 hat keine nutzerseitige Lösung. Entscheidung: **WebView-Pfad auf Windows, Android und
iOS; nativer Stack (`livekit/rust-sdks`) nur auf Linux** — der Gratis-Pfad auf drei
Plattformen wiegt schwerer als die Einheitlichkeit; akzeptierter Preis sind zwei Medienpfade
auf Dauer. Die Naht ist **eine explizite Verzweigung im Frontend**, gesteuert über eine von
Rust gemeldete Fähigkeit (`webview` / `native` / `none`), kein Trait mit Steuerungsumkehr.
**Gestaffelt:** Voice erscheint zuerst auf den drei WebView-Plattformen, Linux zeigt es
ehrlich als noch nicht verfügbar — der WebView-Pfad validiert das Signaling-Protokoll billig,
bevor der schwere native Stack hineinsteckt. **Nebenwirkung: Experiment E5 der WebRTC-Recherche
(Arch/Fedora prüfen) entfällt**, weil Linux den nativen Stack unbedingt nutzt.
- [Voice-Topologie](issues/04-voice-topologie.md) — **Mesh, harte Obergrenze sechs
Teilnehmer**, serverseitig durchgesetzt; der Server fasst nie Audio an. **Kein spekulativer
SFU-Vorbau**: ein SFU-Protokoll ist client-zu-Server adressiert, ein Mesh-Protokoll
peer-zu-peer — eine „SFU-taugliche Naht" heute wäre Selbsttäuschung. Ein SFU wäre ein
bewusster Protokollbruch in einem eigenen Effort, nicht ausgeschlossen, aber auch nicht
vorbereitet. Dazu **eine Datenschutz-Politik je Instanz**, vom Server gesetzt und im
Signaling übermittelt: *streng* = `iceTransportPolicy: "relay"` **plus** Opus `cbr=1`/`usedtx=0`,
*offen* = Standard-ICE und Standard-Opus. Die Kopplung folgt der Bedrohung — beide Lecks
entstehen am selben Ort: über das Relay sieht der Betreiber Paketgrößen und damit
Sprechaktivität, direkt sieht er nichts, dafür lernt der Peer die IP. Der Client **zeigt den
Modus an**. TURN bleibt damit optional: ohne TURN läuft Voice im offenen Modus eingeschränkt,
im strengen gar nicht — und der Betreiber legt den Schalter selbst um.
- [Voice-Signaling-Protokoll](issues/09-voice-signaling-protokoll.md) — **Discord-Modell:**
`voice_rooms` ist eine eigenständige Entität mit *zwei möglichen Eltern* — `workspace_id`
(dauerhafter Sprachkanal, für alle Workspace-Mitglieder sichtbar und betretbar) **oder**
`conversation_id` (Direktanruf aus DM/Gruppe, endet beim Auflegen), per `CHECK` genau eines.
Das spiegelt das Muster, das `conversations` schon benutzt. **Autorisierung läuft durch genau
eine Funktion**, weil Rollen später Conversations *und* Räume gleichermaßen einschränken
sollen. Raumzustand in `voice_participants` in Postgres — nötig, weil Mehr-Instanz-Betrieb
seit Ticket 08 ab Tag eins möglich ist; Geisterzeilen fängt `last_seen` über den bestehenden
15-s-Heartbeat ab. Adressierung über **`session_id`, nicht `user_id`** (Multi-Device).
**Der Beitretende bietet an** — Konvention, kein Mechanismus; bei Video/Screenshare später
durch Perfect Negotiation zu ersetzen. **Fingerprint-Pinning: Naht jetzt, Füllung durch den
E2EE-Effort** — bis dahin zeigt der Client dauerhaft und nicht wegklickbar an, dass das
Gespräch unverifiziert ist. Eine eigene Voice-Identität wurde bewusst verworfen, weil Nutzer
sonst später zwei Identitäten und zwei Verifikationsrituale hätten. Politik und
zeitbeschränkte TURN-Credentials reisen in der Beitrittsantwort; Raumgröße wird serverseitig
durchgesetzt.
- [Client-Integration](issues/10-client-integration.md) *(Prototyp:
`rustification-client`, Branch `prototype/frontend-zustandsmodell`)* — **Der Client vergibt die
Nachrichten-ID**, nicht der Server. Der Prototyp hat gezeigt, dass optimistisches Senden
sonst nicht zusammenführbar ist: dieselbe Nachricht steht zweimal da, einmal mit `seq` und
einmal ohne. `messages.id` kommt damit vom Client statt aus `DEFAULT gen_random_uuid()` —
Nebengewinn: Senden wird idempotent, ein Wiederholungsversuch ist ein No-op.
**Zweiter Befund: eine Ungelesen-Zahl ist ohne Nachladen nicht ableitbar**, weil `seq`
Events zählt und nicht Nachrichten. Daraus die Regel: Rust lädt **für DMs** nach (Zahl),
für Channels und Gruppen nicht (nur ein Punkt) — dasselbe Discord-Verhalten wie beim
Raummodell, und `conversations.kind` gibt die Unterscheidung schon her. Zustandsform: je
Server ein Verbindungszustand, je Conversation `{geladen, ungelesen, nachrichten[]}`; keine
Zustandsbibliothek nötig, weil es keinen abgeleiteten Zustand gibt.
## Not yet specified
- **Presence, Typing-Indikator und Read-Receipts als Event-Typen.** Bewusst hinter das
erste lauffähige Push geschoben — es sind Goodies, nicht der Hauptfokus. Read-Receipts
brauchen zusätzlich ein Schema (`message_reads` existiert nicht). **Haben seit
*Monotoner Cursor* ein Zuhause:** sie werden `kind`-Werte in `conversation_events`, das
Log trägt sie ohne Umbau mit. Wird spezifizierbar, sobald
*Dumb-Pipe-Event & Backfill-Endpunkt* steht. **Ausnahme:** die Teilnehmerliste
eines Voice-Rooms ist nicht dasselbe wie Chat-Presence und bleibt im Scope von
*Voice-Signaling-Protokoll*.
- **Verhalten beim Mobile-Resume aus dem Hintergrund.** Was genau passiert, wenn die App
nach Minuten oder Stunden zurückkommt — Reconnect-Backoff, Cursor-Sprung, UI-Zustand.
Hängt an *Dumb-Pipe-Event & Backfill-Endpunkt* und *Verbindungsaufbau & Auth über WS*.
- **Rate-Limiting auf Handshakes.** *Verbindungsaufbau & Auth über WS* hat das Limit pro
Nutzer entschieden (acht, älteste weicht), ein Rate-Limit auf Verbindungsversuche aber
bewusst offengelassen: es trifft ein anderes Missbrauchsmuster und ist ohne Betriebserfahrung
nicht kalibrierbar — ein Reconnect-Sturm nach einem Neustart sieht aus wie ein Angriff.
Wird scharf, sobald es Zahlen aus dem Betrieb gibt.
- **Observability des WS-Layers.** Was misst man an offenen Verbindungen, Registry-Größe,
Zustellverzögerung? Seit *Fan-out-Architektur* gibt es zwei konkrete Pflichtmetriken statt
einer vagen Frage: die **Füllung der Postgres-Notify-Queue** (läuft sie voll, scheitern
Transaktionen beim Commit) und **Reconnects des `PgListener`** (jeder löst einen
Resync-Hinweis an alle Verbindungen aus). Der Rest ist noch nicht scharf.
- **Auth-UI bei N Servern.** N Server heißt N Refresh-Zyklen und N Ablaufzeitpunkte — wie
sieht die Oberfläche aus, wenn Server C eine Neuanmeldung braucht, während A und B laufen?
Die Speicherfrage ist durch *Wo lebt die WS-Verbindung im Client* geklärt (Liste im Store,
Geheimnisse hinter dem `SecretStore`-Trait); die UI-Seite hängt an
*Verbindungsaufbau & Auth über WS*.
- **Rollenmodell für Conversations und Voice-Rooms.** Erklärtes Ziel: Rollen sollen später
steuern, welche Conversations und welche Voice-Rooms für wen sichtbar und betretbar sind.
*Voice-Signaling-Protokoll* hat dafür die Naht gesetzt — Autorisierung läuft durch genau eine
Funktion, damit kein raumspezifisches Modell entsteht. Das Modell selbst ist noch nicht
scharf: `workspace_members.role` kennt heute nur `owner`/`admin`/`member`, ein
Berechtigungsbegriff fehlt völlig.
- **Mobile-Plattform-Setup vor der Implementierung.** `src-tauri/gen/android` und
`gen/apple` existieren noch nicht — Mobile ist nie initialisiert worden. Dazu die sieben
Experimente aus §8 der WebRTC-Recherche, allen voran E1 (`getUserMedia` auf einem
physischen iPhone, je einmal mit und ohne Info.plist-Key) und E2
(`MODIFY_AUDIO_SETTINGS` wirklich zwingend?). Blockiert die Spec-Arbeit **nicht**,
gehört aber vor die Implementierung — vermutlich als Task-Ticket im Umsetzungs-Effort.
## Out of scope
- **Vollständiges E2EE-Design** — Schlüsselverwaltung, Multi-Device-Sync (Desktop + Mobile
parallel ist gefordert), Rotation, Gruppen-Mitgliederwechsel, Geräteverlust,
Attachment-Verschlüsselung. Eigener Folge-Effort in der Größenordnung dieses hier.
In *dieser* Map steckt davon nur, was das Protokoll heute festlegen muss.
**Neu bekannte Kopplung (14.08.2026):** *sicheres* Voice hängt am E2EE-Effort. Der
Signaling-Server kann DTLS-Fingerprints tauschen (RFC 8827), und der Gegenmechanismus
verlangt, den Fingerprint signiert durch einen E2EE-Kanal zu übertragen — den es heute
nicht gibt. *Voice-Signaling-Protokoll* legt die Naht fest, kann sie aber nicht einlösen.
Voice ist damit erst nach dem E2EE-Effort gegen den Betreiber geschützt.
**Zweite Kopplung:** `voice_rooms.name` ist nutzererzeugter Inhalt und fiele unter die
Blob-Regel — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne
vorher Schlüsselmaterial zu haben. Ein Schlüsselverwaltungsproblem, das dort gelöst gehört.
- **Audio-Übertragung selbst** — WebRTC-Peers, Codecs, Echo-Cancellation, SFU-Betrieb.
Dieser Effort endet beim Signaling.
- **Push-Notifications / Background-Delivery auf Mobile** — FCM/APNs, Delivery-Queue,
Wakeup-Semantik. Orthogonale Infrastruktur.
- **Föderation (Server-zu-Server)** — bewusste Produktentscheidung: isolierte Instanzen
sind das Differenzierungsmerkmal gegenüber Matrix, nicht der Rückstand. Der Client
verbindet sich zu mehreren Servern; die Server reden nie miteinander.
- **Wahl der `SecretStore`-Implementierung** — *Wo lebt die WS-Verbindung im Client* hat die
Naht festgelegt (Tokens und Refresh-Tokens hinter einem Trait mit zwei Methoden), die
Implementierung aber bewusst an den E2EE-Effort abgegeben: der hat dasselbe Problem für
Geräteschlüssel, in verschärfter Form. OS-Keychain ist der naheliegende Kandidat, hat aber
auf Linux die schwächste Stelle (Secret Service braucht gnome-keyring oder KWallet).
- **Portable Schlüssel-Identität** (Schlüsselpaar statt Server-Account als Identität) —
reizvoll im E2EE-Kontext, aber eine Entscheidung des E2EE-Efforts. Lässt sich später
ergänzen, ohne den WS-Layer zu brechen.
- **Mehr-Instanz-Betrieb** — Sticky Sessions, Load-Balancer-Konfiguration, Deployment.
Im Scope ist nur die *Trait-Naht*, die den späteren Wechsel ermöglicht.
- **Serverseitige Suche** — unter E2EE ohnehin tot; braucht clientseitige Indizes.
- **Metadaten-Minimierung** (Sealed Sender, Padding) — widerspricht direkt der Entscheidung,
Read-Receipts und Presence überhaupt zu wollen.
- **TURN-Betrieb** (coturn-Konfiguration, Credential-Rotation) — optionale externe
Abhängigkeit, nicht Teil der Spec.