matrix-axon

ADR 0126 — Room detail acquisition, confirmed state absence, and change notification

Status: Proposed; tracked in issue 621, part of issue 618. Records the concrete acquisition policy that ADR 0111 deferred to the implementation, and amends ADR 0124’s transport scope.

Context

ADR 0111 sets the architecture for cached room metadata: typed local reads, bounded acquisition for what sync does not supply, and explicit availability. Issues 619 and 620 shipped the typed /metadata read and authoritative member counts. Three gaps remain, and each has a different source.

Two metadata types never arrive through sync. matrix-sdk-ui 0.19.1’s SyncService requests canonical-alias, creation, join-rule, encryption, power-level, and history-visibility state, but not m.room.guest_access or m.room.server_acl. Those two /metadata snapshots stay unknown unless an unrelated timeline event happens to carry them. The primary service exposes no supported way to add required state (ADR 0124’s context).

unknown cannot become “absent”. A room that never had an m.room.server_acl event has no ACL, but Axon cannot say so: a missing tuple means either “the room has none” or “Axon has not been told”, and ADR 0111 forbids guessing between them. Sync cannot settle it. A Sliding Sync response gives no completeness guarantee for required_state (ADR 0124’s consequences), and a server or proxy can cap or ignore entries without saying so. matrix-sdk 0.19.1 reaches the same conclusion for encryption: Room::request_encryption_state does not trust sync’s silence, and asks the room-state endpoint, treating only 404 M_NOT_FOUND as absent. A false “absent” for encryption is security-relevant, because a client would present an encrypted room as unencrypted.

Two values exist only on the account’s homeserver. The aliases the homeserver maintains for a room (GET /rooms/{roomId}/aliases) and whether it publishes the room in its directory (GET /directory/list/room/{roomId}) are not room state. They need a remote request, a cache, and a policy for permission failures and unsupported servers.

Clients poll because nothing tells them what changed. Required-state-only updates are persisted without a live frame, so the TUI’s /whereami and the planned web panel re-read /metadata and /info every 30 seconds (ADR 0114, issue 657). Remote acquisition makes this worse: a panel that opens before a fetch completes has no way to learn that it did.

Two existing mechanisms bear directly on the choices below. ADR 0124’s isolated state-only Sliding Sync transport already covers every non-invite room, pages through them 100 at a time, and already requests creation and power-level state for each. ADR 0122’s HomeserverGate (PR 836, not yet merged) is the one place background requests to a homeserver are admitted and backed off.

Decision

1. The supplementary transport also requests guest access and server ACL

Add m.room.guest_access and m.room.server_acl (state key "") to the ADR 0124 transport’s allowlist. The transport becomes a general supplementary-state transport rather than an image-pack transport: rename the module and use connection ID axon-state. Its position is in memory only (ADR 0124), so the rename costs one fresh connection, which every process restart already starts.

Everything else in ADR 0124 is unchanged: no SDK sync processing of these responses, only allowlisted required_state reaches room_state, one request in flight, SDK retries disabled, the same deadlines and backoff, and the same timestamp, redaction, and departed-room ingestion rules. The primary SyncService remains the sole owner of SDK room state.

This is preferred over repairing each room with GET /rooms/{roomId}/state/{type}. Per-room repair is one upstream request per room per type, needs its own refresh schedule, and still misses later changes, whereas the transport delivers cold-start state in its existing paged range and later changes live. The added content is two small singletons per room on a connection that already carries power levels.

What the transport delivers is positive evidence only. A type it does not deliver remains unknown; the transport never establishes absence (decision 2).

2. Known absence is confirmed by the room-state endpoint

A missing tuple becomes known absent only when the account’s homeserver answers GET /rooms/{roomId}/state/{type}/ with 404 M_NOT_FOUND. For a joined member that response is the specification’s statement that the room has no such state, unlike sync’s silence.

/metadata gains two nullable fields on each snapshot: absence_confirmed_at (Unix ms) and absence_check, which is decision 3’s refresh object (idle, queued, or running, the last failure’s category, and next_attempt_at). Both are null for a type with a tuple, and absence_check is also null when the type has never been attempted, so a client can tell “no confirmation yet, but one is coming” (queued or running) from “confirmation failed or was denied” (idle with a failure or a future next_attempt_at). The snapshot status enum is unchanged, so existing clients keep working.

status absence_confirmed_at Meaning
unknown null Axon has not been told; as today, nothing can be inferred.
unknown present The homeserver confirmed the room has no such state as of that time.
any other null A tuple exists; its status describes it, and no confirmation is ever shown.

Known absent is a statement about the room’s current state, not a manufactured value: the API still returns no content. A client may apply a Matrix default only where the specification defines what the absent state means, such as no m.room.encryption meaning the room is not encrypted, or no m.room.server_acl meaning no servers are denied.

/metadata keeps its contract of no upstream request on the read path; scheduling a job is local, and the route never waits for it.

3. Homeserver details are a separate account-scoped cache

