ADR 0111 records the architecture for cached room metadata and bounded progressive enrichment. This guide documents the implemented API, current sync coverage, verification, and remaining work. Tracking issue 618 records the remaining acquisition, discovery, and client work.
GET /v1/accounts/{account_id}/rooms/{room_id}/metadata returns eight typed snapshots from that account’s cached room state.
It requires the same bearer authentication as other /v1/ reads.
It performs one database query for a fixed set of singleton tuples, with no homeserver request, membership aggregation, or discovery-cache merge.
Each state’s PostgreSQL-rendered content is limited to 128 KiB before transfer and JSON decoding.
This is a local transfer budget with headroom for JSONB spacing above Matrix’s compact event-size limit; it is not an upstream event-validity check.
PostgreSQL still detoasts and renders the full selected value before measuring it; this cap does not bound database-side rendering work.
Issue 631 tracks that remaining resource bound.
An oversized state keeps its provenance but withholds its content; other fields remain available.
| Snapshot | State type | Detail fields |
|---|---|---|
aliases |
m.room.canonical_alias |
alias, alt_aliases |
creation |
m.room.create |
room_version, creator, additional_creators, federate, room_type, predecessor |
join_rules |
m.room.join_rules |
join_rule, allow conditions |
encryption |
m.room.encryption |
algorithm, rotation_period_ms, rotation_period_msgs |
power_levels |
m.room.power_levels |
Role thresholds, users, events, notifications.room |
server_acl |
m.room.server_acl |
allow_ip_literals, allow, deny |
history_visibility |
m.room.history_visibility |
history_visibility |
guest_access |
m.room.guest_access |
guest_access |
Creation content maps Matrix m.federate to API federate and Matrix type to API room_type.
These names deliberately differ from the stored Matrix keys.
Each snapshot includes status, event_id, sender, origin_ts, redacted, redaction_event_id, and content.
origin_ts is the upstream event timestamp, not the time of the last successful sync or an access-freshness guarantee.
| Status | Meaning |
|---|---|
unknown |
No cached tuple; Axon cannot establish that the setting is unset. Provenance and content are null. |
available |
Typed content is available, including explicit empty lists and omitted fields. |
unavailable |
A tuple exists with SQL NULL or JSON null content; neither establishes redaction or removal. |
partial |
Valid fields and entries remain available; invalid_fields identifies withheld malformed data. |
invalid |
Stored content has an incompatible shape; content is withheld. |
too_large |
Stored content exceeded the content-size bound; content is withheld. |
Only available and partial snapshots have non-null content.
A partial snapshot preserves valid fields and entries and reports malformed paths in invalid_fields (for example, rotation_period_msgs, allow[], or users.*).
Paths use wildcards instead of upstream map keys, are deduplicated, and never contain offending values.
Clients must not treat a malformed field or a filtered list/map as confirmed absent or complete, or apply defaults to an invalid field.
All other snapshots have an empty invalid_fields list unless typed decoding still fails after field validation.
Nullable content fields preserve omission instead of filling Matrix defaults.
In particular, an unknown encryption snapshot does not mean “unencrypted,” and an unknown alias snapshot does not mean “no aliases.”
For available creation state, the enclosing sender provides the create-event sender when a room version omits the creator content field.
A creation predecessor can omit event_id; its room_id remains available.
Power levels describe configured state, with legacy decimal strings and floats normalized to integers (floats truncate toward zero); they are not resolved permissions and must not drive authorization decisions.
Legacy normalization currently does not consult the room version; issue 632 tracks version-aware validation or diagnostics.
Unknown condition types retain their type and optional room_id; extension-specific payloads are not exposed by this typed read.
Redaction evidence is independent of content availability, consistent with Matrix redaction semantics.
redacted: true means the SDK supplied a redacted state form.
redacted: null means Axon has no positive redaction evidence, including legacy rows and unknown tuples.
Absence of the SDK marker does not prove that content is original rather than server-pruned or censored, so Axon does not emit false.
redaction_event_id identifies a known redaction when available; a null ID does not negate positive evidence.
An empty object or NULL content alone never establishes redaction.
Already-redacted state can retain room-version-protected fields and remain available or partial.
Axon uses the SDK’s retained content instead of implementing its own pruning rules.
A raw timeline redaction row alone never changes the metadata’s evidence or withholds its content, with one exception below for state the SDK never holds.
This also avoids withholding legitimate protected fields in pre-migration rows that already contain a redacted form.
A bounded worker reconciles SDK-redacted singleton state into the shared room_state projection.
It covers the eight metadata types plus name, topic, avatar, and tombstone, so /metadata, /info, room-list summaries, and space display enrichment use the same retained state.
It does not enumerate membership or space-link state keys.
Live redactions enqueue a hint only if the target event ID matches a current metadata/display singleton in the same account and room.
The filter uses fixed primary-key probes, with a two-second deadline; failure conservatively queues a hint, without treating the timeline row as evidence.
Marker-free singleton state writes check only their own SDK cache entry and enqueue a hint if that same event is already redacted there.
Ordinary message redactions and normal initial hydration therefore do not enqueue full room scans.
The state-write check closes redaction-before-state delivery races when the target filter saw no tuple yet.
These checks wait for bounded local I/O, never for the subsequent repair.
Each account has one paced worker, a 32-entry hint queue, and at most eight active rooms per tick: four hints and four rooms from a keyset-paged room-summary sweep.
Duplicate hints coalesce; draining stops at four distinct rooms, leaving the channel tail for the next tick.
Queue overflow and interrupted jobs heal through subsequent sweeps.
The worker reads twelve singleton SDK cache entries per room, bounds raw JSON before decoding to 128 KiB, and uses a two-second deadline for each room and each sweep-page read.
Guest-access and server-ACL state usually reaches Axon only through the supplementary connection, so the SDK cache may hold no copy to repair from, and that connection does not redeliver a redacted target.
When the SDK has no entry of either type, the worker makes one more bounded query for a stored m.room.redaction event targeting Axon’s current tuple of that type (tuples up to 128 KiB, redactions up to 64 KiB, under the reconciliation deadlines).
A homeserver delivers redactions it has accepted but not applied: from room version 3 it applies one only after confirming that its sender may redact others’ events or is on the target sender’s server.
The worker applies a stored redaction only under that same rule, using the room’s current power levels from the SDK cache; without them, only a redaction from the target sender’s server applies.
It then redacts the tuple as the SDK store would: ruma’s redact under the room-version rules of the SDK’s stored room info.
The result goes through the same event-ID-guarded repair write, so a replacement is never touched by its predecessor’s redaction.
Ticks run once per second with missed ticks skipped; after a complete sweep the next sweep waits five minutes.
That idle interval reduces repeated background SQLite reads; retained non-joined rooms can still occupy cheap sweep-page slots, but the worker checks SDK membership before reading singleton state.
All acquisition is local SDK cache I/O; this worker makes no homeserver requests.
One room or field failing is logged and skipped without stopping sync.
The worker is canceled and joined with its account run.
Repair updates only an existing tuple whose event ID still matches the SDK form. It cannot overwrite a replacement, advance state from historical data, or recreate a purged room or removed account. A repaired display tuple and its room-summary projection commit in the same transaction. Same-event replays cannot clear positive evidence or restore original content; a new replacement event has independent evidence. Startup sweeps repair a crash between the SDK cache commit and Axon’s projection update without a cold resync or re-dispatched state event. The nullable evidence columns are added by a forward-only migration; older rows are not guessed or rewritten in a bulk backfill.
A client should re-read when reopening the panel, on reconnect, and on a room_details.changed frame naming metadata (see Change notification).
That frame follows every change to a stored snapshot, from either sync connection and from redaction repair, so an open panel needs no polling.
Unknown account/room IDs return unknown snapshots with HTTP 200, matching existing cached state-read conventions.
Cached state from a room the account has left remains historical cached state according to the existing retention policy; this endpoint does not assert current membership or upstream access.
GET /v1/accounts/{account_id}/rooms/{room_id}/info adds nullable member_counts.
An observed summary contains joined, invited, and observed_at (Unix milliseconds).
Counts come from an atomic SDK RoomInfo summary snapshot, never from the lazily loaded member-list projection.
A temporary, version-pinned SDK base patch preserves whether each count was supplied and invalidates that evidence on membership transitions and initial: true room replacements.
See crates/third-party/README.md for the upstream revision, release checksum, complete patch, and removal criteria.
A pair is published only when both fields are known; an explicitly supplied zero invited count is valid, while a missing invited field remains unknown.
A server-reported zero joined count clears any previous pair because a joined room must include the account itself.
A missing SDK room, uninitialized summary, or count outside signed 64-bit storage range leaves the last persisted observation unchanged.
Positive non-joined membership evidence or a known membership transition invalidates it, including a rejoin whose new summary omits counts.
Existing rows start unknown; there is no membership backfill or new room-list aggregation.
observed_at is the database-clock timestamp captured before Axon read the current changed pair from its local SDK cache.
Identical observations do not rewrite the summary row or advance its timestamp.
It records when the current pair changed locally, rather than the most recent confirmation of that pair, and is not a freshness signal.
Observations and leave watermarks both use the PostgreSQL clock, so a difference between the database and application host clocks cannot delay a valid rejoin observation.
The database rejects observations older than the persisted observation or invalidation watermark.
A recent observation can still reflect a disconnected SDK’s old summary.
Consumers must consider account sync health as well as the timestamp; this endpoint does not assert upstream freshness.
Counts persist across process restarts and remain readable while sync is disconnected.
A local leave atomically clears the observation; a rejoin needs both counts supplied in its new membership epoch.
A confirmed upstream gone verdict also withholds counts.
Retained room state after leave remains historical, while this count field becomes unknown.
Each account has one cancelable worker started before sync and before the awaited startup decryption sweep.
A slow key-backup request cannot hold up local member-count reconciliation.
The worker subscribes before startup reconciliation.
One-second ticks skip missed ticks and process at most four queued hints or retries plus four keyset-paged room-summary rows.
A coalesced queue holds at most 32 rooms and makes up to four attempts for a projection-order race or transient failure; overflow and exhausted retries fall back to the progressive sweep.
An observation superseded by an equal or newer database watermark is terminal and consumes no retry slot.
At most 32 SDK notifications are consumed per tick, coalescing duplicates within that budget and preserving the receiver tail.
Lagged notifications wake a sweep without rewinding its cursor, so continuous traffic cannot starve later rooms.
Each page and each room observation has a two-second deadline, and every room failure is logged and skipped independently.
Reconciliation transactions also set PostgreSQL-local statement and lock deadlines of 1.5 seconds and one second, so timed-out awaits cannot leave unbounded database work.
After a complete sweep, another begins after five minutes; startup begins immediately.
An offline-to-online transition sends a coalesced wake signal to resume scanning at the next paced tick, including reconnects that do not restart the account task.
Recovery time scales with the number of cached summaries and local I/O, rather than promising a fixed five-minute completion for every account.
There is no account-sized in-memory dedup map, unbounded room enumeration, or remote request.
The worker only updates existing account/room summary rows; purge or account removal cannot be undone by a late completion.
Its cancellation token and join handle are owned by the account run.
Cancellation during a page read stops either worker before it drains hints or reads SDK state.
An unexpectedly closed SDK update channel stops the member-count watcher with an account-scoped warning.
The keyset traversal is shared with singleton-state redaction repair.
Existing tracing controls suffice; warnings identify the worker, account, room where applicable, and an allowlisted database failure category or deadline without logging private metadata bodies.
Set RUST_LOG=warn,axon_sync::room_sweep=debug to see local SDK reconciliation sweep started and local SDK reconciliation sweep completed for each account and worker.
Completion includes the number of room-summary rows visited, elapsed seconds, and whether another pass was requested during traversal.
Completion means every page was traversed; rooms whose SDK counts remain unknown can still have no observation.
Each stored or cleared pair publishes a room_details.changed frame naming member_counts; clients re-read /info on that frame, on reconnect, and on reopen.
A local leave clears counts in the database without the worker, so it publishes no member_counts frame; clients learn of the leave from its own membership events.
Server counts complete issue 620; web and TUI presentation remain separate follow-ups.
Two maintenance improvements remain deferred: sharing each account’s traversal between the count and redaction workers to reduce database round trips, and automatically checking that the documented SDK patch reproduces the vendored source from the pinned release archive. The workers currently share traversal code but keep independent cursors and deadlines.
GET /v1/rooms adds nullable display_name to each room (ADR 0118).
It is the name the Matrix spec’s naming algorithm gives the room: m.room.name, else the canonical alias, else a name built from up to five other members (“Alice, Bob”, “Alice, Bob, and 3 others”, “Empty Room (was Alice)”, “Empty Room”).
matrix-rust-sdk computes it on each sync response that touches the room, excluding service members listed in io.element.functional_members; Axon copies the SDK’s cached value and never derives it from the lazily loaded member list.
The member-count worker above writes it in the same per-room step, so it shares that worker’s hints, retry queue and paced sweep.
The counts run first and the display name second, each under its own two-second deadline, and the retry queue records which of the two failed and reruns only that one.
Only joined rooms are written, values are cut to 512 characters, and an unchanged value is not rewritten.
A room whose summary row does not exist yet takes a bounded retry and is picked up by a later sweep.
Existing rows start null after the upgrade and fill as the first sweep reaches them.
A summary rebuild, including the per-room rebuild a purge runs, keeps the column: the value cannot be re-derived from stored events, and no SDK update follows a rebuild to rewrite it.
Each applied change publishes a room_display_name.changed WebSocket frame with room_id and display_name.
That includes the first fill after the upgrade, one frame per room at the sweep’s pace of a few rooms per second, so a client that loaded the list while the column was null picks up names without reloading.
Clients should title a room name, then canonical_alias, then display_name, then its room ID: the state fields change as soon as their events arrive, while the calculated name follows the reconciler.
Warnings carry source = "sdk_display_name"; set RUST_LOG=warn,axon_sync::display_names=trace to see each stored value’s account and room, never the name itself.
A room_details.changed WebSocket frame tells a client which of a room’s reads to repeat (ADR 0126 decision 5).
{
"type": "room_details.changed",
"account_id": "…",
"payload": {
"room_id": "!room:example.org",
"sources": ["metadata", "member_counts"]
}
}
| Source | Re-read | Published when |
|---|---|---|
metadata |
GET …/metadata |
A tuple of one of the eight types is inserted, replaced, or changes its content or redaction evidence |
member_counts |
GET …/info |
The member-count worker stores a different pair or clears one |
sources is never empty and lists sources in the order above; clients must ignore values they do not know, because later steps of issue 621 add homeserver_details.
The frame is a signal, not a payload: a client never applies a diff from it.
The store reports whether each write changed the stored row, so identical rewrites, redeliveries after a restart, and writes the freshness guard refuses publish nothing.
Two concurrent writes of the same tuple can both report a change; that costs one extra re-read.
Each account has one coalescer. The primary sync hands state to Axon one event at a time with no end-of-room signal, so producers record changes and the coalescer publishes one frame per room, with the union of its sources, once the account has had no change for 100 ms. Under continuous changes it publishes no later than one second after the first pending change, and at once when 256 rooms are pending, so neither a busy account nor a large first sync can grow the queue without bound. A room whose changes straddle a flush receives two frames. The coalescer is canceled and joined with the account run, after the workers that feed it, and publishes what is pending when it stops. SDK event handlers keep their context after the run stops, so a change they record later is published at once rather than held. The bus is lossy like every other frame, so a lagged or reconnecting client re-reads.
After the upgrade that added the frame, the first sweep of the supplementary connection publishes one frame per room that has guest-access or server-ACL state, at the pace of its 100-room range growth.
Advertised aliases are the canonical alias and alt_aliases in m.room.canonical_alias.
These can be read locally when that state tuple is cached.
They are not a complete federation-wide alias enumeration.
The separate Matrix /rooms/{roomId}/aliases endpoint lists aliases maintained by the account’s homeserver and requires its own permission-aware acquisition and cache.
Client follow-ups should use “Advertised aliases” and “Aliases on your homeserver” rather than “Full alias list.”
See the Matrix alias API.
Axon uses matrix-sdk-ui 0.19.1’s SyncService room-list required-state defaults.
Those request canonical-alias, creation, join-rule, encryption, power-level, and history-visibility state, including their full content.
The defaults do not request guest-access or server-ACL state.
Axon’s supplementary state-only connection (axon-state, the custom emoji transport from ADR 0124) requests both for every non-invite room, in its existing 100-room pages, and delivers later changes live.
It feeds the same state projection, so these two snapshots fill after its first sweep and change like the others.
What either connection delivers is positive evidence only: a type it does not deliver stays unknown, because Sliding Sync does not guarantee complete required state.
Even a requested state type can be unknown before room hydration or if no tuple was supplied.
The API deliberately does not hide these gaps with default values or perform a remote fetch on every detail read.
Issue 621 tracks the rest of ADR 0126: confirming known absence through the room-state endpoint, and acquiring homeserver-local aliases and directory publication.
Authoritative member counts are cached separately as described above; the lazily loaded member list is not an authoritative count. Paginated unjoined-room discovery and summary enrichment are issue 622. The discovery cache, progressive updates, and acquisition budgets follow ADR 0111 and are not implemented by the cached state endpoint. Web and TUI consumption are issue 623 and issue 624.
The HTTP integration tests seed real PostgreSQL state and exercise the authenticated router without an upstream service. The regression suite covers already-redacted state, filtered redaction hints, queued bursts spanning multiple ticks, stale SDK event IDs, raw redaction rows without applied evidence, both log/state arrival orders, account/room isolation, replacement events, ordered and concurrent replay protection, shared display projection repair, and evidence persistence after reopening the store. A PostgreSQL-gated SDK-cache test applies a redaction in the real SDK state store, invokes the actual reconciliation consumer without re-dispatching a state event, and verifies retained fields and purge safety. Use a throwaway database; these tests run migrations and write fixture accounts.
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-api --test http room_metadata -- --ignored
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-store --test state -- --ignored
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-sync --lib state_redaction -- --include-ignored
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-store --test member_counts -- --ignored
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-sync --lib member_counts -- --include-ignored
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-sync --lib display_names -- --include-ignored
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-sync --lib supplementary_state -- --include-ignored
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-store --test state room_state_upsert_reports_only_real_changes -- --ignored
cargo test -p axon-sync --lib room_details
cargo test -p axon-api --lib room_details_changed
DATABASE_URL=postgres://axon:axon@127.0.0.1:5432/axon_test cargo test -p axon-api --test http room_state_read_endpoints -- --ignored
cargo test -p axon-sync --lib room_sweep
cargo test -p axon-api --test openapi
For a running instance, call the new endpoint for a joined room with advertised alternative aliases and compare its aliases.content with the room’s Matrix state.
Replace the alias state with a newer event that removes the canonical alias and empties alt_aliases; the next read must contain the replacement event ID and empty list.
Query a different account or unknown room and verify that missing tuples remain unknown.
Do not paste bearer tokens, raw event bodies, or private room identifiers into verification logs.
Use an isolated Synapse/PostgreSQL/Axon stack with disposable Matrix accounts and persistent SDK storage. Do not point these tests at an operator database or use production credentials.
ban and invite thresholds and restricted join rules with an allow condition./metadata to report the state event IDs, then redact each power-level and join-rule event through Matrix.
Poll until Axon reports positive evidence and the matching redaction ID.
The ban threshold and restricted join conditions must survive; invite is removed in version 10 and retained in version 11.
Check that /info uses the same retained join rule./v1/rooms clears its name after reconciliation.The fixture homeserver may reject creation-event redactions; verify that a rejected request leaves Axon’s evidence unchanged rather than treating the attempt as an applied redaction. These small-room checks validate reconciliation and recovery, not throughput for hundreds of rooms.