matrix-axon

Axon MVP — Technical Specification

Audience: Engineers and reviewers with working Matrix and Rust literacy. Light on background; decisions and tradeoffs are the focus.

Related docs: prd.md, implementation.md.

Status: MVP has not shipped. This document freezes at MVP ship per implementation.md’s convention; until then, treat it as a living record — several items below (web client, OAuth) have already moved from “post-MVP roadmap” to “shipped ahead of freeze” as the sequencing evolved. See implementation.md’s status banner for current progress.

Context & goals

Axon is the agent described in prd.md: a self-hosted persistent state layer for one human’s Matrix accounts, consumed by arbitrary clients (axon-tui, a terminal client, is the MVP reference client; a web client, axon-web, was pulled forward from the roadmap below and is now under active parallel development — see ADR 0031/0046; native mobile/desktop clients remain deferred) through a stable HTTP + WebSocket API.

This document records the architectural decisions for the MVP and the tradeoffs we weighed. It is not an implementation guide — that is implementation.md.

Architecture overview

                 ┌─────────────────────────┐
                 │   Upstream              │
                 │   Homeserver(s)         │
                 │   (Synapse / Dendrite)  │
                 └────────────┬────────────┘
                              │ Matrix C-S API
                              │ (Simplified Sliding Sync,
                              │  media)
                              ▼
                 ┌──────────────────────────┐
                 │   Axon (single binary)   │
                 │                          │
                 │   axon-sync              │
                 │   axon-crypto            │
                 │   axon-store ──────── Postgres
                 │   axon-search ─────── Tantivy
                 │   axon-media ──────── disk cache
                 │   axon-api               │
                 └────────────┬─────────────┘
                              │ Axon API (REST + WS, /v1/)
                              ▼
                       ┌─────────────┐
                       │  axon-tui   │  (terminal client)
                       │  (deferred: │
                       │   web,      │
                       │   mobile)   │
                       └─────────────┘

Trust boundary. Axon is a Matrix device, not a homeserver extension. It is trusted by its single human owner the way a desktop client would be. Upstream homeservers do not trust it beyond what they trust any other client. Clients on the south side of Axon are trusted only as far as their per-device bearer tokens.

Settled decisions

Single binary + Postgres + local disk

One Rust binary (axon), one Postgres database (docker-compose reference deployment), media cached to local disk. No microservices. No required external dependencies beyond Postgres.

S3-compatible media storage is intentionally out of scope for MVP. The media proxy is a bounded LRU disk cache; the upstream homeserver remains the source of truth. Durable / off-host media storage gets revisited when there is a hosted Axon deployment or a multi-host operator that needs it.

matrix-rust-sdk for sync and crypto

Sync, olm/megolm, key backup, cross-signing, and verification are delegated to matrix-rust-sdk. We do not reimplement any of it. Gaps discovered during implementation get upstreamed.

Simplified Sliding Sync only

Sync uses Simplified Sliding Sync (MSC4186) only. No legacy /sync fallback. Tradeoff:

We document the requirement and revisit if deployments hit a homeserver without SSS.

Account model: one Axon per human, N Matrix accounts inside

A single Axon process serves a single human. That human may have N Matrix accounts (e.g. personal + work, across different homeservers) inside one Axon.

Multi-human-within-one-process is a non-goal (see prd.md). Operators serving multiple humans run one Axon per human.

Account lifecycle and active-account gating

