matrix-axon

Axon MVP — Implementation Specification

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.

Project layout

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.

Settled stack

Settled decisions inherited from tech-spec.md

Read the tech spec before starting. Highlights that gate implementation:

Milestones

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.md and 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.md keeps 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 on axon-web (axon-tui not at all). docs/client-parity.md tracks that per capability, per client.

1. Workspace scaffolding

Verification: docker compose up -d postgres && cargo build succeeds; CI passes on the first push.

2. Config + bootstrap

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):

Milestone 2 complete — the binary boots: typed config, Postgres pool + migrations, and an axum server with /healthz.

3. Sync engine v0

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):

Non-obvious choices made in 3b (see ADR 0012):

Non-obvious choices made in 3a (see ADRs 0006–0008, 0010, 0011):


4. Event store schema

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):

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):

Non-obvious choices made in 4b (see ADR 0016):

5. Client API v0

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):

Non-obvious choices made in 5a (see ADR 0019):

6. Mutations

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):

7. Account lifecycle and auth

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.

7a. Homeserver account lifecycle & verification

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.

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.

7b. Client ↔ axon bearer-token auth

The local-API gate. (This is the work formerly numbered M8.)

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.

7c. Sender-device trust & content authentication

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.

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):

Non-obvious choices made in 7b (bearer-token auth — see ADR 0029):

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.

8. Relation aggregation

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.

8a. Aggregation backend

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.

8b. Aggregation API endpoints

Account-nested per the M5a convention:

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):

Non-obvious choices made in 8b (relation aggregation API surface — this PR):

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.

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):

10. History backfill

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.

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):

11. Media proxy

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”).

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.

12. Drafts and per-device read state

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.

Verification: Two axon-tui instances (acting as separate devices); typing a draft in one updates the other within a second.

13. Deployment docs

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.

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.

14. OAuth authorization server

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:

15. Media send/upload

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.

16. Device-list / discovery endpoint

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.

17. Media thumbnail proxy

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.

18. Live-event ephemeral passthrough

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.

19. Matrix C-S verb batching

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.

Milestone resequencing (post-M6)

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.

Open decisions that gate milestones

The threads question carried over from tech-spec.md is now resolved:

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.

Conventions

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).

Verification per milestone (end-to-end, not just cargo check)

Documentation for agentic contributors

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:

1. 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.

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.

2. Per-crate README.md + crate-level //! rustdoc

Every 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:

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.

3. Architecture decision records under 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.

4. 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.

References and test corpus

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.

Architectural references

Read these for lessons, not to copy code.

Test corpus

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:

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.

What not to build

Mirrors the PRD non-goals and out-of-scope items; the agent should not drift into these: