Implements PLAN.md Phase 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.8 KiB
Relations and threads
An event can point at another through content.m.relates_to: a thread
reply (m.thread), an edit (m.replace), a reaction (m.annotation), a
reference (m.reference). None of that is checked by the authorisation
rules. A relation is a client convention, not part of the room's DAG, and a
server can accept an event without understanding what it relates to. What
the server adds is three things:
- The index. Every event's
m.relates_tois written out with it, so that "the children of this event" is an index lookup. - The listings.
/relationslists an event's children and/threadslists a room's threads. - The summaries. Wherever an event is served, its children are
summarised into
unsigned.m.relations, so a client drawing a timeline knows a message has a thread under it, or has been edited, without asking about every event.
The code is in internal/roomserver/relations.go (the reads and the
summaries) and internal/syncapi/relations.go (the tokens), over the
relation reads of the room store in internal/storage/relations.go.
The index
roomserver_events has two more columns, relates_to_id and rel_type,
taken from content.m.relates_to.event_id and .rel_type when both are
strings. Migration 0012 fills them in for the events already stored. An
m.in_reply_to on its own is not a relation in this sense: a reply points
at an event without being one of its children.
A redaction clears both columns. The redaction algorithm strips the content they came from, so a redacted thread reply leaves its thread, and a redacted edit stops being one.
GET /_matrix/client/v1/rooms/{roomID}/relations/{eventID}[/{relType}[/{eventType}]]
The children of one event, newest first by default.
| Parameter | |
|---|---|
relType, eventType |
Narrow by relation type, then by event type. |
dir |
b (default) or f. |
from, to |
Pagination tokens. They are /messages tokens: a sync's prev_batch works as a from. |
limit |
50 by default, 1000 at most. |
recurse |
Include the children's children, down to three levels. The response then carries recursion_depth: how deep it found anything. The filters apply at every level. |
The parent has to be visible to the user, and the children are bounded to
what they may see, by the same visible range as a timeline read
(rooms.md). A parent the user may not see is 404, not 403:
whether a particular event exists in a room is itself not theirs to know.
GET /_matrix/client/v1/rooms/{roomID}/threads
The room's threads, the most recently active first. A thread is a root
that has at least one m.thread child; its activity is its newest child.
include=participated keeps the threads the user started or replied in.
Each root carries its m.thread summary. from takes the next_batch of
the previous page, a token of its own (t<position>), because threads are
ordered by activity rather than by any one stream.
A thread whose root this server does not hold — sent before this server joined the room and never backfilled — is not listed: there is no event to show it under.
Unread counts and receipts
Each notification is filed under its thread, a threaded read receipt reads
only that thread (thread_id: "main" only the main timeline), and a sync
whose filter sets unread_thread_notifications gets the counts per thread.
How, and what stays a room total, is in push.md.
Bundled aggregations
Every event a timeline read returns — /sync, sliding sync, /messages,
/context, GET /event, /relations and /threads — gets
unsigned.m.relations when it has children:
| Key | What |
|---|---|
m.thread |
latest_event (the newest reply, in full), count, and current_user_participated — true for a user who sent the root or any reply. |
m.replace |
The newest edit, in full. Only an edit from the original's sender counts, and only one of the same event type; a state event cannot be edited, and a redacted event's edits are not shown. |
m.reference |
chunk: the IDs of the events referencing it, oldest first, at most 50. |
Annotations (reactions) are not summarised. The spec dropped their aggregation, and every client counts reactions from the events themselves.
The summaries are computed when the event is read, by one grouped query over the batch of events being returned, bounded to what the user may see: a user who has left the room sees a thread's count and latest reply as of when they left.
What is deliberately missing
| Thing | Why |
|---|---|
| Rejecting a thread whose root is itself a relation | The spec says a server should; Warp accepts it, and clients render it as an ordinary reply. |
The relation filters of a room event filter (related_by_rel_types, related_by_senders) |
Ignored, like every filter field Warp does not act on; the client gets a superset. |