The sync server for Matron — a chat system for talking to Claude Code agents from your phone, desktop, or browser.
matron-journal is a small, server-authoritative journal service: every user
has one append-only, strictly-ordered event log (the journal), and every
device — phone, laptop, or agent bridge — is just a cursor into it. Clients
reconnect, say "I have seq N", and replay forward. Everything a user sees is
an event in that log — messages, tool output, read markers, conversation
metadata, session status — with a per-user monotonic seq assigned
server-side and at-least-once delivery behind idempotency keys, so publishers
fire-and-forget and retry safely. No federation, no CRDTs, no client-side
merge — one user, one log, many cursors.
Status: v0.1.0 — pre-1.0, the protocol may change. The apps are in beta: TestFlight (iPhone / Mac) and Play Store testing (Android) — see matron.chat.
ONNXRUNTIME_NODE_INSTALL_CUDA=skip npm install
MATRON_DB=./matron.db MATRON_PORT=9810 npm start
Semantic search uses the optional @huggingface/transformers dependency.
Its onnxruntime-node dependency downloads CUDA libraries (about 340 MB)
at install time unless ONNXRUNTIME_NODE_INSTALL_CUDA=skip is set; search
runs on the CPU, so the install line above sets it, as does
deploy/vps/setup.sh. Use
npm install --omit=optional to leave semantic search out entirely. See
MATRON_SEMANTIC_SEARCH below for memory use and the first-boot model
download.
Node 20+. A systemd unit template is in
deploy/matron-journal.service.
npm start runs bin/matron-journal, which starts src/server.js. On macOS
it runs from a copy of the node binary named matron-journal (kept in
~/.matron/bin, refreshed after a node upgrade), so per-app firewalls, which
identify a program by its executable, show the journal under its own name
rather than as node. The copy keeps node's code signature; the firewall may
ask once more after a node upgrade. Set MATRON_NAMED_NODE=0 to run under
node itself; on Linux that is what always happens.
printf %s "$PW" | MATRON_DB=./matron.db npx matron-admin user add alice --password-stdin
MATRON_DB=./matron.db npx matron-admin user admin alice on
MATRON_DB=./matron.db npx matron-admin agent add alice box-2
MATRON_DB=./matron.db npx matron-admin link-code alice --server-url https://journal.example.com --expires 30m
MATRON_DB=./matron.db npx matron-admin device list alice
MATRON_DB=./matron.db npx matron-admin device revoke <device_id>
MATRON_DB=./matron.db npx matron-admin device private <device_id> on
MATRON_DB=./matron.db npx matron-admin device protected <device_id> on|off
MATRON_DB=./matron.db npx matron-admin approval-key list alice
MATRON_DB=./matron.db npx matron-admin approval-key revoke alice <key_id>|--all
MATRON_DB=./matron.db npx matron-admin offload [--days N]
MATRON_DB=./matron.db npx matron-admin expire-logs [--hours N]
MATRON_DB=./matron.db npx matron-admin status
MATRON_DB=./matron.db npx matron-admin two-factor status
MATRON_DB=./matron.db npx matron-admin two-factor require on|off
MATRON_DB=./matron.db npx matron-admin user 2fa-reset alice
MATRON_DB=./matron.db npx matron-admin user 2fa-exempt demo-review on|off
MATRON_DB=./matron.db npx matron-admin panic status alice
MATRON_DB=./matron.db npx matron-admin panic clear alice
Passwords come from stdin (--password-stdin) or the MATRON_PASSWORD env
var, never from the command line, where any local user could read them via
ps.
Clients log in with POST /login and get their own device token; agents are
provisioned with matron-admin agent add, which prints a token once.
link-code prints a QR code and a short code for signing in a second device
without retyping credentials.
Two-factor sign-in: each user turns it on in the web app's settings with an
authenticator app (TOTP) and gets ten recovery codes. two-factor require on
makes it compulsory, so users without it enrol at their next password sign-in.
user 2fa-exempt <name> on lets one account (a review or demo login, say)
keep signing in with its password while it is required; an exempt user who
has set two-factor up is still asked for their code.
user 2fa-reset is the way back in for someone who has lost both their app
and their recovery codes. Details are in docs/protocol.md,
"Two-factor sign-in".
Approval keys (step-up): an app can enrol a device-held P-256 key (first
with the password and a two-factor code, later devices approved from one
that already has a key). Once an account has one, the journal asks for a
statement signed with it before a tap on a question an agent marked
step_up, before approving a spawn, chat invite or file transfer aimed at
a box marked protected (device protected), and before account changes
that widen access (password, pairing, device links, two-factor, the
Coordinator's switches, revoking another device). For a protected box the
signed statement is forwarded to the box, which checks it against keys
pinned on the box (tools/approval-verify.mjs is a reference verifier;
test/fixtures/approval-vectors.json holds test vectors). It needs
MATRON_PUBLIC_URL; an account without a key works as before, except that
a box marked protected stays the user's own: the Coordinator cannot
approve its asks or control its sessions, and the journal's own routines,
alerts, hand-over and talk sessions do not reach it. App tokens left unused
for MATRON_CLIENT_IDLE_DAYS (default 30) stop working. Details are in docs/protocol.md, "Approval
keys (step-up)".
Next steps: install matron-bridge on each dev box, then sign in from an app with your journal URL + username.
| Variable | Default | Description |
|---|---|---|
MATRON_DB |
./matron.db |
Path to the SQLite database file |
MATRON_PORT |
9810 |
Listen port |
MATRON_SQLITE_CACHE_MB |
64 (or 1/16 of RAM on hosts under 1 GiB, minimum 8) |
SQLite page cache per connection, in MiB. Pages are cached as they are touched and never released, so a large value adds that much resident memory after a retention pass or search rebuild, on top of the OS page cache; matron-admin opens its own. Raise it on a large host with a busy disk. 0 or an invalid value keeps SQLite's stock cache |
MATRON_BIND |
127.0.0.1 |
Bind address (put a TLS-terminating proxy in front for wss://) |
MATRON_STALL_LOG |
on | Set to 0 to turn off the diagnostics that log event-loop stalls (stall: …), requests slower than 500 ms (slow request: …) and slow maintenance passes. The work itself is unaffected |
MATRON_GITHUB_CLIENT_ID |
Matron's published OAuth App id (Ov23likaFmqoegaDMTtz) |
GitHub OAuth App client id for account linking (device flow works with the id alone). Set to the empty string to disable linking; set your own app's id, with its secret, for the web flow |
MATRON_GITHUB_CLIENT_SECRET |
unset | Client secret of an OAuth App registered for this journal (callback https://<journal>/github/callback). Enables the one-click web flow |
MATRON_GITHUB_DEVICE_FLOW |
on | Set to 0 to turn off the device flow (code entered on GitHub's site). It is always off when a client secret is set, so a journal with the web flow offers only that |
MATRON_GITHUB_HOST |
github.com |
GitHub Enterprise host, if any |
MATRON_TOKEN_KEY |
unset (tokens stored as-is) | 64 hex chars (openssl rand -hex 32). Seals stored GitHub tokens and two-factor secrets with AES-256-GCM; existing GitHub rows are sealed at the next start. Two-factor sign-in needs it. Losing it means every user re-links GitHub, and two-factor users need matron-admin user 2fa-reset |
MATRON_WEB_DIR |
unset (nothing served) | Directory of the built tracker web app. Served read-only with index.html fallback for /u/* and /app/* |
MATRON_APPLE_APP_IDS |
unset (404) | Comma-separated TEAMID.bundle.id list for /.well-known/apple-app-site-association, claiming /u/* |
MATRON_ANDROID_PACKAGE |
unset (404) | Android package name for /.well-known/assetlinks.json; needs MATRON_ANDROID_CERT_SHA256 too |
MATRON_ANDROID_CERT_SHA256 |
unset | Comma-separated signing-cert SHA-256 fingerprints (AA:BB:…) for assetlinks |
MATRON_MEDIA_DIR |
<db dir>/media |
Blob storage root |
MATRON_MEDIA_MAX_BYTES |
50 MiB (52428800) |
Upload size limit |
MATRON_STT_AZURE_KEY |
unset (off) | Azure Speech resource key. Set it and every user's voice note, and the audio track of every user's video, is transcribed here on upload (Azure fast transcription), along with voice notes on tracker items; a bridge reads the words instead of running its own whisper, and can ask for a retry (POST /media/:id/transcript). Needs ffmpeg on PATH. With MATRON_WHISPER_MODEL set too, whisper is the fallback when the cloud call fails |
MATRON_STT_AZURE_KEY_FILE |
unset | File holding the key, used when MATRON_STT_AZURE_KEY is unset; read at boot. Set but unreadable = cloud transcription off, said at boot |
MATRON_STT_AZURE_MODEL |
MAI-Transcribe-2 |
Azure transcription model; the empty string = Azure's standard model |
MATRON_STT_AZURE_ENDPOINT |
https://northeurope.api.cognitive.microsoft.com |
Azure Speech endpoint of the resource the key belongs to |
MATRON_STT_LOCALE |
en (en-GB for the standard model) |
Locale sent to Azure |
MATRON_WHISPER_MODEL |
unset (off) | Path to a whisper.cpp model (…/whisper.cpp/models/ggml-base.bin). With MATRON_STT_AZURE_KEY set, whisper is the fallback behind Azure: a note whose cloud call fails (an error, not an answer with no words in it) is transcribed here instead, one at a time. Without a key it is the transcriber, for voice notes on tracker items only (chat notes stay the origin box's job). Needs ffmpeg on PATH. Under the shipped systemd unit install whisper.cpp outside /home (e.g. /opt/whisper.cpp) — ProtectHome=yes hides it otherwise |
MATRON_WHISPER_CLI |
<model dir>/../build/bin/whisper-cli |
whisper-cli binary, if not in whisper.cpp's own layout |
MATRON_WHISPER_LANGUAGE |
en |
Language passed to whisper |
MATRON_WHISPER_PROMPT |
built-in Matron vocabulary | Vocabulary hint passed to whisper-cli as --prompt: names and terms it should spell right (box names, sudo, PR). Set your own list, or the empty string to send no prompt |
MATRON_TTS_AZURE_KEY / MATRON_TTS_AZURE_KEY_FILE |
unset: the transcription key, MATRON_STT_AZURE_KEY / MATRON_STT_AZURE_KEY_FILE |
Azure Speech key (or a file holding it) for voice mode's cloud voice, POST /tts. With no key at all the route answers 501 and the apps use the on-device voice. See "Text to speech" in docs/protocol.md |
MATRON_TTS_AZURE_REGION |
northeurope |
Region of the speech key |
MATRON_TTS_MODEL |
MAI-Voice-2.1-Flash |
Speech model; the Azure voice name is <voice>:<model> |
MATRON_TTS_VOICE |
en-GB-Harry |
Default voice (en-GB-Harry or en-GB-Emily) |
MATRON_TTS_DAILY_CHARS |
300000 |
Characters of text per user per UTC day that may be sent for speech; over it POST /tts answers 429 |
MATRON_TTS_DISABLED |
unset | 1 turns cloud speech off even when a key is present |
MATRON_MEDIA_USER_QUOTA_BYTES |
2 GiB (2147483648) |
Per-user media storage quota |
MATRON_MEDIA_REAP_HIGH_PCT |
90 |
Reap a user's oldest attachments once their footprint hits this % of the quota. 0, a non-integer, or a value over 100 disables the reaper |
MATRON_MEDIA_REAP_LOW_PCT |
70 |
Reap down to this % of the quota; reaped events tombstone to expired: true. Must be lower than the high %, or the reaper is disabled (same invalid-value rules) |
MATRON_MAX_REPLAY |
50000 |
Replay gap above which clients are told to re-snapshot |
MATRON_RETENTION_DAYS |
30 |
Offload tool_output payloads older than this (0 disables) |
MATRON_TOOL_LOG_TTL_HOURS |
24 |
Delete offloaded tool-log blobs older than this |
MATRON_VOICE_NOTE_TTL_DAYS |
7 |
Delete an item voice note's audio this many days after its transcript landed; the transcript stays (0 disables) |
MATRON_DEVICE_RETENTION |
unset (off) | Comma-separated <deviceId>:<days> (e.g. 4:30,9:7): expire the content of each device's sessions and rooms once older than that many days — payloads become tombstones, search rows and unshared blobs are deleted. Any invalid entry disables the whole setting. See docs/protocol.md Per-device content expiry |
MATRON_TRANSFER_MAX_BYTES |
10 GiB (10737418240) |
Per-file size cap for agent-to-agent file transfers |
MATRON_TRANSFER_USER_QUOTA_BYTES |
20 GiB (21474836480) |
Per-user cap on the declared size of transfers whose bytes the journal still holds or will hold (in flight, or delivered within the last hour before their copy is deleted), separate from the media/attachment quota |
MATRON_TRANSFER_MAX_AWAITING_PER_SENDER |
50 |
How many of one sending box's transfers may wait for the user's approval at once; the next POST /transfers is 409 too_many_awaiting |
MATRON_WS_PING_MS |
55000 |
WebSocket heartbeat interval (kept under common 60s proxy idle timeouts) |
MATRON_KEEPALIVE_TIMEOUT_MS |
120000 |
How long an idle HTTP connection is kept open. Must be longer than the idle time of any reverse proxy in front of the journal, or the proxy can reuse a connection the journal is closing and answer 502 |
MATRON_RPC_MAX_BYTES |
16384 |
Max inbound WebSocket RPC frame size |
MATRON_TOOL_STREAM_MAX_BYTES |
1048576 |
Max bytes per live tool-output stream buffer |
MATRON_TOOL_STREAM_MAX_BUFFERS |
64 |
Max concurrent tool-output stream buffers |
MATRON_TOOL_STREAM_IDLE_MS |
1800000 |
Idle expiry for tool-output stream buffers |
MATRON_SEMANTIC_SEARCH |
on | off disables the similar tier of /search/all (the model is never loaded; search stays lexical). When on, the embedding model (Xenova/bge-small-en-v1.5, quantised) runs in-process and adds about 150 MB RSS (the whole process peaks under 300 MB while indexing a 3 GB journal). It is on by default: on a host without that headroom, set MATRON_SEMANTIC_SEARCH=off. The first index pass embeds every tracker doc, one at a time on one thread, paced like the backfills below. A missing optional dependency or a failed model load also turns it off; neither fails a boot or a request |
MATRON_BACKGROUND_DELAY_MS |
30000 | How long after listening the startup backfills (message search index, is_diff, image sizes) and the first vector index pass wait before starting. They then run one at a time, and each saves its place per batch, so a restart resumes rather than starting over |
MATRON_BACKGROUND_PACE |
1 | Rest between background batches, as a whole multiple of the batch's own time: 1 keeps that work to at most half of one core, 0 only yields one event-loop turn |
MATRON_SEMANTIC_MODEL_DIR |
unset (<db dir>/models, downloaded on first boot) |
Directory holding a pre-seeded model, laid out as <dir>/Xenova/bge-small-en-v1.5/{config.json,tokenizer.json,tokenizer_config.json,onnx/model_quantized.onnx}. When set, the model is never downloaded, so boot needs no network. When unset, the first boot downloads the model (about 35 MB) into <db dir>/models, which must be writable |
MATRON_COORDINATOR_CONSENT_DAILY_CAP |
20 |
The Coordinator's chat and spawn consent approvals per user per rolling 24 h. 0 is no cap. See docs/protocol.md Consent approval |
MATRON_COORDINATOR_TRANSFER_DAILY_CAP |
100 |
The Coordinator's file-transfer approvals per user per rolling 24 h, counted apart from the cap above. 0 is no cap. See docs/protocol.md File transfers |
MATRON_COORDINATOR_ROUTINE_CAP |
10 |
Routine-born self-approvals (a spawn or chat ask the Coordinator makes and approves itself during a scheduled routine) per user per rolling 24 h, on top of the daily cap. 0 is no cap |
MATRON_VERIFIER_API_KEY / MATRON_VERIFIER_API_KEY_FILE |
unset (verifier off) | Anthropic API key, inline or read from a file, for the verifier that judges the Coordinator's routine-born self-approvals. With no key those approvals are refused 409 verifier_unavailable and the card waits for the user. See docs/protocol.md Consent approval for the causes, envelopes and verifier rules |
MATRON_VERIFIER_MODEL |
claude-opus-5-5 |
Model the verifier calls |
MATRON_VERIFIER_TIMEOUT_MS |
60000 |
Bound on the whole verifier call; a timeout fails closed (the approval is refused and the card waits for the user) |
MATRON_RESEND_API_KEY / MATRON_RESEND_API_KEY_FILE |
unset (off) | Resend API key, inline or read from a file, for password reset by email ("Forgot password?"). A sending-only key is enough. Needs MATRON_MAIL_FROM and MATRON_PUBLIC_URL too; without all three the feature is off and the apps hide it. Users need an address on file: matron-admin user email <name> <address>, the web admin page, or their own Settings |
MATRON_MAIL_FROM |
unset | From address for reset emails, on a domain verified with Resend, e.g. Matron <no-reply@example.com> |
MATRON_PUBLIC_URL |
unset | This journal's public https:// origin, e.g. https://journal.example.com. Reset links point at <origin>/app/reset-password (served by MATRON_WEB_DIR). Required, because building links from a request's Host header would let anyone have the journal mail out a link to their own site. Approval keys need it too: a signed statement names the journal by this URL's host, and without it they are off |
MATRON_CLIENT_IDLE_DAYS |
30 |
Days an app (client) token may go unused before it stops working and the app must sign in again; fractions allowed, 0 turns the expiry off, an invalid value warns and uses the default. Agent (bridge) tokens never expire. The device keeps its row and its approval key, and the device list marks it idle_expired |
MATRON_SECURITY_LOG |
unset (stdout only) | A file each security event line is appended to, as well as stdout (docs/protocol.md "Security event feed"). An unwritable file is reported once; the events still go to the database and stdout |
MATRON_SECURITY_FEED_TOKEN |
unset (feed off) | Read-only token for GET /security-events, the feed an external watcher reads: 32 to 128 characters of A-Z a-z 0-9 _ -. Not a device token: it opens nothing else. Needs MATRON_SECURITY_FEED_ACCOUNT |
MATRON_SECURITY_FEED_ACCOUNT |
unset (feed off) | The user name the feed token is bound to: the feed serves that account's events and no other's |
MATRON_PANIC_TOKEN |
unset (panic lock off) | Token for POST /panic and GET /panic, with which an external watcher locks one account in one request: 32 to 128 characters of A-Z a-z 0-9 _ -. It can only lock that account and read whether it is locked; it is unlocked by matron-admin panic clear on the journal host, or by one of the account's devices that holds an approval key, with a signed statement. Needs MATRON_PANIC_ACCOUNT. See Panic lock in docs/protocol.md |
MATRON_PANIC_ACCOUNT |
unset (panic lock off) | The user name the panic token is bound to |
MATRON_APNS_KEY_FILE / _KEY_ID / _TEAM_ID / _TOPIC |
unset | All four set = push enabled; otherwise push is an inert no-op |
MATRON_WEBPUSH_VAPID_KEY_FILE |
unset | PEM P-256 private key file for Web Push; set together with the subject |
MATRON_WEBPUSH_SUBJECT |
unset | Web Push contact URL (mailto: or https:); set together with the key file |
MATRON_WEBPUSH_DISABLED |
unset | 1 or true switches Web Push off |
MATRON_WEBPUSH_EXTRA_HOSTS |
unset | Comma-separated extra push-service hosts beyond fcm.googleapis.com, updates.push.services.mozilla.com, *.push.apple.com, and *.notify.windows.com; exact hosts or *.suffix |
MATRON_PUSH_GATEWAY_URL |
unset | Push relay URL when you have no APNs key — see Push relay section |
MATRON_RELAY_PORT / MATRON_RELAY_BIND |
9821 / 127.0.0.1 |
matron-push-relay only |
iPhone / Mac / Android / matron-bridge (agent)
desktop / web client │ WS /ws
│ WS /ws + HTTP │
└──────────────┬───────────────────────┘
▼
matron-journal
per-user append-only journal
(SQLite, WAL, one file)
- Bridges (e.g. matron-bridge) connect as agent devices: they create conversations, publish Claude's output into the journal, and receive the user's messages for the conversations they own.
- Apps — matron-apple (iPhone + Mac), matron-android, matron-desktop, matron-web — connect as client devices: they render the journal, send user messages, and get push notifications when disconnected.
- dev-boxer provisions a fresh box with the whole stack in one command.
- Cursor replay — reconnect with
{op:'hello', token, cursor}and receive everything after it, then live frames on the same socket. - Two device kinds —
client(apps) andagent(bridges), with per-conversation agent ownership so multi-bridge fleets only receive their own traffic. - Auth — argon2id password hashes, per-device bearer tokens, login rate limiting + lockout, instant device revocation.
- QR / link-code sign-in —
matron-admin link-codeprints a QR code and short code; a second device signs in via the/link/*+/pair/*flows without retyping credentials (src/link.js,src/pairing.js,src/rendezvous.js). - Search — FTS5 full-text search over journal prose,
GET /search(src/search.js). - Agent chat rooms — agent-to-agent conversations; every invite and
join asks the user for consent, every time (
src/participants.js). - Agent-to-agent file transfers — one agent sends a file to the other
agents in a room it belongs to; chunked, resumable uploads, a secret-scan
verdict on the consent card, Range-able downloads, and automatic cleanup
once a file is collected or a week passes uncollected
(
src/transfers.js,src/transfer-consent.js; see docs/protocol.md Agent-to-agent file transfers). - Device privacy — private agent devices hidden from ordinary agents
(
matron-admin device private). - Media — blob upload/download with per-user authorization and sharded on-disk storage.
- Push — direct HTTP/2 APNs (ES256 provider JWT,
node:http2), no sygnal: priority tiers, per-conversation coalescing, silent badge-clear pushes, dead-token pruning, per-device sandbox/prod environment. - Retention — old
tool_outputpayloads offload from the hot table to blob files on a schedule, and a TTL pass deletes those blobs afterMATRON_TOOL_LOG_TTL_HOURS(default 24); journal rows are never deleted. - Ops —
/metricsendpoint,matron-adminCLI (user add/passwd,agent add,device list/revoke/private on|off|auto,approval-key list/revoke,link-code,offload,expire-logs,agent-chat pending|approve|deny,status), systemd unit indeploy/, WAL checkpoint tuning (measured). - Conformance fixtures — golden wire-protocol exchanges under
test/fixtures/conformance/that client implementations replay too, so server and clients can't silently drift.
Dependencies: better-sqlite3, ws, argon2, qrcode, qrcode-terminal.
That's the whole list.
Generate a VAPID key with node tools/webpush-vapid-keygen.js <out.pem>, then
set MATRON_WEBPUSH_VAPID_KEY_FILE to that file and MATRON_WEBPUSH_SUBJECT
to a mailto: or https: contact URL. Web Push is off unless both are set;
an incomplete pair fails startup. The web app must be served over HTTPS with
its sw.js at the root of its path.
Only fcm.googleapis.com, updates.push.services.mozilla.com, subdomains of
push.apple.com, and subdomains of notify.windows.com are accepted by
default. Extend this list with MATRON_WEBPUSH_EXTRA_HOSTS, a comma-separated
list of exact hosts or *.suffix entries. A wildcard matches subdomains
only, not the suffix itself.
Only the app author holds the APNs key for the chat.matron.app bundle id, so a
self-hosted journal cannot talk to Apple directly. Set
MATRON_PUSH_GATEWAY_URL=https://push.matron.chat and the journal sends each
push as an event with nothing readable in it: device token, environment, a
category (attention / done / activity / wake), badge count, and
conversation-id routing fields. The relay maps the category to a fixed generic
string ("Your agent needs you", "Your agent finished its turn", …).
What a notification actually says (an item's title, a snippet, its buttons) travels only as a sealed box: the journal encrypts it to a public key the device registered, the relay and Apple pass the ciphertext along, and the app's notification extension opens it on the device. The relay holds no key that opens a box. A device that registered no key gets the generic string alone. The construction, and what each party can and cannot see, are in docs/protocol.md under "Sealed notification content".
Running your own relay (needs an Apple Developer membership and the app's APNs key, so this is for the hosted one's operator):
MATRON_APNS_KEY_FILE=… MATRON_APNS_KEY_ID=… MATRON_APNS_TEAM_ID=… \
MATRON_APNS_TOPIC=chat.matron.app npx matron-push-relay
The wire protocol is small: ~20 Bearer-authenticated HTTP endpoints (auth,
snapshot, history, search, media, push, device pairing/linking, agent-chat
consent) and one WebSocket (/ws) speaking journal frames — see
docs/protocol.md for the full list.
- Operational reference: docs/protocol.md
- Machine-checkable fixtures: test/fixtures/conformance/
npm test
Runs the full suite (node --test), including the protocol conformance
suite replayed against a real in-process server. npm run loadtest drives a
synthetic multi-device load against a scratch server.
- Vulnerability reporting policy: SECURITY.md
- Shared-infra VPS (push relay + demo journal, cloudflared + systemd): deploy/vps/README.md
- WAL checkpoint profiling method + numbers: docs/wal-checkpoint-profile.md
History: the journal replaced a whole Matrix homeserver + client-sync stack for Matron's use case, in a few thousand lines of Node with SQLite underneath.