One acquisition table holds one row per account, room, and kind: local_aliases, directory_visibility, and the absence confirmations of decision 2, one kind per state type. It is never merged into room_state, so homeserver-local aliases stay distinct from the advertised aliases in m.room.canonical_alias, and synced state stays authoritative for everything it covers. Rows are deleted on account removal (foreign-key cascade), room purge, and local leave (the room_summaries.hidden_left transition), so a removal is never served from cache. The table is bounded by the account’s room count times ten kinds; it needs no separate eviction.

GET /v1/accounts/{account_id}/rooms/{room_id}/homeserver_details returns the cached local_aliases and directory_visibility rows immediately and never waits for the homeserver. Each detail separates the last conclusive answer from the state of attempts to refresh it, as ADR 0111 requires, so a failed refresh leaves usable stale data visible.

A transient failure updates only the refresh fields. A conclusive answer replaces the value, including forbidden after a previous available, so lost permission removes the cached list.

The read schedules a refresh when a detail is unknown or expired and its next_attempt_at has passed; otherwise it is a pure read. Repeated reads and client polling therefore cannot amplify upstream traffic beyond the expiration and backoff schedule. Acquisition, including absence confirmation, is limited to rooms the account has joined; any other room, including retained rooms after a leave and unknown room IDs, returns unknown with no request, so neither endpoint can be used to probe arbitrary rooms through the homeserver. Issue 622 decides whether discovered unjoined rooms are added.

4. One paced acquisition worker per account, behind the homeserver gate

Each account run owns one acquisition worker, canceled and joined with the run like the member-count and redaction workers. The API reaches it through a consumer-owned port in axon-api with an axon-server adapter (ADR 0021), so axon-api does not depend on axon-sync.

A change made elsewhere to a room’s local aliases or directory publication while it is cached is not observed until the entry expires, so AVAILABLE_TTL is the staleness bound for those two details. If Axon later adds alias or directory writes, they invalidate the entry.

A room’s jobs drain at JOB_INTERVAL per request, so a room with all eight types absent takes about 2.5 seconds to fill completely, and a typical room with two or three absent types about one second; this is accepted because the panel opens immediately from local data and each job’s frame updates it. Opening more than about ten rooms in quick succession evicts the first rooms’ entries; they are requested again if those panels are read again.

In keeping with ADR 0121 decision 3, the limits are constants:

Constant Value Why
QUEUE_CAPACITY 32 Matches the member-count and redaction hint queues; holds about ten rooms’ panels
JOB_INTERVAL 250 ms Paces a burst of panel opens without delaying a single one noticeably
REQUEST_TIMEOUT 10 s These responses are tiny; a slower answer is load, and ADR 0122 counts under 5 s as fast
MAX_RESPONSE_BYTES 64 KiB Far above any plausible answer, small enough to buffer per account
MAX_LOCAL_ALIASES 256 A homeserver-local list beyond this is pathological; withheld as too_large, not cut
AVAILABLE_TTL 15 min Alias and directory changes are rare and usually made from the same client that reads them
CONCLUSIVE_DENIAL_TTL 1 h forbidden and not_found change only with permissions or publication
ABSENCE_TTL 24 h Sync delivers a later state event live; re-confirmation only bounds a delivery failure
UNSUPPORTED_TTL 24 h A homeserver gains an endpoint only on upgrade
FAILURE_BACKOFF 1 to 30 min Doubles per consecutive failure, at least any Retry-After; matches ADR 0122’s cooldown

5. room_details.changed replaces panel polling

A new live frame, room_details.changed, carries account_id, room_id, and sources, a set naming what to re-read:

The frame is a signal to re-read the named endpoint, not a payload; clients never apply a diff from it. The store’s upserts report whether they changed a row, so identical rewrites, re-confirmations that only advance confirmed_at, and refreshes that confirm the same value publish nothing. Each room’s processing step publishes at most one frame, with the union of its sources, so a sync batch touching several types of a room sends one frame. The first sweep after an upgrade publishes about one frame per room that has guest-access or server-ACL state, paced by the transport’s range growth; this matches the display-name fill in ADR 0118.

With this frame, a client keeps a panel fresh by re-reading on the frame, on reconnect, and on reopen, and drops its polling. Bus lag is handled as for every other frame: a lagged client re-reads on its reconnect.

6. Diagnostics

The acquisition worker logs under axon_sync::acquisition with account_id, room_id, kind, outcome, elapsed_ms, and queue depth, at debug for each job and warn for repeated failures and for a 200 absence-confirmation response (a sync coverage gap). The supplementary transport’s existing debug line gains per-batch counts of guest-access and server-ACL events. Neither logs alias values, state content, response bodies, or tokens; failure categories are allowlisted, as for the member-count worker. Existing tracing controls suffice; no developer option is added.

Alternatives considered

Consequences

Delivery

Each step is one server-silo PR, with OpenAPI, the regenerated web schema, docs/room-metadata.md, and docs/client-parity.md updated in the same PR:

  1. Supplementary state and room_details.changed: decisions 1 and 5, with the metadata and member_counts sources.
  2. Acquisition: decisions 2, 3, 4, and 6, the homeserver_details source, and absence_confirmed_at. Starts after PR 836 (ADR 0122) merges, so its requests use the real gate.

Verification