Files
Warp/docs/relations.md
T
tajniak81andClaude Opus 5.5 487e0f410b
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 10: hardening and ops
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>
2026-10-17 20:21:31 +02:00

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:

  1. The index. Every event's m.relates_to is written out with it, so that "the children of this event" is an index lookup.
  2. The listings. /relations lists an event's children and /threads lists a room's threads.
  3. 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.