# 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": "", "cursor": }` 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=&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.