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.
9.3 KiB
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 — das hat mehrere Fragen dieses Tickets bereits beantwortet. Was dort feststeht und hier nicht neu aufgemacht wird:
- Ein Zähler
seqpro Conversation, vergeben unter Zeilen-Lock. Der Cursor ist einbigint. - Ä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 übercreated_at.
Zu entscheiden
- Event-Format.
{"conversation": "<uuid>", "cursor": <n>}ist die Arbeitsannahme. Reicht das? Braucht es einen Ressourcen-Diskriminator (messagesvs.reactionsvs.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}/messagesum?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_createdvs.message_editedvs.message_deletedeinzeln, oder ein gemeinsamesmessage_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 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:
{ "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 /syncManifest: {conversation_id, seq}für alle MitgliedschaftenGET /conversations/{id}/changes?since=NDelta oder resyncGET /conversations/{id}/messages?before=<seq>&limit=seitenweise Historie — für resyncund für Zurückscrollen/changesbekommt 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.