Axon is a self-hosted personal agent for Matrix: a persistent state layer (sync, E2EE decryption, search, media proxy) that sits between a user’s homeserver(s) and their clients.
Arbitrary clients consume it through a stable, versioned HTTP + WebSocket API at /v1/.
See docs/mvp/prd.md for the full product description.
| File | Contents |
|---|---|
docs/mvp/prd.md |
Product requirements — what we’re building and why |
docs/mvp/tech-spec.md |
Architecture decisions and tradeoffs |
docs/mvp/implementation.md |
Milestone-by-milestone build plan (authoritative for agentic contributors) |
docs/adr/ |
Architecture decision records — decisions made during implementation |
docs/integration-testing.md |
Running axon against a local Synapse (sync + M3c re-decryption) by hand |
docs/README.md |
Index of every guide under docs/, grouped by audience — add new docs here |
docs/install.md |
End-user install steps per platform; README.md links here for anything beyond the one-liners |
docs/building.md |
Building and running from source (prerequisites, first-time setup, run.sh) |
docs/server-admin.md |
Server operation: sign-in, tokens, SSO, push, bootstrap, management API, TLS |
docs/technical-reference.md |
Architecture, database deadlines, environment variables and config |
docs/client-parity.md |
Human-maintained cross-silo matrix of which /v1/ capabilities each client (TUI, web, future iOS) actually exposes — update it in the same PR that changes a tracked row’s status, from any silo |
docs/demo-coverage.md |
Human-maintained record of which demo scene shows each visually significant capability (ADR 0086) — update it in the same PR that changes what a client renders, from any silo |
scripts/demo-stack.sh |
Brings the ADR 0086 demo world up (up/record/down) for recording client videos. record --capture has ffmpeg record the TUI’s own window (X11/XWayland) instead of a human running a screen recorder. Not a test and not a gate — the stack it starts is meant to stay up while a human (or --capture) records against it. |
scripts/demo-web.sh |
One-shot pipeline for the ADR 0086 web recordings: up/record/assemble/upload/down, or all end-to-end. assemble stitches each platform’s per-scene clips into the single ordered tour demo.html and api-docs.yml expect — see clients/web/README.md § Demo recording. |
scripts/demo-all.sh |
Orchestrates the two scripts above under one command: tui/web run either demo on demand, all [--upload] records (and optionally publishes) both in one pass. Reimplements neither — TUI capture still needs a real X11/XWayland desktop session, so all fails loudly rather than skipping it silently; --web-only opts out on purpose. |
scripts/integration-test.sh |
One-command end-to-end re-decryption test: seeds an encrypted room + key backup via axon-itest, runs axon as a fresh device, and asserts UTDs back-fill. Runnable in CI on demand via .github/workflows/integration.yml (manual workflow_dispatch). |
scripts/matrix-oauth-test.sh |
ADR 0097 black-box and real interoperability lanes against pinned Synapse + MAS: API boundaries, acquisition, fresh SDK-device grant, and unsupported capability. Runs on demand via integration.yml or smoke.yml (notifies the dev room), and after main pushes/nightly via smoke.yml; secret-bearing runs upload no artifacts. |
matrix-axon/
Cargo.toml # workspace
AGENTS.md # this file
CLAUDE.md # one-line pointer to this file
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)
axon-itest/ # dev-only: integration-test seeder (the `seed` binary)
axon-test-support/ # dev-only: in-memory signing fixtures shared by server tests
clients/
tui/ # axon-tui — terminal client for the Axon API, should grow to support all API endpoints as they are enabled
web/ # axon-web (Vite + Preact + TS, ADR 0046) — alpha client
smoke/ # black-box smoke harnesses (depend on no axon-* crate; ADR 0025)
tui/ # axon-smoke-tui — PTY-drives the real axon-tui against an in-process API stub
# also ships axon-demo-tui — the ADR 0086 demo pilot (not a test, not a gate)
server/ # axon-smoke-server — black-box API/WS smoke against a real stack
matrix-oauth/ # ADR 0097 black-box + real Synapse/MAS/SDK QR interoperability
local-stack/ # axon-smoke-local-stack — boots Synapse + Postgres + axon; writes a JSON manifest
corpus/ # declarative demo content (ADR 0086); rendered by `up --corpus`
demo.toml # personas, spaces, rooms, messages — relative timestamps
media/ # CC0 avatars (committed) + photos (generated placeholders, not committed)
openapi/ # OpenAPI 3.1 spec (source of truth)
docs/
mvp/ # PRD, tech spec, implementation spec (frozen at MVP ship)
adr/ # architecture decision records
README.md # index of the guides below and beside it
install.md building.md server-admin.md technical-reference.md # user and operator guides
self-hosting.md # produced in Milestone 13 (deployment docs; still pending)
docker-compose.yml # Postgres 16 for dev; Synapse under `integration` profile
packaging/ # nFPM .deb/.rpm, and the Homebrew formula and cask templates
scripts/
integration-test.sh # end-to-end E2EE re-decryption test vs local Synapse
.github/workflows/ # public repo: GitHub-hosted runners have free minutes, so most workflows trigger on push or release (via new tag); a few stay manual-dispatch only for other reasons (expensive/manual runs)
api-docs.yml # build the Pages site: homepage + API reference at https://matrix-axon.github.io/matrix-axon/api.html
check-environment.yml # local-runner environment check
cross-build.yml # fmt, clippy, test, and build for MacOS, Linux, and Windows; on tags, publishes the Homebrew formulas and cask; every server binary carries the web client (ADR 0116); Windows executables are Authenticode-signed when the `ARTIFACT_SIGNING_*` repo variables exist (ADR 0119)
lint-and-clippy.yml # required PR/main Rust gate: cargo fmt + clippy + test
lint-and-test.yml # cargo fmt + clippy + test
package.yml # nFPM .deb/.rpm (amd64 on ubuntu-22.04, arm64 on ubuntu-22.04-arm); tag uploads to GitHub Releases; the binary carries the web client (ADR 0116)
embed-web.yml # builds the web client into axon-server, runs the Postgres-gated router tests, then boots the binary and fetches the bundle back (ADR 0116)
release-plz.yml # on main (push, or manual dispatch): opens the release PR that bumps the Cargo version, and tags its merge (ADR 0106, release-plz.toml)
release-notes.yml # draft release notes grouped by PR label while the release PR is open; publishes them on the tag and opens the CHANGELOG.md PR (ADR 0108, .github/release.yml)
integration.yml # selectable E2EE re-decryption and Matrix OAuth QR real-service suites
matrix-oauth.yml # reusable Matrix OAuth QR real-service workflow used by integration and smoke
smoke.yml # S1 black-box smoke (PR 1: TUI PTY suite; PR 2 added the server gate)
postgres-tests.yml # nightly + on demand, never on PRs: every `#[ignore]`d Postgres-backed test via scripts/postgres-tests.sh (one fresh database per test target)
.github/actions/ # actions relied on by the workflows
check-environment/ # check environment on local runners before proceeding
web-bundle/ # build clients/web and export AXON_WEB_DIST, for any job that builds axon-server with `embed-web`
Each crate’s own Cargo.toml description is the source of truth;
this table is the orientation copy.
| Crate | Purpose |
|---|---|
axon-core |
Shared types, errors, and configuration. |
axon-store |
Postgres-backed event store, room state, and account data. |
axon-sync |
matrix-rust-sdk sync-engine wrapper (Simplified Sliding Sync); owns verification and all crypto-adjacent logic. |
axon-crypto |
Reserved stub — stays empty; verification lives in axon-sync (ADR 0027; see the “axon-crypto stays a stub” convention below). |
axon-search |
Tantivy full-text search index, populated on event ingestion. |
axon-media |
Media proxy with a bounded on-disk LRU cache for mxc:// URLs. |
axon-api |
axum HTTP + WebSocket handlers; OpenAPI spec via utoipa. |
axon-server |
The axon binary — wires the crates together and owns the process. |
axon-itest |
Dev-only end-to-end integration-test seeder (plays a verified Matrix client). |
axon-test-support |
Dev-only in-memory signing fixtures shared by Axon server tests. |
account_id.clients/.
Follow any subtree AGENTS.md there;
clients/tui/AGENTS.md covers axon-tui-specific conventions./sync.origin_ts is BIGINT milliseconds since Unix epoch.provenance = 'upstream_homeserver' for MVP./v1/.
WebSocket at /v1/ws.
Envelope {type, account_id, payload}.crates/axon-store/migrations/, UTC timestamp prefixes (YYYYMMDDHHMMSS_description.sql, via sqlx migrate add) to avoid cross-branch collisions;
forward-only — see ADR 0004.
For any table that carries updated_at TIMESTAMPTZ, add a BEFORE UPDATE trigger (using a shared trigger_set_updated_at() plpgsql function) so application queries never need to remember updated_at = now() — the DB enforces it automatically.room_invites projection (not events / room_state / GET /v1/rooms — stripped invite state has no event id).
axon-sync’s watch_invites captures client.invited_rooms() the same way ADR 0070 captures unread counts, prunes on positive absence (or a non-empty invited list) or a 404 from a leave/reject attempt classified as LeaveOutcome::Gone, and fans out invite.added / invite.removed.
GET /v1/invites is the reconnect source of truth.
Accept is existing POST …/rooms/join;
reject is existing POST …/rooms/{id}/leave.
Known-contact DM auto-join (ADR 0040) is unchanged.
axon-web consumes this (Invites row + /invites inbox);
axon-tui does not — it has no inbox and is the outstanding consumer, tracked in docs/client-parity.md.main is immutable — never edit it, not even a comment. sqlx checksums each migration file’s entire contents into _sqlx_migrations and refuses to start when the bytes no longer match, so a comment-only or whitespace edit is a hard startup failure (migration <version> was previously applied but has been modified) on every database that already applied it — including every deployed instance, which is exactly who never re-reads the file.
To change schema, add a new timestamped migration;
to correct or expand a comment, say it in the new migration that supersedes it rather than editing the old one.
scripts/check-migrations-immutable.sh enforces this.
It runs in CI from migrations-immutable.yml, which is pull_request-triggered on purpose: only that event supplies the canonical base (base.sha) without a credential the fork doesn’t have.
It also runs from the pre-push hook (migrations-immutable in .pre-commit-config.yaml), which covers branch pushes before a PR exists.
It compares against the canonical main — not your fork’s, which lags, so git fetch upstream main before trusting a clean local result — and only attributes changes made by commits this branch adds, so inheriting a migration change from main is never reported as yours.
Only files that already exist on that base count as published — a migration your own branch adds is not in anyone’s database yet, so amending it in a follow-up commit before the PR merges is fine.
It also checks uncommitted work, so you can run it by hand at any point — though not through the hook, where pre-commit stashes unstaged changes first and the script therefore sees only committed state (the right semantics for a push gate).
A commit that must change a published migration anyway (reverting a bad edit back to the bytes deployments already applied, as #375 did) declares itself with a Migration-edit-approved: <reason> trailer in its message;
the check then reports the change and passes, and the PR should justify it.
If a database is already broken this way, axon db repair-migrations --apply (--features dev-tools, CONTRIBUTING.md §Troubleshooting) rewrites the metadata for a checksum-only drift.docs/adr/, monotonic NNNN-kebab-title.md.
Numbers collide across branches just like migrations do — before claiming the next number, check the open branches/PRs (git ls-tree -r origin/<branch> docs/adr/, or scan the open PRs), not just main.
If two land on the same number, the later-merged one renumbers.thiserror in libraries;
anyhow only at the axon-server binary boundary.tracing with structured fields — always include account_id, room_id, event_id where applicable.
Logging and debugging support should always be considered as part of the design of new features or functionality.
Before finalizing a new or modified feature, consider whether there is adequate logging and also whether any “developer mode” configurable option should be added to allow debugging in the future./v1/ additively — an independently-distributed axon-tui binary can run against a server for a long time without upgrading. scripts/check-openapi-compat.sh (CI: openapi-compat) fails a PR that breaks compatibility; a deliberate exception declares itself with an API-Breaking-Change: <reason> commit trailer (ADR 0099).crates/ (server-side infrastructure), clients/ (client-side infrastructure, separated into tui and web), and smoke/ (testing infrastructure).
Behavioral changes — anything that changes logic, functionality, or intent — must stay within a single silo per commit/PR.
Typically, changes to the web and tui clients should be separate PRs except where a global change impacts both clients.
If a behavioral server change breaks client compatibility, ship the server change on its own; don’t bundle a client-side fix into the same PR even to keep main working, even temporarily — open a separate, immediate follow-up PR for the client side.
Exception for generated sync artifacts: a PR may cross the crates/ ↔ clients/web boundary only to keep a generated file in sync with its source of truth (e.g. regenerating clients/web/src/api/schema.d.ts from openapi/openapi.json via pnpm check:api) — this is mechanical, not behavioral, and does not violate this rule.
Server-first sequencing: for a large feature spanning server and client (e.g. QR sign-in, encryption key backup), do not touch client code until the server-side implementation is settled; land and stabilize the server piece first.
If a bug in another silo is discovered mid-task, do not fix it inline — stop, return to the appropriate silo, and address it as its own separate change.
Files can be added to docs/ combined with other silos where they are directly related to the change in that silo.
The user can override this rule, but the agent should never do this on its own.axon-crypto stays a stub.
Verification and all crypto-adjacent logic live in axon-sync — the engine can’t be thin, it needs ClientManager, the per-identity locks, and supervision (ADR 0027).
Do not add logic to axon-crypto;
it remains a reserved stub.cargo check) and a Code review guide (a suggested file-by-file review order, dependencies first, plus a “where to keep a close eye” section calling out correctness, security, and lifetime concerns).
Match the format of PRs #6 and #7.
Scope both guides to the PR’s actual diff.
Subsequent changes to code in PR’s should be impelmented as additional commits and pushes so that responsive comments can identify the precise commit that addresses the issue.
Generally, the commits in a PR will all be squashed at merge time, subject to developer approval..github/release.yml).
Label every PR you open, when you open it, with the one label that best names its effect on users: breaking-change, security, enhancement, bug, performance, documentation, dependencies, build or ci.
Where a PR fits several, release.yml lists it under the first match in that order, so pick the one you want it listed under.
Use skip-changelog for a PR that has no place in the notes, such as a typo fix in a comment or a test-only change.
An unlabelled PR is listed under “Other Changes”, so a missing label is a visible defect, not a harmless omission.
Title the PR as a conventional commit (feat:, fix:, and so on, with ! for a breaking change), since the squash commit takes the title and release-plz picks the next version from it (ADR 0106).
If you cannot set labels with the tools you have, say so in the PR body so a developer can.main.
Code is incorporated into main when PRs are merged.
Any exception should be approved by a developer. git force-push should only be used to fix an error in branch history and not without developer approval.git push --delete, jj git push --deleted, remote pruning).
Only deleting the branch through GitHub’s own “Delete branch” button on the merged PR retargets stacked children to the merged PR’s base instead of closing them.
So: before deleting a merged PR’s branch, check for open PRs based on it (gh pr list --base <branch>) and either retarget them first (gh pr edit <n> --base main) or delete the branch via the GitHub UI.
This has already cost us real work once — a feature PR was auto-closed this way and went unnoticed until live testing rediscovered the gap it addressed — so treat a nonzero gh pr list --base result as a hard stop.page.reload() is reported as navigate by Navigation Timing under Firefox, while every engine including Firefox reports reload for an in-page location.reload() (measured on Firefox 151; Chromium and WebKit report reload for both).
Nothing a user does reaches the driver’s reload, so the browser needed no workaround at all — but before that was measured, this repo spent eight review rounds building production ones: user-agent sniffing, deprecated-API fallbacks, and a sessionStorage marker, each of which introduced its own bugs.
So, in order: (1) before adding any compatibility shim for navigation, storage, or timing, measure the signal in the target engine and paste the reading into the PR — a claim about a browser is a measurement, not an inference from a failing test;
(2) drive reload and history e2e from inside the page, because a driver-level page.reload()/goBack() is not guaranteed to be classified the way the user-initiated action is;
(3) if a fallback really is warranted, enumerate and test every supported signal state — a modern value, no modern value, a throwing accessor, each legacy override — not just the case that motivated it, and preserve established fallback behavior unless the PR documents the compatibility change on purpose.
Also keep the PR’s verification and review guides synchronized with the branch’s actual Playwright projects and final file layout before requesting re-review;
a guide naming a deleted test, or a Playwright project the branch does not define, wasted a reviewer’s time here more than once.#N is a GitHub autolink — never use it for anything else.
GitHub renders #<number> (in PR/issue bodies, comments, and commit messages) as a link to the issue or pull request with that number.
Only write # immediately before a number when you mean a link to that exact, existing issue or PR.
For every other numbered thing — review-comment indices, milestone phases, list items, ordinals, counts, versions — omit the # (write “comment 4”, “step 3”, “milestone 7a”, “v2”) so prose never sprouts bogus cross-links to unrelated issues./v1/, axon-server calling a homeserver, the TUI calling axon — is a “what could go wrong?” checkpoint.
Before merging code that crosses one, account for: (1) Timeouts — every outbound call gets one;
never await a remote unbounded.
(2) Bounded resources — a fixed pool (workers, connections, semaphore permits) must be paired with timeouts and/or cancellation, so one slow/hung peer can’t permanently consume it.
(3) Hostile input — validate size and shape before allocating from it;
cap bodies/images/list lengths;
never size a buffer or allocation directly from a number the peer controls.
(4) Concurrency — name the shared mutable state and the lock/owner guarding it (the cold-connect gate vs. live-task severing in 7a is the model), and state which lost-update/reconnect race is closed and how.
(5) Partial failure — one account/room/event failing is logged and skipped, never fatal to the loop (the established “best-effort, never fatal to sync” philosophy).Database deadlines and bulk isolation.
All server SQL uses centrally initialized session statement, lock, acquisition, and idle-transaction deadlines from [database.timeouts].
Direct PostgreSQL and session-mode poolers are supported; transaction-mode poolers cannot preserve session deadlines or advisory locks.
Ordinary writes/authentication and hot reads have separate pools of database.max_connections slots each.
Status, indexing, and bulk maintenance each reserve one additional connection; startup verifies the full 2 * max_connections + 3 budget.
Migrations and repair use their longer statement budget for lock waits too.
Large event deletions commit bounded batches with atomic search obligations and durable retry state.
Status event totals are maintained by database triggers; progress failure must not discard build, sync, management, or disk status.
axon token issue printing the raw bearer token to stdout, or axon init emitting a generated secret — and even then only at the moment of issue, never re-logged afterward.
Secret-bearing inputs (login password, 4S recovery key) are consumed once and dropped, never persisted (ADR 0008, 0026).deleting teardown breadcrumb + boot reconcile (7a-4) and the transactional search_outbox (9a) are the models: write a durable intent, make the completion idempotent, heal on resume.TIMELINE_SELECT projection, the config-discovery rules, and the TUI parsers are all single-source-of-truth for exactly this reason.README.md and the guides indexed in docs/README.md), contributor setup (CONTRIBUTING.md), or agent instructions (AGENTS.md, subtree AGENTS.mds) describe must update those docs in the same PR — they must reflect the project’s state once the PR merges.
Stale docs are a review finding.prettier runs with proseWrap: "preserve", so it normalizes tables, lists and emphasis and never touches a line break in body text.
The reason is diffs.
An unwrapped paragraph makes every edit one enormous changed line;
a hard wrap at a column makes a one-word edit rewrap the whole paragraph, so a typo fix arrives as a ten-line diff.
A break per sentence gives a diff that names the sentence that changed, and never rewraps.
Column wrapping was measured and rejected: proseWrap: "always" on README.md rewrote 62 lines into 162 and still left lines of 226, 218 and 194 characters, because tables and long links cannot be broken.
This applies to prose you write, and to paragraphs you are already rewriting for other reasons.
Which files have already been reflowed is not listed here.
.git-blame-ignore-revs is that list — one entry per reformat, each naming the files it covered — and it is the only copy, because a reformat has to add itself there anyway and a second list would go stale the first time one didn’t.
Assume a file you have not checked is still hard-wrapped at a column.
Nothing is reflowed on sight, because a reformat mixed into an ordinary PR is a diff nobody can review and a blame nobody can skip.
To reflow one, do it alone, in its own PR with no other change of any kind — see the note in .git-blame-ignore-revs for why a commit-level split cannot substitute for a separate PR.
That is two pull requests, not one.
A squash SHA does not exist until the squash does, so listing it is necessarily a second, later change: reflow PR → merge → a one-line follow-up adding the SHA.
Skipping that follow-up leaves the reformat un-ignorable, which was the entire reason for isolating it.`backticks` is content, so prettier cannot re-indent the continuation line and dedents it to column 0 instead — dropping the list indentation, or the blockquote’s >.
CommonMark’s lazy continuation means it still renders correctly, so the damage never shows in the output and only shows in the source.
Keep the span on one line, or shorten it.docs/mvp/implementation.md “What not to build” for the full list.
APNs and FCM delivery is ADR 0113.
The server router wakes a registered iOS, iPadOS, or Android device.
macOS, Windows, Linux, and the browser stay on local notifications./v1/management/, in their own sub-router behind one layer for the [server] management_api switch — add a management route there, never beside it.
A handler that changes credentials (mint, revoke, bind, unbind) takes the CredentialChange extractor, which is the step-up check: a non-expiring token, or a session whose upstream sign-in was within ten minutes.
A write that removes a credential takes the store’s credential lock and, from the API, the lockout guard.
This is runtime state only; it does not edit the config file.embed-web cargo feature compiles clients/web/dist (or AXON_WEB_DIST) into axon-server, and the router’s fallback serves it unless [server] web_client is off.
The feature is off by default, and the gate’s --all-features builds on a checkout with no bundle only warn and embed nothing, so cargo and the pre-push gate need no Node.
With the default clients/web/dist, build.rs runs pnpm itself when the bundle is missing or older than clients/web sources, so a local cargo build --features embed-web needs no separate pnpm build.
It does nothing when AXON_WEB_DIST is set, when CI is set (and not false/0), or when AXON_WEB_AUTOBUILD is 0, false, no or off (the gate’s clippy hook sets it; set it in an IDE’s cargo check --all-features too); with no pnpm, or a failed build, it warns and embeds what dist holds.
The autobuild is a developer convenience, not a release mechanism: a release lane without CI that forgets AXON_WEB_DIST would build from the checkout instead of failing, so release lanes still set AXON_WEB_DIST.
On Windows it runs pnpm.cmd. Its staleness check and failure handling are mtime-only (#840), and it takes no lock and has no timeout (#841).
AXON_WEB_DIST is the explicit form: it must be an absolute path, and a directory there with no index.html fails the build, which is what release pipelines rely on.
Every job that ships a server binary builds with --features embed-web and AXON_WEB_DIST set (the web-bundle action exports it), then runs scripts/assert-web-embedded.sh on the result; a new release lane must do the same.
The one exception is the Docker server image (the root Dockerfile, publish-images.yml), which stays without the web client because the stack’s web container serves it; do not change that outside #687.
scripts/embed-web-smoke.sh boots such a binary against a throwaway database and checks the bundle over HTTP, in CI (embed-web.yml) or by hand.
The cache rules in crates/axon-api/src/web.rs and in deploy/web/Caddyfile are the same rules (ADR 0087): change one, change the other.m.tag favourite), in which case the standard should be used in code..jj/ and .git/ are present).
If the developer has jj in their local environment, prefer jj commands for committing and branching;
git commands still work but are not the primary workflow.
Key operations:
jj describe -m "..."jj newjj new <bookmark>@originjj edit <change-id>jj bookmark create <name> -r @ / jj bookmark set <name> -r @jj git push --bookmark <name>jj rebase -d <base-bookmark> then re-pushgh pr create --base <base> --head <branch>;
gh’s “uncommitted changes” warning in colocated mode can be ignored as long as the bookmark was pushed correctly..pre-commit-config.yaml is the only list of checks the repo runs before a push, for both front-ends (ADR 0092).
It mirrors CI’s web job (web-lint-and-test.yml) and Rust fmt/clippy/test jobs (lint-and-test.yml / lint-and-clippy.yml), path-filtered so a web-only push does not run clippy and a rust-only push does not run pnpm.
The automatic required lint-and-test PR job also runs cargo test --all; keeping it in the hook catches failures before review begins.
The hook requires the pre-commit runner (pipx install pre-commit, uv tool install pre-commit, pip install --user pre-commit, or sudo apt install pre-commit);
everything else it needs it installs itself.
Enable it once per clone:
./scripts/setup-hooks.sh — runs pre-commit install --hook-type pre-push, so the gate fires natively on git push.
(If you have an older clone, run it again: it used to set core.hooksPath = .githooks, that directory is gone, and a stale hooksPath silently disables every hook without git saying so.)jj users: git hooks don’t fire under jj, so push through the jj-hooks tool (cargo install jj-hooks) instead: jj-hooks --runner pre-commit push.
To make jj push do that by default, add this alias to ~/.config/jj/config.toml:
[aliases]
push = ["util", "exec", "--", "jj-hooks", "--runner", "pre-commit", "push"]
Use jj push, not jj git push;
direct jj git push bypasses the alias.
Both front-ends read the same .pre-commit-config.yaml;
only the driver differs.
On a failure, run the command printed by the hook (for example pnpm --dir clients/web format for formatting, cargo fmt --all for rustfmt, or pnpm --dir clients/web gen:api for schema drift) and push again.
To skip deliberately: SKIP=<hook-id> jj push for one hook, git push --no-verify for all of them.
CI is the backstop for fmt, clippy, cargo test --all, and the web job.
For what is in the gate, read .pre-commit-config.yaml itself — it is the list, top to bottom in the order it runs, with each hook’s command, path filter, and a comment on why it is there.
Do not restate it here: a prose copy is a second list to keep in sync, which is the exact failure ADR 0092 exists to end.
What belongs here is only what that file cannot say about itself: cargo test --all runs both locally and in the required PR Rust job, and two things are deliberately absent from the hook — Playwright (browsers and minutes; web-e2e.yml gates PRs, and clients/web/AGENTS.md says when to run it locally) and the Docker smoke lanes (opt in per push with RUN_SMOKE=<lane>).
clients/web code changes, the local gate is pnpm --dir clients/web lint, pnpm --dir clients/web test, and pnpm --dir clients/web build.
Do not use plain tsc --noEmit as a substitute for the build typecheck: the web package’s root tsconfig.json is a project-reference file with no direct inputs, so plain tsc --noEmit can check zero app files.
Use pnpm --dir clients/web build (or, for TypeScript only, pnpm --dir clients/web exec tsc -b) so the referenced app/node projects are checked.Full conventions are in docs/mvp/implementation.md under “Conventions.”
axon init (M13, ADR 0051) is the first-run bootstrap — it owns starter-config generation, secret generation, and local-service setup.
Config precedence and discovery are described in the Milestone 2 notes below (figment: defaults < TOML < DATABASE_URL < AXON_-prefixed env).
axon init owns config/secret generation and first-run setup;
helper and launcher scripts must not reimplement it.
Scripts stay as source-checkout developer scaffolding.AXON_* outranks the TOML file and can silently clobber user/platform config.
Prefer the Rust config APIs over shell/PowerShell copies of the discovery rules.--config <path> and AXON_CONFIG=<path> must fail loudly when the file is missing.
Silent absence is acceptable only for convention-based discovery (./axon.toml, the platform config dir)..env loading, Cargo invocation, and optional dev Postgres — nothing more.
No secret generation, config-file writing, or storage-location overrides unless the script’s stated purpose is bootstrap.AXON_SYNC__DATA_DIR, AXON_SEARCH__INDEX_PATH, and AXON_MEDIA__CACHE_DIR explicitly — otherwise it reads and writes the real platform dirs (ADR 0050) and leaks state between runs.docs/technical-reference.md should list the most common config options (the README only links to it), and .env.example should include examples of every option that can be set via environmental variables.
Update these files when config options are added or changed.Recurring failure modes distilled from M1–M13 review. They apply across crates and clients.
search_outbox account-purge sentinel, 9a), not shape-guessing.docs/reviews/2026-07-web-client-review.md.)
The one exception stays: secret-bearing inputs are consumed once by design, per the secrets rule above.unread_suppression_reason was handed successor_room().is_some() where Store::list_rooms hides on any tombstone;
the branch reading probe_proves_room_absent folded “we could not tell” into “reachable” while the predicate’s own tests asserted !probe_proves_room_absent(Some(500));
the web read effect never called isVisibleTimelineEvent;
the TUI room-load path filters its receipt pick through should_show_event and its marker pick, one line below, not at all (#167).
When extracting a predicate, build the seam that lets its consumer be tested too — a fake, a loopback server, a pure function taking the outcome — and prefer a type that makes the third state unrepresentable over a bool the call site can quietly collapse (guardrail 4, one level up).When implementing a new feature:
cargo fmt on the code you modified after finishing making changes;
limit the scope to the code you changedcargo clippy on the code you modified after making changesdocs/, AGENTS.md, CLAUDE.md, README.md, CONTRIBUTING.md, subtree AGENTS.mds — and no source, config, migration, workflow, or CI file), the fmt/clippy/test gate exercises nothing in the diff, so pushing past the hook (git push --no-verify) is acceptable.
Any commit that touches code stays subject to the full gate below.ubuntu-latest, windows-latest, macos-latest) get free, unlimited standard-runner minutes.
lint-and-clippy.yml triggers automatically on pull requests and pushes to main, while cross-build.yml and publish-images.yml trigger on release tags;
workflows stay workflow_dispatch-only only where that’s the right call for other reasons (e.g. expensive/manual smoke or integration runs), not because of a minutes budget.
The pre-push hook remains the earliest safety net, while the required lint-and-test job repeats the full Rust suite on the pull-request commit.
Before pushing, either install the hook once per clone with ./scripts/setup-hooks.sh (git) or the jj-hooks alias above (jj), or, if you haven’t, run it by hand:cargo fmt --all -- --check
cargo clippy --all-features --all-targets -- -D warnings
cargo test --all
The tui smoke lane runs in CI on every pull request (smoke.yml): it needs no Docker and takes about 90 seconds.
The Docker-backed lanes stay out of the PR path — they need a true-local Synapse stack — and run on push to main and nightly instead.
Use scripts/smoke-gate.sh <mode> before pushing component changes that cross process/network boundaries, so a Docker-lane break is caught before it lands rather than after:
| Changed area | Smoke gate |
|---|---|
smoke/tui or terminal rendering in clients/tui |
scripts/smoke-gate.sh tui |
| TUI changes that depend on real Axon/local-stack behavior | scripts/smoke-gate.sh tui-true-local |
smoke/server, crates/axon-server, crates/axon-api, crates/axon-sync, or crates/axon-store API/sync behavior |
scripts/smoke-gate.sh server |
smoke/local-stack or shared smoke harness behavior |
scripts/smoke-gate.sh all |
The pre-push hook does not run Docker-backed smoke by default.
Opt in with RUN_SMOKE=tui, RUN_SMOKE=server, RUN_SMOKE=tui-true-local, or RUN_SMOKE=all — the smoke-gate hook is a no-op unless that variable names a lane, and works the same under git push and jj push.
CI can also run the Docker-backed lanes by hand: dispatch smoke.yml with a lane of tui-true-local, server or all, or with matrix-oauth-all (or one of matrix-oauth-api, -acquire, -grant, -unsupported) to run the Matrix OAuth QR suite alone and notify the axon-developer room; integration.yml offers the same suites without the notification.
Through M19 landed;
MVP has not shipped.
MVP is gated on finishing M13 — docs/self-hosting.md and the non-Docker deployment recipes (#221).
What each milestone delivered, and the non-obvious choices made along the way, lives in docs/mvp/implementation.md under that milestone’s heading, with the reasoning in its ADR.
It is deliberately not repeated here: this file is the conventions those choices produced, not the record of making them.