A human-centric, cross-silo tracker for one recurring problem: Axon ships a server capability, one client adopts it, and there is no single place that says whether the other clients ever caught up. This surfaced concretely in review of issue #279 (Adam’s comment): TUI had no tracking against web once web existed, web now reads ephemeral indicators (typing, receipts) that TUI still doesn’t, and interactive SAS device verification shipped a full UI in TUI but never got one in web.
This doc is not auto-generated and does not replace AGENTS.md’s
“Current state” (server-side landing history) or the ADR log (design
decisions). It answers one question per row: for a capability the server
already exposes, which clients actually surface it to a user?
crates/, clients/tui/, clients/web/) — per AGENTS.md’s
docs/ cross-silo exception, this file can land alongside a change in any
one silo without violating the one-silo-per-PR rule.Legend: Done · Gap (server has it, this client doesn’t) · Planned (designed, not landed) · Not started · Deferred (deliberately not building yet)
There is no column for the packaged desktop and mobile builds, and that is
deliberate. ADR 0102 withdrew the separate SwiftUI and Compose clients: those
builds are the same dist as axon-web inside a Tauri shell, so on every
capability in this table their value is axon-web’s value, by construction. A
column of its own could only ever repeat the one beside it.
What the shell adds is not Matrix capability but platform plumbing — transport,
deep links, file save, external links, camera permission, the OS credential
store — none of which is a row here. Where the shell genuinely cannot follow
axon-web, the Notes column says so: the known case is service workers, which
ADR 0102 keeps banned, so anything built on one (issue #23’s media service
worker) is web-only.
| Capability | Server (/v1/) | axon-tui | axon-web | Notes |
|—|—|—|—|—|
| Text send / edit / redact / react | Done (M6, ADR 0021) | Done | Done | |
| Custom reaction images (MSC4027) | Done — authenticated media proxy accepts active reaction MXC keys | Gap — displays reaction keys as text | Done — room and thread reaction chips load images through the media proxy | Existing keys remain unchanged when toggling; web supports available pack selection; TUI selection remains deferred |
| Custom emoji pack discovery | Done — personal/current-room/canonical-ancestor pack snapshots, preview authorization, and optional shortcode on reaction sends | Gap | Done — searchable available packs in room/thread reaction pickers and /react shortcode completion | Subscribed packs from other rooms are deferred |
| Standalone sticker sends | Done — sticker pack mode and send-sticker, including threads | Gap | Done — searchable composer picker, standalone shortcode sends, replies, threads, and retry | Personal, current-room, and joined canonical ancestor packs; mixed-use images can be both reactions and stickers |
| Bundled URL preview text cards (MSC4095) | Done — metadata passed through in event content | Gap — no preview cards | Done — room/thread messages and media captions | Accepts m.url_previews and com.beeper.linkpreviews; formatted spoilers suppress cards; image rendering is tracked separately below |
| Opt-in homeserver preview generation | Done — explicit account-scoped helper, bounded staged thumbnail retrieval and nonfatal fallback outcomes | Gap | Gap | Generation discloses the URL to the homeserver; local recovery retains bytes independently of server expiry |
| Bundled URL preview images | Done — authenticated media proxy resolves plaintext/encrypted preview descriptors | Gap — no preview cards | Done — bounded room/thread/caption image cards, with text fallback | MSC4095 and Beeper aliases; no linked-page or remote image fetch; authoring remains separate |
| Bundled URL preview sends and protected images | Done — optional typed previews on text/caption sends, staged thumbnails and encryption-aware Matrix media/proxy | Gap — no preview authoring | Gap — no preview authoring | ADR 0125; native share intake remains a separate stage; see send contract |
| Media send (m.image/m.file) | Done (M15, ADR 0059) | Done | Done | |
| Media read proxy + LRU cache | Done (M11, ADR 0045) | Done | Done | |
| Media preview (audio/video/pdf/text) | n/a — client-side rendering over the existing proxy | Gap — download only (a terminal cannot play media) | Done (ADR 0072) — tap/click the card to open the shared viewer; full-width captions and a top-right download icon; message touch gestures preserved | Blob-backed with per-kind size ceilings; Range streaming deferred to a media service worker |
| Syntax highlighting (code blocks + text attachments) | n/a — client-side rendering | Gap — plain text | Done (ADR 0073) — highlight.js core + per-language lazy chunks; language from language-* class, extension, or shebang | The sanitizer has preserved language-* classes since ADR 0046; nothing consumed them until now |
| Image gallery rows (adjacent images from one sender) | n/a — inferred client-side; Matrix has no album and Axon cannot carry a marker (#130) | Gap — one row per image | Done (ADR 0081) — adjacency-inferred grid, ungroup control remembered for the session | Grouping is a heuristic: same sender, ≤60s apart, same day, nothing between. A bridge stamping identical timestamps will group images that were never one post |
| Lightbox paging across a timeline | n/a — client-side | Gap — single image view | Done (ADR 0081) — arrows, swipe, bounded auto-pagination into history | Paging spans the timeline rather than a gallery, so a mis-grouped run cannot become a navigation cage; only the counter is run-aware |
| Save a displayed image to the device | n/a — client-side over the existing proxy | Done — download to disk | Done (ADR 0081) — share sheet where the platform takes files, transient anchor otherwise | window.open is banned repo-wide for the eventual Tauri desktop shell (M-W12, groundwork only — not yet released), so the anchor is the only sanctioned path |
| HEIC / HEIF images | n/a — the proxy passes the sender’s bytes and type through untouched (is_inline_safe) | Gap — no HEIF decoder in the image crate; sniffs the format and says so, no render | Gap — Chromium and Gecko decode no HEIC; names the format and offers Download (ADR 0101), renders natively under WebKit | A shared limitation, not a web bug: whether to ship a decoder is the open question in ADR 0101, and it is a license decision (libheif is LGPL-3.0) more than a rendering one |
| Media thumbnail proxy | Done (M17, ADR 0063) | Gap — client-side downscale only; doesn’t call the server endpoint | Done | Called out explicitly in AGENTS.md’s M17 note |
| Full-text search | Done (M9) | Done — interactive filters, relevance/date sort, and initial room grouping | Done | |
| Drafts (cross-device) | Done (M12, ADR 0048) | Done | Done | |
| Read markers (cross-device, Axon-internal) | Done (M12, ADR 0048) — the underlying device_state store was dropped by a force-push and restored in PR 226; the TUI read-marker feature itself was separately reverted (116b3cb) and re-landed in PR 217 (clients/tui/src/app/read_markers.rs) | Done | Done, plus a thread_read_markers namespace TUI doesn’t have | Reverse gap: web is ahead here |
| Inbound ephemeral passthrough (typing, receipts) | Done (M18, ADR 0056) | Done (app/ephemeral.rs; typing + read receipts shown as a bottom-border status on the message pane) | Done (stores/ephemeral.ts) | Adam’s motivating example |
| Outbound read receipts to homeserver | Done (#278, ADR 0067) — POST .../rooms/{room_id}/read | Done — second spawn in read_markers::spawn_read_put; a receipt that failed transiently (no answer, 5xx, 429) is reported back and marked failed, so the next read naming it re-sends it (#625) | Done (stores/ephemeral-sender.ts, fired from RoomPage’s read-marker choke point; the floor advances only on acceptance, and a transient failure is retried with backoff, #625) | Both piggyback on their client’s existing debounced, forward-only read-marker choke point; best-effort, never surfaced to the user |
| Read receipts that cover thread replies | n/a — client-side (ADR 0096); the route already accepts any event id | Not started — read_targets_for filters thread members out of the target, so an arrival-newest reply stays unreachable (#207) | Done — ThreadPanel names its arrival-max displayed member behind RoomPage’s caught-up gate | Without it, a room whose arrival-newest events are all thread replies badges on every load (#207) |
| Read receipts that cover a redaction-hidden event | n/a — client-side; the route already accepts any event id | n/a — should_show_event has no redaction filter, so a redacted event stays a receipt candidate and never blocks the target | Done (#339) — the receipt candidate set admits events hidden only by redaction, which the rendered timeline still hides | A trailing event redacted moments after it arrives otherwise pins the room’s server-derived count forever (#337). TUI is immune structurally rather than by design — adding a hide-redacted setting there would reintroduce it |
| Thread read state from other clients’ receipts | Done (M18, ADR 0056) — m.receipt is forwarded verbatim, thread_id included | Not started | Done (connectThreadReceipts; a thread read in Element clears here, durably) | Needs the MSC3771 thread_id the passthrough already carries; axon’s own receipts stay unthreaded (ADR 0096) |
| Outbound typing notice | Done (M19a, ADR 0068) — PUT .../rooms/{room_id}/typing | Done — app/typing.rs, driven by note_draft_activity + a flush_due_typing tick | Done (stores/ephemeral-sender.ts, driven by the composer) | Throttled true, cleared on empty/command/submit/room-switch/idle |
| Interactive SAS device verification | Done (7a-6, ADR 0027/0028) | Done — full emoji-modal flow | Done — Accounts picker + emoji modal; inbound attention band above Invites and a topbar chip; Esc parks | Web uses GET …/devices rather than TUI’s pasted id; no auto-open. User information’s Verify opens a coming-soon notice; the web client still cannot start a cross-user verification. |
| Matrix OAuth QR account acquisition | Done (ADR 0097) — authenticated, polling-based display/scan flows at /v1/accounts/login/qr; successful login is accepted only when Axon’s new device is cross-signed | Planned — display QR, enter/display check code, poll, cancel | Done — unified /accounts acquisition with lazy QR display, camera/image scan, check-code handling, approval link, polling, reload resume, and cancellation | MAS/Synapse interoperability and black-box smoke remain the final integration slice after client UIs |
| Matrix OAuth browser sign-in | Done (ADR 0117) — authenticated, polling-based flow at /v1/accounts/login/browser using the device authorization grant; yields a link and user code, and activates the account unverified | Planned — show link and code, poll, cancel | Done — third method on /accounts: link opened in a new tab, user code, polling, cancellation, resume after reload; a finished sign-in stays on the page and leads into verification or recovery | For homeservers with no password login and users with no second Matrix client; real MAS/Synapse smoke lane is the next slice |
| Matrix OAuth QR device authorization | Done (ADR 0097) — account-scoped polling flows recheck current trust before releasing secrets and require authorization-server approval | Not planned — no concrete terminal grant use case | Done — unified /accounts grant UI with camera/image scan, QR display, check codes, approval link, polling, cancellation, flow-id-only reload recovery, and expected-account guidance | Element X/MAS interoperability and black-box smoke remain the final integration slice |
| Device-list / picker endpoint | Done (M16, ADR 0060) | Gap — no picker UI; verification still requires a blind device id | Done — DevicePicker on Accounts consumes GET …/devices (self only); User information lists the devices of the user it shows | TUI still pastes a device id |
| Room-state reads (spaces, pins, info, upgrades) | Done (ADR 0084) — typed children/parents, pinned-event, room-info, and upgrade-chain reads | Partial — shallow space tree, collapse/expand, and live children refresh (ADR 0103); /whereami reads /info member counts, the loaded /members projection, and the /upgrade chain (ADR 0114); pins and space parents remain outstanding | Done — a browser-local Spaces picker filters direct children; Room Information shows state details, pins, relationships, and upgrade links | room_type on RoomDto identifies joined spaces without probing every room |
| Extended cached room metadata | Done — typed /metadata snapshots with availability, event provenance, and explicit redaction evidence; guest access and server ACL from the supplementary connection, and room_details.changed on each change (ADR 0126); details and acquisition gaps | Partial (ADR 0114, issue 653) — /whereami renders all eight snapshots with explicit unknown, partial, and redacted wording; an open popup re-reads on reconnect, after a metadata or tombstone change or a redaction, and every 30 seconds measured from when the previous read settled (issue 657) | Not started (issue 623) | Advertised aliases, creation, join conditions, encryption settings, configured power levels, ACLs, history, and guest access; missing state stays unknown. Clients refresh an open panel on room_details.changed instead of polling |
| Authoritative room member counts | Done (ADR 0111) — cached SDK summary counts and observation time in /info; unknown until observed; room_details.changed names member_counts on each change (ADR 0126) | Done (ADR 0114) — /whereami shows joined and invited counts with the time they last changed, and unknown until observed; an open popup re-reads on reconnect, after a metadata or tombstone change or a redaction, and every 30 seconds measured from when the previous read settled (issue 657) | Not started (issue 623) | Independent of the partial /members list, which /whereami shows separately and does not treat as complete (#659), and which /whereami can filter locally by name or Matrix ID (#656); leave invalidates counts and restart uses bounded reconciliation |
| Calculated room display names | Done (ADR 0118) — RoomDto.display_name from the SDK’s spec naming algorithm, room_display_name.changed | Done (ADR 0118) — server-calculated titles, live updates, filtering, sorting, and /room completion | Done (stores/room-list.ts’s roomTitle, fed by RoomDto.display_name and room_display_name.changed) — every unnamed room is titled whether or not m.direct lists it; /members is fetched only for a 1:1 DM’s peer avatar | Clients should title rooms name, then canonical_alias, then display_name, then the room ID |
| Room membership (leave/forget/invite/kick/ban/unban) | Done (M19b, ADR 0068) — POST .../rooms/{room_id}/{leave,forget,invite,kick,ban,unban} | Done (ADR 0079 TUI-M19-1) — slash commands /leave, /part, /forget, /invite, /kick, /ban, /unban | Partial (M19-W1/W4) — /leave, /part, /forget, /invite, /cancel, and Room Information invite/cancel-invite; kick/ban/unban remains | TUI exposes moderation as selected-room slash commands; web M19-W5 will add kick/ban/unban |
| Room entry (join/knock/create) | Done (M19c, ADR 0068) — POST .../rooms/{join,knock,dm} and POST .../rooms | Not started | Done (M19-W2/W3/W4) — /join, /knock, Matrix room-link interception, opt-in browser matrix: handler, visible find/join plus directory join-from-result, and create-room/DM flows | Web W4 also wires Room Information member DM actions; homeserver-wide user-directory search remains a follow-up server/API gap |
| Room settings (name/topic/avatar/tags) | Done (M19d, ADR 0068) — PUT .../rooms/{room_id}/{name,topic}, PUT/DELETE .../rooms/{room_id}/avatar, and PUT/DELETE .../rooms/{room_id}/tags/{tag} | Not started | Partial (M19-W6, ADR 0100) — name/topic/avatar editing in Room Information, with a power-level caution that never blocks, and staged-avatar upload; tags not built | tags writes m.tag room account data, not a state event. Reads and live fan-out shipped in ADR 0103 / #367 (RoomDto.tags, write-path upsert, account_data.changed); M19-W7 writes m.favourite from the room list (#368); Room Information still does not edit tags |
| Tags and is_direct on the room list | Done (ADR 0103, #367) — RoomDto.tags / is_direct, write-path m.tag upsert, account_data.changed | Done (#369) — /pin /unpin p/Shift-p write m.favourite; /filter fav and the divider read it; local pinned_rooms migrate once (homeserver wins); is_direct replaces the name/alias DM heuristic | Done (#368) — star and /pin write m.favourite; drag-reorder and Alt-↑/↓ reorder the favourite prefix; local pinnedRooms migrates once | Empty tags is omitted on the wire. Favourites are Matrix m.favourite; local pin lists migrate once per client. TUI has no drag-reorder; pin-to-top only |
| Instance preferences (space_order) | Done (ADR 0103, #367) — instance_preferences, GET/PUT /v1/preferences/{key} allowlisted to space_order, preferences.changed | Done — TUI root ordering via GET/PUT, cross-client updates, and reconnect refresh (ADR 0103) | Done (#368) — settings.spaceOrder migrates once to space_order; preferences.changed restacks the rail | Not Matrix account data: one mixed-account rail, last-write-wins, echo-suppressed by device_id. Unknown keys 400. Unset GET is 404 so a client can one-shot upload |
| Mobile message gesture preferences | Done (ADR 0104) — message_gestures is a strictly validated instance preference | N/A — no touch interface | Done (ADR 0104) — configurable double tap, touch and hold, and swipe left; timestamp hold copies message text | Shared across clients of one Axon instance. Physical swipe right remains mobile back navigation |
| Power levels | Done (M19e, ADR 0068) — PUT/GET .../rooms/{room_id}/power_levels | Not started | Partial — GET is read by Room Information to caution about room-settings editing (ADR 0100); never to block, and no editor yet (M19-W8) | Server-only for writes; client UI (power-levels editor, self-demotion confirmation) is separate follow-up work. The read-only client check is advisory and cannot block: PowerLevelsDto carries no events map, and from room version 12 a creator holds infinite power yet never appears in users, so a creator reads as users_default (#324). The homeserver’s 403 stays authoritative. Write merges role thresholds and per-user levels into one m.room.power_levels event; rejects a change that would strand the caller below the level needed to self-correct unless acknowledge_self_demotion is set |
| Account actions (profile/ignore/directory search) | Done (M19f, ADR 0068) — PUT .../profile/display_name, PUT/DELETE .../profile/avatar, GET .../users/{user_id}/profile, PUT/DELETE .../users/{user_id}/ignore, GET .../directory/public_rooms | Not started | Partial — public-room directory search; User information reads GET .../users/{user_id}/profile (room member name and avatar stay the header when this room has a roster row); profile editor, ignore-list management, and starting cross-user verification remain | public_rooms is a paginated read, not a mutation like the other four |
| Invited-room visibility (see incoming invites, accept/reject) | Done (ADR 0091) — persisted room_invites, GET /v1/invites, invite.added / invite.removed WS frames. Accept/reject reuse existing join/leave. | Gap — no inbox; nothing consumes GET /v1/invites or the invite frames | Done — Invites row + /invites inbox with Accept/Reject and Accept all/Reject all | ADR 0091 scoped client UI as “web first”, TUI out of scope for that ADR — not a decision against a TUI inbox; known-contact DM auto-join (ADR 0040) is unchanged |
| Presence (inbound + outbound) | Deferred (ADR 0056) | Not started | Not started | No ADR planned until the lag question is addressed |
| Unread / notification counts | Done (ADR 0070, issue #313) — RoomDto’s persisted notification_count/highlight_count plus the unread_counts.changed WS frame | Gap — RoomEntry.unread_count is a local, live-only heuristic (app/timeline.rs); doesn’t consume the server-persisted field, so counts still reset on restart | Done (stores/rooms.ts’s unreadCount/unreadTotal, fed by RoomDto and unread_counts.changed); app-icon badge also ships (ADR 0080) | Web-client consumption was ADR 0070’s own deliberate follow-up and has since landed; TUI is now the outstanding consumer |
| Remote push for a suspended phone | Partial — registration routes Done (PUT/DELETE /v1/push/registrations, PR #734); delivery is still the remaining server half (issue #604) | N/A — a terminal has no device token | Done on the packaged iOS, iPadOS, and Android shell when message notifications are on; the browser and desktop shells do not register | Default disclosure is activity. Web Push, Windows push, and waking a quit Mac app are out of this cut |
| Megolm key backup (originate + honesty) | Done (ADR 0098) — AccountDto.backup snapshot, recover auto-enable/join/export-resume, POST /v1/accounts/{id}/backup/enable, recover flatten + redecrypt/backup_action | Done — /backup enable [user] (masked key; empty Enter kicks upload) and /status snapshot; /recover unchanged | Done — account-card snapshot + badge, honest recover/login notices (backup_action, never “Keys recovered”), Enable backup (optional key; muted when already uploading), UTD “Recover keys” link to /accounts | Server GET cache of exists_on_server may lag another client; enable then 409s. No account.backup WS yet (#292). Recover does not decrypt pre-Axon history |
| Native Sign in with Apple (/v1/oauth/apple/native/*) | Done (ADR 0054 PR 3) — nonce-bound challenge, keyless verification, explicit bind/bootstrap | N/A — a terminal has no Apple SDK | Done in the iOS shell only (ADR 0054 PR 4) — native sheet for login, Settings link for bind, Keychain token storage; a browser keeps the redirect flow | The one row where the shell differs from axon-web in a browser, in the opposite direction from service workers: the native path needs an OS SDK that a browser does not have |
| Management API: linked sign-in identities (/v1/management/oauth/identities) | Done (ADR 0109 step 2) — list and unbind behind the server.management_api switch; credential changes need a non-expiring token or a sign-in within ten minutes (recent_sign_in_required); last-credential guard (last_credential, allow_lockout=true); status.management.enabled | Not planned yet — decision tracked in #585 | Done (ADR 0109 step 3, #580) — Settings lists the linked sign-ins with Unlink, hidden when the list answers management_disabled (it does not call /v1/status, whose backfill query is too heavy to run per visit); the confirmation names the provider’s own controls and explains last_credential before repeating with allow_lockout=true; recent_sign_in_required re-runs the session’s sign-in (native sheet in place, or the browser redirect, which returns to Settings) and finishes the change, for unlinking and for linking an Apple ID alike | Unbinding makes Axon forget the identity; it does not revoke the provider’s own authorization |
| Management API: tokens and identity binds (/v1/management/tokens, /v1/management/oauth/binds) | Done (ADR 0109 step 4, #581) — list (no secrets, current, created_by_token_id), mint (secret returned once, no-store) and revoke with the last-credential guard; start a browser bind and poll its status (pending / completed / expired); mint, revoke and bind need step-up; bind_unavailable when OAuth or the provider is off, 429 past five pending binds; a completed bind stays readable for a day | Not planned yet — decision tracked in #585 | Not started — ADR 0109 step 7 | The CLI verbs (token issue/list/revoke, oauth bind) are unchanged and share the bind implementation |
AGENTS.md’s “Current state” section.docs/adr/.