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

98 lines
4.8 KiB
Markdown

# 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](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](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. |