Because the data model is N-account from day one, accounts need a real lifecycle rather than a one-shot config provision. Provisioning an account only from config strands the previous row when the config changes, and any row with a decryptable token would otherwise keep syncing and sending — a stale, deconfigured account acting on the user’s behalf. (Observed in practice; tracked in GH #14 and #24.)

The single store_key that encrypts every account’s access token at rest is a known blast-radius concern; rotation stays deferred (ADR 0008) and is tracked against #24.

Relation aggregation: server-side, read-time, over stored relations

Edits (m.replace), reactions (m.annotation), replies (m.in_reply_to), and threads (m.thread) are all relation events. Axon stores them as ordinary events with the relation in the relates_to hot column. Leaving aggregation to clients breaks down because a client only holds a timeline window: a reaction or edit that lands outside that window (e.g. long after the original message) is silently missed, producing wrong reaction counts and stale message bodies (GH #22).

We aggregate server-side instead. The store resolves the latest edit per target, groups reactions by target and emoji, and lists replies and thread members — all via expression/partial indexes over relates_to, computed at read time (cheap at Riley scale; materialized counters are a later optimization). Raw relation events stay on disk (append-mostly, provenance preserved — the same philosophy as redaction masking); the timeline read collapses edits into their target rather than rewriting rows. Threads are simply the m.thread case of this machinery, so they ship in the MVP as part of aggregation rather than as a separate later feature.

Event provenance

Every event row carries a provenance field. For MVP it is always upstream_homeserver. The field exists so a future federated ingestion path (a peer Axon importing decrypted history for a shared room) can be modeled cleanly without schema changes. See “Federation deferral” below.

Event store schema: hybrid hot-columns + JSONB

Hot fields are extracted to indexed columns; full content stays as JSONB. Considered:

Tradeoff: a bit of write-time cost extracting the hot columns, paid back many times over on timeline pagination, ranking, and filter queries.

Append-mostly storage and room-lifecycle semantics

The store is append-mostly. Membership changes do not retroactively rewrite history. Leaving / being banned / rooms being deleted upstream do not delete the local archive by default. Per-room retention policy (retain / hide / delete) is exposed but defaults to retain. Room upgrades (m.room.tombstone) link old and new rooms in the data model so timeline navigation and search work across the upgrade.

Redactions and message deletion. Matrix expresses message deletion as m.room.redaction events that point at the redacted event’s event_id. We follow Matrix semantics:

Content authentication

The store keeps original event bytes (ciphertext for encrypted events, signed JSON for unencrypted), the megolm session ID and re-decryption metadata, and sender device identity and cross-signing chain at the time of receipt. This means decrypted rows can be re-verified against the cryptographic evidence Matrix already provides; we do not invent a separate HMAC or agent-level signing layer.

Verification is exposed as an opt-in API capability: clients fetch decrypted events normally, or request a verification bundle per event / per query when they need it. Most traffic carries no verification overhead. This capability — plus a sender_trust field on ordinary timeline reads so clients can flag messages from unverified senders — is delivered in M7c (sender-device trust & content authentication); the storage it draws on (sender device identity + cross-signing chain at receipt) lands earlier in the event-store schema.

Live updates: WebSocket with a custom envelope

One bidirectional WebSocket at /v1/ws. Server → client carries timeline events, presence updates, typing notifications, and read-receipt fan-out; client → server carries typing, draft sync, and read markers. Considered:

Local-API auth: bearer tokens for MVP, OAuth later

MVP issues long-lived bearer tokens via an axon token issue CLI subcommand. Each token is bound to a human-readable label / device name, hashed at rest, and individually revocable. axum middleware validates Authorization: Bearer … on every /v1/… request.

Full OAuth 2.0 + PKCE was on the roadmap and has since shipped ahead of MVP freeze: axon is its own minimal OAuth 2.0 authorization server plus an OIDC relying party to Google/Microsoft, behind the same TokenVerifier seam described below (M14, ADR 0054). Sign-in with Apple remains deferred to the iOS client work. The token storage table, the middleware, and the API shape were designed so OAuth issuance could drop in without breaking the wire protocol, and it did. Tradeoffs we accepted for the original bearer-only MVP scope:

API versioning: path-prefix /v1/, SemVer on the spec

All routes live under /v1/…. SemVer applies to the OpenAPI spec. Breaking changes bump the major and move to /v2/…. Previous major remains supported in parallel for a defined window — target: two minor releases past the next major’s GA, which gives client authors a real deprecation runway. Considered date-based versioning (Stripe-style) and Accept-header media-type versioning; both are heavier than this API needs.

Search: backend in MVP, single default analyzer

Tantivy index populated on event ingestion. account_id is a facet field; queries can scope to one account or aggregate across all. BM25 ranking. Filters: room, sender, account, date range.

Single language-agnostic analyzer for MVP. The analyzer chain is Tantivy’s default tokenizer plus three built-in token filters: LowerCaser (case-insensitive matching), AsciiFoldingFilter (diacritics fold — café matches cafe), and Stemmer (light morphological stemming — cats matches cat, running matches run). These are configuration, not code we write; Tantivy ships them all.

What the analyzer handles vs. what it doesn’t, so expectations are explicit:

Two known limitations of the single-analyzer choice, both pointing at the deferred per-language work:

Per-language detection, per-room overrides, and CJK tokenization are deferred. Rationale: Riley-shaped users have mostly English / Latin-script content; the cost-benefit of per-language analyzers doesn’t pay off at MVP scale, and we keep the door open by versioning the index schema.

Encryption at rest. The Tantivy index contains decrypted message text — search is the whole point. It lives on the same disk as Postgres and inherits whatever the operator deploys for filesystem-level encryption (LUKS, dm-crypt, ZFS native encryption, encrypted-volume cloud disks). Application-level encryption of the index would defeat search; we do not attempt it. Per-account content-encryption keys, with whatever search story comes with them (encrypted search schemes, decrypted-on-the-fly query-time indexes, etc.), are a v2 concern and explicitly out of scope for MVP. The threat-model section flags this.

Bridges

Bridged events flow through as ordinary Matrix events. The agent does not parse mautrix / Beeper / other bridge-specific formats and does not surface a normalized bridge_metadata field. Clients render whatever the bridge places in event content. Normalization is on the post-MVP roadmap.

Onboarding: fresh sync only

First run logs the agent in as a new Matrix device and runs a fresh sliding sync. No importer from Element X / gomuks / Fluffychat / others. The initial-sync success criterion (under ten minutes for a Riley-shape account) is what makes fresh sync acceptable.

Remote push

ADR 0113 is the design. The server routes a wake through APNs (iOS and iPadOS, one registration) and FCM (Android) when an unread-count observation matches that ADR. Web Push, a Windows push service, and a wake for a quit Mac app are not in this cut. The phone still does not obtain a platform token or render the alert until the client half lands.

Open decisions

Almost every architectural question was resolved during planning. The table below maps each resolution. One question remains genuinely open and is called out separately.

Question Decision
Multi-tenancy model One human per Axon; N Matrix accounts inside, account_id-scoped tables. Multi-human-per-process is a non-goal.
Sliding sync vs legacy sync fallback Simplified Sliding Sync only.
Event store schema Hybrid hot-columns + JSONB.
Search analyzer defaults Single language-agnostic analyzer for MVP.
Push payload format ADR 0113. Per device, an opaque activity ping by default. Sender and room, or a 140-character preview, are opt-in.
OAuth implementation Bearer tokens for MVP; OAuth 2.0 + PKCE was planned post-MVP but shipped ahead of freeze (M14, ADR 0054).
Live-update transport WebSocket with custom envelope.
API versioning policy Path-prefix /v1/, SemVer; previous major supported two minor releases after next major GA.
Migration story Fresh sync only for MVP.
Bridge event handling Treated as ordinary Matrix events; no normalization.
Media storage backend Local disk LRU cache for MVP. S3-compatible storage deferred until a hosted deployment needs it.
Account lifecycle Explicit account states + active-account gating + runtime login/verify/recover/logout/delete (logout retains the archive; delete purges). In MVP (resolves GH #14, #24).
Relation aggregation Server-side, read-time aggregation of edits/reactions/replies/threads over stored relations. In MVP (resolves GH #22).
Threads In MVP, as the m.thread case of relation aggregation (no separate milestone).

Open: how to handle redacted events? Should Axon expose and index redacted events or hide them from its user? This depends on how we view the purpose of redactions: are they to get rid of bad content that no one should see, or, e.g., moderation decisions that may want to be reviewed/reverted later?

Threat model summary

Push disclosure is decided in ADR 0113. The bullet below says what a provider can see.

Federation deferral

Axon v1 is one-agent-per-human with no agent-to-agent communication. We keep the door open by capturing event provenance now: every event row records where its decrypted content came from (upstream_homeserver only, for v1) and preserves the cryptographic evidence (original ciphertext, megolm session, sender device keys, cross-signing chain) so a peer agent’s content could be verified against the homeserver’s signatures without trusting the peer.

Implications for MVP schema:

We do not build any federation code in v1.

Roadmap signposts

Originally post-MVP, roughly in priority order — several of these have since shipped or begun ahead of MVP freeze, noted inline:

  1. Push (APNs and FCM together; web push after that). P0 immediately after MVP. (Server router landed, ADR 0113. The client half has not started.)
  2. Full OAuth 2.0 + PKCE. (Shipped ahead of freeze — M14, ADR 0054.)
  3. Bridge metadata normalization. (Not started.)
  4. Import-from-existing-client onboarding (Element X store reader, maybe gomuks). (Not started.)
  5. Durable media storage (S3-compatible backend) when a hosted Axon deployment needs it. (Not started; still explicitly out of scope — see “What not to build” in implementation.md.)
  6. Per-room / per-language search analyzers. (Not started.)
  7. Spaces as first-class API resources. (Not started.)
  8. Native clients (iOS first, then desktop) and a web client. See ADR 0031 for the client strategy. (Web client (axon-web) pulled forward and under active parallel development, well beyond the original alpha scope — see docs/client-parity.md. iOS/Android/macOS native clients have not started.)
  9. Federation of agents v2. (Not started.)

(Threads moved into the MVP as part of relation aggregation; they are no longer a post-MVP track.)