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

325
docs/realtime/map.md Normal file
View File

@@ -0,0 +1,325 @@
# 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.