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,183 @@
# Dumb-Pipe-Event & Backfill-Endpunkt
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Wie sieht der Änderungs-Event genau aus, und welche Semantik hat der
`?since`-Backfill-Endpunkt, der ihn einlöst?
Die Protokollform ist entschieden (Dumb Pipe: der Socket trägt „hier hat sich etwas
geändert", der Client lädt per REST nach). Dieses Ticket macht sie präzise.
**Entblockt am 14.08.2026** durch [*Monotoner Cursor*](02-monotoner-cursor.md) — das hat
mehrere Fragen dieses Tickets bereits beantwortet. Was dort feststeht und hier **nicht**
neu aufgemacht wird:
- Ein Zähler `seq` pro Conversation, vergeben unter Zeilen-Lock. Der Cursor ist ein `bigint`.
- Änderungsachse ist die Tabelle `conversation_events`; **eine Log-Zeile ist der Event**.
Damit sind Edits, Soft-Deletes, Reactions und Attachments abgedeckt — sie brauchen hier
keine eigene Achse mehr.
- Der Backfill liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events.
- Initialer Zustand ohne Cursor, zu alter Cursor und Antwortlimit laufen alle über
**denselben `resync`-Pfad**.
- Sortierung erfolgt über `messages.seq`, nicht über `created_at`.
### Zu entscheiden
- **Event-Format.** `{"conversation": "<uuid>", "cursor": <n>}` ist die Arbeitsannahme.
Reicht das? Braucht es einen Ressourcen-Diskriminator (`messages` vs. `reactions` vs.
`members`), oder ist ein Cursor pro Conversation, der alle Änderungen umfasst,
einfacher und ausreichend? Ein Diskriminator spart Fetches, kostet aber die Einfachheit,
die den Dumb Pipe überhaupt rechtfertigt.
- **Serialisierung.** JSON oder etwas Kompakteres. Bei ~40 Byte Nutzlast ist der
Unterschied vermutlich irrelevant — dann ist JSON die faule, richtige Antwort. Prüfen,
nicht annehmen.
- **Versionierung.** Braucht ein Protokoll mit einem Event-Typ überhaupt eine Version?
Was ist die Regel für die Zukunft, wenn Presence und Typing dazukommen (siehe Fog)?
- **Backfill-Endpunkt.** Erweiterung von `GET /conversations/{id}/messages` um `?since`,
oder ein neuer Endpunkt? Paginierung, Obergrenze pro Antwort, Verhalten bei sehr
großem Rückstand (Client war zwei Wochen offline).
- **Genaue Form der `resync`-Antwort.** Eigener HTTP-Status, ein Feld in der normalen
Antwort, oder ein eigener Endpunkt? Und wie groß ist das Antwortlimit tatsächlich?
- **`kind`-Taxonomie im Log.** Welche Werte gibt es, und wie fein? `message_created` vs.
`message_edited` vs. `message_deleted` einzeln, oder ein gemeinsames `message_changed`?
Der Backfill löst ohnehin auf — feinere Werte nützen also nur der Server-Seite und
späteren Konsumenten.
- **Endpunkt-Zuschnitt.** Ein Backfill pro Conversation, oder einer über alle Conversations
eines Servers (der Client hat womöglich 40 offene)? Letzteres spart Requests beim
Wiederverbinden, verkompliziert aber Limit und `resync`, weil beides dann pro Conversation
unterschiedlich ausfallen kann.
- **Was bleibt im Klartext.** Auch wenn der Inhalt später ein Blob ist: Conversation-ID,
Cursor, Absender und Zeitstempel muss der Server sehen, um zu routen und zu ordnen.
Diese Grenze hier explizit festhalten — sie ist die Zusage an den E2EE-Folge-Effort.
### Randbedingung
Der Event muss unter dem **8-kB-Payload-Limit von Postgres `NOTIFY`** bleiben. Das ist
kein Zufall, sondern der Grund, warum diese Protokollform gewählt wurde — siehe
*Fan-out-Architektur*.
Flüchtiges (SDP/ICE, später Typing) läuft **nicht** über diesen Event. Die Naht verläuft
entlang persistiert vs. flüchtig; das flüchtige Format entsteht in
*Voice-Signaling-Protokoll*.
---
## Answer
Entschieden am 14.08.2026.
### 1. Der Event ist minimal: `{conversation, seq}`
Rund 60 Byte. Die Server-Identität steckt nicht drin — sie ergibt sich aus dem Socket, über
den der Event kam. Der `seq` ist kein Luxus: der Empfänger verwirft damit sofort, was er
schon hat, statt blind nachzuladen.
**Verworfen wurden `kind` und `target_id` im Event.** Das entscheidende Argument war die
Umkehrbarkeit: ein zusätzliches Feld lässt sich später **additiv** ergänzen, weil ein
unbekanntes Feld ignorierbar ist. Es gibt also keinen Grund, heute dafür zu bezahlen. Sollte
sich der vorhergesehene Fall einstellen — Read-Receipts aus der Fog bumpen den Zähler in
einem aktiven Channel und lösen bei allen Clients Backfills aus, die nichts Sichtbares
ändern — wird `kind` nachgerüstet, ohne dass Bestehendes bricht.
Damit bleibt `conversation_events.kind` **rein serverintern**. Die Taxonomie ist ein
Implementierungsdetail, kein Protokollbestandteil, und muss nicht versioniert werden.
### 2. Aufholen nach Reconnect: Manifest, dann gezielte Backfills
Ein leichter Endpunkt liefert für alle Conversations, in denen der Nutzer Mitglied ist, den
aktuellen `seq`-Stand — eine Liste `{conversation_id, seq}`. Das ist **eine** günstige
Abfrage, weil `conversations.seq` seit [*Monotoner Cursor*](02-monotoner-cursor.md) als
Spalte existiert: ein Join über `conversation_members`, kein Scan über Nachrichten.
Der Client vergleicht mit seinen eigenen Cursorn und holt nur die tatsächlich geänderten.
Bei vierzig Conversations und drei geänderten sind das 1 + 3 Requests statt 40 — pro Server,
und der Client hält mehrere gleichzeitig.
Der Endpunkt beantwortet zwei weitere Fragen gratis: die Ungelesen-Anzeige über alle
Conversations, und den Erstverbindungsfall ohne jeden Cursor.
Verworfen: ein Sammel-Backfill mit Cursor-Map (müsste ein POST mit Body sein, Limit und
`resync` würden pro Conversation unterschiedlich, und eine große Antwort ist bei Abbruch
komplett verloren) sowie N parallele Backfills (vierzig Requests pro Reconnect, davon
siebenunddreißig leer — genau das Verhalten, das beim Server-Neustart einen Thundering Herd
erzeugt).
### 3. `resync` ist ein unterschiedener Antworttyp, kein Fehlerstatus
Der Backfill antwortet immer mit 200:
```json
{ "kind": "delta", "cursor": 4711, "messages": [...], "reactions": [...], "attachments": [...] }
{ "kind": "resync", "cursor": 4711 }
```
Der Grund ist praktisch, nicht ästhetisch: `resync` ist kein Fehler, sondern ein erwarteter
Ausgang — er tritt bei jeder Erstverbindung auf. Als 4xx liefe er durch
`error_for_status()`-Reflexe, landete in Fehler-Logs und Alarmen, und die Reconnect-Logik
könnte ihn mit einem echten Netzfehler verwechseln. Ein unterschiedener Antworttyp zwingt
den Client, beide Fälle explizit zu behandeln — was er ohnehin muss.
**Antwortlimit: 500 Entitäten als Startwert.** Eine Stellschraube, nachträglich änderbar
ohne Protokolländerung. Wird es überschritten, ist die Antwort `resync`.
Der `delta`-Fall liefert **Tombstones mit** — soft-gelöschte Zeilen müssen im Ergebnis
erscheinen, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen
Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`).
### 4. Die Klartext-Grenze, konkret gezogen
Bindend für den E2EE-Folge-Effort. **Klartext bleibt**, weil der Server es zum Routen,
Ordnen, Autorisieren und Durchsetzen von Eindeutigkeit braucht:
`conversation_id` · `seq` · alle IDs · `user_id` (nur der Autor darf bearbeiten) ·
`created_at` / `updated_at` / `deleted_at` · `attachments.size_bytes` · `attachments.url`
Zeitstempel zu verschlüsseln wurde erwogen und verworfen: der Betreiber sieht den Zeitpunkt
ohnehin am Socket-Verkehr, der Gewinn wäre null.
**Blob wird:**
| Feld | Begründung |
|---|---|
| `messages.content` | der eigentliche Inhalt |
| `attachments.filename` | ein Dateiname wie `Kündigung_Mueller.pdf` ist so sensibel wie der Nachrichtentext |
| `attachments.mime_type` | verrät die Art des Dokuments |
| `reactions.emoji` | **deterministisch** verschlüsselt, damit `UNIQUE (message_id, user_id, emoji)` weiter greift — der Server weist Doppelreaktionen ab, ohne sie lesen zu können |
`size_bytes` bleibt zwangsläufig sichtbar.
Der **Mechanismus** bleibt dem E2EE-Effort überlassen, die **Feldliste** nicht. Begründung:
die Migration über diese Tabellen fällt in diesem Effort an — die Grenze später zu ziehen
hieße, dieselben Tabellen ein zweites Mal zu migrieren, und bis dahin lägen Dateinamen im
Klartext auf einem Server, dessen Betreiber als Angreifer gilt.
### 5. Abgeleitet, nicht eigens entschieden
Diese drei folgen aus dem Obigen; sie sind als Vorschlag zu lesen und jederzeit umkehrbar:
- **Serialisierung: JSON.** Bei 60 Byte Nutzlast bringt ein Binärformat nichts, was den
Werkzeugverlust aufwöge.
- **Keine Protokollversion.** Ein Event-Typ, Felder werden additiv ergänzt, unbekannte
Felder ignoriert. Ein Versionsfeld wäre Zeremonie ohne Gegenwert.
- **Drei Endpunkte statt einer Erweiterung von `/messages`:**
| Endpunkt | Zweck |
|---|---|
| `GET /sync` | Manifest: `{conversation_id, seq}` für alle Mitgliedschaften |
| `GET /conversations/{id}/changes?since=N` | Delta oder `resync` |
| `GET /conversations/{id}/messages?before=<seq>&limit=` | seitenweise Historie — für `resync` und für Zurückscrollen |
`/changes` bekommt einen eigenen Endpunkt, weil seine Antwort nicht nur Nachrichten
enthält, sondern auch Reactions, Attachments und Tombstones — eine andere Form als die von
`/messages`.
### Nebenbefund
`list_messages` gibt heute **alle** Nachrichten einer Conversation unbegrenzt zurück
(`ORDER BY created_at ASC`, kein Limit). Das ist unabhängig vom Realtime-Umbau ein Problem
und wird durch die Seitenaufteilung oben mit behoben.