Audience: an agentic coder (Claude Code or similar) scaffolding and building the Axon MVP. Reads top to bottom; a coder following it without reading the other docs should be able to scaffold the workspace, run Postgres, and reach Milestone 1.
Related docs: prd.md, tech-spec.md.
End-state target. Create incrementally as milestones land.
matrix-axon/
Cargo.toml # workspace
AGENTS.md # canonical orientation for agentic contributors
CLAUDE.md # one-line pointer to AGENTS.md
crates/
axon-server/ # binary; wires components together
axon-core/ # shared types, errors, config
axon-store/ # Postgres + sqlx; event store, account data
axon-sync/ # matrix-rust-sdk sync engine wrapper
axon-crypto/ # RESERVED STUB — verification lives in axon-sync (ADR 0027)
axon-search/ # Tantivy index
axon-media/ # media proxy + disk-cache backend
axon-api/ # axum HTTP + WS handlers, OpenAPI (utoipa)
(each crate has its own README.md + crate-level //! rustdoc)
clients/
tui/ # axon-tui (terminal client; the alpha client)
openapi/ # spec source of truth (handwritten + utoipa-emitted)
docs/
mvp/ # this directory
adr/ # architecture decision records
self-hosting.md # produced in Milestone 13
docker-compose.yml # Postgres for dev
axon-tui is the alpha client — a terminal client that exercises the full /v1/ surface end-to-end. It replaced the originally-planned axon-web (Vite + React) as the reference client; the API is the deliverable, and the TUI is the integration surface that proves it. A web client remains a credible later addition consuming the same API.
Cargo.toml once initial scaffolding lands.axon-tui, a Rust terminal client, replacing the originally-planned Vite + React web alpha.axon-tui, being Rust, consumes the API types directly.tech-spec.mdRead the tech spec before starting. Highlights that gate implementation:
account_id.events.provenance defaults to upstream_homeserver.origin_ts is bigint milliseconds since Unix epoch (matches Matrix origin_server_ts).type = m.room.redaction and redacts = <event_id>; the target row’s content is masked at read time, original ciphertext / megolm metadata preserved./v1/ws, envelope {type, account_id, payload}.axon CLI subcommand./v1/.account_id is a facet field.Each milestone has explicit deliverables and a verification step that exercises real behavior, not just cargo check. Stop and ask before deviating; if an ambiguity arises that the specs do not cover, raise it instead of picking silently.
Status: Milestones 1–19 have shipped. MVP has not: it is gated on finishing M13 (
docs/self-hosting.mdand the non-Docker deployment recipes) — see #221 and the M13 section below. What each milestone actually landed, and the non-obvious choices made along the way, is recorded per milestone below;AGENTS.mdkeeps the conventions those choices produced, not the history of making them. Shipped server-side is not the same as reaching a user: of the M19 verbs, room settings and power levels have no client consuming them at all, and account actions have only partly landed onaxon-web(axon-tuinot at all).docs/client-parity.mdtracks that per capability, per client.
lib.rs / main.rs stubs and minimal Cargo.toml files.docker-compose.yml running Postgres 16 with a named volume.cargo fmt --check, cargo clippy -- -D warnings, cargo test.AGENTS.md and a one-line CLAUDE.md pointer (see “Documentation for agentic contributors” below).docs/adr/ with 0001-record-architecture-decisions.md (the meta-ADR adopting the practice).Verification: docker compose up -d postgres && cargo build succeeds; CI passes on the first push.
axon-core (figment or config-rs). Sources: TOML file + env overrides.axon-store opens a sqlx pool, runs migrations from crates/axon-store/migrations/.axon-server starts an axum server with a /healthz route.Verification: Run axon against the docker-compose Postgres; curl localhost:PORT/healthz returns 200.
Non-obvious choices made in Milestone 2 (see ADRs 0002–0003):
DATABASE_URL < AXON_-prefixed env (__ = nesting, e.g. AXON_SERVER__PORT). File resolved from an explicit --config/Config::load_from path, else $AXON_CONFIG, else ./axon.toml, else <platform config dir>/config.toml, else the pre-rename <legacy platform config dir>/axon.toml, else env-only. Sample at axon.toml.example.127.0.0.1:8080; pool max_connections = 5; log info (overridable by RUST_LOG).tls-rustls (no OpenSSL); migrations embedded via sqlx::migrate!. Store is a Clone handle over PgPool, shared into axon-api as router state.pgcrypto (no tables until M3–4). Timestamp-prefixed filenames — see ADR 0004./healthz: liveness-only, always 200, no DB ping.axon_core::Error is acyclic — its Store(String) variant carries a message so axon-core need not depend on axon-store; leaf crates impl From<LeafError> for axon_core::Error.query! macros yet, so sqlx compile-time checks aren’t triggered and tests need no DB. When checked queries land in M3, add a Postgres service or a .sqlx offline cache..pre-commit-config.yaml is the whole local gate — fmt, clippy, cargo test --all, the web job, and the cheap repo checks — enabled per clone with ./scripts/setup-hooks.sh (git) or the jj-hooks alias (jj). See the pre-push hook bullet above and ADR 0092. (Historical note: this used to describe .githooks/pre-commit as a fmt + clippy subset with “full cargo test stays in CI”. Both halves were wrong at that time — .githooks/pre-push had been running cargo test --all, while CI gained its required pull-request copy later.)Milestone 2 complete — the binary boots: typed config, Postgres pool + migrations, and an axum server with /healthz.
accounts table in axon-store: (account_id, user_id, homeserver_url, device_id, access_token_encrypted, sync_token, created_at, …).axon-sync wires one matrix-rust-sdk Client per account. MVP provisions a single account from config but the code path iterates over all rows in accounts.axon-store scoped by account_id with provenance = 'upstream_homeserver'.Verification: Point axon at a Synapse running in docker; log in a test account; watch decrypted rows accumulate in the events table over a fresh sync.
Non-obvious choices made in 3c (see ADR 0014):
crates/axon-sync/src/redecrypt.rs): two drivers — the SDK’s room_keys_received_stream() (drains the pending UTDs for each arriving (room_id, session_id)) and a startup sweep (catches keys already in the crypto store or imported by recover() before we subscribe). By default the startup sweep is a bounded, one-time-per-row pass: SweepScope::StartupUnattempted selects only rows that have never had a startup attempt (utd_startup_redecrypt_attempted_at IS NULL) and marks each attempted row afterward so it is not retried on every boot (issue #9). Operators can opt into the legacy every-boot full sweep (SweepScope::AllPending, always_redecrypt_utds_on_startup=true), and the same AllPending scope backs the explicit POST /v1/accounts/{id}/utds/redecrypt retry endpoint. The sweep first calls backups().download_room_keys_for_room() per room — recover() imports the backup decryption key but not the megolm room keys themselves, so on a quiet account nothing else would fetch them; the arrival-stream path skips the download (its keys just landed). Runs as a child task of run_account on a child CancellationToken, joined on return. Per-row failures are logged and skipped — never fatal to sync. ADR 0014.events.megolm_session_id hot column + partial index: UTDs lift content.session_id into a first-class column; events_pending_utd_idx (account_id, room_id, megolm_session_id) WHERE content IS NULL makes the arriving-key lookup an index hit rather than a JSONB scan. The back-fill UPDATE … WHERE content IS NULL guard is idempotent and won’t clobber a row a live dispatch already decrypted.recover() (ADR 0011, 0014): originally consumed a config-supplied sync.account.recovery_key once on boot to import the megolm backup + cross-signing keys (the queue’s driver on a fresh, unverified device); that boot path is retired along with the rest of config-based provisioning (ADR 0024) — the recovery key is now always supplied via POST /v1/accounts/{id}/recover (7a-5, ADR 0026) instead. Never persisted either way — no column on accounts; durable at-rest storage + interactive verification stay in M4/M5.Non-obvious choices made in 3b (see ADR 0012):
Client::add_event_handler(persist_timeline_event) with AnySyncTimelineEvent + RawEvent context. Registered on the Client before SyncService::start() so no events are missed during initial sync. matrix-rust-sdk decrypts Megolm payloads before dispatch, so raw_event in the events table holds the plaintext envelope for decrypted events (or the m.room.encrypted envelope — ciphertext + session_id — for UTDs). ADR 0012.(id BIGSERIAL, event_id TEXT, room_id TEXT, account_id UUID, sender TEXT, origin_ts BIGINT, event_type TEXT, content JSONB, raw_event JSONB, provenance TEXT DEFAULT 'upstream_homeserver', received_at TIMESTAMPTZ). raw_event is the full event envelope as dispatched (plaintext for decrypted events, the m.room.encrypted ciphertext for UTDs); content is the extracted decrypted payload. Unique on (account_id, event_id) — upsert is idempotent. content is nullable: UTDs arrive as m.room.encrypted with content = NULL; the M3c re-decryption queue back-fills those rows, reading the ciphertext straight from raw_event. Index on (account_id, room_id, origin_ts DESC) for timeline reads. M4 (“Event store schema”) adds hot-column refinements and sibling tables holding the original ciphertext + megolm session metadata + sender device keys (keyed by event_id, for every event) so decrypted rows stay re-verifiable against Matrix’s signatures — distinct from M3c re-decryption, which reads UTD ciphertext straight from raw_event.json feature to sqlx-postgres (transitively enables sqlx-core/json) so serde_json::Value binds as JSONB.SIGINT blocked in the kernel pending queue, never delivered to any handler). No code change to axon was needed. The original with_graceful_shutdown + tokio::signal::ctrl_c() shutdown path is correct. If Ctrl-C does not work, run python3 -c "import signal; print(signal.pthread_sigmask(signal.SIG_BLOCK, []))" — if 2 appears, open a new terminal window.Non-obvious choices made in 3a (see ADRs 0006–0008, 0010, 0011):
matrix-sdk (→ rusqlite → libsqlite3-sys 0.35) collides with the sqlx umbrella’s sqlx-sqlite (libsqlite3-sys 0.28–0.30) over the links = "sqlite3" native lib. Fix: drop the umbrella for sqlx-core + sqlx-postgres directly (pinned =0.8.2), embed migrations via include_dir (hand-built Migrator, same checksum format as sqlx::migrate!), and align sqlx onto the aws-lc-rs rustls provider to match matrix-sdk (two providers → runtime TLS panic). ADR 0006.matrix_sdk_ui::sync_service::SyncService (not the low-level SlidingSync); one Client + one SQLite store per account under sync.data_dir/<account_id>; one supervised task per account, restarted with exponential backoff on SyncService Error/Terminated; CancellationToken for graceful drain. ADR 0007.pgp_sym_encrypt keyed by sync.store_key; password consumed once, never stored. Tokens treated as long-lived; M_UNKNOWN_TOKEN recovery + MSC2918 refresh deferred to M4. ADRs 0008, 0010.query/query_as (no query! macros, no compile-time DB); FromRow hand-implemented. CI stays DB-free; account store methods covered by #[ignore] integration tests (cargo test -p axon-store -- --ignored with DATABASE_URL).axon device is unverified, so encrypted rooms show UTDs until it obtains keys. Two complementary paths (ADR 0011): the bootstrap/fallback account recovery key (Secure Storage / 4S) — one recover() call unlocks both key backup and cross-signing — landed in M3c (transient-only); the mature path, BFF-proxied interactive verification (axon streams the SAS emoji over /v1/ws so the user verifies the axon session from the axon client; after trust, the user’s other devices gossip the secrets so the recovery key never touches the server), is M5.event_id, room_id, account_id, sender, origin_ts (bigint ms), type, redacts, relates_to, decrypted_body_text.event_id.(room_id, origin_ts DESC), (account_id, room_id), unique (event_id), partial index on redacts where not null.origin_ts).decrypted_body_text for redacted events and emit a redacted_because field; original ciphertext / megolm metadata stay in sibling tables.Verification: SQL queries: paginate the most recent N events in a room reverse-chronologically; redact an event and confirm timeline reads mask its content while ciphertext sibling row remains; cursor-based pagination returns stable results across calls.
E2EE key acquisition & device trust (ADR 0011). A fresh axon device is unverified, so encrypted rooms show UTDs until it obtains keys. Two complementary paths; this milestone builds both pieces that can stand alone, while the interactive verification UX that exercises the second lands in M7a (over the M5 WebSocket):
client.encryption().recovery().recover(key) restores both the Megolm key backup (history) and the cross-signing private keys (so axon self-verifies and future keys flow). Add sync.account.recovery_key, encrypted at rest like the access token (ADR 0008) — prefer transient-only handling of this crown-jewel secret. This path needs no client, so it’s what lets this milestone prove decryption end-to-end before any front-end exists.VerificationRequest, accept(), read sas.emoji() / sas.decimals(), confirm() / cancel(). “Headless” means axon has no UI of its own, not that it can’t verify; the SDK API is fully programmatic. The user-facing emoji exchange can’t be exercised until the /v1/ws WebSocket exists (M5); the interactive verification UX itself was deferred and now lands in M7a alongside the rest of the Matrix-account lifecycle. Note: the layout originally called axon-crypto “the thin verification surface over rust-sdk crypto,” but the M7a-6 implementation lives in axon-sync (it needs ClientManager / identity locks / supervision); axon-crypto stays a reserved stub — see ADR 0027 for the boundary rationale.Verification (E2EE): against a real homeserver with key backup enabled, supply sync.account.recovery_key, confirm UTD rows flip to decrypted = true as backed-up keys arrive, and that axon shows as a verified/cross-signed device.
Non-obvious choices made in 4a (see ADR 0015):
events): redacts, relates_to (JSONB, captured generically incl. m.thread but not thread-indexed — threads deferred), decrypted_body_text; plus (account_id, room_id) and a partial (account_id, redacts) WHERE redacts IS NOT NULL index. Unique stays (account_id, event_id) (account-scoped), a deliberate deviation from the spec’s literal (event_id).(account_id, event_id), FK→events ON DELETE CASCADE: event_ciphertext, event_megolm_session, event_sender_device_keys. The megolm + device siblings come from the SDK’s EncryptionInfo (handler extractor Option<EncryptionInfo> on the live path; Arc<EncryptionInfo> on the re-decryption path) for every decrypted event. event_ciphertext is written on the UTD path only — the SDK never surfaces the ciphertext of events it decrypts before dispatch, so live-decrypted events have no ciphertext row (documented limitation, ADR 0015). Sibling writes are best-effort: logged, never fatal to sync. The pure extractors live in crates/axon-sync/src/meta.rs.Store::room_timeline: newest-first, cursor on (origin_ts, id) (the BIGSERIAL id is the tiebreaker so pages never overlap/skip). Redaction is masked at read time via a LEFT JOIN LATERAL (LIMIT 1) — content/decrypted_body_text nulled, redaction_event_id set — leaving the stored row and ciphertext sibling untouched. No HTTP endpoint yet (that’s M5); verified via --ignored store tests.SyncServiceBuilder::with_room_list_timeline_limit, driven by new sync.timeline_limit (default 20). Deepens new syncs only; not retroactive backfill./v1/ws), so axon-crypto stays a stub until M5.Non-obvious choices made in 4b (see ADR 0016):
room_state / account_data are current-value projections, upserted in place — not logs. The raw events still land in events (state events are part of the timeline); these tables hold the resolved latest value a room-summary or read-marker read needs. room_state PK is the Matrix state identity (account_id, room_id, event_type, state_key); account_data PK is (account_id, room_id, event_type).room_id = '' (a NOT NULL DEFAULT '' sentinel — real room ids start with !), so the natural PK carries uniqueness and ON CONFLICT targets it directly. A nullable room_id would make two global rows for one type “distinct” under SQL NULL semantics and defeat the upsert. The store API maps room_id: Option<&str> ↔ '' at the boundary.room_state upsert is freshness-guarded (ON CONFLICT … DO UPDATE … WHERE EXCLUDED.origin_ts >= room_state.origin_ts): an older replayed state event can’t clobber newer state. account_data events carry no timestamp, so theirs is plain last-write-wins.crates/axon-sync/src/engine.rs): persist_state_event (AnySyncStateEvent), persist_room_account_data (AnyRoomAccountDataEvent), persist_global_account_data (AnyGlobalAccountDataEvent). All reuse the one PersistContext. The global handler takes no Room argument — global account data has no room, and the SDK skips a handler whose Room extractor fails. updated_at on both tables is maintained by the shared trigger_set_updated_at() trigger./v1/:
GET /v1/rooms (list rooms across all accounts; filterable by account_id; sorted by most-recent activity).GET /v1/rooms/{room_id}/timeline (paginated, reverse-chronological by default).GET /v1/events/{event_id}./v1/ws. Envelope: {type, account_id, payload}. Live timeline events fan out from the sync engine.axon-tui consumes the Rust API types directly, and a future web client can regenerate them from the OpenAPI spec).ApiResponse<T> / ApiError envelope type in axon-api and a
custom IntoResponse impl so all handlers return consistent JSON shapes and error
bodies. (Deferred from M2; designing against zero real handlers is premature.)Verification: Boot the server, curl /v1/rooms, hit /v1/rooms/{id}/timeline, open a websocat session to /v1/ws and see live events arrive tagged with account_id as new events come in over sync.
Interactive verification UX deferred to M7a. The SAS-emoji exchange over /v1/ws — formerly planned here as “M5c”, consuming the M4 plumbing — was never built. It now lands in M7a, where it belongs conceptually: verifying axon’s device for a Matrix account is part of that account’s lifecycle. M5 ships the /v1/ws channel the exchange rides on; M7a wires the flow. See M7a.
Non-obvious choices made in 5b (see ADR 0020):
tokio::sync::broadcast, owned by the sync engine. SyncEngine::live_events() hands a broadcast::Sender<LiveEvent> clone to AppState; the /v1/ws handler subscribe()s once per connection. A slow client gets RecvError::Lagged (skips the backlog, stays connected) — sync is never back-pressured by a client. Capacity 1024; the producer skips the work entirely when receiver_count() == 0.LiveEvent lives in axon-core (the only crate both sibling producers/consumers share) and is wire-neutral; axon-api owns the envelope and maps LiveEvent → EventDto. The WS payload is the same EventDto the read API returns.{ "type": "timeline.event", "account_id": <uuid>, "payload": <EventDto> }. type is namespaced so M5c verification frames extend it without colliding. Live frames are always redacted: false (a redaction is a separate later event)./v1/ws delivers events arriving after connect; history is the HTTP read API’s job. Not in the OpenAPI doc (a WS upgrade isn’t expressible in OpenAPI 3.1) — golden test unaffected. No auth yet (M8). Re-decryption back-fill of a UTD is not re-emitted over WS (documented scope limit).Non-obvious choices made in 5a (see ADR 0019):
GET /v1/rooms is the flat cross-account aggregate (newest-activity first, optional ?account_id= filter); detail routes nest the account: GET /v1/accounts/{account_id}/rooms/{room_id}/timeline and GET /v1/accounts/{account_id}/events/{event_id} (event under the account, not the room — the store keys events by (account_id, event_id)). A deliberate deviation from the spec’s literal flat routes, consistent with M7’s already-nested /v1/media/{account_id}/…. Convention going forward: nest account_id on all account-scoped resource routes — so M6 mutations become /v1/accounts/{account_id}/rooms/{room_id}/send, dropping account_id from the body.{ "data": <T> } (ApiResponse<T>), errors { "error": { "code", "message" } } (ApiError), each with one IntoResponse. StoreError → logged 500 with a generic body. Missing event → 404; bad cursor/param → 400; an unknown room’s timeline is an empty 200 page, not 404.(origin_ts, id) TimelineCursor is serialized to the wire as base64url("{ts}.{id}"), returned as next_cursor per page (null at the end); a malformed cursor is a 400. Codec in crates/axon-api/src/cursor.rs.AppState + FromRef seam. axon-api::router takes AppState { store } and handlers extract State<Store> via FromRef, so 5b can add a broadcast::Sender field with zero churn to existing handlers.openapi/openapi.json (regenerate with UPDATE_OPENAPI=1 cargo test -p axon-api --test openapi), making drift a CI failure. TypeScript client stubs are deferred to M11.list_rooms(Option<account_id>) aggregates events for activity + latest event id and pulls name/topic/avatar/alias from the room_state projection in one query (note avatar state is m.room.avatar → content.url). get_event(account_id, event_id) reuses room_timeline’s read-time redaction masking via a shared TIMELINE_SELECT projection.POST /v1/rooms/{room_id}/send (send message; payload includes account_id).
body is plain text; optional format + formatted_body (paired,
org.matrix.custom.html only) carry rich text verbatim (issue #77).PUT /v1/rooms/{room_id}/events/{event_id} (edit; same optional formatting).DELETE /v1/rooms/{room_id}/events/{event_id} (redact).POST /v1/rooms/{room_id}/events/{event_id}/reactions (react).Client.Verification: Send a message via curl, watch it round-trip through sliding sync, appear in the timeline, and arrive over WS. Redact and confirm the timeline read masks content.
Non-obvious choices made in 6 (see ADR 0021):
axon-api defines the MessageSender trait it needs (send_message/edit/redact/react; AppState holds Arc<dyn MessageSender>) and stays free of axon-sync/matrix-sdk. axon-sync exposes a concrete SdkGateway implementing no foreign trait; axon-server owns the GatewayAdapter newtype that impl MessageSender and maps axon_sync::GatewayError → axon_api::SendError. axon-core stays pure data. axon-api and axon-sync never depend on each other.ClientManager (connection) vs SdkGateway (message semantics). The manager is the single owner of per-account Clients — build/auth/cache via get_or_connect, evict, with a per-account single-flight guard. The gateway only builds ruma content and sends. Both share the same client per account (same crypto store + send queue).get_or_connect → run sync → on failure evict + backoff). The gateway connects lazily through the same get_or_connect. A send during a homeserver outage returns 503/502 and is retried by the client — correct, since unreachability is a normal recurring condition the supervisor rides out.account_id in the path (M5a convention); every mutation returns { "data": { "event_id": "$…" } } at 200. The created event is not echoed — it round-trips through sync into the timeline read and /v1/ws. Edits are sent as a raw m.replace envelope. Redact reason is ?reason=. The SDK mints a fresh txn id per send, so a client retry can duplicate (acceptable pre-auth; revisit M8).Auth and trust, split into three subphases. 7a brings the Matrix accounts under runtime control (login, verify, recover, logout, delete) and finally closes the interactive-verification work deferred from M5 (the old “M5c”). 7b puts the client ↔ axon bearer-token gate in front of the whole API. 7c delivers sender-device trust: evaluating and exposing whether the devices that other people sent from are cross-signed, so clients can flag messages from unverified senders. Said another way: 7a is auth between axon and the homeserver(s), 7b is auth between a client and axon, and 7c is the trust axon reports about the senders of the events it stores.
Today an account is provisioned exactly once from config, and there is no supported way to add, verify, or remove one at runtime. Changing sync.account.user_id in config does not replace the account — it inserts a new accounts row and strands the old one, which keeps syncing and can still send (any row with a decryptable token gets connected). This was hit in a real debugging session: a message went out authored by a previously-configured account that was no longer in config. 7a makes the Matrix-account lifecycle a first-class API and folds in the interactive device verification deferred from M5. It is the milestone that closes GH issues #14 (stale-DB cleanup) and #24 (account lifecycle / active-account gating / runtime provisioning).
Config becomes optional past 7a. The minimal boot configuration should be just the things that need server-side plumbing before anything else can run — Postgres connection + the store_key. Everything account-shaped is then configurable through the API: mint the first token via the axon CLI (7b, DB-only), then POST /v1/accounts/login. The existing sync.account.* config drops from required to an optional bootstrap convenience (and is a candidate for removal once the API path is the norm). The design target: axon boots clean with no accounts and waits for the API to provision them.
Account state machine. Add an explicit lifecycle state to accounts — active or deactivated, plus a transient deleting (below) — kept orthogonal to verification status (a device can be active but not yet verified: it syncs and shows UTDs until it acquires keys). The sync engine and the M6 mutations gateway connect and serve only active accounts — never “any row with a decryptable token,” the bug behind #24; get_or_connect gates on state. deactivated is a reversible pause that retains all data — a stale or token-expired account stops syncing and sending but is not erased (a natural home for the per-account failure isolation in ADR 0010). This is not a soft-delete model: deactivated is the soft stop (reached via logout or internal token-failure); deletion (via DELETE, below) is a hard removal of the row, not a resting deleted tombstone. The third state, deleting, is transient — a crash-recovery breadcrumb set while teardown is in flight and gone once the row is, never a resting state a client observes long-term. There are no dedicated state-setter endpoints — state is a consequence of the lifecycle verbs (login → active, logout → deactivated, re-login → active) plus internal failure handling, never a value a client PUTs directly.
Lifecycle endpoints (account-nested per the M5a convention). These are destructive (DELETE) and secret-bearing (login/recover accept a password and a recovery key), so they must not sit open on the network before auth exists. Until the 7b token gate lands they are bound to loopback (127.0.0.1) only — local administration on the host, never the public bind address. Consequently the M7a-without-7b intermediate state is not remotely deployable: M13’s private-mesh-VPN guidance is defense-in-depth for the whole /v1/ surface once 7b ships, not a substitute for application auth on these specific endpoints (M13 can’t retroactively gate an earlier milestone). Once 7b lands, the loopback restriction lifts and the bearer gate applies to them like every other route.
GET /v1/accounts and GET /v1/accounts/{account_id} — list / read accounts with their lifecycle state, verification status (is axon’s device cross-signed yet), and sync progress. This is what a client polls to decide whether to prompt the user to verify-or-recover, and it’s the read side of the lifecycle (never exposes the token or other secrets).POST /v1/accounts/login — body { homeserver_url, username, password }. Identity is keyed by canonical (user_id, homeserver_url) (the accounts upsert key), with one unambiguous rule: a login for a pair not currently present mints a new account_id; a login matching an existing deactivated account reactivates that same row — reusing its account_id and its retained archive (the decrypted events + search index), not minting a new axon-side identity; a login after a hard DELETE (the row is gone) mints a fresh account_id. In every case axon logs in as a fresh Matrix device — logout invalidated the prior device’s token upstream, so that device is dead and its SDK store can’t be reopened under a newly-issued device ID — provisions a fresh per-account SDK store for the new device, encrypts the new access token at rest (ADR 0008), reacquires room keys via recover/verify (below) before switching the device into service, and starts sync. Reactivation therefore restores the archive, not the dead device’s crypto/session store: the account_id is axon’s stable handle, decoupled from the Matrix device identity behind it. This is the supported way to add accounts 2…N without swapping config and stranding the prior account. The password is consumed once and never stored (matches the M3 login path) — a crown-jewel secret handled transient-only. Concurrent login / logout / delete on one account are serialized by a single async lifecycle lock (so a stop never races a re-spawn) and each verb is idempotent.POST /v1/accounts/{account_id}/verify — starts an interactive SAS (emoji) exchange; a single request can’t describe the whole state machine, so the protocol is explicitly asynchronous:
{ device_id }) — one of the user’s other trusted devices — rather than leaving target selection implicit; verify returns a flow_id (the verification transaction). The state machine is keyed by (account_id, flow_id), so concurrent exchanges don’t collide.confirm / cancel as HTTP (…/verify/{flow_id}/confirm, …/verify/{flow_id}/cancel). The shipped 7a-6 contract is HTTP-only and has no explicit accept verb — the protocol-level accept needs no human decision, so the driver performs it automatically; only confirm (the emoji comparison) is a client decision. The “equivalent /v1/ws commands” and a client accept operation are deferred to the 7b auth work (a reliable bidirectional command channel needs the client identity 7b establishes); the WS socket stays server→client send-only. See ADR 0027 for the rationale. Flow state is readable out-of-band: GET …/verify lists the account’s active flows and GET …/verify/{flow_id} returns one flow’s replayable state — current stage, target device, and the SAS emoji() / decimals() (re-derivable from the live SasVerification object while the flow is alive). This is the endpoint a reconnecting client reads to resume.verification.{requested,sas,done,cancelled} over /v1/ws carry the sas.emoji() / sas.decimals() and the outcome. A peer-initiated request surfaces as verification.requested with no HTTP kickoff.cancel; on emoji mismatch the user cancels. A client that drops and reconnects does not assume the old socket’s frames — it re-reads per-flow state via GET …/verify/{flow_id} (account-level status can’t carry the live flow_id, stage, target device, or the SAS values that may have arrived on the lost socket; those values are re-readable from the live flow, so nothing one-shot is lost). Operations are idempotent. SAS is notoriously under-implemented across Matrix clients — getting reconnect, cancel, mismatch, and timeout right is an explicit goal here, not a nice-to-have.After mutual confirm, axon is cross-signed and the user’s other devices gossip the cross-signing secrets and the key-backup key — so the recovery key never has to live server-side. This is the mature key-acquisition path (ADR 0011).
POST /v1/accounts/{account_id}/recover — the bootstrap path: accept a Secure-Storage (4S) recovery key and call client.encryption().recovery().recover(key), which imports the megolm key backup and the cross-signing private keys into the per-account crypto store. Two effects: (1) holding the recovered user-signing key lets axon self-verify its own device with no interactive partner — “verify a device via backup-key recovery”; and (2) the imported keys let the existing M3c re-decryption queue flip already-stored UTD rows to decrypted. It does not fetch history — recovering keys is not the same as fetching messages (ADR 0011/0018). Pulling a room’s pre-install timeline is M10 backfill, which consumes exactly these keys; this is why M7a (acquire keys + verify the device) precedes M10 (use them). The recovery-key string is transient-only — never persisted, consistent with the M3c boot-time recover() — but note that is distinct from the imported backup keys, which persist durably in the crypto store, so M10 still has them.POST /v1/accounts/{account_id}/logout — invalidate the device’s access token upstream and move the account to deactivated, retaining all of axon’s data (the decrypted archive, search index, media cache stay). A logged-out device’s token is dead, so the account can’t sync or send anyway — but the archive is the whole reason axon exists, so logout keeps it. Reversible: a fresh login re-authenticates and returns the account to active. This is the non-destructive stop.DELETE /v1/accounts/{account_id} — the destructive teardown. “Every trace” spans more than Postgres, so this is an ordered, idempotent, crash-recoverable operation, and the order is load-bearing: the row is the only durable key that lets a reconcile re-find the external resources, so the row is deleted last. (1) move the row to deleting — a durable marker that survives a crash, recording that external cleanup is still owed — and invalidate the token upstream if still live; (2) cancel the account’s in-flight sync/backfill tasks; (3) evict its cached SDK Client (so no live handle holds the store dir); (4) delete its documents from the Tantivy search index (a DB cascade can’t reach the index — M9); (5) remove the on-disk SDK store at data_dir/<account_id>/; (6) purge its entries from the media cache (M11); (7) only now delete the accounts row — FK cascades drop events / account_data / room_state. Removing the row last is the crash-safety guarantee: if the process dies mid-teardown, a row left in deleting at boot tells the reconcile to re-run the sequence from the top. It cannot lean on orphan-store-dir detection for this — that catches the SDK dir but never the search docs or media entries, which are keyed by account_id and would be stranded forever if the row vanished first. No resting tombstone is kept; once teardown finishes the row is gone, and re-adding the same Matrix account later is a fresh login with a new account_id. This replaces today’s manual DB surgery (#14). (Client and docs should make the logout/delete distinction explicit — “log out, keep history” vs “delete account, remove everything.”)Store-dir GC. Deletion removes the per-account store dir; a boot-time reconcile prunes orphan store dirs under data_dir/ — those with no matching accounts row at all (any lifecycle state), not “no active account.” Keying GC off row existence rather than lifecycle state is the load-bearing distinction: a deactivated account is a real row that may be reactivated, so GC must never prune its dir on the basis of a transient paused state — pruning by “not active” is exactly the failure mode behind #24 (5 genuine row-less orphans were observed there). Note the dir itself is not what makes reactivation work: reactivation logs in a fresh device with a fresh store and reacquires keys (see login), so a deactivated account’s old dir holds a now-dead device’s crypto state and is simply cleaned up when the account is finally DELETEd. Orphan-dir GC is thus a backstop for row-less dirs only; the deleting-state recovery above is what drives the rest of a teardown to completion. (The location and configurability of data_dir itself — XDG / macOS conventions rather than sitting next to the binary — is tracked separately in #45.)
Verification status. Persist per account whether axon’s device is verified / cross-signed (distinct from lifecycle state), so the API can report key-acquisition state and a client can prompt verify-or-recover while the device is still unverified. This is not a write-once boolean — a stale “verified” is worse than none: it is re-derived from the SDK’s current cross-signing / device state and invalidated when that changes (the device’s trust is reset, cross-signing is rotated, etc.), so what the API reports tracks reality rather than a one-time flip.
Out of scope here but explicitly tracked: store_key rotation (one key decrypts every account’s token) stays deferred (ADR 0008), noted against #24 so it isn’t lost once multi-account raises the stakes. Per-account authorization scoping remains a non-goal — one human owns all their accounts.
Verification (7a): POST /v1/accounts/login against a real homeserver provisions a second account that syncs independently; from a trusted Element session drive POST …/verify, watch the SAS emoji arrive over /v1/ws, confirm both sides, and see axon become cross-signed and subsequently-sent encrypted messages decrypt without a recovery key. Alternatively POST …/recover with a 4S key flips already-stored UTD rows to decrypted and marks the device verified (without fetching history — that’s M10). POST …/logout moves the account to deactivated — confirm it neither syncs nor sends while its archive is retained. Crucially, confirm logout → restart axon → fresh login reactivates the same account_id with its archive intact — as a fresh Matrix device that reacquires keys via recover/verify, not a resurrection of the dead device’s crypto store (the reconcile must not have pruned the deactivated row’s dir, and reactivation must also succeed without depending on its contents). DELETE /v1/accounts/{id} removes the row, its search-index docs, and the SDK store dir entirely — confirm the deleted account’s messages no longer appear in search, and that a crash injected mid-teardown leaves a deleting row that the next boot’s reconcile drives to completion. Exercise the SAS flow’s unhappy paths, not just the happy one: cancel, emoji mismatch, timeout, and a mid-flow client reconnect (state recovered via GET …/verify/{flow_id}, not account status). Once 7b lands, confirm a remote or unauthenticated lifecycle call is rejected.
The local-API gate. (This is the work formerly numbered M8.)
axon token issue --label <name> CLI subcommand: mints a random token, stores a hash, prints the token once. axon token list and axon token revoke <id>.tokens table: (id, label, hash, created_at, last_used_at, revoked_at).Authorization: Bearer … on every /v1/… route — including the 7a lifecycle endpoints — updates last_used_at, and rejects revoked tokens.Sec-WebSocket-Protocol or the initial envelope, validated on accept.Design the token storage and middleware so a future OAuth 2.0 + PKCE issuer can replace the CLI mint path without breaking the on-the-wire Authorization header or any consumer code. The first token is minted by the CLI (bootstrap); clients carry it thereafter. Until 7b lands, the read/mutation routes are unauthenticated like the rest of the pre-auth API, and the destructive/secret-bearing 7a lifecycle endpoints are restricted to loopback (above) — so there is no remotely-reachable unauthenticated lifecycle surface at any point. M13’s VPN is then defense-in-depth over the whole authenticated API, not a stand-in for app auth.
Verification (7b): Issue a token; hit /v1/rooms with and without the header; revoke; confirm the next call is rejected. Confirm the M6 txn-id retry-duplication caveat is now attributable to a token.
7a verifies axon’s own device. This subphase is about a different set of devices: the ones that sent the messages axon ingested — i.e. other Matrix users’ devices in your encrypted rooms. It is the standard Matrix per-message “shield”: when you read a message from Bob, was it sent from a device Bob himself has cross-signed (so it’s really Bob), or from some new/unverified device on his account (possible impersonation or a compromised account)? axon, being a real Matrix device, holds the cryptographic evidence to evaluate that; 7c surfaces it per event so a client can badge “⚠️ sent from an unverified device.”
This is not about the clients that connect to axon. Those (the TUI, a future web app) are not Matrix devices and are never Matrix-verified — they authenticate to axon with a bearer token (7b), full stop. And nothing here is “verified with the homeserver”: in Matrix the homeserver is untrusted; device trust is cross-signing between users’ own keys, peer-to-peer. So three independent things: (1) axon’s own device trust (7a), (2) client→axon token auth (7b), (3) the trust axon reports about the senders of stored events (7c, here).
E2EE is a core rationale for Matrix, and authenticating who actually sent each message is half of it — without this signal, someone who pops Bob’s account could inject a message from a fresh device and the reader sees nothing. ADR 0011 deferred originating verification of other people’s devices; 7c picks up the evaluation-and-display half of that deferral. (Actively running SAS against another user stays out of scope; 7c reports trust, it doesn’t establish it interactively.)
The storage partly exists but is not yet usable behavior. ADR 0015’s event_sender_device_keys sibling persists the sending device’s identity keys and a verification-state verdict at decryption time — a snapshot. It does not persist the sender’s full cross-signing chain, so 7c does not pretend to read one from disk: the durable record is the at-decryption verdict (below), while the richer chain detail is fetched live from the SDK when a client asks for the bundle. The tech-spec’s “Content authentication” section already promises an opt-in verification-bundle API; nothing currently evaluates or exposes any of this — 7c closes the gap and makes that promise real.
Encryption/Identity surface) — a sender_trust of verified / unverified / unknown (no device keys) / verification_violation (the sender’s identity was already in conflict when the event arrived; a violation that emerges later is the overlay in the last bullet, not a change to this value). This is a snapshot at decryption time — what Matrix’s evidence said when the event arrived — and is deliberately distinct from the sender’s current device trust, which can change afterward. The two can differ (a device trusted when it sent can later be revoked, and vice versa), so they’re reported as separate facts: the per-event snapshot below, plus current trust available via the verification bundle. Persist the snapshot alongside the existing sender-device-keys sibling.sender_trust field to the timeline EventDto (and the live /v1/ws frames), so a client can badge a message — exactly what lets axon-tui put a warning glyph on messages from unverified devices, like it already does for misleading URLs.GET /v1/accounts/{account_id}/events/{event_id}/verification composes the durable at-decryption snapshot (sending device identity + verdict) with a live SDK lookup of the sender’s current cross-signing chain and device trust, plus megolm session provenance — for clients that want to show or audit it. If the sending device is no longer available upstream (deleted, or the identity is gone), the bundle returns the stored snapshot with the current-trust portion marked unknown rather than failing. Opt-in; ordinary reads carry no extra overhead.verification_violation as an overlay on affected events when the SDK reports a sender identity change — so a client isn’t silently showing a now-distrusted message as trusted, without mutating the original snapshot underneath it.Verification (7c): in an E2EE room, a message from a cross-signed device reads sender_trust: verified and one from an unverified device reads unverified; axon-tui badges the latter; the verification-bundle endpoint returns the device identity + (live) cross-signing evidence for a given event; a sender identity change surfaces a verification_violation overlay on the affected events while their at-decryption snapshot stays unchanged.
Non-obvious choices made in 7a (state machine — see ADR 0022; discovery — ADR 0023):
Homeserver discovery is server-side (ADR 0023). homeserver_url is optional on login: omitted → axon resolves the MXID’s server name via .well-known/matrix/client (fallback https://<server name>; candidates validated by scheme + a non-empty /_matrix/client/versions) in axon-sync’s discovery module, before the per-identity lock (which is keyed by the resolved URL). Clients never discover — two clients resolving the same user differently would mint duplicate accounts under the (user_id, homeserver_url) natural key. Supplied → the escape hatch for broken well-known and plain-HTTP loopback dev homeservers; it’s normalized (trailing slash trimmed so it keys identically to a discovered URL) and scheme-checked (HTTPS unless loopback — a plain-HTTP public host is refused so the password can’t leave in cleartext), but not probed (caller-asserted; a bad URL surfaces at SDK login). Discovery failures are 502 and touch nothing. A 2xx well-known whose body isn’t JSON is treated as absent (fall back), not invalid — catch-all servers (Caddy/SPA/default-vhost) 200 every path with an empty/HTML body. On both paths the MXID’s domain is then checked against the homeserver’s declared server name (/_matrix/key/v2/server, best-effort): @adam:matrix.example.org — a common confusion — is a 400 did-you-mean naming @adam:example.org, never a misleading 401 and never a silent login as a different identity.
accounts.state (active / deactivated / deleting), orthogonal to a verified flag. active syncs+sends; deactivated is a reversible pause that retains all data (logout / token loss); deleting is a transient teardown breadcrumb a later boot reconcile completes (deletion is a hard row removal, no resting tombstone). A separate verified bool caches whether axon’s own device is cross-signed (re-derived from the SDK, not write-once; its derivation is stubbed to false until a later 7a PR). state is never set directly by a client — it’s a consequence of the lifecycle verbs.state = active at the single choke point. Store::list_accounts() returns only active rows (the safe default the sync boot loop iterates); ClientManager::get_or_connect refuses a non-active account with GatewayError::AccountNotActive (→ 403), so the lazy gateway send path is gated too. This is the groundwork for #24, not the whole fix: the gate is cold-connect only (an already-cached client isn’t re-checked), so on its own it gives only “a non-active row gets no new client”. Severing a live account is done actively by the lifecycle verbs: logout (7a-3) flips the row to deactivated first (so the gate refuses new connects), then cancels the account’s supervised task and awaits its drain (per-account cancellation: a token + join-handle registry populated in spawn_supervised — cancellation is cooperative, and the task holds the SDK store dir until drained, so a re-login must not restage it earlier; an overrunning drain escalates to abort, and a task surviving even that fails the verb with the task left registered, which login refuses until a retry reaps it), then evicts its cached client (ClientManager::take, which serializes on the slot lock to close the reconnect race) and best-effort invalidates the device token upstream; DELETE (7a-4) reuses the same cancellation. 7a-4 (#61) added explicit account deletion, an interrupted-delete reconcile, and an orphan-store-dir GC (ADR 0024). Config-based account provisioning has since been retired entirely (closing #65, and with it the two-restart resurrection hole in #66): SyncEngine::start no longer touches accounts before spawning tasks, AccountProvision/SyncConfig.account are gone, and every account now comes from an explicit runtime call (login or the token-import verb below) — so DELETE is durable by construction, with no transitional guard left to carry. Wider views get their own explicitly-named accessor (never a shared “all rows” method that could misfire from the connect path): the read API uses list_client_visible_accounts (active + deactivated — a logged-out account stays discoverable for re-login; excludes transient deleting), and teardown reconcile / orphan GC add theirs in 7a-4.AccountState is a Rust enum in axon-store (re-exported), stored as TEXT + CHECK (matches the rest of the schema, not a PG ENUM); an unknown stored value is a column-decode error, not a silent default.recover (7a-5, ADR 0026) was originally the boot-time recover() promoted to an on-demand verb; the boot path it was promoted from has since been retired along with config-based provisioning. POST /v1/accounts/{id}/recover takes a 4S recovery key and runs the same single SDK call (encryption().recovery().recover()), then reuses redecrypt::sweep_pending_utds (bounded by RECOVER_SWEEP_TIMEOUT so the under-lock backfill can’t starve a concurrent logout/delete) to back-fill the UTDs the imported keys now unlock. It requires an active account (it needs a live, authenticated client via get_or_connect): a deactivated row is a 409 (log in first), deleting a 409, unknown id a 404. Errors are classified, not blanket-400’d: only the SDK’s SecretStorage failure (wrong/rotated key, or no Secure Backup) is a readable 400 (LifecycleError::RecoveryFailed, with a stable non-leaky message), never a silent permanent UTD; any other RecoveryError is a generic 500 (logged, not the caller’s fault). Held under the same per-identity lock as login/logout/delete; the recovery-key string is consumed once and never persisted. Loopback-bound until 7b like the other secret-bearing verbs.POST /v1/accounts/import (ADR 0024) adopts an existing access_token + device_id — the runtime replacement for the retired sync.account.access_token config path (e.g. a token issued by another client, or an SSO-only account with no password). Same idempotency shape as login (new identity mints a row; active is a no-op; deactivated reactivates using its stored endpoint; deleting is a 409), but homeserver_url is required (no MXID-based discovery for a bare token) and is consulted only when minting a new row. Unlike login, no homeserver call confirms the token up front — restore_session is purely local — so AccountLifecycle::import_token (axon-sync) validates it with a whoami round-trip before activating: a token belonging to a different user or device is rejected as AuthFailed (→ 401), same as login’s rejected-credential case.verified is now derived from the SDK, not stubbed false (ADR 0026). The bool caches whether axon’s own device is cross-signed, re-derived from get_own_device().is_cross_signed_by_owner() (the check the SDK uses internally — accurate immediately, unlike the verification_state() subscriber, which only refreshes on a /keys/query round-trip). It is not write-once: a per-account verification watcher child task in run_account (same child-token + drain lifecycle as the re-decryption queue) subscribes to verification_state() and re-derives+persists on every change (login, recover/verify, cross-signing rotation/reset). recover also persists it synchronously so an immediate read-back reflects the new state. Watcher and recover both write verified under the same per-identity lock (owned by SyncEngine, shared with every AccountLifecycle and watcher) so neither clobbers the other (lost-update race). logout resets verified to false as it deactivates, so a deactivated account reports false and a re-login (fresh, unverified device) can’t read a stale true. AccountDto.verified stays Option<bool> on the wire (forward-compat) but is now always populated.axon-sync. POST /v1/accounts/{id}/verify {device_id} starts an emoji exchange against one of the user’s trusted devices and returns a flow_id; POST …/verify/{flow_id}/confirm and …/cancel advance/end it (loopback-bound, like the other secret-/trust-bearing verbs); GET …/verify and GET …/verify/{flow_id} (open) read replayable per-flow state so a client that dropped its socket resumes without assuming the frames it missed. Live progress streams as verification.{requested,sas,done,cancelled} frames over /v1/ws (the LiveFrame::Verification arm of the now-widened live-event bus; peer-initiated requests surface as verification.requested with no HTTP kickoff, via an m.key.verification.request to-device handler each account’s supervised task registers). Flows are ephemeral and never persisted — the SDK’s in-flight SAS state isn’t serializable, so a flow is re-readable across a client reconnect (this process up) but not across a process restart; an in-memory registry holds the live SDK objects (get/list re-derive emoji/decimals live), and a terminal flow’s outcome is retained for a 5-min grace TTL (so a missed cancelled is still readable) then evicted lazily. Cross-signing is the last step, so any crash before it leaves the device merely unverified, never half-trusted. confirm does not write verified — the existing watch_verification watcher stays the single writer of that column (ADR 0026). The VerificationEngine lives in axon-sync (it needs ClientManager/locks/supervision); axon-crypto stays a stub. Adapted onto the API’s VerificationService port by axon-server, same as the lifecycle port. Boundary + contract decisions are recorded in ADR 0027 (raised in the PR 6 review): (a) verification lives in axon-sync, not the axon-crypto “thin verification surface” the layout originally named — the engine can’t be thin (it needs connection/locks/supervision); (b) the operations contract is HTTP-only — no client→/v1/ws commands and no explicit accept verb (the protocol accept needs no human decision, so the driver accepts automatically; the spec’s “equivalent WS commands” + client accept are deferred to 7b); (c) every started/accepted request advertises SAS only (request_verification_with_methods/accept_with_methods — the SDK default includes QR, deferred); (d) the incoming listener rejects anything that isn’t is_self_verification() (M7a verifies axon’s own device only); (e) confirm gates on the live SAS state (can_be_presented) so a too-early confirm can’t return a false success, and distinguishes a done flow (idempotent ok) from a cancelled one (error); (f) verification drivers are run-scoped — cancelled on every supervised sync-run teardown via the flow registry, so a driver never outlives its evicted client — and start takes the per-identity lifecycle lock so it can’t race login/logout/delete; (g) pre-7b, the read + /v1/ws surfaces stay open (trust-state disclosure accepted under the existing “not remotely deployable without 7b” constraint) and frames ride the lossy bus (clients must re-read GET …/verify/{flow_id} on reconnect — it’s the source of truth). Scope: this PR is the backend API only. The PRD frames SAS as “a first-class account-lifecycle API (and axon-tui can drive them)” — the API is the 7a-6 deliverable; the axon-tui verify UI that consumes it is separate client work (alongside the M12 first-run flow), not part of this PR, so 7a-6 being landed does not mean an interactive verify UX is available end-to-end yet.Non-obvious choices made in 7b (bearer-token auth — see ADR 0029):
/v1/, replacing the loopback guards. Every /v1/ route — HTTP and the WebSocket, including the lifecycle verbs — now requires a valid Authorization: Bearer <token>; the per-route require_loopback guards and the loopback module are gone, and the lifecycle verbs are reachable off-box with a token. The HTTP gate is a single require_bearer layer (from_fn_with_state) over a /v1 sub-router, so no route can be added without it; /healthz sits outside the gate. The WebSocket can’t ride that layer (a browser can’t set a header on a socket), so ws_handler checks the token itself at upgrade time — from the Authorization header or a bearer.<token> Sec-WebSocket-Protocol entry — and rejects with 401 before upgrading.TokenVerifier port. The tokens table is (id, label, hash, created_at, last_used_at, revoked_at) — no account_id (one human owns all accounts; per-account authz is a non-goal). A token is high-entropy, so it’s stored as a plain SHA-256 (base64), not encrypted (cf. the access token’s pgp_sym_encrypt, ADR 0008) and not a slow password KDF; verify_token is one UPDATE … WHERE hash = $1 AND revoked_at IS NULL RETURNING id that matches and stamps last_used_at in a single round-trip. Revocation tombstones (revoked_at), never deletes. Verification sits behind axon-api’s TokenVerifier trait (AppState holds Arc<dyn TokenVerifier>; shipped impl StoreTokenVerifier), the seam a future OAuth issuer replaces without changing the Authorization wire contract or any route.axon with no subcommand runs the server as before; axon token issue --label <name> / list / revoke <id> connect only the store (no sync engine, no HTTP) — issue prints the raw token once to stdout (logs go to stderr), list never prints a secret. The OpenAPI doc now declares an HTTP-bearer security scheme with a global security requirement. axon-tui does not yet send the token (separate client work).Landed in 7a: PR 1 (the account state machine), PR 2 (runtime login, POST /v1/accounts/login), PR 3 (logout, POST /v1/accounts/{id}/logout), PR 3a (server-side homeserver discovery), PR 4 (DELETE teardown + boot reconcile + orphan-dir GC, #61), PR 5 (recovery-key key acquisition, POST /v1/accounts/{id}/recover, + verified-flag derivation), and PR 6 (interactive SAS verification, …/verify + verification.* WS frames, #76).
7b put a bearer-token gate in front of the whole /v1/ surface — see the 7b note below and ADR 0029.
Matrix expresses edits, reactions, and threads as relation events whose m.relates_to carries a rel_type — m.replace (edit) / m.annotation (reaction) / m.thread. Replies are shaped differently and are a common trip-up: a reply has no rel_type; it nests m.relates_to.m.in_reply_to.event_id pointing at the replied-to event. axon already stores the whole m.relates_to block in the relates_to hot column (ADR 0015), so both shapes are on disk. But reading them raw forces every client to re-aggregate over whatever timeline window it happens to hold, which silently breaks for relations that land outside that window — a reaction or an edit that arrives long after the original message. The TUI hit exactly this: reactions and edits to messages older than the loaded 50-event slice are dropped (GH issue #22). M8 moves aggregation server-side so the API serves resolved, complete views regardless of pagination.
This subsumes the formerly-standalone Threads milestone (old M13): a thread is just the m.thread case of the same machinery, and the store already captures m.thread generically, so the work is additive and backfill-free — it resolves over whatever rows are stored now, and applies automatically to the deep history M10 backfills in later. Split: 8a builds the store-layer aggregation, 8b exposes it over the API.
events.relates_to must cover both relation shapes, or replies are silently unfindable: (a) an expression/partial index keyed by target event_id and rel_type for the rel_type relations — edits/reactions/threads (generalizing the thread index sketched in ADR 0017); and (b) a separate index on the nested reply target, relates_to->'m.in_reply_to'->>'event_id', since replies carry no rel_type. Both make “all relations pointing at event X” an indexed lookup rather than a window scan, and both apply retroactively to already-stored rows — additive, backfill-free.(account_id, …):
m.replace). Resolve the latest edit per target by origin_ts; surface the replaced content plus edit metadata (edited, edit_count, latest_edit_ts). The raw edit events stay on disk (append-mostly; provenance and original ciphertext preserved — the same philosophy as redaction masking). The timeline read collapses them into the target rather than emitting standalone edit rows. matrix-rust-sdk has relation-aggregation support we can lean on, but the durable resolution is a store concern so it holds for events outside any client window.m.annotation). Group by target, then by key; per-emoji counts (plus the senders, for “did I react / who reacted”).m.in_reply_to). Direct replies to an event — matched on the nested m.relates_to.m.in_reply_to.event_id target (no rel_type).m.thread). Thread membership; a per-thread summary (root event + latest reply + reply count) and a thread-scoped timeline read (reuse the M5 cursor pagination, scoped to a thread root).m.replace is honored only when its sender equals the original event’s sender; edits from anyone else are dropped. An edit whose replacement msgtype is incompatible with the target is dropped.(sender, key) counts once no matter how many annotation events arrived.origin_ts, tie-broken by a stable key (the store’s monotonic insertion id, then event_id) so the winner is deterministic when timestamps collide — never “whichever the query happened to return.”Verification (8a): Seed a room with a message, then add a reaction and an edit far outside the default timeline window; store-layer reads return the correct per-emoji count and the edited body regardless of window position; thread and reply lookups resolve over rows stored before this milestone (proving the backfill-free claim). Add regression cases for the validity rules: an edit from a non-sender is ignored; a redacted edit reverts to the prior body and a redacted reaction leaves the tally; a duplicate (sender, key) reaction counts once; two edits with equal origin_ts resolve deterministically; and a reply is found via its nested m.in_reply_to target.
Account-nested per the M5a convention:
GET /v1/accounts/{account_id}/events/{event_id}/reactions — per-emoji tallies (issue #22 Option A): { "👍": { "count": 2, "me": true, "senders": […] }, "❤️": { … } }.GET /v1/accounts/{account_id}/events/{event_id}/replies — direct replies to an event.GET /v1/accounts/{account_id}/rooms/{room_id}/threads — thread list (root + latest reply + reply count).GET /v1/accounts/{account_id}/rooms/{room_id}/threads/{root_id}/timeline — thread-scoped timeline, reverse-chronological with the same cursor pagination as the room timeline.reactions summary and edited / edit_count fields on EventDto (issue #22 Option B). An optional GET …/events/{event_id}/edits exposes the forensic edit history./v1/ws): raw relation events keep flowing live so clients can apply deltas, but the aggregation endpoints and the EventDto fields are the authoritative resolved view. Dedicated aggregation-update WS frames (a delivered tally delta) are a later add, not MVP.Verification (8b): GET …/reactions returns grouped counts for a message whose reactions arrived in a later page; the timeline read shows the latest edited body with no stray edit rows; GET …/threads lists a thread with the correct reply count and its scoped timeline returns only that thread’s events, reverse-chronological with stable pagination; an edit / reaction / reply sent over M6 round-trips and shows up aggregated.
Non-obvious choices made in 8a (relation aggregation backend — see ADR 0033):
events.relates_to (20260620120000_relation_aggregation_indexes.sql) make “all relations pointing at X” an index lookup; the resolution happens in SQL on read, not as tallies maintained on ingest. The raw edit/reaction events stay on disk untouched (append-mostly, same philosophy as read-time redaction masking, ADR 0015); the read folds them. Additive and backfill-free — applies retroactively to rows stored before M8 and automatically to the deep history M10 backfills in later.rel_type. events_relation_target_idx keys (account_id, rel_type, target event_id) for edits/reactions/threads; events_reply_target_idx keys the nested relates_to->'m.in_reply_to'->>'event_id' for replies (which carry no rel_type, so the first index never sees them).TIMELINE_SELECT projection now does edit collapse + reaction tally. It grew two LEFT JOIN LATERALs (latest-valid-edit; per-emoji reaction tally) and an accounts join (for the reaction me flag) alongside the existing redaction-mask LATERAL. So room_timeline, get_event, event_replies, and thread_timeline all return the same resolved TimelineRow (now carrying edited / edit_count / latest_edit_ts / reactions) — one set of rules, one FromRow, and replies/thread members are themselves aggregated for free.m.replace is honored only when its sender equals the original event’s sender (impersonation guard; ADR 0021 noted this is unenforced upstream), it is well-formed (m.new_content present), and its replacement msgtype matches the target’s. The latest valid edit wins by origin_ts DESC, id DESC (deterministic on ties; COUNT(*) OVER () carries the count alongside the LIMIT 1 winner). A redacted target wins over its edits — the projection masks a redacted row even when a valid edit exists, so a redaction can’t be undone by a later edit. Reactions dedup (sender, key) via COUNT(DISTINCT sender); a redacted reaction drops from the tally. event_replies requires rel_type IS NULL so a thread member (which also nests an m.in_reply_to fallback) isn’t double-counted as a plain reply. Thread reply_count includes redacted members (structure preserved so a redacted root doesn’t make the thread vanish).room_timeline excludes m.replace and m.annotation events (now surfaced on their target row); replies and thread members keep their own rows. COALESCE(rel_type, '') keeps NULL-rel_type rows (ordinary messages, replies) from being filtered out by the NOT IN. The forensic edit trail stays on disk for the 8b …/edits endpoint.Non-obvious choices made in 8b (relation aggregation API surface — this PR):
Store reads (event_reactions / event_replies / event_edits / room_threads / thread_timeline); no aggregation logic lives in axon-api. New reads: GET …/events/{event_id}/reactions (per-emoji tally, keyed-by-emoji map — issue #22 Option A), …/replies, …/edits (forensic trail), …/rooms/{room_id}/threads, and …/threads/{root_id}/timeline (thread-scoped, reusing the room timeline’s opaque cursor pagination verbatim). (8b review folded a few additive changes back into the 8a store SQL — see the unreact and bounded-reads bullets below.)EventDto now carries the resolved view. from_row maps the TimelineRow aggregation fields (edited / edit_count / latest_edit_ts / reactions) onto the wire, so the existing room_timeline / get_event endpoints serve the edited body in place with no stray edit rows — no new route needed (issue #22 Option B). On the /v1/ws live stream the From<LiveEvent> impl sets them to raw defaults (edited:false, reactions:null): a frame is a pre-aggregation event, and the read API is the authoritative resolved view (the live raw relation events still flow so clients can apply deltas — a dedicated aggregation-update WS frame is deferred, not MVP).200 with an empty collection when there’s nothing (no reactions / replies / edits / threads, or an unknown event/root), matching room_timeline’s “an unknown room and an empty timeline are indistinguishable” stance — the store reads don’t probe base-event existence, so adding a 404 would cost an extra query for no client benefit. The single-event get_event keeps its 404.my_event_ids so clients can still unreact (8b review). Collapsing the timeline strips the raw m.reaction rows, so a client can no longer scan events to find its own reaction ids to redact. The tally (both the /reactions endpoint and the on-row reactions map) therefore exposes my_event_ids — the account user’s own reaction event ids for the key, the ids redacted to withdraw. axon-tui reads badges and unreact ids from this aggregated map (collect_reactions / own_reactions_for) rather than raw events. Regression: unreact_uses_aggregated_reaction_ids_from_timeline walks react → reload → unreact.reactions map is now the sole source of badges and unreact choices, and the collapsed timeline no longer carries the raw m.reaction row a mutation produces/redacts, send_react and withdraw_reaction patch the target message’s tally in place (apply_local_reaction bumps count/me and records the returned id; remove_local_reaction drops the owner’s ids, clears me, decrements the owner’s single distinct-sender contribution, and removes an emptied key). Without this the badge would persist and /unreact would re-redact already-withdrawn ids until a manual reload. Tests: local_react_makes_badge_and_unreact_available_before_reload, local_unreact_clears_badge_and_choice_before_reload, local_unreact_keeps_other_senders_count_and_drops_my_contribution.event_edits / event_replies / room_threads answer in one shot, so each LIMITs at RELATION_READ_CAP (1000) to bound the row count against a pathological/hostile room. The paginated room/thread timelines remain the path for genuinely large result sets.RELATION_READ_CAP caps grouped output rows, but each reaction tally also builds per-key senders / my_event_ids arrays and a variable number of keys — all unbounded if every matching m.reaction is aggregated, and the shared TIMELINE_SELECT had no key cap at all. Round 2 added a raw-scan LIMIT; round 3 fixed that it was non-deterministic and evictable (an unordered LIMIT before grouping, so one sender’s duplicate reactions could consume the whole budget and hide other senders’ reactions / the account’s own my_event_ids). So both event_reactions and the TIMELINE_SELECT react LATERAL now DISTINCT ON (sender, key) first (keeping each pair’s lowest event_id), then keep the oldest REACTION_AGG_PAIR_CAP (1000) pairs by (origin_ts, event_id) before grouping. Deduplicating before the cap means a single spammer can’t evict others; the explicit order makes truncation stable across identical reads. Bounding the deduped pair set bounds count, both arrays, and the key cardinality on every timeline/get-event/reply/thread row. my_event_ids thus holds the account’s canonical reaction id per key (a pathological double-react self-heals on the next read). TIMELINE_SELECT became a LazyLock<String> so the cap interpolates from the one constant.reactions field is a typed map, not an opaque object (8b review). EventDto.reactions is Option<BTreeMap<String, ReactionDto>>, so the OpenAPI references ReactionDto for the map values (generated clients can type count / me / senders / my_event_ids). from_row deserializes the store’s reactions JSON into the typed map; a malformed/absent tally degrades to None.The Tantivy index. It runs after aggregation (M8) — so the text it indexes is the latest edited body, not a superseded one — and before backfill (M10), deliberately: with the index in place first, the deep history that backfill pages in is indexed incrementally as it arrives, in the same single pass that stores it, rather than needing a second bulk sweep over the corpus.
axon-search opens a Tantivy index.event_id, account_id (facet), room_id (facet), sender (facet), origin_ts (date), body (text).body analyzer chain: default tokenizer + LowerCaser + AsciiFoldingFilter + Stemmer (English). All built-in Tantivy token filters — register the analyzer once and reference it from the field schema.events table already holds the live-synced slice (M3+) that predates the index; a bulk pass streams those existing rows (batched, ordered) in so they’re covered too. It runs on first boot after search is enabled, gated by an index-built marker (search-index metadata / a search_index_built flag) so it does not repeat, and is also exposed as an axon search reindex CLI subcommand for schema-change rebuilds. The index is derived data keyed by event_id, so the pass is idempotent and a from-scratch rebuild is always safe. Because backfill comes later and indexes incrementally, this one-time build only ever covers the comparatively small pre-backfill slice — not the deep history.m.replace / m.annotation / m.room.redaction (re)derives and writes the target’s doc; indexing a message derives its own doc from the projection (which already folds in any relations that arrived first). A redaction removes the target doc; an edit rewrites it to the latest body. A lightweight reconciliation pass closes any window where a relation was applied before its target existed.GET /v1/search?q=…&account_id=…&room_id=…&sender=…&from=…&to=….FuzzyTermQuery toggle on this endpoint, not an analyzer change.Verification: Index a known corpus (e.g. dump 1000 events from a test room); assert an exact phrase query returns the expected top hit; confirm case- and diacritic-insensitivity (cafe matches café) and plural matching (cat matches cats); confirm an edited message is found by its new text and not its old; latency p95 under 200ms on the Riley-shape target. Cover the out-of-order ingestion cases backfill produces: ingesting an edit before its target, then the target, leaves the index showing the edited body (not the stale one); ingesting a redaction before its target leaves the target unsearchable once it arrives. (That a phrase from a backfilled pre-install message becomes searchable is asserted in M10, once backfill has streamed it through the now-existing index.)
Non-obvious choices made in 9a (search indexing engine — see ADR 0039):
(account_id, event_id)”, resolved through the same Store::get_event projection the read API uses — so a message indexes its latest edited body and a redacted event is removed. The affected ids are the event’s own plus, for a relation/redaction, its target’s (the store computes this fan-out in the outbox statement). This makes indexing order-independent (the case M10 backfill produces, paging newest→oldest): an edit or redaction arriving before its target is a no-op until the target lands, then the target re-derives from the projection (which already folds the relation in). Standalone m.replace/m.reaction events are never their own document — their text belongs to the target.search_outbox(seq, account_id, event_id) in the same statement as the mutation (upsert_event, update_decrypted_event, delete_account_row, via WITH … RETURNING), so a change and its indexing obligation commit atomically. Entries are engine-neutral (an affected id, never a Tantivy doc); a re-decryption enqueues its own id (the only durable signal for an in-place UPDATE that mints no new events.id), and event_id = '' is the account-purge sentinel. The single actor owns the sole writer and drains the outbox in seq order, committing a batch then advancing a durable contiguous cursor (search_meta.outbox_cursor) and pruning. axon-sync only pokes it with a best-effort IndexHandle::notify wakeup hint — droppable without consequence, since the obligation is already on disk. This (replacing an earlier lossy try_send queue) is what converged the design in review.events is the rebuild source; the outbox is the prunable delta. On a fresh index (new/empty/schema-bumped directory — detected by an axon_schema_version sidecar checked before open_or_create, which would otherwise fail on a schema change) the actor seeds from Store::events_for_index (a keyset, projection-aware scan, one query per batch, throttled, as a background task so boot never blocks). Pruning the outbox is therefore always safe — it never holds the only copy needed to reconstruct a lost index. Enabling search after M10 backfill indexes the full corpus, since the seed reflects whatever is in events.delete_account_row appends the purge sentinel in the same statement that drops the row; search_outbox has no FK to accounts, so the obligation outlives the cascade — a crash, or a deletion while search is disabled, is healed on the next enabled drain. When the actor is live the lifecycle verb additionally flushes (drain-to-empty + ack) so the documents are gone before it returns (ADR 0024 ordering).account_id/room_id/sender are exact-match STRING fields (robust to !/:/@// in ids without facet-path escaping; STRING | STORED doubles as the hydration field), a deliberate deviation from the tech spec’s “facet” naming. The delete key is a composite doc_key = "<account_id>\u{1f}<event_id>" so a per-account delete removes exactly one document (the same event_id can exist under two accounts). The schema is versioned so this can be revisited with a clean rebuild.(account_id, event_id); the API hydrates each via get_event, so Postgres stays the source of truth and a since-redacted hit is dropped on hydrate. Cross-account by default, account_id an optional filter; offset/limit pagination (BM25 score doesn’t compose with the timeline cursor). Search disabled → ingestion hooks are no-ops and GET /v1/search returns 503 (9b).axon-search is a leaf (axon-core + axon-store only); axon-sync → axon-search directly (the producer is axon-sync, no port inversion, no cycle), and 9b’s axon-api → axon-search is likewise direct (matrix-free, like axon-store).Sync alone only ingests events going forward (plus the shallow sync.timeline_limit window on a room’s first sync — ADR 0015). Backfill is the engine that reaches back for a room’s pre-existing history, so the timeline read, the M8 aggregations, and the M9 search index cover more than the post-install slice. recover() (M7a) imports the keys to decrypt old messages; it does not fetch the messages — those must be paged from the room. (ADR 0018.) Backfill runs last of the read-side milestones on purpose: by now the shared ingestion path already feeds M8 aggregation and the M9 search index, so every event backfill pulls is aggregated and indexed incrementally as it streams in — one pass over the deep history, no separate bulk reindex. Together with sync this is what satisfies the PRD’s full-history success criterion and the 100–200k-event working-set target.
/messages), decrypting with the keys already imported by recover() / gossip (M7a), and persisting through the same ingestion path as live sync — so hot columns, crypto siblings, redaction handling, the M8 aggregation indexes, and the M9 search index all apply uniformly, and re-runs are idempotent (ON CONFLICT DO NOTHING).room_backfill table: (account_id, room_id, oldest_seen_token, complete, updated_at)) so progress survives restarts and the engine knows where to resume and when a room is exhausted.sync.timeline_limit bump as the “bounded substitute” for real backfill (ADR 0015).Verification: Point axon at a room with substantial pre-existing history; confirm the stored event count climbs toward the room’s full history rather than the initial window; confirm backfilled encrypted events decrypt (keys via recover()); confirm a search for a phrase from a pre-install message now returns it (proving incremental indexing during backfill); kill and restart axon mid-backfill and confirm it resumes without duplicating rows.
Non-obvious choices made in M10 (history backfill — see ADR 0043, ADR 0044):
run_account beside the re-decryption queue (child cancel token, drained on shutdown), it pages each incomplete joined room one page older, then idles and re-polls client.joined_rooms() (sync.backfill_idle_poll_secs) — which is how a newly joined / re-joined room is picked up without a restart, and why “joined rooms only” is safe. The instant window a room shows on first sync is still sync.timeline_limit (now only a cold-start knob); backfill extends history behind it.Room::messages) does not dispatch through add_event_handler, so the driver persists each paged TimelineEvent itself via persist_event_core (refactored out of the live handler) — hot columns, upsert_event (+ the M8/M9 search_outbox obligation), crypto siblings all apply. Backfilled events skip the /v1/ws emit (replaying deep history through the live bus would mislabel it as just-arrived); they reach clients via timeline reads + search instead.room_backfill(account_id, room_id, oldest_seen_token, complete, events_backfilled): the resume token is the SDK Messages.end; a room is complete when end is NULL. Saved after each fully-persisted page, so a mid-page crash re-pages idempotently (upsert_event is ON CONFLICT DO NOTHING, so no duplicate rows or outbox obligations). sync.backfill_target_depth = 0 (default) means “to room start”; a non-zero cap stops paging without marking complete (raising it later resumes).sync.backfill_min_free_bytes / _percent (cheap fs4::statvfs before each page). Live sync is unaffected. A shared BackfillHealth handle (threaded like index) records paused + free-bytes; exposed at GET /v1/status (poll-based — a backfill.paused/resumed WS frame was deferred because the live bus is account-scoped but the disk signal is engine-global). Guards the local box; a split-Postgres deployment monitors the DB host separately.GET /v1/search hydrates hits through Store::get_event_if_joined, which drops events in rooms the local user has left/been banned from — reusing list_rooms’ NOT EXISTS(m.room.member = leave/ban) predicate (ADR 0037). Non-destructive and reversible (re-join restores). sync.purge_on_leave (default off) additionally makes a leave/ban trigger Store::purge_room: one atomic statement deletes the room’s events (cascading siblings) + state + room account-data + backfill cursor and enqueues a room-scoped search purge (a \u{1f}room\u{1f}<id> outbox sentinel the writer applies via Tantivy delete_query on the account_id and room_id terms, so a shared room’s other account is untouched).Landed. M11 — media proxy (PR 197, ADR 0045-media). axon-media resolves mxc:// URLs against the owning account’s homeserver behind GET /v1/media/{account_id}/{server}/{media_id}, with range-request support and a bounded on-disk LRU cache (default 5 GB) purged on eviction and account deletion. No S3 backend — the homeserver is the durable source of truth (see “What not to build”).
axon-media resolves MXC URLs against the upstream homeserver for the relevant account.GET /v1/media/{account_id}/{server}/{media_id} with proper caching headers and range-request support.synapse-s3-storage-provider); there’s no case to reinvent it in the agent, whose cache is a bounded LRU with the homeserver as source of truth.Verification: Send a message with an image attachment, fetch the image through /v1/media/…, confirm it renders inline in axon-tui (or curl the URL and inspect the bytes). Fill the cache past its limit and confirm LRU eviction.
Landed. M12 — drafts and per-device read state (partial). The server-side device_state store, its GET/PUT /v1/devices/{device_id}/state/{namespace} route, and the device_state.changed WS fan-out — keyed (account_id, device_id, namespace, key), last-write-wins by server clock (PR 191, ADR 0048) — were dropped from main by a post-merge force-push and restored in PR 226; verify they’re present before building on them. The TUI’s cross-device draft sync that consumes them landed (PR 192). Cross-device read markers (PR 196) were merged and then reverted (116b3cb), so read-marker state is not currently in main — treat that surface as unlanded until it returns.
device_state keyed by (account_id, device_id, namespace, key) with an opaque value blob and updated_at.GET/PUT /v1/devices/{device_id}/state/{namespace}.updated_at.Verification: Two axon-tui instances (acting as separate devices); typing a draft in one updates the other within a second.
Landed. M13 — deployment scaffolding (partial — this is the current MVP gate). Platform-convention data/config/cache dirs (PR 206, ADR 0050); the axon init first-run config bootstrap that generates a starter config + store_key (PR 207/208, ADR 0051); and the Docker deployment framework (ADR 0052) has since landed for real — deploy/ now has a working Dockerfile, a full-stack docker-compose.yml (Postgres + axon-server + axon-web behind one front door), run-docker.sh, a Caddy/Tailscale TLS profile, and publish-images.yml pushes axon-server/axon-web images to GHCR on a beta-* tag or by hand. The Dockerfile in the project root is for development, not deployment, as it only builds axon-server. Tagged-release single-binary artifacts also shipped: cross-build.yml builds Linux/Windows/macOS (Intel + Apple Silicon) on every version tag and uploads axon-tui/axon-server/combined zips per platform. README.md documents axon init, the Docker quick start, and the beta image flow. Still missing: the docs/self-hosting.md walkthrough itself (a reader following one doc top-to-bottom, per the M13 verification criterion) and the non-Docker deployment recipes (Railway, DigitalOcean, AWS, bare VPS) — tracked against #221. Until those land, M13 isn’t done and MVP hasn’t shipped.
docs/self-hosting.md covering:
axon-core’s config loader).POST /v1/accounts/login, device verification (POST …/verify or …/recover), token minting, running axon-tui.pg_dump + media cache directory + the per-account SDK store dirs under data_dir/), upgrades, logs.axon + Postgres on your own machine, axon-tui connecting over localhost. This is also the dev and first-run path.Verification: A reader who has not touched the codebase follows the doc top to bottom on a fresh VM and reaches the “daily-driver through axon-tui” PRD success criterion. At least one deployment recipe is exercised end-to-end (any of them) by someone other than the author.
Apple follow-up (ADR 0054): the provider foundation and browser integration now support credentialed Apple browser login, CLI binding, and bootstrap. The final core-server layer adds independently enabled keyless native verification, five-minute server-owned challenges, explicit owner/bootstrap binding, and atomic challenge/replay/token redemption. See the native contract for configuration and verification. Native client UI and real-device acceptance, authorization revocation/account deletion, and additional smoke coverage remain subsequent silo-specific work. The original milestone history below describes the Google/Microsoft-only state at initial delivery.
Landed. M14 — OAuth authorization server (ADR 0054). Axon is its own minimal OAuth 2.0 authorization server for its own public clients (PKCE mandatory) and an OIDC relying party to Google/Microsoft, purely to verify the bound owner (upstream tokens never leave the process). It preserves ADR 0029’s TokenVerifier seam exactly — new nullable tokens columns, no change to the Authorization wire contract. Landed in two PRs, not the five originally planned — the first delivered more than its “architecture/schema” name suggested:
oauth_identities, oauth_refresh_tokens, oauth_authorization_requests, oauth_bind_requests, oauth_consumed_identity_tokens); the OidcProvider trait + GenericOidcProvider (discovery-doc-driven, covers Google and Microsoft, including Microsoft’s {tenantid}-templated multi-tenant issuer matching); JWKS caching; Path A (web-redirect+PKCE) and Path B (native identity token) token orchestration with refresh-token rotation; the un-gated /v1/oauth/* router + rate limiting; and axon-server’s boot-time wiring that constructs real GenericOidcProviders for Google/Microsoft from config. Apple is explicitly out (no AppleProvider yet; the binary refuses to boot with oauth.providers.apple.enabled = true).oauth_identities permanently empty — Path A/B both hard-reject any unbound identity, and there was no way to ever bind one. This PR adds axon oauth bind --provider <google|microsoft> (a device-code-style handshake: the CLI prints a URL, an admin opens it in any browser — same or a different machine, since it just needs to reach axon’s already-running /v1/ surface — and the CLI polls the DB until it completes) plus axon oauth identities list/unbind. GET /v1/oauth/bind shares the Path A callback route, disambiguated by state’s shape (a bind request’s state is its own device_code, a UUID; Path A’s is never UUID-shaped). Sign-in with Apple stays deferred until the iOS client work that actually requires it (App Store Guideline 4.8) starts — it needs its own paid Apple Developer account and a verified HTTPS domain regardless of when the code lands, so there’s no benefit to building AppleProvider early.Landed. M15 — media send/upload (ADR 0059) — shipped, server and both clients. The write-side counterpart to M11’s download proxy: Axon accepts a staged upload over /v1/, then sends it into a room as m.image or m.file through the account’s SDK client (crates/axon-api/src/uploads.rs, StagedUploadService). Landed as planned (staging substrate → send-media mutation → reconcile/integration coverage), plus client UX that was originally out of scope for the milestone: axon-tui’s /send command (ADR 0061/0062) and axon-web’s attach/drop/paste (ADR 0065). Still no audio/video send, no thumbnail generation on upload (M17 added thumbnails on the read side only), and no off-host storage — those remain out of scope, not just unstarted.
Landed. M16 — device-list / discovery endpoint (ADR 0060). Closes ADR 0053 item 2: GET /v1/accounts/{account_id}/devices (optional ?user_id=, defaulting to the account’s own user) lists a user’s Matrix devices live from the SDK (client.encryption().get_user_devices()), so a client can build a real SAS-verification device picker instead of requiring a blind id (issue #84, ADR 0028/0034). Server-only, single PR, no new storage — same consumer-owned-port + composition-root-adapter shape as 7a-6/7c (DeviceListService/DeviceListEngine/DeviceAdapter), read-only with an explicit accounts.state == active gate and no per-identity lock (same reasoning as ADR 0058’s trust bundle). Independent of M14 (OAuth), which continues separately. The axon-tui/web-client picker UI is separate follow-on client work, not part of this milestone.
Landed. M17 — media thumbnail proxy (ADR 0063, issue #253). GET /v1/media/{account_id}/{server_name}/{media_id}/thumbnail?width=&height=&method= proxies the homeserver’s native thumbnail endpoint (matrix_sdk::media::MediaFormat::Thumbnail, via a new SdkMediaProxy::download_thumbnail) rather than only the full-resolution original M11 serves. Server-only, single PR — ThumbnailMethod/ThumbnailSpec live in axon-core (shared by the axon-api port and the axon-media cache-key input); the cache key is extended with a "thumb:"-namespaced hash (etag_for_thumbnail) so a thumbnail variant is cached independently of the original, with zero changes to the existing LRU/eviction machinery. Encrypted media is out of scope by hard architectural necessity, not a v1 corner-cut: the spec/SDK only honor a thumbnail request for plain (unencrypted) MediaSource — a homeserver never sees encrypted-media plaintext to thumbnail — so the route 400s before ever calling the proxy. No client changes in that PR (clients/web/ didn’t exist yet), and the TUI’s existing client-side downscale is untouched; the web client’s MediaImage now consumes this endpoint.
Landed. M18 — live-event ephemeral passthrough (ADR 0056, backend PR). LiveFrame::Ephemeral fans out an allowlisted raw ephemeral event (m.typing, m.receipt) verbatim as an ephemeral.passthrough /v1/ws frame — the generic escape hatch for the room-scoped ephemeral long tail the homeserver delivers via sync but axon previously dropped (typing, receipts, unread counts), so a future room-scoped signal reaches clients via config alone rather than a bespoke LiveFrame variant touching all four crates. Server-only, single PR. axon-sync registers one plain add_event_handler(forward_ephemeral_event) in run_account (no child task — matrix-sdk dispatches AnySyncEphemeralRoomEvent through the ordinary per-room handler path, same as room state, via Raw<AnySyncEphemeralRoomEvent> so the SDK only captures the raw bytes rather than fully deserializing a discarded typed event) that checks the raw event’s size against a EPHEMERAL_EVENT_MAX_BYTES cap (256 KiB, mirroring RELATION_READ_CAP/REACTION_AGG_PAIR_CAP — the shared live-event bus fans out to every account’s /v1/ws clients, so an oversized EDU from one account’s homeserver must not cost every other account ring-buffer space), parses the raw JSON via a parse_raw_json helper shared with the persist_* handlers, checks the event type against the new sync.ephemeral_event_types allowlist (default ["m.typing", "m.receipt"], fails closed, config-extensible, allowlist misses logged at debug), and forwards {account_id, room_id, event_type, content} unmodified — nothing is persisted, since ephemeral events mutate no store row. Deliberately minimal: no typing-frame coalescing and no separate ephemeral lag domain, both considered and deferred until lag is actually measured (the ADR lists them as mitigations, not requirements). m.presence stays off the default allowlist and, unlike other future room-scoped EDUs, cannot be enabled by config alone even once the lag question is settled: presence is account-scoped and dispatches through a structurally different matrix-sdk handler kind (HandlerKind::Presence, no Room argument) that forward_ephemeral_event never receives — forwarding it needs a second handler registration, not just an allowlist edit. run_account logs a boot warning if m.presence is configured anyway, so this doesn’t fail silently. The frame shape (room_id: Option<String>) still accommodates presence’s eventual wire shape once that handler exists. Considered and rejected giving m.typing a bespoke typed frame/route (to pre-resolve display names or self-filter server-side): axon-tui already caches room members for message rendering, so that work is either trivial client-side code or something the client does anyway, while a bespoke frame would add a room_state read to what’s otherwise a zero-I/O forward and undercut the ADR’s whole point on its first use case. No outbound route exists (or is planned here) for a client to send its own typing/receipt state to the homeserver — that’s separate work symmetric to issue #130. Client consumer (axon-tui typing indicator + read receipts) is a separate follow-up PR per the one-silo-per-PR rule.
Landed. M19 — Matrix C-S verb batching (design; ADR 0068). Proposed: closes the remaining Matrix C-S parity gaps inventoried in issue #279 (room-membership verbs, room settings, account/profile actions) by stamping ADR 0021’s consumer-owned-port + composition-root-adapter pattern once per trait group rather than once per verb, reusing SdkGateway’s existing room-resolution and error-mapping helpers verbatim. Planned as a 6-PR sequence: M19a typing notice (piggybacks on the already-designed #278/ADR 0067 read-receipt relay, no new ADR) → M19b existing-room membership (leave/forget/invite/kick/ban/unban, via SdkGateway::room()) → M19c room entry (join/knock/create, via ClientManager::get_or_connect directly — split from M19b for its bigger test surface: M10 backfill pickup, DM/encryption flags) → M19d room settings (name/topic/avatar/tags, one route per field) → M19e power levels, split out alone because a bad write can permanently strand the account below the PL needed to self-correct, and the PR must decide an explicit guardrail before shipping → M19f account actions (profile/ignore/directory search). Tier-C gaps needing real design work are tracked separately, not part of M19: invited-room visibility (no stripped-state handling anywhere in axon-sync/axon-store today — recommended as the next milestone after M19, since it’s the precondition for M19b’s invite being useful in reverse); presence (stays deferred per ADR 0056, no new ADR until the lag question is actually addressed); and unread/notification counts (an independent per-room SDK summary mechanism — ADR 0068 corrects issue #279’s own text, which mistakenly suggested folding this into #241’s unrelated account-level sync-readiness signal). M19a landed, bundled with the read-receipt relay it piggybacks on (#278/ADR 0067) since neither had code yet: a new EphemeralSender port (crates/axon-api/src/sender.rs), split from MessageSender because these two sends are best-effort/fire-and-forget from the caller’s perspective. POST /v1/accounts/{account_id}/rooms/{room_id}/read (body {"event_id"}) sets both the public read receipt and the private fully-read marker via Room::send_multiple_receipts; PUT .../rooms/{room_id}/typing (body {"typing"}) calls Room::typing_notice. Both resolve through SdkGateway::room() exactly like the M6 mutations. The event named on the wire is the client’s, sent verbatim — and choosing it is subtle (ADR 0089). A receipt is interpreted in arrival order, while every axon read surface sorts by origin_ts (display order). The two diverge for backfilled events — routinely, since a mautrix portal backfills the pre-existing conversation with its real, older timestamps after emitting the portal’s own state. So a client must name the event with the greatest EventDto.arrival_order among those it has displayed, not the last one in display order, and must keep its forward-only receipt floor on arrival_order too (an origin_ts floor can never name a backfilled message). arrival_order is events.id, present on timeline reads and /v1/ws frames alike; Store::upsert_event returns it so the live path can carry it. Do not reintroduce server-side resolution of the target. An earlier version substituted the SDK event-cache chunk’s last event when it appeared to be within the client’s display range; review caught that a client pages by display order, so an event stamped below its page floor is not in the page however recently it arrived — the substitution acknowledged messages that were never rendered. The server cannot prove what a client displayed, so it does not try. The same send also records what it learned about whether the homeserver still serves the room (SdkGateway::note_room_reachability, ADR 0090) — a failure flags room_upstream_reconcile as suspect, a success clears it — which is what lets the ADR 0070 unread watcher pin a purged or tombstoned room’s count to zero instead of leaving it frozen forever. The watcher re-reads that table into its in-memory suppression set on every re-sweep rather than accumulating verdicts — an insert-only set cannot un-suppress a room whose row was cleared, so a recovered portal would stay pinned to zero until restart (caught in review). That write is deliberately verdict-free: the 404 M_UNKNOWN a purged room returns names the event, not the room, and M_UNKNOWN is also how Synapse reports its own internal errors, so only the watcher’s own bounded probe (classified on HTTP status, never on errcode) may conclude a room is absent. M19b landed: a new MembershipSender port (crates/axon-api/src/sender.rs), split from MessageSender for the same reason as EphemeralSender — none of these six calls return an event id the caller needs, since the resulting m.room.member state event round-trips through sync like any other state change. POST .../rooms/{room_id}/{leave,forget} take no body; POST .../rooms/{room_id}/invite takes {"user_id"}; POST .../rooms/{room_id}/{kick,ban,unban} take {"user_id", "reason"?}. All six resolve through SdkGateway::room() and call straight through to the matching Room::{leave,forget,invite_user_by_id,kick_user,ban_user,unban_user} method, except forget, which checks Room::state() up front and rejects a still-joined/invited room as a clean 400 rather than letting the SDK’s WrongRoomState fall through map_sdk_err’s generic upstream-error catch-all. Each call is bounded by sync.membership_mutation_timeout_secs (default 15s, mirroring ephemeral_send_timeout_secs’s pattern) per the boundary-robustness guardrail above, and each handler logs a structured success/failure line with account_id, room_id, the target user where applicable, elapsed time, and the mapped error on failure, per ADR 0068’s structured-logging requirement for M19 routes. M19c landed: a new RoomEntrySender port (crates/axon-api/src/sender.rs), split from MembershipSender because none of these four resolve through an existing Room — there is no room yet — so they go through ClientManager::get_or_connect directly rather than SdkGateway::room(), and because every method here returns the resulting room’s id rather than (). POST .../rooms/join (body {"room_id_or_alias", "server_names"?}) calls Client::join_room_by_id_or_alias; POST .../rooms/knock (body {"room_id_or_alias", "reason"?, "server_names"?}) calls Client::knock; POST .../rooms/dm (body {"user_id"}) calls Client::create_dm, which — under the e2e-encryption feature axon-sync already enables — bundles m.room.encryption into the room’s initial_state and attempts mark_as_dm internally; POST .../rooms (body: name/topic/invite/is_direct/public/preset/encrypted, all optional) calls Client::create_room, and when encrypted: true, SdkGateway::build_create_room_request puts the same m.room.encryption entry into initial_state before creation — Room::enable_encryption()’s post-hoc, racy path is never used. Both create_room and create_dm double-check Room::is_direct() after the SDK call and retry mark_as_dm explicitly (propagating any failure through Axon’s own structured logging) if the SDK’s internal attempt silently failed — the SDK only logs that failure via its own internal error! macro rather than surfacing it. Each call is bounded by sync.room_entry_timeout_secs (default 30s, longer than membership_mutation_timeout_secs since these can involve federation resolution), and each handler logs the same structured success/failure shape as M19b. Known, accepted risk: create_room/create_dm are not idempotent — Matrix’s createRoom endpoint has no idempotency key, so a client retry after an Axon-side timeout can create a second room upstream if the first attempt actually succeeded after Axon’s own client-side future was dropped; there is no protocol-level fix available at this layer, so this is documented rather than solved (see the doc comments on SdkGateway::create_room/create_dm). M19d landed: a new RoomSettingsSender port (crates/axon-api/src/sender.rs), split from MembershipSender because set_tag/remove_tag write room account data (m.tag, private to this account), not a state event, despite ADR 0068 grouping all four fields under one “room settings” batch — the other three (name/topic/avatar) do resolve through SdkGateway::room() exactly like M19b. PUT .../rooms/{room_id}/name (body {"name"}) calls Room::set_name; PUT .../rooms/{room_id}/topic (body {"topic"}) calls Room::set_room_topic — an empty string is the clear signal for both, since the SDK has no separate “remove” primitive for name/topic. PUT .../rooms/{room_id}/avatar (body {"upload_id"}) claims an already-staged upload (the same StagedUploadService flow send_media uses — Axon has no route that hands a client an mxc:// URI to reference directly, and the ClaimedUpload → MediaAttachment mapping is shared with send_media via a From<ClaimedUpload> for MediaAttachment impl in crate::uploads rather than duplicated per handler) and calls Room::upload_avatar, which uploads the bytes and sets m.room.avatar in one call; DELETE .../rooms/{room_id}/avatar calls Room::remove_avatar. set_name/set_topic/set_avatar/remove_avatar all share a SdkGateway::ensure_joined guard (mirroring forget’s own state guard above) before touching the SDK: send_state_event/upload_avatar internally call ensure_room_joined and fail with an SDK-local WrongRoomState that map_sdk_err can’t distinguish from an upstream failure, so a non-joined room now fails as a clean 400 instead of a misleading 502 for any of the four — an initial version of this PR only added the guard to set_avatar, missing that the other three hit the same SDK precondition; caught in review and generalized into one shared helper rather than three more copies of the same check. set_avatar additionally requires a declared content_type on the staged upload (rejecting a missing one rather than defaulting to image/png, since that default exists for message sends where any declared kind is fine, not for an avatar where the whole point is asserting it’s actually an image). PUT/DELETE .../rooms/{room_id}/tags/{tag} (PUT body {"order"?}) call Room::set_tag/remove_tag, gated by a new parse_tag_name helper that accepts only m.favourite/m.lowpriority/m.server_notice/u.-prefixed custom tags of at most 255 bytes and rejects anything else as a clean 400 — TagName’s own From<T: AsRef<str>> conversion is infallible and would otherwise silently accept a made-up, non-namespaced tag. The custom-tag check delegates its prefix validation to ruma’s own UserTagName: FromStr (picking up any future change to what ruma considers valid automatically) and layers two additional, deliberately stricter rules on top: a bare u. with no name after it is rejected (ruma’s own parser alone would accept it), and the 255-byte cap bounds an otherwise-unbounded custom tag name (mirroring the upload-metadata caps in axon-api::routes::uploads) rather than letting an oversized one round-trip to a homeserver-dependent rejection. order is validated against the Matrix spec’s documented [0, 1] range for m.tag (validate_tag_order) — ruma’s own TagInfo carries it as a bare Option<f64> with no bound, so this closes the one input in the whole PR that otherwise skipped boundary validation before reaching the SDK. All six routes use PUT/DELETE rather than M19a/b/c’s POST, since these are idempotent field-set/clear operations. Every non-avatar call reuses sync.membership_mutation_timeout_secs rather than a separate room-settings timeout — both are local-room state/account-data writes with no federation resolution, so there’s no reason to tune them independently; avatar-set reuses media.upstream_upload_timeout_secs instead, since it does a real media upload like send_media. M19e landed: a new PowerLevelsSender port (crates/axon-api/src/sender.rs), split from RoomSettingsSender — unlike M19d’s four fields, this is the one M19 verb where a client mistake isn’t just a bad write but a potential permanent lockout: dropping your own resolved power level below what’s needed to send another m.room.power_levels event succeeds at the protocol level (no M_FORBIDDEN for map_sdk_err to catch), leaving no way to self-correct. PUT .../rooms/{room_id}/power_levels (body: ban/invite/kick/redact/events_default/state_default/users_default, all optional, plus a users map of user id → level and an acknowledge_self_demotion flag) merges role-threshold and per-user changes into one in-memory RoomPowerLevels and sends exactly one m.room.power_levels state event — deliberately not matrix-sdk’s own apply_power_level_changes/update_power_levels, which are two independent read-modify-send round trips and would fire two state events for one client request, letting the guardrail see a stale intermediate state between them. Before sending, SdkGateway::check_self_demotion_guardrail computes the caller’s resolved level in the merged result via ruma’s RoomPowerLevels::for_user/for_state(StateEventType::RoomPowerLevels) (the precise level required to send m.room.power_levels, not the state_default/events_default shorthand ADR 0068 used illustratively — an events override specifically for that event type is rare but spec-legal) and rejects the write as a clean 400 unless acknowledge_self_demotion is set. GET .../rooms/{room_id}/power_levels returns the same fully-resolved levels the write path computes internally — added because no generic Tier-2 state read exists yet (ADR 0055’s proposal was never implemented), so without it no client could read a room’s power levels through Axon at all. set_power_levels shares M19d’s ensure_joined guard; both routes reuse sync.membership_mutation_timeout_secs, same reasoning as M19d’s non-avatar calls. room_name/room_avatar/room_topic stay out of scope here, owned by M19d’s dedicated routes. M19f landed: a new AccountActionsSender port (crates/axon-api/src/sender.rs) covering this account’s own profile and ignore list, another user’s profile, and public-room directory search — kept as one PR for the same reason M19b’s six verbs were, since none of these seven routes resolve through a Room handle; every call goes through ClientManager::get_or_connect directly. PUT .../accounts/{account_id}/profile/display_name (body {"display_name"}) calls Account::set_display_name, with an empty string clearing it — the same convention as M19d’s set_name/set_topic — issuing the SDK’s real profile-field-delete call under the hood rather than writing an empty string to the homeserver. PUT .../profile/avatar (body {"upload_id"}) claims an already-staged upload and calls Account::upload_avatar, mirroring M19d’s room-avatar upload+set shape (including the same “content type must be declared and be an image” validation); DELETE .../profile/avatar calls Account::set_avatar_url(None). GET .../users/{user_id}/profile calls Account::fetch_user_profile_of and reads the typed DisplayName/AvatarUrl static profile fields off the response — a genuine read mixed into the same port as the mutations, the same shape M19e’s PowerLevelsSender already has. PUT/DELETE .../users/{user_id}/ignore call Account::ignore_user/unignore_user; ignore_user rejects an attempt to ignore the account’s own user id up front as a clean 400, since the SDK’s own guard (Error::CantIgnoreLoggedInUser) is a local precondition failure with no client_api_error_kind that map_sdk_err’s catch-all would otherwise surface as a misleading 502. GET .../directory/public_rooms (query: server, search_term, limit, since, all optional) calls Client::public_rooms_filtered — the one M19f route ADR 0068 called out as needing its own request/response shape rather than the {"data": {}} empty-object envelope the other four share, since it’s a paginated read, not a mutation. Every mutation reuses sync.membership_mutation_timeout_secs; public_rooms reuses sync.room_entry_timeout_secs instead, since — like join/knock — a directory search can resolve against another homeserver via the server field. M19 (all six PRs, M19a–M19f) is now complete.
Superseded since: of the three Tier-C gaps this record defers, invited-room visibility has since landed — ADR 0091 gave it a dedicated room_invites projection, GET /v1/invites, and invite.added / invite.removed frames, so the “no stripped-state handling anywhere in axon-sync/axon-store” above describes the state at M19 design time, not today. It reached axon-web as an Invites row plus an /invites inbox; axon-tui has no inbox and is the outstanding consumer (see docs/client-parity.md). ADR 0094 further amends ADR 0091 to state that a 404 with M_NOT_FOUND or M_UNKNOWN is positive per-room evidence that a pending invite can be pruned. Presence and unread/notification counts are unaffected by this note — presence is still deferred (ADR 0056), and unread counts landed under ADR 0070 with axon-tui likewise still on a local heuristic.
Milestones 1–6 shipped as originally numbered. After M6, the sequence was rethought to reflect what the project actually needs next — a real account lifecycle, server-side relation aggregation, and a terminal client in place of the planned web alpha. The current plan (this document) supersedes the original M7–M13 ordering, and has itself been extended twice more since (M15–M19 added; the web client revived as a second, parallel client track). Expect further resequencing before MVP ships — this table is a living bridge, not a final numbering.
| Now | Was | Change | Status |
|---|---|---|---|
| 7a Homeserver account lifecycle & verification | — (new) + old “M5c” | Login / verify / recover / logout / delete as a first-class API; closes the interactive verification deferred from M5 and GH issues #14, #24. | Shipped |
| 7b Client ↔ axon bearer-token auth | old M8 (Auth) | Unchanged in substance; renumbered. | Shipped |
| 7c Sender-device trust & content authentication | — (new) | Evaluate + expose whether other senders’ devices are cross-signed; delivers the verification-bundle API the tech-spec already promises. | Shipped |
| 8 Relation aggregation (8a backend, 8b API) | — (new), subsumes old M13 (Threads) | Edits / reactions / replies / threads aggregated server-side (GH issue #22). Threads are the m.thread case, no longer a standalone milestone. |
Shipped |
| 9 Search | old M9a | Runs after aggregation (indexes latest bodies) and before backfill, so backfilled history indexes incrementally. | Shipped |
| 10 History backfill | old M9b | Now runs last of the read-side milestones, so it aggregates + indexes incrementally in one pass over the deep history. | Shipped |
| 11 Media proxy | old M7 | Unchanged in substance; renumbered. | Shipped |
| 12 Drafts & per-device read state | old M10 | Unchanged in substance; renumbered. | Partial — drafts shipped; cross-device read markers were reverted (116b3cb) and later re-landed on the TUI side (PR 217); confirm current status against docs/client-parity.md before relying on it |
| 13 Deployment docs | old M12 | Retargeted at axon-tui; first-run flow now uses the M7a account endpoints. |
Partial — axon init, platform dirs, and a full Docker Compose stack (deploy/, ADR 0052) shipped; docs/self-hosting.md and the non-Docker deployment recipes (Railway, DigitalOcean, AWS, bare VPS) are still unwritten. This is the current MVP gate. |
| 14 OAuth authorization server | — (new) | Axon becomes its own minimal OAuth 2.0 AS + OIDC relying party (Google/Microsoft), ADR 0054. Pulled forward from the post-MVP roadmap in tech-spec.md. |
Shipped for Google/Microsoft; Apple browser callbacks/binding implemented pending real Apple verification; native server/client work pending (ADR 0054 addendum). |
| 15 Media send/upload | — (new) | Adds the write-side counterpart to M11: staged uploads plus m.image / m.file send over Axon’s /v1/ API. |
Shipped (server); client UX followed on both axon-tui (ADR 0061) and axon-web (ADR 0065) |
| 16 Device-list / discovery endpoint | — (new) | GET /v1/accounts/{account_id}/devices, closing ADR 0053 item 2 so a client can build a real SAS device picker instead of a blind id. |
Shipped (server); no client picker UI yet on either axon-tui or axon-web |
| 17 Media thumbnail proxy | — (new) | GET .../media/{...}/thumbnail, ADR 0063. |
Shipped |
| 18 Live-event ephemeral passthrough | — (new) | m.typing/m.receipt forwarded verbatim over /v1/ws, ADR 0056. |
Shipped |
| 19 Matrix C-S verb batching (typing/membership/settings/profile) | — (new) | Closes the remaining Matrix C-S parity gaps inventoried in issue #279, stamping ADR 0021’s consumer-owned-port pattern once per trait group instead of once per verb; six PRs (M19a–M19f), see ADR 0068. | Shipped (all six PRs, server-side); client UI for room settings/power levels/account actions is separate follow-up work not yet started on either client |
| (revived, not dropped) Web client | old M11 (Web alpha, originally dropped in favor of axon-tui) |
Reinstated as a second, parallel reference client rather than staying a “credible later addition” — see ADR 0031 (client strategy) and ADR 0046 (framework pick + roadmap). It runs its own M-Wn milestone series alongside the numbered sequence above, tracked in docs/client-parity.md rather than in this table. |
Active, not yet at parity with axon-tui — see docs/client-parity.md |
Older docs (AGENTS.md “Current state”, the ADR log) still reference the original numbers as historical context; this table is the bridge. References to milestone numbers in those frozen/append-only docs are not retro-renumbered.
The threads question carried over from tech-spec.md is now resolved:
m.thread case of the M8 aggregation machinery. The store captures m.relates_to generically (incl. m.thread) in events.relates_to (ADR 0015), so this is additive and backfill-free — the thread membership of every already-stored event is recoverable from data on disk. See Milestone 8.Everything else is settled. If an ambiguity arises during implementation that neither the PRD nor the tech spec covers, stop and ask rather than picking silently.
Follow Matrix OSS community conventions first; fall back to standard Rust conventions where Matrix doesn’t speak to the question. Match matrix-rust-sdk’s style and naming where there is overlap (event types in snake_case like the Matrix spec, room/event identifiers as opaque strings, error enums per crate with thiserror).
matrix-axon-* on crates.io if we ever publish; internal workspace paths crates/axon-*. Binary name axon.crates/axon-store/migrations/; numeric prefix; sqlx migrate.account_id and provenance (default upstream_homeserver) and link to original ciphertext rows.account_id from day one. Cross-account aggregation happens in the API layer, not the store layer./v1/…. WebSocket at /v1/ws. Envelope {type, account_id, payload} on every WS message.axon-core defines the top-level error enum; crates re-export their own narrower errors that convert into it. Use thiserror for definitions and anyhow only at binary boundaries.tracing with structured fields including account_id, room_id, event_id where applicable. Match matrix-rust-sdk’s span layout where the two libraries are in the same call path.cargo check)/v1/ws, or recovery-key); logout and confirm it deactivates with its archive retained and a fresh login reactivates it; delete and confirm the DB rows and SDK store dir are gone; confirm a deactivated account neither syncs nor sends.sender_trust on timeline events distinguishes cross-signed from unverified senders, the verification-bundle endpoint returns per-event evidence, and a sender identity change surfaces as verification_violation.The OpenAPI spec covers the wire protocol but not the codebase. Future coding agents (Claude Code, Codex, Cursor, whatever comes next) need a separate set of in-repo docs to understand structure, intent, and non-obvious decisions. We maintain four:
AGENTS.md (canonical) + CLAUDE.md (pointer)AGENTS.md at the repository root is the vendor-neutral orientation doc that most coding agents now look for by convention. CLAUDE.md is a one-line pointer to AGENTS.md so Claude Code finds it without us maintaining two copies.
AGENTS.md during Milestone 1. Initial contents: project name, one-paragraph summary, pointer to docs/mvp/, the directory tree from “Project layout” above, a short conventions section that links to the “Conventions” section of this doc, and a “Current state” section that records which milestone is in flight.CLAUDE.md during Milestone 1 with one line: See AGENTS.md.AGENTS.md as you go, but only with what is still live guidance — a convention, an invariant, or a gotcha that constrains the code the next agent is about to write. Its “Current state” section is a one-paragraph status (what has landed, what gates MVP), not a recap of how the project got there.### heading in this document, with the reasoning in its ADR. That history used to be appended to AGENTS.md instead, which is how its “Current state” section grew to two-thirds of the file before being moved here; do not restart the practice. Where a choice is also a rule the next agent must follow (e.g. “invite state lives in room_invites, not events”), state the rule in AGENTS.md and leave the history here.AGENTS.md is a high-density orientation, not a wiki. If a section grows past a page, break it out into a dedicated doc under docs/ and leave a one-liner pointer behind.Goal: any agentic contributor opening this repo cold reads AGENTS.md and is productive within minutes, without having to grep around or re-read the MVP specs.
README.md + crate-level //! rustdocEvery crate under crates/ has a README.md and a crate-level doc comment (//!) in lib.rs. They serve different audiences but cover the same ground:
axon-store owns Postgres connections; axon-api consumes a Store handle).Rustdoc renders for human-readable browsing on docs.rs (if we publish) and for cargo doc --open locally. The README.md is what an agent or human reads first when grepping by file. Keep them consistent; if they drift, the README is the source of truth and rustdoc is regenerated to match.
docs/adr/Lightweight ADRs (Michael Nygard format — Context / Decision / Consequences, one page max) capture non-obvious decisions as they’re made. Filename pattern: NNNN-kebab-case-title.md, monotonically numbered.
Write an ADR when:
The first ADR (0001-record-architecture-decisions.md) is the meta-ADR adopting the practice — created in Milestone 1.
Don’t write ADRs for decisions the MVP specs already settle; those are anchored in docs/mvp/ and re-stating them in ADRs creates drift. The ADR directory is for what happens during implementation that the specs don’t cover.
docs/mvp/ (this directory, locked at end of MVP)PRD, tech spec, and implementation spec freeze at MVP ship. After that, they become historical reference — changes to product/architecture go through new docs (or new versions). An agent reading them later should treat them as “what we decided going in,” not “current state.”
The current state of the system lives in AGENTS.md and the ADR log.
We’re not the first project in this space. Other Matrix clients have already hit the protocol’s sharp edges; their issue trackers are a cheap source of edge cases we should cover from the start.
Read these for lessons, not to copy code.
A tests/fixtures/ directory holds JSON event payloads, recorded sync responses, and end-to-end scenarios that drive the protocol-level test suite. Build it up by harvesting categories from the trackers above and add fixtures as new edge cases appear (whether discovered locally or upstream).
Categories to cover, all with at least one fixture before MVP ships:
m.room.tombstone arrives during active reading).m.room.message variants, bridge-specific body and formatted_body content, reply-to chains across the bridge).Treat this as a living checklist: when an upstream issue closes with “fixed bug in X edge case” and X is one we’d plausibly see, add the fixture for X if we don’t have it.
Mirrors the PRD non-goals and out-of-scope items; the agent should not drift into these:
/v1/management/ lets a client administer the instance. It manages runtime state — credentials, sign-in identities, the search index — and is not a config editor.)TokenVerifier seam, ADR 0054. axon oauth bind connects an identity; Apple/Sign-in-with-Apple stays deferred to the iOS client work.)axon-tui ships; rich UI does not.m.image / m.file mutations. The client UX followed: axon-tui’s /send (ADR 0061) and the web client’s picker/drop/paste (ADR 0065, M-W8.5).)store_key rotation. One key decrypts every account’s token; rotation stays deferred (ADR 0008), tracked against #24.