aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 12:03:02 +0100
committertv <tv@krebsco.de>2026-11-02 16:29:16 +0100
commit29cad923a5e2d7dc3207c8d456440f8c37bfd7b6 (patch)
treec3ca35fa4841fb38f1d8c167aa3a47cf6909a8cb
parent9ecb59816f168647340776d72b735e60efc3b978 (diff)
add documentation
-rw-r--r--README.md108
-rw-r--r--docs/api.md247
-rw-r--r--docs/architecture.md306
-rw-r--r--docs/configuration.md240
-rw-r--r--docs/dashboard.md324
-rw-r--r--docs/development.md52
-rw-r--r--docs/example.toml280
-rw-r--r--docs/glossary.md154
-rw-r--r--docs/lifecycle.md238
-rw-r--r--docs/measurements.md299
-rw-r--r--docs/operations.md149
-rw-r--r--docs/peers.md358
-rw-r--r--docs/remakes.md410
-rw-r--r--docs/transports.md296
-rw-r--r--docs/trust.md114
15 files changed, 3575 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e626f69
--- /dev/null
+++ b/README.md
@@ -0,0 +1,108 @@
+# continuum
+
+A replicated [Automerge](https://automerge.org) document for fleets of machines that
+aren't always online, read and written as JSON by path (`settings/theme`,
+`servers/0/name`) at any depth. Every node holds the whole document, takes reads and
+writes on its own, and syncs with its peers whenever it can reach them. Think
+Syncthing, but for structured data behind an HTTP API.
+
+There is no leader and no quorum for writes: a node keeps working through a network
+partition, on a laptop on a train, or as the only one left. Concurrent writes are
+merged, and conflicts are reported rather than hidden.
+
+A node is its key, not its address. Nodes find and reach each other over any of three
+transports, one or several at once, each a cargo feature:
+
+* **[Mycelium](https://github.com/threefoldtech/mycelium)**: an encrypted overlay
+ network addressed by keys, so NAT, changing IP addresses and firewalls don't need
+ configuring.
+* **[libp2p](https://libp2p.io)**: direct connections over TCP and QUIC, with relays
+ (members of the swarm, or a relay-only continuum), hole punching, UPnP and LAN
+ discovery for nodes behind NAT.
+* **Tor**: an onion service per node (arti, embedded), reachable from anywhere without
+ a forwarded port. For reachability, not anonymity.
+
+A node on several transports reaches each peer over the fastest path they share, falls
+back to the next when one fails, and carries the traffic between nodes that share
+none.
+
+The one thing that takes a quorum is a remake: compacting the history, or another move
+to a new document. A majority of the members must vote for it, so it waits, and in the
+end aborts, while too many of them are out of reach; reads and writes go on meanwhile
+([`docs/remakes.md`](docs/remakes.md)).
+
+## What it isn't
+
+Continuum is eventually consistent. Reads are local and may be behind; there are no
+compare-and-set, transactions, leases, locks or leader election. Don't build
+coordination on it.
+
+## Quick start
+
+```sh
+# The transports are cargo features: mycelium, libp2p, tor (any, at least one);
+# dashboard adds the live page (`/admin/dashboard`).
+cargo build --release --features mycelium,dashboard
+
+# An HTTP API user (Argon2 hash; e.g. with the `argon2` command-line tool).
+printf '%s' 'secret' | argon2 "$(openssl rand -hex 16)" -id -e | sed 's/^/alice:/' > htpasswd
+
+# The first node starts a new swarm. --node-key-create makes the node's key on its first
+# start (a new key is a new node; keep the file).
+./target/release/continuum --genesis --htpasswd htpasswd --state-file state.json \
+ --node-key-file node.key --node-key-create --mycelium-enable
+
+# Further nodes, on other machines, join through any node of it: one of its addresses
+# (`addresses` in `GET /admin/stats`: `mycelium:<public key>`, `libp2p:<multiaddr>`,
+# `tor:<onion>.onion:<port>`), or for Mycelium its public key or IPv6 address, or a
+# host name with an AAAA record for one.
+./target/release/continuum --seeds <address of the first node> --htpasswd htpasswd \
+ --state-file state.json --node-key-file node.key --node-key-create --mycelium-enable
+```
+
+A node uses the transports enabled, one at least (`--mycelium-enable`,
+`--libp2p-enable`, `--tor-enable`). With Mycelium, each node runs its own Mycelium
+daemon, unless pointed at one with `--mycelium-rest-url`; `--mycelium-peers` names
+Mycelium nodes to connect through. With libp2p, `--libp2p-listen` says where to listen
+(`/ip4/0.0.0.0/tcp/9661`), and seeds are multiaddrs
+(`libp2p:/ip4/192.0.2.7/tcp/9661`); with Tor (`--tor-enable`), `--tor-state-dir`
+keeps the onion service's key, and seeds are onion addresses. [`docs/transports.md`](docs/transports.md)
+has the rest. The HTTP API listens on `127.0.0.1:3000`:
+
+```sh
+curl -u alice:secret -X PUT localhost:3000/store/config/feature \
+ -H 'Content-Type: application/json' -d '{"enabled": true}'
+curl -u alice:secret localhost:3000/store/config/feature
+curl -u alice:secret -N localhost:3000/stream/config # follow changes
+curl -u alice:secret localhost:3000/peers # who is out there
+```
+
+## Documentation
+
+**Running nodes**
+
+* [`docs/configuration.md`](docs/configuration.md): options, files and imports (`continuum --help` lists them);
+ [`docs/example.toml`](docs/example.toml) has every option, explained.
+* [`docs/transports.md`](docs/transports.md): Mycelium, libp2p and Tor: what each is for, how they combine,
+ relays, and their options.
+* [`docs/lifecycle.md`](docs/lifecycle.md): starting, joining, rejoining and stopping a node.
+* [`docs/operations.md`](docs/operations.md): adding and removing nodes, keys, backups, upgrades, what to
+ alert on.
+* [`docs/remakes.md`](docs/remakes.md): compacting history and other moves to a new document.
+* [`docs/trust.md`](docs/trust.md): who can do what.
+* [`docs/measurements.md`](docs/measurements.md): `/admin/stats` and its Prometheus metrics.
+* [`docs/dashboard.md`](docs/dashboard.md): the dashboard, a live page of a node's view of the swarm.
+
+**Writing applications**
+
+* [`docs/api.md`](docs/api.md): the store API for applications: consistency, concurrent writes,
+ streams.
+* [`docs/openapi.yaml`](docs/openapi.yaml): the HTTP API (also served at `/openapi.yaml`, with a Swagger
+ UI at `/swagger-ui`).
+
+**Working on continuum**
+
+* [`docs/architecture.md`](docs/architecture.md): the map of the code, and where to read on.
+* [`docs/development.md`](docs/development.md): building and testing quickly.
+* [`docs/peers.md`](docs/peers.md): the protocol between nodes.
+* [`docs/glossary.md`](docs/glossary.md): the words used in the code, logs, API and docs.
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..5b4dac4
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,247 @@
+# The store API, for applications
+
+What an application can rely on when it reads and writes through a node's HTTP API. The
+endpoints and their answers, one by one, are in [`openapi.yaml`](openapi.yaml) (served by every node at
+`/openapi.yaml`, with a Swagger UI at `/swagger-ui`); this page is about what they mean
+together.
+
+## The model
+
+The store is one document, the same on every node once they have synced, read and
+written as JSON. A path names a place in it: `settings/theme` is the key `theme` in the
+map `settings`, and within a list a segment is an index (`servers/0/name`; `-1` the last
+item). Every node holds all of it and answers from its own copy: there is no leader to
+ask, and no request goes to another node.
+
+```sh
+curl -u alice:secret -X PUT localhost:3000/store/settings \
+ -H 'Content-Type: application/json' -d '{"theme": "dark", "fontSize": 14}'
+curl -u alice:secret localhost:3000/store/settings/theme # "dark"
+curl -u alice:secret -H 'Accept: text/plain' localhost:3000/store/settings/theme # dark
+curl -u alice:secret -X DELETE localhost:3000/store/settings/fontSize
+curl -u alice:secret -X POST localhost:3000/store/queue/-1 -d '"job 7"' # append
+curl -u alice:secret -X POST 'localhost:3000/store/stats/visits?increment=1' # count
+```
+
+* `GET /store/<path>` reads, `PUT` writes (what is there then reads as the value
+ written; the maps on the way are created), `DELETE` deletes (`?prune=true` also
+ removes the maps it leaves empty), and `POST` inserts into a list at an index, the
+ items from there moving up, or with `?increment=n` adds `n` to a counter (Concurrent
+ writes), or with `?cast=<type>` casts what is there to another type (Types).
+* Bodies are JSON (`application/json`, the default), text (`text/plain`, stored as a
+ string), or a value of a type of its own (Types); answers are JSON, or as `Accept`
+ asks. Numbers in JSON are stored as integers where they fit, as floats otherwise.
+* Every request needs a user from the node's `--htpasswd` (basic authentication). Users
+ are all alike: each can read and write everything but protocol data (below).
+* A key with `/` in it, or an empty key, can be written inside a value at its parent,
+ but not named in a path: read it through the parent.
+
+## Types
+
+Every value is stored as one of Automerge's types: a map, a list, a text, or a scalar:
+a string, bytes, a signed or unsigned integer, a float, a boolean, null, a counter or a
+timestamp (milliseconds since 1970). Every read says which in `x-continuum-type`
+(`map`, `list`, `text`, `string`, `bytes`, `int`, `uint`, `float`, `boolean`, `null`,
+`counter`, `timestamp`), and so does `?shallow=true` for each entry.
+
+Each has a JSON form, so any value reads as JSON: bytes as base64, counters and the
+integers as numbers, timestamps as RFC 3339 strings (`2026-11-01T21:50:20.345Z`), or as
+`Accept: application/json; timestamp-format=…` says: `rfc3339`, `unix` (seconds, with
+the milliseconds as a fraction), `unix-ms` or `http-date` (RFC 9110). JSON alone
+doesn't tell them apart: written as JSON, values are maps, lists, strings, signed
+integers, floats, booleans and nulls.
+
+A scalar is also read and written as its own media type, its value in the body:
+
+| Type | Media type | Body |
+|---|---|---|
+| string, text | `text/plain` | the characters |
+| bytes | `application/octet-stream` | the bytes |
+| int | `application/vnd.continuum.int` | decimal |
+| uint | `application/vnd.continuum.uint` | decimal |
+| float | `application/vnd.continuum.float` | decimal |
+| boolean | `application/vnd.continuum.boolean` | `true` or `false` |
+| counter | `application/vnd.continuum.counter` | decimal (a `PUT` sets it) |
+| timestamp | `application/vnd.continuum.timestamp; format=…` | as the format says (`rfc3339` by default) |
+
+```sh
+curl -u alice:secret -X PUT localhost:3000/store/logo -H 'Content-Type: application/octet-stream' --data-binary @logo.png
+curl -u alice:secret -H 'Accept: application/octet-stream' localhost:3000/store/logo > logo.png
+curl -u alice:secret -X PUT localhost:3000/store/deploy/at \
+ -H 'Content-Type: application/vnd.continuum.timestamp; format=unix' -d 1793569820
+curl -u alice:secret -H 'Accept: application/vnd.continuum.timestamp; format=http-date' \
+ localhost:3000/store/deploy/at # Sun, 01 Nov 2026 21:50:20 GMT
+```
+
+Typed JSON (`application/vnd.continuum.typed+json`, for reads and writes) keeps every
+type: it is JSON, but for what JSON would show as another type, which is a single-key
+object whose key is a tag: `{"$uint": 5}`, `{"$counter": 5}`, `{"$bytes": "<base64>"}`,
+`{"$timestamp": "2026-11-01T21:50:20.345Z"}` (in its `timestamp-format`) and
+`{"$text": "…"}` (a text); a float is a number, as in plain JSON. A map whose only key
+starts with `$` is `{"$map": {…}}`, so nothing else is taken for a tag; any other
+single `$` key is `400`. A whole map with typed values in it is written at once this
+way, and reads back as written. A text written over a text is changed where it differs
+(a diff, as splices), so concurrent edits elsewhere in it stay.
+
+Types are sticky: plain JSON (or text) written over a value takes its type where it is
+that type's JSON form. A string over a timestamp that reads as one (RFC 3339, or as the
+body's `Content-Type: application/json; timestamp-format=…` says, where numbers are
+read as timestamps too), over bytes that is base64, over a text (then changed where it
+differs); an integer over a float, an unsigned integer (if it isn't negative) or a
+counter (which it sets). So what a client read as JSON writes back as it was stored,
+whole maps included. Typed JSON and a type's own media type say the type themselves,
+but for an integer over a float, which stays a float in typed JSON too.
+
+A write that changes the type of what it writes over (a string over a timestamp that
+doesn't read as one, a map over a list) says which in `x-continuum-retyped`
+(`deploy/at: timestamp -> string`, comma-separated); with `?retype=false` it is refused
+instead (`409`, naming the first). A `PUT` answers with what is there after it, read
+back, so its `x-continuum-type` is what the value was stored as.
+
+`POST <path>?cast=<type>` casts what is there to a type (as `x-continuum-type` names
+it), and answers as a `PUT` does: numbers (integers, floats, counters, timestamps as
+milliseconds since 1970) to numbers where they are exactly that, any scalar but bytes
+to a string or a text as its media type shows it (a timestamp in `?format=`,
+`rfc3339` by default) and back, and bytes to a string or a text and back as UTF-8.
+Anything else, or a value that isn't one of the type (`"soon"` to a timestamp, `2.5`
+to an integer), is `409`, saying why; maps, lists and nulls aren't cast.
+
+```sh
+curl -u alice:secret -X POST 'localhost:3000/store/deploy/at?cast=timestamp&format=unix'
+```
+
+`Accept` is taken in its order (quality values aren't weighed): the first media type the
+value can be shown as, JSON for `*/*` or without an `Accept`, and `406` (naming those it
+can be shown as) if none. `text/plain` shows a string as it is and anything else as
+JSON.
+
+## Consistency
+
+**Reads are local.** A node answers from its copy, which may lack writes other nodes
+made a moment ago: they arrive by sync, as fast as messages get from the writer to
+this node (a hop between neighbours, a few in a large swarm; [`measurements.md`](measurements.md),
+Propagation), and after a partition once it heals. There is
+no read that waits for the rest of the swarm.
+
+**Writes are local too, and never refused for want of others.** A write is applied to
+the node's copy at once (the answer holds the value written) and spreads from there.
+Two nodes cut off from each other both go on taking writes; when they meet again, the
+writes merge. So a write that was answered `200` is durable on that node (with a
+`--document-file`, within `--document-append`, 5 s by default) and reaches the others
+eventually, but it isn't confirmed by anyone else when answered.
+
+**Read your own writes** on the node you wrote to: its later reads include the write.
+On another node, they include it once synced. `?heads=true` on a read gives the
+document's heads (`x-continuum-heads`, the hashes of its latest changes): two reads with
+the same heads read the same document. Every write answers with the heads after it, in
+the same header. `?at=<heads>` reads the document as it was at those heads, on any node
+that has them (`404` on one that doesn't, yet), however it changed since: a client that
+wrote through one node and reads its write through another reads with the write's
+heads, and retries on `404`.
+
+**One request is one change.** What a `PUT` writes is applied whole or not at all, also
+on the other nodes: a map written in one `PUT` never shows half its keys. There are no
+transactions across requests: to change several values together, write their common
+parent.
+
+## Concurrent writes
+
+Writes made on different nodes before they synced are concurrent. They merge, the same
+way on every node:
+
+* **Different keys** both stay: two nodes writing `settings/theme` and
+ `settings/fontSize` get both.
+* **The same key**: one write wins, the same one everywhere. "Newest" means by
+ Automerge's operation order (the write made after seeing more changes; between equals,
+ by the writer's actor ID), not by any clock: of two truly concurrent writes, which wins
+ is arbitrary, but it is the same on every node.
+* **Maps written at the same key** merge into one: their keys are united, each key as
+ above.
+* **A write and a delete** of the same key: the write stays (a delete removes only what
+ the deleting node had seen).
+* **List inserts** both stay, in an order every node agrees on. Writing a list item
+ (`PUT` at its index) overwrites it: two nodes writing the same item concurrently are
+ two writes of the same key.
+* **Counters** (`POST <path>?increment=n`, made by the first increment) add up: two
+ nodes incrementing one counter by 1 concurrently leave it 2 higher. Two nodes making
+ the same counter concurrently make two, and one wins with its increments.
+* **Strings** are values, not texts: concurrent writes to one string don't merge
+ character by character; one wins.
+
+A write changes only what differs from what is there: a map written over a map keeps
+the keys that stay (and what is inside them), gains those it lacks and loses those the
+value doesn't have; a list written over a list does the same item by item, by
+position; a value equal to the one there isn't written again. So a `PUT` of `settings`
+as a whole, concurrent with a `PUT` of `settings/theme` elsewhere, keeps the theme,
+unless the whole write changed the theme too (then one wins) or left it out (then the
+theme's write stays, as against a delete). Where the path holds concurrent values, or
+one of another kind (a map written over a list), it is written anew.
+
+`?conflicts=true` on a read answers `{"value": …, "conflicts": [...]}`: every place on
+the way where concurrent values were resolved, with all candidates and which was
+selected (`mergedMaps` or `newestWins`). A later write at that path ends the conflict.
+
+## Following changes
+
+`GET /stream/<path>` (or `/stream` for everything) is newline-delimited JSON: one event
+with the current value, then one whenever the value there changes, by a write on this
+node or by sync:
+
+```
+{"key":"settings/theme","value":"dark"}
+{"key":"settings/theme","value":"light"}
+{"key":"settings/theme","deleted":true}
+```
+
+An event carries the value as a read would return it then. Changes in quick succession
+may come as one event with the latest value: a stream tells the value, not every write.
+A change that leaves the value as it was sends nothing. The connection stays open; after
+it breaks, reconnect, and the first event is the current value again.
+
+## While a node isn't ready
+
+A node serves the store once it holds the swarm's document ([`lifecycle.md`](lifecycle.md)):
+
+* **Joining** (a new node, or one restarted without its document file): `503` for
+ reads, writes and streams, but for `GET /store`, the whole store, which answers with
+ what has arrived so far. `GET /ready` (no authentication) says `200` once it is ready.
+* **Rejoining** a newer epoch after a remake it missed: reads answer from the document
+ before, with `x-continuum-stale` naming that epoch; writes are answered as usual and
+ go into that document (so reads show them), carried over into the new epoch when the
+ rejoin ends, unless newer writes touched their paths (Concurrent writes); streams are
+ `503`.
+* **Remakes** ([`remakes.md`](remakes.md)) don't interrupt anything else: a node switches to the new
+ document between two requests, and writes made meanwhile are carried over (at
+ their path, or where the remake's rules put them; [`remakes.md`](remakes.md), Writes around a
+ remake).
+
+Load balancers can send requests to any ready node: `/ready` is the check.
+
+## Who wrote what
+
+Every change records the node that committed it (as its Automerge author) and, for a
+write through the API, the user who wrote it (in its message, as a trailer like Git's
+`Signed-off-by:`: `User: alice`), with when (by the writer's clock). User names are
+each node's own (`--htpasswd`), so who wrote it is that user at that node: `alice` on
+two nodes is one person only where their operators make it so, which nothing checks.
+`GET /admin/changes` lists them,
+newest first, with the paths each touched; `GET /admin/changes/settings` those that
+touched what is at `settings`.
+Every node holds every change, so user names are known to the whole swarm, and so is
+who wrote what, until a remake drops the history ([`remakes.md`](remakes.md)).
+
+## Protocol data
+
+`_continuum` holds what the nodes coordinate through: the swarm ID, the member registry,
+remakes and votes ([`architecture.md`](architecture.md), The document). It is part of the store and shows
+in `GET /store`, but isn't application data: only users in `--protocol-writers` may
+write it, for repairs. Keep application data out of it, and leave it out when reading
+the whole store if it's in the way.
+
+## Large stores
+
+`?shallow=true` on a read answers what is there one level deep (a map's keys with their
+types and sizes, short scalars' values) instead of the whole value, to browse a large
+store a level at a time. Requests are limited to 2 MiB. Every write keeps its history
+until a remake compacts it; a document of many small writes grows faster than its
+content ([`remakes.md`](remakes.md)).
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..e0e2c94
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,306 @@
+# Architecture
+
+Continuum is a replicated Automerge document, read and written by path: every node
+holds the same document, exposes it over an HTTP API, and syncs it with its peers over one or more
+transports (Mycelium, libp2p, Tor; [`transports.md`](transports.md)). There
+is no leader and no central service; nodes find each other through seeds and a member
+registry kept in the document itself. Everything the swarm agrees on, it agrees on
+through that document.
+
+This page is the map: what the parts are, how they fit, and where the complexity sits.
+The details live in the other pages ([`lifecycle.md`](lifecycle.md), [`operations.md`](operations.md), [`peers.md`](peers.md), [`transports.md`](transports.md),
+[`remakes.md`](remakes.md), [`api.md`](api.md), [`configuration.md`](configuration.md), [`measurements.md`](measurements.md), [`dashboard.md`](dashboard.md), [`trust.md`](trust.md),
+[`glossary.md`](glossary.md)) and
+in the module docs at the top of each source file.
+
+## Layers
+
+```
+ operators, applications
+ │ HTTP (basic auth)
+ ┌────────▼────────────────────────────────────────────────────────────────┐
+ │ api/ /store, /stream, /peers, /admin/*, /debug/* │
+ ├─────────────────────────────────────────────────────────────────────────┤
+ │ state/ AppState: the replica, lifecycle, peer states, timings, │
+ │ shared tables; the Host impl for remakes │
+ ├──────────────────────────────┬──────────────────────────────────────────┤
+ │ swarm/ │ remake/ │
+ │ peers, sync, relay, topology,│ epochs: proposal, votes, decision, │
+ │ members, blobs, catch-up, │ snapshot, switch, rejoin, quarantine │
+ │ stability, bootstrap, files │ (reaches the node only through `Host`) │
+ ├──────────────────────────────┴──────────────────────────────────────────┤
+ │ document/ Replica (document + epoch), the store view, the document │
+ │ file │
+ ├─────────────────────────────────────────────────────────────────────────┤
+ │ transport/ message types and wire format, traffic counting; the │
+ │ identity layer (node IDs) over the paths layer (addresses, │
+ │ PING/PONG, the debug firewall) over the transports: │
+ │ Mycelium (REST client, daemon), libp2p, Tor │
+ └─────────────────────────────────────────────────────────────────────────┘
+ Mycelium daemon · libp2p peers and relays · the Tor network
+```
+
+Dependencies point downwards, with two deliberate exceptions: [`state/`](../src/state/) is the hub
+everything else reaches (it holds the shared state and is reached by most modules), and
+[`remake/`](../src/remake/) never touches `AppState`, peers or sync directly; it sees the node through
+the `Host` trait ([`remake/mod.rs`](../src/remake/mod.rs)), which `AppState` implements. That boundary is what
+keeps the epoch machinery testable with plain documents.
+
+## The document
+
+One Automerge document per node (`document::replica::Replica`: the document, its epoch,
+cached heads). Automerge is used directly only in `document` and in what remakes do to
+documents ([`remake/`](../src/remake/): `snapshot`, `delta`, `catchup`, `kind`, `quarantine`); the rest of
+the node names its types as `document` re-exports them (`Document`, `ChangeHash`,
+`Change`, `ActorId`) and works through `document`'s functions. Two views of it:
+
+* **The store** (`document::store`): JSON over slash-separated paths, with Automerge's
+ concurrent values merged into one view (maps union, scalars newest-wins) and
+ reported on request (`?conflicts=true`). The HTTP API and the remake kinds work
+ through it; nothing else reads Automerge objects directly. Within a list, a path
+ segment is an index (`servers/0/name`); a negative one counts from the end. A key with `/` in it, or
+ an empty one, can be written inside a value but not named in a path: it is read and
+ written through its parent.
+* **Protocol data** under `_continuum`: the swarm ID, the member registry, forget
+ records, remake records, the votes register and epoch metadata. The nodes coordinate
+ through these keys and nothing else: there is no control channel besides the
+ document and the handshake. `--protocol-writers` may edit them for repairs; the
+ swarm ID and epoch metadata never.
+
+**Epochs.** The document's history can't be dropped in place, so a *remake* moves the
+whole swarm to a new document (same content, no history; or new content), the next
+epoch. Nodes on different epochs don't sync. The epoch is `{generation, id}`; the ID
+is the hash of the new document's first change, which every node builds itself, byte
+for byte the same ([`remake/snapshot.rs`](../src/remake/snapshot.rs)). That determinism is what lets a node verify
+it switched to the right document without trusting anyone.
+
+## Modules
+
+**[`transport/`](../src/transport/)** — [`mod.rs`](../src/transport/mod.rs) defines the message types (HELLO, HELLO_REPLY, sync
+envelope, STATUS/STATUS_REPLY, PING/PONG, blob messages, LINKS/LINKS_REPLY) and the `Transport` trait;
+[`wire.rs`](../src/transport/wire.rs) is the wire format (a version byte, bumped with every change to the format,
+and a kind byte, then the message in MessagePack, derived from the types in [`mod.rs`](../src/transport/mod.rs)
+with codecs for hashes and IDs); [`mycelium/`](../src/transport/mycelium/) (with the `mycelium` feature) is the REST
+client (polling inbox, outbound POST, one topic, `continuum`, for every message), with
+[`mycelium/daemon.rs`](../src/transport/mycelium/daemon.rs) running the daemon (an external one by its URL, or one it starts
+and supervises); [`libp2p/`](../src/transport/libp2p/) (with the `libp2p` feature) is libp2p as a transport,
+direct connections with one message per stream, relays and hole punching ([`driver.rs`](../src/transport/libp2p/driver.rs),
+[`behaviour.rs`](../src/transport/libp2p/behaviour.rs)), the addresses known per peer ([`book.rs`](../src/transport/libp2p/book.rs)), and which of them are
+ignored or unroutable ([`ignore.rs`](../src/transport/libp2p/ignore.rs), [`routes.rs`](../src/transport/libp2p/routes.rs)); [`tor.rs`](../src/transport/tor.rs) (with the `tor` feature) is Tor as a
+transport, an onion service per node and one connection per pair, authenticated by the
+challenge handshake of [`challenge.rs`](../src/transport/challenge.rs); [`paths.rs`](../src/transport/paths.rs) makes one address space
+(`<scheme>:<target>`) of the node's transports (one or more), routing each send to the
+transport of its address, measuring the paths to peers on several (PING, PONG) and
+sending over the best, the next when one fails; [`identity.rs`](../src/transport/identity.rs) is the identity layer above it, so the node
+deals in node IDs only (WHO and ME prove which node is at an address; [`peers.md`](peers.md)); [`traffic.rs`](../src/transport/traffic.rs) counts messages and
+bytes per peer; [`stats.rs`](../src/transport/stats.rs) the transports' own figures; [`firewall.rs`](../src/transport/firewall.rs) is the debug firewall (`/debug/firewall`), which the paths layer applies to
+everything, by the node at each address; [`sim.rs`](../src/transport/sim.rs) (tests only) is a
+simulated network: many nodes in one process, with per-link delay, loss and
+duplication from a seed, nodes taken down and brought up, and messages injected as if
+a node had sent them. It sends the wire encoding, so tests exercise the codec, or a
+transport's envelope around it ([`development.md`](development.md)).
+
+**[`simtest/`](../src/simtest/)** (tests only) — whole nodes on the simulated network, built as `main`
+builds them, with a 250 ms keepalive and a 1 s sync reply timeout: joining and
+converging, dormancy and return, late messages, a stalled catch-up source; and an
+ignored 25-node soak test with a propagation bound (`cargo test soak -- --ignored`). How to build and test quickly: [`development.md`](development.md).
+
+**[`swarm/`](../src/swarm/)** — the live protocol between nodes:
+
+| Module | Does | Documented in |
+|---|---|---|
+| `bootstrap` | genesis, joining through seeds (forever), starting on a document | [`lifecycle.md`](lifecycle.md) |
+| [`peer/`](../src/swarm/peer/) | handshake, keepalives and healing, peers' epochs, admission with a swarm key, forget records, inbound dispatch, the peer worker (a file each) | [`peers.md`](peers.md) |
+| `sync` | the sync worker, Automerge sync messages with retries and reply deadlines | [`peers.md`](peers.md) |
+| `changes` | the change publisher: the store paths each change touched (`change_events`) | module docs |
+| `document_file` | the worker keeping the document file (`document::file`) up to date | module docs |
+| `source` | the one peer a catching-up node syncs with, and when to give it up | [`peers.md`](peers.md) |
+| `relay` | announcing heads to neighbours and pulling what's missing (partial mesh) | module docs |
+| `links` | answering LINKS with this node's links, for the dashboard's map | [`dashboard.md`](dashboard.md) |
+| [`topology/`](../src/swarm/topology/) | which members are neighbours: ring plus low-score links, stable members preferred | module docs |
+| `members` | the member registry: aliases, alive stamps, tiers, the electorate | module docs |
+| `stability` | a node's own connectivity record and its stable/transient tier | module docs |
+| `auth` | the pre-shared swarm key and handshake tags | [`trust.md`](trust.md) |
+| `blob` | chunked content-addressed transfers from several peers | module docs |
+| `rtt`, `throughput` | round-trip times (of the paths, from HELLOs until measured) and sync throughput, for the node's own choices | [`measurements.md`](measurements.md) |
+| `skew` | this node's clock against its peers' (from their PONGs), and a warning while it is off | [`measurements.md`](measurements.md) |
+| `pair` | which side of a pair leads (sends the first keepalive) | module docs |
+| `stats` | node-wide figures for `/admin/stats` | [`measurements.md`](measurements.md) |
+| `state_file` | what a node remembers across restarts | [`lifecycle.md`](lifecycle.md) |
+
+**[`remake/`](../src/remake/)** — epochs and the move between them ([`remakes.md`](remakes.md) for operators):
+
+| Module | Does |
+|---|---|
+| `records` | remake records and the votes register in the document |
+| `api` | `/admin/remakes`: proposing, reports, aborting, superseding |
+| `proposal` | the checks before a proposal, superseding, the electorate and quorum |
+| `vote` | who votes for what, tallies, the one-vote-per-epoch rule |
+| `coordinator` | counts votes, writes the decision, builds the snapshot on commit |
+| `kind` | `compact`, `replace`, `import`: how the new document is built and where later writes go |
+| `snapshot` | the deterministic build and its verification; epoch metadata |
+| `switch` | the remake worker: switching this node to a committed epoch, replaying writes since the base |
+| `catchup` | rejoining a newer epoch from peers (download, verify, replay own writes) |
+| `delta` | replaying changes between two sets of heads into another document |
+| `quarantine` | writes a remake had no place for |
+| `content` | the new document of a `replace`/`import`, fetched by voters |
+| `local` | in-memory per-node remake state: prebuilt snapshots, archive, own actors |
+
+**[`document/`](../src/document/)** — [`replica.rs`](../src/document/replica.rs) (the document with its epoch), [`store.rs`](../src/document/store.rs) (the JSON
+view), [`file.rs`](../src/document/file.rs) (the document file: full save plus appended increments), [`changes.rs`](../src/document/changes.rs)
+(changes as bytes: one by its hash, and deltas between heads), [`sync.rs`](../src/document/sync.rs) (one sync with
+a peer, `SyncSession`: Automerge's sync state with our workarounds for unreliable
+delivery), [`meta.rs`](../src/document/meta.rs) (the protocol's paths under `_continuum`: the only place their
+names are spelled).
+
+**[`api/`](../src/api/)** — axum handlers: `store` (`/store`, `/stream`), `peers`, [`stats/`](../src/api/stats/) (JSON and
+Prometheus), `document` (backup export), `log` (filter at runtime), `config`
+(`/admin/config`), `health` (`/health`, `/ready`), `debug`, `auth` (htpasswd with
+Argon2), [`dashboard/`](../src/api/dashboard/) (with the `dashboard` feature: the page and its events;
+[`dashboard.md`](dashboard.md)), and [`mod.rs`](../src/api/mod.rs) (router, protocol-path rules). The remake routes
+come from [`remake/api.rs`](../src/remake/api.rs). [`docs/openapi.yaml`](openapi.yaml) describes it all; nodes serve it at
+`/openapi.yaml`, with a Swagger UI at `/swagger-ui`.
+
+**Top level** — [`main.rs`](../src/main.rs) reads the configuration, picks the starting document and
+runs the shutdown sequence; [`node.rs`](../src/node.rs) builds a node's state and starts its workers
+(also used by the simulator);
+[`config/`](../src/config/) loads options from defaults, files and arguments ([`configuration.md`](configuration.md));
+[`force.rs`](../src/force.rs) is the one format for overriding checks; [`files.rs`](../src/files.rs) reads and writes the
+node's own files and counts them; [`clock.rs`](../src/clock.rs) is the node's wall clock (every Unix
+timestamp it stamps or judges by goes through it, so tests can skew one node's);
+[`locks.rs`](../src/locks.rs) takes std locks without poisoning (`lock_or_recover` and the like);
+[`backoff.rs`](../src/backoff.rs), [`logging.rs`](../src/logging.rs); [`testrand.rs`](../src/testrand.rs) (tests only) is a seeded generator and a
+runner for property tests over many seeds (topology, heads summaries, replay).
+
+## Runtime
+
+**Tasks.** Started by [`node.rs`](../src/node.rs), running for the life of the process:
+
+* the **consumer**: every transport's inbound messages, through the paths and the
+ identity layer (Mycelium polls its inbox, two polls at once so HTTP round trips
+ overlap; libp2p and Tor read their connections), handed one at a time to
+ `peer::handle_message`;
+* the **sync lanes**: four tasks that apply inbound sync messages, each sender's on one
+ lane in arrival order, so a slow apply doesn't hold up the consumer
+ (`peer::lanes`);
+* the **sync worker**: one queue of dirty peers, coalesced, one Automerge sync
+ message generated per peer per pass and sent by a task of its own, so a slow send
+ holds up no other peer (`swarm::sync`);
+* the **peer worker**: when the earliest is due, keepalives, dormancy, HELLO retries,
+ overdue sync replies; every 5 s (and at once when the node becomes ready), forget records
+ (also when they changed), registry renewal, stability, measurements, the state file
+ (`swarm::peer::run_peer_worker`);
+* the **topology worker**: recomputes the neighbours when the member list or a peer's
+ reachability changes (`swarm::topology::run_worker`);
+* the **remake worker**: every second, on record changes and when a vote is due,
+ voting, switching, rejoining (`remake::switch::run_worker`);
+* the **change publisher**: diffs the document after each change for `/stream`;
+* the **document file** writer, if configured;
+* **bootstrap**, until the node is ready, then the HTTP server.
+
+Short-lived tasks: a coordinator per remake this node proposed, blob serving and
+fetching, the snapshot download of a rejoin, announcement rounds.
+
+**Locks.** The document is an `RwLock<Replica>`; what changes it (writes, applying
+sync messages and fetched changes, the epoch switch) takes the write lock, and what
+only reads it (generating sync messages, looking changes up, saving) the read lock,
+both on the blocking pool. Peers are a `RwLock<HashMap<_, Arc<Mutex<PeerState>>>>`: the map lock
+is held briefly, each peer's mutex for one operation. Lock order is document, then
+peer, wherever both are held (sync generation, the epoch switch). Whether a write may
+go in is checked under the write lock too: a rejoin's fresh document takes none until
+the rejoin is done (`Replica::writable`), so a write checked as ready just before the
+rejoin can't land in it; it goes into the document before the rejoin (`StaleView`),
+which is replayed when it ends. A rejoin begins and ends under the write lock, with
+the lifecycle (taken after the document's) and the stale view. The small shared
+tables (RTT, throughput, neighbours, stability, catch-up source, remake state) sit
+behind std mutexes and are never held across an await.
+
+**Lifecycle.** `Joining` → `Ready`, and back to `Joining` (keeping the swarm ID) while
+a rejoin runs. Only a ready node registers, votes, serves writes and is followed as a
+catch-up source. The phases, what each serves and how the files come in: [`lifecycle.md`](lifecycle.md).
+
+## The paths that matter
+
+**A write.** `PUT /store/{path}` → `AppState::mutate_doc` (one Automerge change under
+the write lock, stamped with the clock) → `publish_change` (bumps the version, emits
+`DocumentChanged`) → the sync worker generates a message per neighbour → each
+neighbour applies it, relays its new heads to *its* neighbours, which pull what they
+lack. In a full mesh (up to `k + 1` members) the writer reaches everyone directly.
+
+**An inbound message.** Consumer → `handle_message`: drop our own and stale-run
+replies, admit (swarm key), mark the peer seen, record its epoch (ignoring late ones),
+then by type: HELLO answered, HELLO_REPLY establishes membership and discovers peers,
+sync queued on its lane if on our epoch (held back from everyone but the source while catching
+up), STATUS answered with a STATUS_REPLY and compared for divergence, blobs served or collected,
+LINKS answered with the node's links if the asker is a member, and again on each change while it subscribes (`swarm::links`, for the dashboard);
+PING and PONG (the paths layer answered the PING already) only mark the peer seen, and
+after a PONG the peer's round-trip time is its path's again.
+
+**A remake.** Proposal written by the coordinator → every electorate member votes once
+it holds the content → the coordinator commits on a majority (checking the deadline
+first, writing the decision atomically with a re-check) → after the switch delay
+every node builds the snapshot, verifies the epoch ID, replays writes since the base
+into it and swaps documents → nodes that missed it rejoin: download the snapshot in
+chunks, sync, replay their own writes. [`remakes.md`](remakes.md) has the operator's view,
+[`remake/*.rs`](../src/remake/) the rules.
+
+## What is kept, and where
+
+Nothing in the document is durable unless `--document-file` says so: a node that
+restarts without one starts empty and syncs. The optional files ([`files.rs`](../src/files.rs)):
+
+| File | Holds | Written |
+|---|---|---|
+| state file | swarm ID, members to rejoin through | once ready, when the selection changes, at shutdown |
+| document file | the document, this node's actor IDs | appended every few seconds, rewritten on epoch change and growth |
+| backup | `GET /admin/document`, restored with `--recover-from` or imported | by the operator |
+
+Everything else (archive of old epochs, quarantine, prebuilt snapshots, measurements,
+peer states) is in memory and gone at restart.
+
+## Design rules
+
+The decisions that recur throughout the code; changing one means revisiting the
+places that rely on it.
+
+* **The document is the only shared state.** Membership, forgetting, remakes and votes
+ are all keys under `_continuum`. Nodes never agree on anything out of band.
+* **One writer per key.** Each part of a remake record, each registry entry, each
+ vote-register entry has exactly one node that writes it, so concurrent writes never
+ fight. Where two writers are possible anyway (decisions after expiry), the record
+ is marked contested and nobody acts on it alone.
+* **Deterministic builds, verified by hash.** A snapshot's bytes depend only on the
+ base document and the proposal, so the epoch ID doubles as proof of the content.
+* **Messages may be late, lost or duplicated.** Every message carries epoch and
+ epoch-change count; sync is idempotent and repaired by deadlines; keepalives only
+ inform; a HELLO resets the session on both sides.
+* **Peers on different epochs don't sync.** Ever. A node moves epochs by switching
+ (building) or rejoining (downloading), never by merging.
+* **Writes since the base are replayed as state, not history.** Per map key, whole
+ lists and texts; by every switching node, and only by their author on a rejoin.
+* **Members are trusted with everything** ([`trust.md`](trust.md)). The swarm key is the
+ boundary; nothing inside it is signed.
+* **Conflicts are errors; overrides are named.** A contradiction in the configuration
+ or at the API refuses with a message that names the `force` value that would
+ override it, in one camelCase vocabulary everywhere.
+* **Wire formats grow by appending.** New fields default when absent, so a swarm can
+ be upgraded one node at a time (until the first release lifts that constraint).
+
+## Where the complexity sits
+
+Roughly by size and by how many rules interact there:
+
+| Area | Lines (tests included) | Why it is complex |
+|---|---|---|
+| [`remake/`](../src/remake/) | 6,900 | Thirteen modules, each simple, but the sequence spans the whole node: proposal, votes, decision, snapshot, switch, rejoin, replay, quarantine, and every failure in between. Most of the project's rules live here. |
+| [`swarm/peer/`](../src/swarm/peer/) + [`sync.rs`](../src/swarm/sync.rs) + [`source.rs`](../src/swarm/source.rs) | 2,300 | The protocol state machine: handshake, liveness, sync sessions with deadlines, who syncs with whom while catching up. Timing-dependent; tested on the simulated network ([`simtest/`](../src/simtest/)). |
+| [`state/`](../src/state/) | 1,600 | `AppState` and its write helpers ([`mod.rs`](../src/state/mod.rs)), `PeerState` ([`peer.rs`](../src/state/peer.rs)), `Timing`, reads ([`read.rs`](../src/state/read.rs)) and the `Host` impl ([`host.rs`](../src/state/host.rs)). A hub, not a layer. |
+| [`transport/`](../src/transport/) | 11,500 | libp2p ([`libp2p/`](../src/transport/libp2p/), 4,400: addresses, relays, hole punching, which paths to keep), the paths and identity layers (2,300: measuring, failing over, proving node IDs), Mycelium and its daemon ([`mycelium/`](../src/transport/mycelium/), 1,200), Tor (700), the wire format ([`wire.rs`](../src/transport/wire.rs), derived), the simulator. The transports are self-contained behind the `Transport` trait; libp2p's choice of addresses is the subtle part. |
+| [`config/`](../src/config/) | 2,800 | Layered sources, importers and placeholders, conflicts, the report. Self-contained. |
+| [`document/store.rs`](../src/document/store.rs) | 1,200 | Conflict-aware JSON over Automerge. Subtle (merged maps, canonical writes), well covered by the rest relying on it. |
+| [`swarm/topology/`](../src/swarm/topology/), [`blob.rs`](../src/swarm/blob.rs) | 1,500 | Pure algorithms with tests; the topology hashes each member once, then scores the N² pairs by cheap mixing. |
+| [`api/`](../src/api/) | 3,600 | Handlers; the Prometheus rendering is the longest file. The dashboard's page ([`dashboard.html`](../src/api/dashboard/dashboard.html), 2,000 more) is one file of HTML, CSS and script. |
+
+The two places where a change needs the most care are [`peer/`](../src/swarm/peer/)/[`sync.rs`](../src/swarm/sync.rs) (every
+rule about when to send what interacts with every other) and [`remake/catchup.rs`](../src/remake/catchup.rs)
+(a rejoin touches the lifecycle, the document swap, blobs, sync and replay at once).
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..d3f7e56
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,240 @@
+# Configuration
+
+Every option can be given on the command line, in configuration files, or both.
+Precedence, lowest first:
+
+1. built-in defaults;
+2. configuration files, in the order of `--config` (repeatable; later files win);
+3. command-line arguments.
+
+An argument that replaces a value from a file is logged (info) with both values and
+the file. Imported values are never logged, only where they come from; text with
+placeholders is logged as it ends up. Secrets (`mycelium-rest-url`, which may carry
+credentials) are never logged, however they are given.
+
+`continuum --help` lists all options with their defaults.
+
+## Durations and sizes
+
+Durations (`--keepalive`, `--dormant-probe-max`, `--document-append`,
+`--document-rewrite`, `--electorate-window`, `--clock-skew-warn`, and the API's
+`settle`, `switchDelay` and retention `age`) are a bare number of seconds (`30`, `0.5`)
+or components with units in descending order: `ms`, `s`, `m`, `h`, `d`, e.g. `500ms`,
+`500s`, `1h30m`, `1d2h3s`, `1.5h`. Precision is a millisecond; anything finer is an
+error. Sizes (`--blob-chunk`) are a bare number of bytes or a number with `B`, `KiB`,
+`MiB` or `GiB`, e.g. `64KiB`, `1.5MiB`; they must come to whole bytes. In configuration
+files both may also be numbers (seconds, bytes).
+
+What the node reports (stats, `/peers`, remake reports) stays numeric, with the unit
+in the name: `uptimeSecs`, `dormantMs`, `savedBytes`. Timestamps are Unix
+milliseconds, named `…At` or `…Until`.
+
+## Files
+
+TOML (`.toml`) for people, JSON (`.json`) for generated configuration. Keys are the
+long option names. Nested tables join their keys with `-`, so these are the same:
+
+```toml
+mycelium-peers = ["tls://203.0.113.5:9651"]
+
+[mycelium]
+peers = ["tls://203.0.113.5:9651"]
+```
+
+Unknown keys are an error.
+
+An example ([`example.toml`](example.toml) has every option, explained):
+
+```toml
+include = ["/run/secrets/continuum.json"]
+
+bind-addr = "[::1]:3000"
+htpasswd = "/etc/continuum/htpasswd"
+node-key-file = "/var/lib/continuum/node.key"
+seeds = ["node1.example.org", "node2.example.org"]
+alias = { command = ["hostname", "-s"] }
+keepalive = "1m"
+neighbours = 12
+
+[mycelium]
+enable = true
+key-file = "/var/lib/continuum/mycelium.key"
+peers = ["tls://203.0.113.5:9651", "quic://203.0.113.6:9651"]
+```
+
+The same as JSON, as a system like NixOS would generate it:
+
+```json
+{
+ "include": ["/run/secrets/continuum.json"],
+ "bind-addr": "[::1]:3000",
+ "htpasswd": "/etc/continuum/htpasswd",
+ "node-key-file": "/var/lib/continuum/node.key",
+ "seeds": ["node1.example.org", "node2.example.org"],
+ "alias": { "command": ["hostname", "-s"] },
+ "mycelium": {
+ "enable": true,
+ "key-file": "/var/lib/continuum/mycelium.key",
+ "peers": ["tls://203.0.113.5:9651"]
+ }
+}
+```
+
+### Includes
+
+`include` (a path or a list) loads other files first, in the order listed; an entry
+`{ path = "…", optional = true }` is skipped where the file is missing. Relative paths
+are relative to the including file. The including file's own values win over its
+includes, later includes over earlier ones (with a warning, since two includes setting
+the same option is usually a mistake). A cycle is an error.
+
+This keeps public settings in version control and secrets elsewhere:
+
+```toml
+# continuum.toml, committed: the secrets, then local settings over them
+include = ["/run/secrets/continuum.toml", { path = "local.toml", optional = true }]
+seeds = ["node1.example.org"]
+```
+
+## Transports
+
+A node uses every transport enabled: `--mycelium-enable`, `--libp2p-enable` and
+`--tor-enable`, each in builds with its cargo feature (`--features mycelium,libp2p`,
+none by default). None is on by default: a node needs one at least, and without, the
+error says which this build has. As each is its own option, a file that configures a
+transport can enable it too, and including it is all it takes:
+
+```toml
+# continuum.toml
+include = ["libp2p.toml"]
+
+# libp2p.toml
+[libp2p]
+enable = true
+listen = ["/ip4/0.0.0.0/tcp/9661", "/ip4/0.0.0.0/udp/9661/quic-v1"]
+```
+
+`--transport-order` is their order of preference (`mycelium,libp2p,tor` by default),
+whether enabled or not; those it leaves out come after, in the default order. How a node
+uses several: [`transports.md`](transports.md), Several transports. Options
+of a transport the node doesn't use (not enabled), or this build doesn't have, are
+ignored, and the node says which at start (`Options of a transport this node doesn't use are ignored`,
+with the transport, whether it's built, and the options); a configuration shared
+between nodes may carry them on purpose. A transport's options are checked when the
+configuration is loaded if the node uses it (Mycelium's always, where it is built), so
+`--print-config` catches a wrong one too.
+
+What each transport is for and its options: [`transports.md`](transports.md).
+
+## Unsetting and conflicting options
+
+A later source can set an option back to its default: `--unset OPTION` on the command
+line (repeatable), `unset = ["option", …]` in a file (overriding its includes and
+earlier files), or `null` for the option in a JSON file.
+
+Some options can't be combined: `mycelium-rest-url` (use a Mycelium daemon running
+elsewhere) excludes the options of a supervised daemon (`mycelium-key-file`,
+`mycelium-key-create`, `mycelium-network-key-file`, `mycelium-network-name`,
+`mycelium-tcp-listen-port`).
+When a later source sets one side, values for the other side from earlier sources are
+dropped (and logged), so `--mycelium-rest-url` on the command line just works with a
+file that configures a supervised daemon. Setting both sides in the same source is an
+error.
+
+Other combinations contradict each other and are an error wherever their values come
+from: `genesis` with `seeds` (a new swarm has nobody to join) or with `recover-from`.
+To start a node with `--genesis` whose configuration file lists seeds, unset them:
+`--genesis --unset seeds`.
+
+## Forcing
+
+Some checks refuse to start the node because something looks wrong, but can be
+overridden when you know better: `--force` lists them by name, or is `true` for all.
+An unknown name is an error. The API's `?force=` parameters use the same format.
+`--force` is accepted on the command line only: it is meant for the one start it was
+given for, and in a configuration file it would apply to every start.
+
+| Name | Check |
+|---|---|
+| `swarmId` | the document to start on (`--recover-from`, `--document-file`) is of another swarm than the state file remembers; with it, the document's swarm wins |
+| `badDocumentFile` | the document file can't be read; with it, it is moved aside and the node starts without it |
+| `replaceDocumentFile` | `--recover-from` while the document file holds a document; with it, the backup wins |
+
+Names are camelCase everywhere, on the command line too, like the API's
+(`switchDelay`, `supersede`, `alive`).
+
+## Importing values
+
+A value can be taken from elsewhere instead of written out:
+
+| In a file | On the command line | Value |
+|---|---|---|
+| `{ env = "NAME" }` | `env:NAME` | environment variable |
+| `{ file = "PATH" }` | `file:PATH` | file contents |
+| `{ command = "COMMAND" }` | `cmd:COMMAND` | output of `sh -c COMMAND` |
+| `{ command = ["prog", "arg"] }` | | output of the program, no shell |
+
+Commands run as the node's user, at startup: whoever can write the configuration can run
+them ([`trust.md`](trust.md)).
+
+The content is text with trailing line breaks removed, read like a command-line
+value: numbers for numeric options, `true`/`false` for switches, comma- or
+line-separated items for lists. With `format = "json"` in a file, or `+json` on the
+command line (`env+json:SEEDS`, `file+json:PATH`, `cmd+json:COMMAND`), it is parsed
+as JSON instead (e.g. a list as `["a", "b"]`).
+
+In files, relative paths are relative to the file, and commands run in its directory.
+
+Any other text value can mix fixed text with imported values: each `${env:NAME}`,
+`${file:PATH}` or `${cmd:COMMAND}` in it is replaced by that content, as text (no
+`+json` in a placeholder). `$$` is a `$`, and any other `$` outside a placeholder is
+an error (a shell-style `$NAME` would otherwise silently stay as it is). A placeholder
+ends at the `}` that matches its `{`: braces inside it balance, and in a command, braces
+in shell quotes (`'...'`, `"..."`) or after a backslash don't count, so a command may
+hold `$`, quotes and braces of its own. Placeholders don't nest. `literal:` on the
+command line turns this off too.
+
+```sh
+continuum --alias 'node-${env:HOSTNAME}'
+```
+
+```toml
+alias = "${env:SITE}-${cmd:hostname -s}"
+# A command with both kinds of quotes: a TOML literal string ('''...''') needs no escapes.
+libp2p-listen = ['''/ip6/${cmd:ip -j addr show wireguard | jq -r '.[].addr_info[]|select(.family=="inet6" and .scope=="global").local'}/tcp/9661''']
+```
+
+A command that prints several lines (above: several addresses) doesn't give one item
+per line: as a list element the text is one item, line breaks and all, and as a whole
+list value it is split at commas and line breaks, the fixed text going with the first
+and last item only. Make it print one (`| head -n 1`).
+List elements can be imported one by one:
+
+```toml
+seeds = ["node1.example.org", { env = "EXTRA_SEED" }]
+```
+
+On the command line, `literal:` passes a value that happens to start with one of the
+prefixes: `--alias literal:env:prod`.
+
+```sh
+continuum --config /etc/continuum.toml --alias env:HOSTNAME --seeds cmd+json:'my-inventory --seeds'
+```
+
+Imports are read once, at start.
+
+## Checking the configuration
+
+`--print-config` prints the effective configuration as JSON and exits: every option
+with its value and where it came from (`default`, `file` with the file, or
+`argument`; `unset` if it was set back to its default there). Imported values are left
+out and their importers listed instead (`imported`), and secrets are left out however
+they were given (`secret`), so the output can be shared. Text with placeholders is
+shown as it ends up, with the text as written under `templates`: keep secrets of
+other options out of placeholders, and import them whole instead. A running node
+answers the same at `GET /admin/config`; with `?values=original`, text with
+placeholders is shown as written instead.
+
+```sh
+continuum --config /etc/continuum.toml --print-config | jq .options.seeds
+```
diff --git a/docs/dashboard.md b/docs/dashboard.md
new file mode 100644
index 0000000..b7e3069
--- /dev/null
+++ b/docs/dashboard.md
@@ -0,0 +1,324 @@
+# The dashboard
+
+A page showing one node's view of the swarm, live: a map of the members and the links
+between them, the node's peers, its figures, and the document, which it can also edit.
+This page is about using it; how it works underneath (events, LINKS, what is sent
+when) is at the end, [Underneath](#underneath).
+
+## Opening it
+
+The dashboard is in builds with the `dashboard` cargo feature:
+
+```sh
+cargo build --release --features mycelium,dashboard
+```
+
+It is at `/admin/dashboard` on the node's HTTP API, by default
+`http://127.0.0.1:3000/admin/dashboard`, and asks for a user from `--htpasswd`, as the
+rest of the API does. The API listens on localhost unless `--bind-addr` says otherwise;
+to look at a node on another machine, forward the port (`ssh -L 3000:localhost:3000
+<machine>`) or put a TLS proxy in front ([`trust.md`](trust.md)).
+
+Every node has its own dashboard, and shows the swarm as that node sees it: what it
+knows of the others, what they report to it, and its own figures.
+
+## The page
+
+At the top, the node's alias and node ID; in orange, when this node's clock is off
+from its peers' ([`measurements.md`](measurements.md), Clocks), by how much; and the connection's status:
+
+| Status | Meaning |
+|---|---|
+| `connecting…` | Opening the page's event stream. |
+| `live` | Up to date. The dot beside it breathes with every update from the node. |
+| `reconnecting…` | The stream broke; the browser reconnects by itself. |
+| `disconnected; retrying…` | The node answered with an error (it may be up but not serving the page yet); again in 5 s. |
+| `node stopped; reconnecting…` | The node said it is stopping; again in 3 s, until it is back. |
+| `paused while hidden` | The page was hidden (another tab, a minimised window) for 30 s: it closed its stream, so the node does no work for it. Shown again, it reconnects. |
+| `new version; reloading…` | The node now serves another version of the page; it is loaded. |
+
+A click on the status refreshes everything at once while live, and reconnects at once
+otherwise. While not live, the page is dimmed and shows what it had last: ages stop
+counting.
+
+Below, five boxes: the swarm's map on the left; the peers, the figures, the document
+and the console on the right. In a narrow window they are stacked.
+
+Dragging the space between two boxes resizes them: between the map and the right
+column, below the peers, between the figures and the document, and above the
+console. Dragged all the way to the right, the right column goes, and the map takes
+the whole width; drag the space right of the map back to bring it again. A
+double-click there sets that space back to its default. The sizes are kept in the
+browser, for the next time the page is opened.
+
+The `▾` before a box's title collapses it to its title (`▸` opens it again). A
+collapsed box isn't drawn at all, so it costs the browser nothing while the node
+updates; opened, it shows what's current. The open boxes take the room the collapsed
+ones leave: in the right column they share it as before (the peers take all of it
+when they're the only one open); the map collapsed, the right column takes the width.
+The right column all collapsed keeps its width: its titles stay at the top, with a
+hint that dragging the space beside them to the right hides the column. Which boxes
+are collapsed is kept in the browser too.
+
+## The map
+
+### Nodes
+
+Every member of the swarm, by alias (or a shortened node ID):
+
+* **This node**: filled blue, slightly larger.
+* **Filled**: the member reports its links to this node.
+* **Hollow**: it reports none: it doesn't answer this node's LINKS, or its last answer
+ is more than 90 s old (it is unreachable from here, stopped, or doesn't yet take this
+ node for a member).
+* **Red outline**: this node holds it dormant (it has been silent, or said farewell).
+* **Dashed orange ring**: its clock is off the swarm's: it differs from the median of
+ the members' clocks by more than `--clock-skew-warn`, beyond what the measurements
+ allow. The members report their peers' clocks with their links, so this works for
+ members this node doesn't reach itself; it needs three members compared.
+
+### Links
+
+Two kinds, drawn over each other:
+
+* **Intended** (dashed grey): the neighbours the member list gives each member, as this
+ node computes them. Shown only where no measured link backs it.
+* **Measured** (coloured): the links members report. A link is drawn if either end
+ reports it.
+
+A measured link is coloured by transport (blue Mycelium, purple libp2p, teal Tor). The
+transport in use is the thick line in the middle, labelled with its round trip in
+milliseconds; the other transports the peers can reach each other on are thin lines
+beside it, from node to node, bowing out a little (furthest at the middle), with their
+round trips smaller. The round trips are drawn near the end that
+measured them: of the two ends' reports, the one drawn is the one with a round trip,
+else the fresher. Then:
+
+* **Solid**: direct.
+* **Dashed**: through a relay.
+* **Dotted**: one end reports no links: what is drawn may be out of date.
+* **Narrow, over a red band**: either end holds the other dormant, or the path in use
+ is down (no path to the peer answers; `/peers` shows its connection as `null`).
+
+Messages this node exchanges run along its links as dots, from sender to receiver.
+They come from the per-peer message counts, which arrive every 2 s: what was sent and
+received since is spread over the next 2 s, at most 6 dots per link and direction,
+shared out by message kind, each kind in its colour (named in the legend; the console
+colours them alike). A dot is as large as its kind's messages were on average
+(by the size's logarithm: a ping small, a large sync message large); the slider right
+after their legend makes all dots larger or smaller, kept in the browser. A picture
+of the traffic, not a record of it; links between other members show none.
+`messages: on` after the slider hides them (`messages: off` shows them again; the
+legend and the slider greyed meanwhile); the choice is kept in the browser.
+
+A label on the map that changes, a round trip or a node's name, breathes once: it
+glows around its new text and fades (not where the system asks for reduced motion).
+
+`links: live` beside the box's title says the members tell this node of their links'
+changes as they happen. Clicked, it's `links: every 30 s`: the members are only asked
+every 30 s, which costs the swarm fewer messages and leaves the map up to 30 s behind
+(this node's own links stay current). It's the node's choice, for every page open on
+it, until clicked again or the node restarts.
+
+The legend above the map names the transports that appear; `hide legend` beside the
+box's title gives its room to the map (`show legend` brings it back).
+
+`layout: static`, beside it, is the default: nodes move only when the map is laid out
+(below). A click switches to `layout: dynamic`: each measured link is as
+long as its round trip is slow, against the median link's, by the square root, from
+0.4 to 2.5 times the usual length; links without a round trip keep the usual length.
+In either layout, a link only intended (dashed grey) gives way to the measured ones: it
+pulls its ends together only a little when stretched, and doesn't push them apart. As
+round trips change, the map moves to the new lengths smoothly. Clicked again, it's
+static, the nodes staying where they are. The choice is kept in the browser.
+
+Hovering over a node or a link highlights it and shows a tooltip: for a link, each of
+its lines with transport, direct or relayed, round trip, and how long ago it was
+measured.
+
+### Moving around
+
+* **Wheel**, or pinching on a touch screen: zoom, around the pointer (or between the
+ fingers, the map moving with them).
+* **Drag beside the nodes, or along a link**: pan.
+* **Two fingers turning**, on a touch screen: the map turns with them; labels stay
+ upright.
+* **Drag a node**: move it. On a dynamic map, the others follow at once, and once
+ let go of, it moves back among them; on a static map it stays where it is put until
+ the map is laid out again.
+* **Double-click a node**: pin it where it is (a dot in its middle): the layout leaves
+ it there. A double-click whose second press drags the node pins it where it's
+ dropped. Double-clicked again, it's let go, and moves to its place among the others
+ (on a static map, only it moves).
+
+The map is laid out when the page opens, and again when the window or the box is
+resized. A member that comes later is placed among the others, which stay where they
+are, as they do when links appear or disappear; pinned nodes always stay put. The view and pins last as long as
+the page is open.
+
+## Focus
+
+What the Figures box shows is what is in focus:
+
+* **Nothing** (the default): this node's figures.
+* **A node** (click it on the map, or its row in Peers): this node's entry for that
+ peer (as `/peers` has it), and the links that member reports. Ringed on the map,
+ highlighted in Peers. A second click on its row, a click beside the nodes or a click
+ on this node goes back.
+* **A link** (click it): whether the member list intends it, each end's report of it
+ (transport, relayed, round trip, dormant, its other transports, and how old the
+ report is), and, where this node is an end, the link as this node has it in `/peers`:
+ where messages go, its paths (each with its connection and round trip), and the
+ addresses known of the other end. Both ends are ringed.
+
+The focus is in the page's address (`#<node ID>`, or the two IDs of a link), so a
+reload keeps it and a link to the page can name it.
+
+## Peers
+
+Every peer this node knows, with:
+
+| Column | Meaning |
+|---|---|
+| peer | Alias, or a shortened node ID (the full one in the tooltip). |
+| status | `active`, `dormant` (for how long: in the tooltip) or `unknown` (not heard from since this node started). |
+| seen | How long ago this node last heard from it. |
+| via | The address this node sends to it at, and whether `direct` or `relayed`. |
+| rtt | The round trip on that path. |
+| clock | How far its clock is ahead of this node's (behind: `−`), give or take (in the tooltip); in orange beyond `--clock-skew-warn`, for sure ([`measurements.md`](measurements.md), Clocks). |
+| neighbour | Whether this node keeps contact with it (HELLOs, keepalives, sync); others it only answers. |
+| same epoch | Whether it is on this node's epoch ([`remakes.md`](remakes.md)); blank while unknown. |
+
+The box is as tall as its rows, up to eight; then it scrolls.
+
+## Figures
+
+The figures of what is in focus, as a tree: this node's `/admin/stats`, or a peer's
+`/peers` entry and links. Branches stay as opened across updates, and ages count on
+between them. What each figure means: [`measurements.md`](measurements.md).
+
+## Document
+
+The swarm's document, as this node holds it, its first level open. A branch is read
+when opened, and read again when the document changes there; what changed or came
+breathes once, as the map's labels do: its row glows and fades.
+
+Editing writes to the document, as the user the page was opened as, and the write goes
+to the whole swarm like any other:
+
+* **Double-click an entry** to edit it: a string as text, anything else as JSON, a map
+ or list whole. Saved, it is shown open. The red `Delete` beside `Save` deletes the
+ entry instead, on a second click (`Delete: sure?`) from half a second to 3 s later: a
+ double-click doesn't delete.
+* **`+` on a map's row** adds a key and its value.
+* **`+` on a list's row** inserts an item at an index (`-1`: at the end); the items
+ from there on move up.
+
+Protocol data (`_continuum`) is written only by users in `--protocol-writers`, and epoch
+metadata and the swarm ID by nobody ([`trust.md`](trust.md)). A key containing `/`, or an empty
+key, is listed but can't be opened.
+
+## Console
+
+The messages this node exchanged with its peers, one by one: when (on the 24-hour
+clock, to the millisecond), which way (`→` sent, `←` received, `↛` refused: the
+transport wouldn't send it), with whom, the type (`HELLO`, `SYNC`, `STATUS_REPLY`,
+`LINKS`, …, in its kind's colour, as on the map) and the size. The latest 500, the
+newest at the bottom; scrolled along while at the bottom. They arrive with every update
+(every 2 s). The PINGs the paths layer sends on its own, and the PONGs it answers
+PINGs with, are listed too: under the node at the address they went to, where it is
+known, else under the address. Not listed: WHO and ME, which stay in the layers
+below the node (they are counted, [`measurements.md`](measurements.md), Traffic).
+
+A click on a message shows it in the figures box: when, with whom, its type and size,
+and the message written out (its bytes as sizes). A click on it again, or on anything on
+the map or in the peers, goes back.
+
+Pointing at a message marks the others of its exchange (a lighter grey, and a bar on
+their left); while one is shown, its exchange stays marked. An exchange is a PING and
+its PONG (by their nonce, wherever they were listed), a HELLO and its replies (by the
+run of the node that sent the HELLO), a LINKS and its replies (by their nonce), a blob's
+requests and answers, from whichever peers (by the blob), and a delta offered in a SYNC
+with the blob it is fetched as. A STATUS and its reply carry nothing that ties them, nor
+does an ordinary SYNC.
+
+## While you read
+
+Updates don't pull things from under you: while text is selected in a box, that box
+waits, and while an entry is edited, the branch it is in waits; both catch up once you
+are done. The map isn't redrawn under the pointer: what you point at stays put.
+
+## When something looks odd
+
+* **The status stays at `connecting…`, or the page doesn't load**: the node isn't
+ built with `dashboard` (404), or the user isn't in `--htpasswd` (401).
+* **A hollow node**: no links report from it. If it is also dormant here, it is
+ probably down or unreachable; if not, it may not take this node for a member yet
+ (it hasn't registered it), or its answers don't arrive.
+* **Only dashed grey between two nodes**: the member list wants them as neighbours, but
+ neither reports a link to the other: they haven't reached each other (yet).
+* **A dotted link**: one end has stopped reporting; the link is as it was last
+ reported.
+* **A red band**: one end holds the other dormant. If the other end still reports the
+ link, the trouble is likely in one direction only.
+
+## Underneath
+
+The page is `GET /admin/dashboard`, authenticated as the rest of `/admin`. It is kept up
+to date through Server-Sent Events (`/admin/dashboard/events`);
+`/admin/dashboard/state` has the latest state at once.
+
+**Events.** One producer per node serves every client, and runs only while one is
+connected (or for a minute after a `/state` request); a page hidden for 30 s
+disconnects until it is shown again. Every 2 s it builds the state; when anything but
+ages changed, it sends every client a JSON merge patch onto the state before. Nothing
+changed, nothing is sent. Ages are as of the event, and the page counts them on. A click
+on the status refreshes at once (`POST /admin/dashboard/refresh`). When the node stops,
+every client gets an `offline` event and its stream ends, so stopping doesn't wait for
+them; the page reconnects once the node is back, and reloads if the node now serves
+another version of it (the state's `page`).
+
+**The map's graphs.** The intended one is the neighbours the member list gives every
+member (`swarm::topology`), as this node computes them. The measured one is every
+member's links as it reports them in reply to a LINKS (sent to every member, at most
+512, while the producer runs). The LINKS subscribes: the member replies again whenever
+its links change (a peer, a transport, relayed, dormant, or a round trip moved by a
+fifth; it looks every 2 s), for 90 s, renewed every 30 s (each renewal answered), and
+ended when the producer stops; a node that is gone stops it by not renewing. A reply
+older than 90 s is left out. A member keeps 64 subscribers at most. With `links: every
+30 s` (`POST /admin/dashboard/links?live=false`; `true` back), the subscriptions end
+and the members are only asked every 30 s, for every page open on this node, until
+asked otherwise or the node restarts.
+
+The links a member reports: its neighbours and the peers it holds dormant (a neighbour
+that goes dormant is one no more, but stays listed until it is heard from again), by
+which transport, whether only through a relay, the round trip (as the paths layer
+measures it), whether it holds the peer dormant or the path is down, the peer's other
+transports with a path up (each with its fastest path's round trip, and whether it goes
+only through a relay), and the peer's clock against the member's ([`measurements.md`](measurements.md),
+Per peer, `clock`). A member answers a LINKS from members only, at most every 5 s, and
+every build answers, with or without the feature (`swarm::links`).
+
+**Clocks.** The clocks in the links chain every member's clock to this node's, along
+the narrowest bounds, and the map takes each against their median: a member more than
+`--clock-skew-warn` off it, beyond the chained bound, is ringed. With fewer than three
+members compared, none is.
+
+**Messages on the map** come from the per-peer message counts in the state (`/peers`,
+`traffic`), not from messages of their own. **The console's** come from a log the node
+keeps only while the producer runs (`traffic::MessageLog`, the latest 1000), sent as a
+`messages` event every 2 s with those since the last.
+
+**The document** is read through the store API, a level at a time as it is opened
+(`GET /store?shallow=true`, then `/store/<path>?shallow=true` for each map opened, and
+`/store/<path>` for a long value). Where it changes, the node says so (a `document`
+event with the paths changed, at most every 2 s), and the page reads again what is open
+there. A key with `/` in it (or an empty key) can't be opened: store paths are split at
+`/`.
+
+Edits go through the store API too, as the user the page was opened as: `PUT
+/store/<path>` (a text saved becomes a string; the root isn't written whole), a list's
+items each on its own (`PUT /store/<path>/<index>`), a new key with `PUT
+/store/<path>/<key>`, an inserted item with `POST /store/<path>/<index>` (`-1` the end;
+an index past the end is refused), and `DELETE /store/<path>`. The dashboard itself
+writes nothing to the document: only edits do.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..93726b1
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,52 @@
+# Development
+
+## The loop
+
+```sh
+cargo check --features mycelium,libp2p,tor,dashboard # does it compile: seconds
+cargo quick # every test but the simulations: seconds
+cargo sim # the simulations (src/simtest/): under a minute
+cargo test --features mycelium,libp2p,tor,dashboard # both
+cargo benchmarks # the benchmarks, in release builds
+```
+
+`cargo quick`, `cargo sim` and `cargo benchmarks` are aliases ([`.cargo/config.toml`](../.cargo/config.toml)),
+with every transport and the dashboard (`dashboard`, `GET /admin/dashboard`;
+[`docs/dashboard.md`](dashboard.md)). Each transport is a feature, none of them default, and a build
+needs at least one: a transport left out takes its tests and dependencies with it
+(`--features libp2p` builds neither `reqwest` and the daemon supervision of Mycelium,
+nor arti; among Mycelium's tests is a swarm simulated on its envelope).
+
+The simulations send the wire encoding alone, as libp2p and Tor do; a simulated net
+can put messages into a transport's envelope instead (`SimNet::with_codec`), and every
+message kind is round-tripped through each codec (`wire::samples`).
+
+The dev profile ([`Cargo.toml`](../Cargo.toml)) keeps the loop short: the crate gets line tables only
+(backtraces still name files and lines), and the dependencies are built optimised and
+without debug info. Automerge's work dominates the tests, so they run several times
+faster; the binaries are a quarter of the size, so they link faster. The dependencies
+build once (a few minutes, longer on few cores); after that, a change rebuilds in
+seconds. To step through a dependency in a debugger, override it locally (`debug =
+true` for that package).
+
+Measured on 2 cores, `--features libp2p,tor` (before Mycelium became a feature), after
+touching one file, with Cargo's default dev profile and with this one:
+
+| | Default profile | This profile |
+|---|---|---|
+| `cargo check` | — | 3 s |
+| test build | 8 s | 6 s |
+| binary build | 13 s | 6 s |
+| tests but the simulations | 6 s | 1.4 s |
+| all tests | 40 s | 31–35 s |
+| test binary | 550–670 MB | 100–155 MB |
+
+## The simulations
+
+The simulations ([`src/simtest/`](../src/simtest/)) run whole nodes on a simulated network
+([`transport/sim.rs`](../src/transport/sim.rs)), in real time: tokio's clock isn't paused, because a node's
+document work runs on the blocking pool, which a paused clock doesn't wait for. So they
+take as long as the protocol's timeouts make them, however fast the machine, and on a
+busy machine some fail now and then (a node's timer fires before the work it waits for
+is done). One failing alone (`cargo sim <name>`) and passing then is that, not a
+regression; one failing again and again is a regression.
diff --git a/docs/example.toml b/docs/example.toml
new file mode 100644
index 0000000..95b6f52
--- /dev/null
+++ b/docs/example.toml
@@ -0,0 +1,280 @@
+# An example configuration with every option, explained (`configuration.md` has the
+# rules). Use it with `continuum --config example.toml`; `continuum --print-config`
+# shows what a configuration amounts to, and `continuum --help` lists the options.
+#
+# Keys are the long option names. Nested tables join their keys with `-`: `key-file`
+# under `[mycelium]` is `mycelium-key-file`. Options left out (or commented out, as most
+# are here) take their defaults, shown in the comments. Command-line arguments win over
+# files.
+#
+# Any value can come from elsewhere instead of being written out:
+# { env = "NAME" }, { file = "PATH" }, { command = "COMMAND" } or
+# { command = ["program", "argument"] }, with format = "json" to parse it as JSON;
+# and text can mix in such values: "node-${env:HOSTNAME}" (`$$` for a `$`).
+# Imported values are never logged or shown by --print-config.
+
+# --- Other files -----------------------------------------------------------------------
+
+# Load these first (a path or a list), in this order; relative to this file. This file's
+# own values win over its includes, later includes over earlier ones. An entry
+# { path = "...", optional = true } is skipped where the file is missing.
+# include = ["/run/secrets/continuum.toml", { path = "local.toml", optional = true }]
+# Options to set back to their defaults here (e.g. one an include sets).
+# unset = ["alias"]
+
+# --- Who this node is ------------------------------------------------------------------
+
+# This node's key (Ed25519, 32 raw bytes): who the node is, whatever transport reaches
+# it. Keep it with the node: a new key is a new node. Required (but with relay-only).
+node-key-file = "/var/lib/continuum/node.key"
+
+# Create a new node key at node-key-file if there is no file there (a new node). Without
+# it, a missing key file is an error, so a mistyped path never makes a new node. Best
+# given once on the command line (--node-key-create) rather than kept here.
+# node-key-create = false
+
+# A name for this node, shown next to its node ID (/peers, metrics). Published in the
+# member list; not unique, never an identity.
+# alias = "node-${env:HOSTNAME}"
+
+# Only relay (libp2p circuit relay), without being a node: no document, no swarm, no
+# node key. Needs libp2p alone (libp2p's enable, no other), its listen and
+# key-file; options about a swarm or a document (node-key-file, seeds, genesis,
+# state-file, ...) are errors with it. The HTTP API, with htpasswd, has /health,
+# /admin/stats and /admin/log only. List such relays in the document
+# (_continuum/relays) or in libp2p's relays.
+# relay-only = false
+
+# --- The HTTP API ------------------------------------------------------------------------
+
+# Address of the HTTP API. Plain HTTP: to expose it, put a TLS proxy in front.
+# bind-addr = "127.0.0.1:3000"
+
+# Users of the HTTP API (an htpasswd file with Argon2 hashes). Required (but with
+# relay-only: without it, no HTTP API).
+htpasswd = "/etc/continuum/htpasswd"
+
+# HTTP API users (from htpasswd) who may write protocol data under /store/_continuum
+# (remake records, members, forget records), e.g. to repair a stuck remake. Such writes
+# act on the whole swarm and are logged. Epoch metadata and the swarm ID are never
+# writable.
+# protocol-writers = ["alice"]
+
+# How log lines look: text (timestamped), journal (for systemd's journal: no timestamp or
+# colours, the level as each line's priority, for journalctl -p), or auto: journal when
+# stdout goes to the journal, else text.
+# log-format = "auto"
+
+# Log node IDs and transport identities as they are. Without it, a member's read
+# `<scheme>:<alias>` wherever they appear (`continuum:<alias>`, `libp2p:<alias>`), as the
+# member list names it.
+# log-raw-ids = false
+
+# Enable the /debug endpoints (tests only).
+# debug-api = false
+
+# --- The swarm ---------------------------------------------------------------------------
+
+# How to reach a node of the swarm to join: an address (`mycelium:<public key>` or
+# `libp2p:<multiaddr>`, as GET /admin/stats lists them), a Mycelium public key or IPv6
+# address, a host name with an AAAA record for one, or a libp2p multiaddr (with or
+# without /p2p/<peer ID>). A node keeps trying until it has joined.
+# seeds = ["node1.example.org", "libp2p:/ip6/fd00::1/tcp/9661"]
+
+# Start a new swarm, as its genesis node, instead of joining one through seeds. Every
+# start with it starts another new swarm: drop it once the swarm exists, and give the
+# node seeds instead. Can't be combined with seeds. With a state file that remembers a
+# swarm, it starts that swarm again instead (empty; nodes of it still running merge with
+# this one). Best given once on the command line (--genesis).
+# genesis = false
+
+# With genesis: make the new swarm's document as a remake of this kind would, instead of
+# starting it empty: import:PATH (an Automerge document, e.g. a backup, without its
+# history and protocol data) or replace:PATH (a JSON document, the store root); compact
+# changes nothing.
+# remake = "import:/var/backups/continuum.automerge"
+
+# Start the swarm again from a backup (as GET /admin/document saves it): this node loads
+# it, on the swarm and epoch it names, and is ready at once; other nodes join it.
+# Members of the swarm still running merge with it, or it rejoins them if they moved on
+# to a newer epoch (the backup is then dropped). For one start: command line.
+# recover-from = "/var/backups/continuum.automerge"
+
+# Pre-shared swarm key: only nodes that know it may join (raw bytes, at least 16; e.g.
+# `head -c 32 /dev/urandom`). Without it, any node that can reach a member may join.
+# swarm-key-file = "/etc/continuum/swarm.key"
+
+# Create a new swarm key at swarm-key-file if there is no file there: on the swarm's
+# first node, as the others need a copy of it (a key of their own keeps them out).
+# Without it, a missing key file is an error.
+# swarm-key-create = false
+
+# Not in files: `force` (override checks that refuse to start: swarmId,
+# badDocumentFile, replaceDocumentFile) is for one start, on the command line only.
+
+# --- Files of this node ------------------------------------------------------------------
+
+# Where this node remembers its swarm across restarts: the swarm ID and some members to
+# rejoin through (not the document). Written once ready, then rarely. Without it, a
+# restarted node knows only its configured seeds.
+# state-file = "/var/lib/continuum/state.json"
+
+# This node's document on disk. Read at start: the node resumes with it, ready at once.
+# Kept up to date while ready: changes are appended every document-append, and the file
+# is rewritten after a switch, a rejoin or a restart, and when the appended changes
+# outgrew it.
+# document-file = "/var/lib/continuum/document.automerge"
+
+# How often changes are appended to the document file.
+# document-append = "5s"
+
+# Shortest time between rewrites of the document file because its appended changes
+# outgrew its full save (a new epoch or a restart rewrites it at once).
+# document-rewrite = "1h"
+
+# --- Timings and sizes -------------------------------------------------------------------
+# Durations: seconds (30, 0.5) or with units (500ms, 30s, 1m, 1h30m, 7d).
+# Sizes: bytes or with units (64KiB, 4MiB).
+
+# Keepalive interval; dormancy, resync and probe timings derive from it. 15–30s on a
+# LAN or between servers, 1–2m over WAN, mobile or metered links.
+# keepalive = "1m"
+
+# Longest wait between HELLOs to a member that doesn't answer: they start at 10
+# keepalives and double up to this, so it bounds how long a returning member, or the
+# other side of a partition, takes to be noticed. Default: 60 keepalives (1h at 1m).
+# dormant-probe-max = "15m"
+
+# Neighbours each node keeps (at least 2). Swarms of up to this many + 1 nodes are a
+# full mesh; larger ones sync over several hops. With several transports, about this
+# many per transport.
+# neighbours = 12
+
+# Members that renewed their alive stamp within this time vote on remakes (a majority of
+# them must agree). At least 7h. Members renew their stamps every seventh of the
+# shortest window any of them publishes, hourly at most, so nodes may differ. Matters
+# on the node that coordinates a remake.
+# electorate-window = "7d"
+
+# What becomes of a write a remake or rejoin would carry over into the new epoch though
+# a newer one touched its path (last writer wins, by the writers' clocks): "drop" leaves
+# it out (logged), "quarantine" keeps it for an operator (GET /admin/quarantine).
+# superseded-writes = "drop"
+
+# Warn in the log when this node's clock differs from most of its peers' by more than
+# this, for sure (beyond half the round trip it was measured over, from their PONGs); 0:
+# never.
+# clock-skew-warn = "2s"
+
+# Chunk size when serving large transfers to other nodes (e.g. an epoch's snapshot to a
+# rejoining node), 1KiB to 4MiB.
+# blob-chunk = "64KiB"
+
+# --- Transports --------------------------------------------------------------------------
+
+# The transports' order of preference, whether enabled or not (each is enabled in its
+# own table: enable = true). A peer is reached on the first enabled one both have until
+# the paths are measured, then on the fastest; nodes on several carry the traffic
+# between nodes that share none. Options of a transport not enabled are ignored.
+# transport-order = ["mycelium", "libp2p", "tor"]
+
+[mycelium]
+# Use this transport (in builds with the mycelium feature). A node needs one at least;
+# none is on by default.
+# enable = false
+
+# Peers of the supervised Mycelium daemon (overlay links), not continuum peers. Public
+# Mycelium nodes are used only if listed here.
+# peers = ["tls://127.0.0.1:9651"]
+
+# Private key of the supervised Mycelium daemon (its identity, and so its address); a
+# new one each start if absent.
+# key-file = "/var/lib/continuum/mycelium.key"
+
+# Create a new key at key-file if there is no file there (a new Mycelium address).
+# Without it, a missing key file is an error.
+# key-create = false
+
+# TCP port the supervised daemon listens on for overlay peers; a free port if absent.
+# tcp-listen-port = 9651
+
+# A private Mycelium network for the supervised daemon: its key and its name, together.
+# They keep other Mycelium nodes from reaching ours at all.
+# network-key-file = "/etc/continuum/mycelium-network.key"
+# network-name = "example"
+
+# Use a Mycelium daemon running elsewhere (its REST API) instead of supervising one.
+# Excludes the options above except peers. Secret: a URL may carry credentials.
+# rest-url = "http://[::1]:8989"
+
+[libp2p]
+# Use this transport (in builds with the libp2p feature).
+# enable = false
+
+# Where to listen. Without, the node only connects out (peers answer over its
+# connections, and a relay makes it reachable). Its listen addresses are published in
+# the member list, loopback and link-local ones left out.
+# listen = ["/ip4/0.0.0.0/tcp/9661", "/ip4/0.0.0.0/udp/9661/quic-v1", "/ip6/::/tcp/9661"]
+
+# The addresses to publish instead of those listened on, e.g. a public address forwarded
+# to this node's listen port.
+# announce = ["/ip4/203.0.113.7/tcp/9661"]
+
+# Its key (Ed25519, 32 raw bytes), which makes its peer ID; a new one each start if
+# absent (the node ID stays the same either way). Worth keeping on a relay, so others'
+# addresses through it stay the same.
+# key-file = "/var/lib/continuum/libp2p.key"
+
+# Create a new libp2p key at key-file if there is no file there (a new peer ID). Without
+# it, a missing key file is an error.
+# key-create = false
+
+# Relay for nodes of the swarm that can't be dialled (bounded: 1024 circuits, 64 per
+# peer, each up to an hour and 1 GiB), and say so in the member list, where nodes that
+# need a relay find it. Needs a listen address others can dial.
+# relay = false
+
+# Relays to use when this node can't be dialled (no listen address others could dial,
+# or AutoNAT says so): multiaddrs, with or without the relay's peer ID. It picks up to
+# two relays, these first, then members that relay. Any libp2p node serving circuit
+# relay v2 will do; most keep circuits short (2 minutes, 128 KiB), enough to punch holes.
+# relays = ["/ip4/203.0.113.7/tcp/9661"]
+
+# Find nodes on the LAN (multicast DNS, IPv4). Those that speak continuum are seeds
+# while joining, so nodes on one LAN find each other without seeds; a swarm key keeps a
+# new node from joining another swarm on the same LAN.
+# mdns = false
+
+# Ask the router to forward this node's listen ports (UPnP), and publish where, so that
+# nodes elsewhere can dial it without a relay or hole punching. Opens those ports on the
+# router for as long as the node runs.
+# upnp = false
+
+# Address ranges libp2p leaves out (VPNs, say): neither published nor offered for hole
+# punching nor dialled, and connections there are refused. Announced addresses are
+# published anyway.
+# ignore = ["10.244.0.0/16", "42::/16"]
+
+# Hosts on the internet (names or addresses), only to tell whether this node has a
+# route to the internet, per IPv4 and IPv6 (the routing table is asked; nothing is sent
+# to them). Where it has none, its own public addresses of that family aren't
+# published. Without, it isn't asked.
+# internet-hosts = ["one.one.one.one"]
+
+# Use public libp2p infrastructure: join the IPFS DHT (through the IPFS bootstrap nodes)
+# to find peers' addresses and public relays, picked after members that relay. Public
+# relays only keep circuits for minutes and some KiB: enough to punch holes through
+# NATs. Makes this node's peer ID and addresses known to the DHT. Without it, nothing
+# public is used.
+# public = false
+
+[tor]
+# Use this transport (in builds with the tor feature).
+# enable = false
+
+# Where arti (Tor, embedded) keeps its state and cache, among them the onion service's
+# key, which makes its address: keep it with the node. Required with tor.
+# state-dir = "/var/lib/continuum/tor"
+
+# The port of this node's onion service.
+# port = 9662
diff --git a/docs/glossary.md b/docs/glossary.md
new file mode 100644
index 0000000..71d3014
--- /dev/null
+++ b/docs/glossary.md
@@ -0,0 +1,154 @@
+# Glossary
+
+The words used in the code, logs, API and docs, and what they mean here.
+
+## Nodes and the swarm
+
+**node**: a running continuum instance, together with its transports (and a Mycelium
+daemon it supervises), document, lifecycle (Joining, Ready, Rejoining), API and log. The word for what a single instance
+does: "the node becomes ready", "a new node joins". Rule of thumb: say *node* when
+nobody else is involved.
+
+**peer**: another node, seen from one node's side. Always relative to someone, which
+is why `PeerState`, `/peers` and dormancy are per peer. "A node forgets a dormant peer"
+reads right; "a peer becomes ready" doesn't. Rule of thumb: say *peer* when you mean
+"the other side" or "the others".
+
+**swarm**: all the nodes together, sharing one document. Identified by the **swarm
+ID**, which the genesis node chooses and records in the document (`_continuum/swarm`). An optional **swarm key** (`--swarm-key-file`)
+is a shared secret every node must prove in the handshake.
+
+**node ID**: who a node is, `continuum:<hex>`, from its **node key**
+(`--node-key-file`); the same whatever transport reaches it. An **address** says where
+a node is on one transport (`mycelium:<public key>`); a node proves its node ID at an
+address with WHO and ME ([`peers.md`](peers.md)).
+
+**seed**: something that can be resolved to reach a node (`--seeds`): an address on
+any transport, or a transport's own short form (for Mycelium a public key, an IPv6
+address, or a host name with an AAAA record for one; [`transports.md`](transports.md)).
+
+**state file**: what a node remembers across restarts (`--state-file`): its swarm ID
+and some members to rejoin through. Not the document.
+
+**document file**: a node's document on disk (`--document-file`), with the actor IDs
+it wrote with; the node resumes with it after a restart.
+
+**standing** of a peer, from one node's side (`Standing`):
+* **stranger**: has sent us something, nothing more.
+* **admitted**: proved the swarm key (or there is none).
+* **member**: completed the handshake and belongs to our swarm. Only members sync.
+
+**registered member**: a node listed in the swarm's member registry
+(`_continuum/members/<node ID>`), which every node writes for itself once ready.
+The registry, not the handshake, decides the topology and who votes on remakes. When
+the context doesn't make clear which is meant, say *registered member*.
+
+**neighbour**: a member this node keeps alive and syncs with on its own (keepalives,
+sync openings, relay). Up to `--neighbours` (k) of them, chosen from the registry
+(`swarm::topology`); with at most k + 1 reachable members, everyone is everyone's
+neighbour (**full mesh**). Other members are only answered.
+
+**alias**: the name a node gives itself (`--alias`), published in the registry. Not
+unique, never an identity; the node ID is.
+
+**dormant**: a peer we haven't heard from for 4 × keepalive. It is probed rarely and
+left out of the topology until it answers again.
+
+**stability tier**: what a node judges about itself and publishes in the registry. It
+becomes **stable** once it was connected at least 95 % of a full day, and **transient**
+again below 80 %. Stable members are preferred as neighbours, forming a backbone.
+
+**alive stamp**: the time a node last renewed its registry entry (about daily). Nodes
+renewed within `--electorate-window` form the electorate of a remake.
+
+## The document and sync
+
+**document**: the replicated Automerge document. The store API reads and writes paths
+in it.
+
+**protocol data**: what the nodes keep under `_continuum` in the document: the swarm
+ID, the member registry, forget records, remake records, the votes register and epoch
+metadata. Only users listed in `--protocol-writers` may change it through the store
+API; the swarm ID and epoch metadata never.
+
+**heads**: the hashes of the document's latest changes, those no other change depends
+on. Several heads mean concurrent changes that no later change has merged yet. Two
+nodes with the same heads have the same document.
+
+**opening**: the first sync message to a peer after a reset, before anything has come
+back from it. After `reset_sync()` the Automerge `sync::State` is new, so
+`their_heads`, `their_have` and `their_need` are all unset; an opening can only say
+"these are my heads, here is a Bloom filter of what we share", and carries no changes
+since we don't know yet what the peer has. The code doesn't track openings
+separately: `their_heads.is_none()` means nothing has come from the peer since the
+reset. Who may open towards whom: [`peers.md`](peers.md), Handshake and join.
+
+**path**: an address of a peer on one of this node's transports (`<scheme>:<target>`). A
+peer with several (on several transports) is reached on the best: paths are measured
+with a **PING** each answered by a **PONG**, and a path that fails, or stops answering,
+is passed over for the next.
+
+**keepalive**: what a pair exchanges when it has been quiet for a keepalive interval
+(`--keepalive`): a PING and its PONG, and every third time a **STATUS** and its
+STATUS_REPLY instead, carrying the sender's heads (or a digest of them and some of
+them), epoch and readiness. The two sides of a pair take turns; the one that **leads**
+the pair sends the first.
+
+**initial sync**: a joining node's first complete sync. It's **caught up** once a ready
+peer with non-empty heads has nothing left that it lacks.
+
+**catch-up source**: the one peer a joining node syncs with while catching up, the
+closest by round-trip time first.
+
+**relay**: in a partial mesh, a node announces new heads to its neighbours, which pull
+the changes they lack.
+
+**blob**: bytes too large for one message, fetched in chunks from several peers at once:
+an epoch's snapshot change (for a rejoining node) or a remake's content (for voters).
+
+## Epochs and remakes
+
+**epoch**: one stretch of the document's life, from one remake to the next. Peers on
+different epochs don't sync. Identified by its **generation** and its ID (the hash of its
+snapshot change), shown as `generation:id`.
+
+**generation**: an epoch's counter: the number of remakes since genesis. It orders
+epochs, so a node can tell whether a peer is behind or ahead. Two epochs of the same
+generation (different IDs) can only come from concurrent remakes.
+
+**genesis**: the first epoch (generation 0), and the **genesis node** that started the
+swarm (started with `--genesis`, empty or with content from `--remake`).
+
+**remake**: a coordinated move of the whole swarm to a new epoch. Kinds:
+**compact** (same content, no history), **replace** (new content as JSON),
+**import** (new content as an Automerge document).
+
+**base**: the heads the new epoch's content accounts for. Writes made after them are
+carried over into the new epoch (**replayed**), at their path or where the remake's
+**rules** say: by every node that switches, and by their author when it rejoins. A compaction's base is the coordinator's
+heads when it commits; other kinds fix it when proposed.
+
+**snapshot**: the new epoch's first change, which every node builds itself, byte for
+byte the same; its hash is the epoch ID.
+
+**coordinator**: the node a remake was proposed to. It counts the votes and writes the
+decision. If it is gone for good, another node can **adopt** the remake and become
+its coordinator.
+
+**electorate**: the registered members whose votes count for a remake, fixed when it is
+proposed. The remake commits once a **majority** (the **quorum**: more than half) voted
+for it. Each member has one vote per epoch, kept in the **votes register**
+(`_continuum/votes`); it is free again only if that remake is aborted.
+
+**switch**: a node moving to a committed epoch by building its snapshot itself.
+
+**rejoin**: a node moving to a newer epoch by fetching it from peers instead (it was away,
+or its own build failed).
+
+**replay window**: how far behind a rejoining node may be and still have its own writes
+carried over automatically.
+
+**quarantine**: a node's own writes a remake couldn't place, kept on that node for an
+operator to replay or discard.
+
+**archive**: the documents of previous epochs a node keeps for a while.
diff --git a/docs/lifecycle.md b/docs/lifecycle.md
new file mode 100644
index 0000000..9fe9442
--- /dev/null
+++ b/docs/lifecycle.md
@@ -0,0 +1,238 @@
+# The life of a node
+
+A node passes through a few phases between starting and stopping. This page says what
+each phase is for, how a node enters and leaves it, and what it does and doesn't do
+meanwhile. The protocol between peers is in [`peers.md`](peers.md), remakes in [`remakes.md`](remakes.md); this
+page refers to them rather than repeating them.
+
+```mermaid
+stateDiagram-v2
+ direction LR
+ [*] --> Starting
+ Starting --> Ready: --genesis, --recover-from,<br/>or a document file holding a document
+ Starting --> Joining: otherwise
+ Joining --> Ready: swarm ID known, initial sync done,<br/>no rejoin running
+ Ready --> Rejoining: enough ready members<br/>advertise a newer epoch
+ Rejoining --> Ready: epoch verified, own writes<br/>replayed or quarantined
+ Rejoining --> Ready: abandoned - old document restored
+ Ready --> Stopping: SIGINT, SIGTERM,<br/>or a transport stops
+ Joining --> Stopping
+ Stopping --> [*]
+```
+
+In the code there are two lifecycle states, `Joining` and `Ready` (`NodeLifecycle`);
+rejoining is `Joining` with the swarm ID kept, and starting and stopping are what
+[`main.rs`](../src/main.rs) does before and after. The phases below are the operator's view.
+
+| Phase | Reads | Writes | Registers, votes, syncs as a source | Files written |
+|---|---|---|---|---|
+| Starting | — | — | no | — |
+| Joining | `503` (`GET /store` root: the live, partial document) | `503` | no | — |
+| Ready | live | yes | yes | state file, document file |
+| Rejoining | the pre-rejoin document, with `x-continuum-stale` | into the pre-rejoin document, carried over at the end | no | document file (the pre-rejoin document) |
+| Stopping | draining | draining | no | state file, document file |
+
+`/health`, `/peers`, `/admin/stats`, `/admin/config` and `/admin/log` answer in every
+phase, and `/ready` says `200` only when ready (both without authentication); the
+remake endpoints and `GET /admin/document` need a ready node.
+
+## Starting
+
+What happens before the node talks to anyone, in order:
+
+1. **Configuration** is loaded from defaults, files and arguments ([`configuration.md`](configuration.md)).
+ Contradictions are errors; `--print-config` shows the result and exits.
+2. **The node key** (`--node-key-file`) and **the swarm key** (`--swarm-key-file`) are
+ read, so a bad file fails at once. A missing key file is an error, unless
+ `--node-key-create` asks for a new node key (a new node), or `--swarm-key-create`
+ for a new swarm key (on the swarm's first node; the others need a copy).
+3. **The state file** (`--state-file`) is read: the swarm ID the node belongs to and
+ members to reach it through.
+4. **A document to start on**, if any: the document file (`--document-file`) or a
+ backup (`--recover-from`). It names the swarm and the epoch the node is on. A new
+ swarm's document is made here too (`--genesis --remake KIND:PATH`).
+5. **The transports** (those enabled), in order: for Mycelium,
+ the node connects to a running daemon (`--mycelium-rest-url`) or starts and
+ supervises one; Tor bootstraps arti. A signal stops the node while they start.
+6. **The workers** start: message consumer, sync, peers, remakes.
+7. **The HTTP API** listens (`--bind-addr`), also while the node joins (the table
+ above says what it answers then).
+
+Then the node is either ready at once or joining:
+
+| Start | Without a remembered swarm | With one (state file) |
+|---|---|---|
+| `--genesis` | a new swarm, ready at once | that swarm again, empty; nodes of it still running merge with this one |
+| document file holding a document | ready at once, on the document's swarm and epoch | the same; the two must name the same swarm |
+| `--recover-from` | ready at once, on the backup's swarm and epoch | the same; the two must name the same swarm |
+| seeds, or none | joining through the seeds | joining that swarm, through the seeds and the remembered members |
+
+Conflicts between these inputs are errors unless overridden with `--force`
+([`configuration.md`](configuration.md), "Forcing"): `swarmId` (the document names another swarm than the
+state file remembers), `badDocumentFile` (the file can't be read: moved aside, start
+without it), `replaceDocumentFile` (`--recover-from` while the document file holds a
+document: the backup wins). `--genesis` with seeds, with `--recover-from`, or with a
+document file holding a document is an error with no override.
+
+### What the files are for
+
+* **The state file** holds the swarm ID and the addresses of up to 16 members alive
+ within the electorate window (stable ones first), and the members' aliases, so the
+ logs name members by them from the start (perhaps stale; a change is logged once the
+ member list says so; `--log-raw-ids` leaves IDs as they are). With it, a restarted node joins only its own swarm (its
+ HELLOs claim the remembered ID) and finds it without configured seeds. Without it,
+ a node remembers nothing, and `--genesis` on every start makes another new swarm
+ while the old one's nodes go on without it. The file is written when the node becomes
+ ready, when the remembered members change (checked every 5 minutes, written at most
+ hourly), and at shutdown; atomically; an unreadable one is moved aside (`.bad`).
+* **The document file** holds the document itself and the actor IDs this node wrote
+ with, so a restart resumes with the data and a later rejoin still replays the node's
+ own writes. It is appended to every `--document-append` (default 5s) and
+ rewritten after an epoch change, a restart, or when the appended part outgrew the
+ full save (at most every `--document-rewrite`, default 1h); a record cut short
+ by a crash is dropped when reading. A node starting from it is ready at once; sync
+ merges what it missed, or it rejoins a newer epoch.
+* **A backup** (`GET /admin/document`) is the whole document with its history, naming
+ its swarm and epoch. `--recover-from` starts a node on it; `--genesis --remake
+ import:FILE` starts a *new* swarm with its content; an `import` remake brings its
+ content into a running swarm ([`remakes.md`](remakes.md)).
+
+### A whole swarm restarting
+
+If every node restarts without document files, none has any data: they all join, wait
+for each other, and none becomes ready. Start one with `--genesis` (with a state file:
+the same swarm again, empty) or with `--recover-from` and a backup; the others join
+it. Nodes with document files are ready at once and merge with each other.
+
+## Joining
+
+The node knows no swarm (or only the remembered one) and has no document. It resolves
+its seeds and says HELLO; the first HELLO_REPLY tells it the swarm ID and other peers
+to greet. It then catches up: it syncs with one peer only, the *catch-up source*, so
+that not every peer sends it the whole document at once; the others wait and are
+answered once the initial sync is done ([`peers.md`](peers.md), "Handshake and join", says how
+the source is chosen and given up).
+
+A joining node never gives up: seeds that don't resolve are retried with backoff,
+unanswered HELLOs are resent, and every five minutes it logs why it is still joining.
+A node without seeds and without `--genesis` has nothing to join and waits (seeds may
+be added later).
+
+If the swarm has moved on to a newer epoch than the node's (a new node starts at
+genesis), the node rejoins while still joining: it downloads that epoch's snapshot
+first, then catches up. Bootstrap waits for the rejoin, so the node becomes ready only
+after the epoch check.
+
+The node becomes **ready** when it knows the swarm ID, its initial sync is done (a
+ready peer with a non-empty document has nothing left that the node lacks) and no
+rejoin is running.
+
+## Ready
+
+The normal phase. The node:
+
+* serves reads and writes, and syncs with its neighbours ([`peers.md`](peers.md), "Member
+ registry and neighbours"); its own writes go out to them, others' changes arrive by
+ sync and relay;
+* **registers** in the member registry, renews its alive stamp daily, and publishes
+ its stability tier;
+* takes part in **remakes**: votes once it holds the content, coordinates those
+ proposed to it, and **switches** when one commits ([`remakes.md`](remakes.md)). A switch is a
+ step inside this phase, not a phase of its own: after the switch delay the node
+ builds the new document, verifies the epoch, replays the writes since the base into
+ it and swaps documents under the lock. Sync with every peer restarts; peers still
+ on the old epoch are told by a STATUS;
+* judges its peers' liveness (dormancy), heals diverged sessions, and answers anyone;
+* keeps its files up to date (above).
+
+## Rejoining
+
+Entered when enough active, ready members (two if this node was ready, one if it
+never was) advertise the same newer epoch, at most 1000 generations ahead: the node
+missed a switch (it was away, or its own build didn't match). With several epochs of
+the newest generation advertised (two remakes committed for one epoch; [`remakes.md`](remakes.md),
+Limits), it follows the one most of them are on (of those, the lowest ID). A ready node
+also rejoins **sideways**, to another epoch of its own generation, when more ready
+members are on it than on its own (counting itself; two at least; on a tie, the lower
+ID): its own writes on the epoch it leaves all go to quarantine (step 5). What happens:
+
+1. at once (under the document's lock), the node swaps in an empty document labelled
+ with the target epoch, which takes no writes; sync with every peer resets; and a
+ ready node **suspends** (`Joining` again, swarm ID kept) and keeps its document as
+ the **stale view**: reads are answered from it, marked `x-continuum-stale`, and
+ writes go into it, answered as usual (and appended to the document file); a node
+ that wasn't ready refuses writes (`503`) as before;
+2. it doesn't sync the stale view with anyone: what it takes is carried over at the
+ end (step 5);
+3. it **downloads the epoch's snapshot** in chunks from ready members on that epoch
+ ([`swarm/blob.rs`](../src/swarm/blob.rs)), then ordinary sync adds the changes made since, from one source
+ as when joining;
+4. once caught up, it **verifies** that the document starts with the target epoch's
+ snapshot change and that the metadata matches;
+5. it decides about **its own writes** the swarm never saw: those made before the
+ rejoin are replayed (at their path, or where the remake's rules say) if within the
+ replay window of the first remake it missed, the rest go to quarantine for an
+ operator ([`remakes.md`](remakes.md), "Quarantine"); those made during the rejoin are replayed
+ whatever the window says. Either way, a write is left out where newer writes
+ touched its path (last writer wins, [`remakes.md`](remakes.md), "Writes around a remake");
+6. at once (under the document's lock, with step 5's replay), the node **resumes**,
+ drops the stale view and takes writes in the live document again; the old document
+ goes to the **archive** (kept as the remake's retention says).
+
+If no peer completes a sync within `max(dormant_after, 60 s)`, or the check in step 4
+fails, the rejoin is **abandoned**: the old document, with the writes it took
+meanwhile, and its epoch are restored, the node resumes (at once, under the document's
+lock), and tries again 30 s later. `/admin/epochs` shows the attempt and any error.
+
+```mermaid
+flowchart TD
+ A[Active, ready members advertise a newer epoch<br/>at most 1000 generations ahead] --> A2{Enough of them for one epoch?<br/>2 if we are ready, or all our active members if fewer;<br/>else 1}
+ A2 -- no --> S{Ready, and more ready members on another<br/>epoch of our generation than on ours?}
+ S -- no --> X[Log and wait]
+ S -- yes --> C
+ A2 -- yes --> B[Target: the newest such epoch;<br/>of several in one generation, the one most of them are on]
+ B --> C{Retry pause over?<br/>30 s after a failure}
+ C -- no --> X
+ C -- yes --> F[At once: swap in an empty document labelled with the target epoch;<br/>if ready: suspend, the old document becomes the stale view, taking writes;<br/>reset sync with all peers, announce epoch:<br/>sync opening to ready peers on it, STATUS to the rest]
+ F --> F2[Download the snapshot change in chunks<br/>from ready members on the epoch; sync waits]
+ F2 --> G[Ordinary sync adds the changes since<br/>or the whole epoch if the download failed]
+ G --> H{Initial sync done?}
+ H -- no --> T{Longer than max of dormant_after and 60 s?}
+ T -- no --> G
+ T -- yes --> Z
+ H -- yes --> I{Snapshot change of the target present?}
+ I -- no --> G
+ I -- yes --> J{Snapshot metadata valid?}
+ J -- no --> Z[Abandon: restore old document and epoch,<br/>resume, retry later]
+ J -- yes --> K{Own changes missing from<br/>the epoch after ours?}
+ K -- none --> W
+ K -- "some, after a sideways rejoin" --> Q
+ K -- some --> L{Within that epoch's replay window?<br/>gap in epochs or time since proposal}
+ L -- yes --> R[Replay them, last writer wins]
+ L -- no, or unknown --> Q[Quarantine: /admin/quarantine]
+ R --> W
+ Q --> W[At once: replay the writes made during the rejoin,<br/>resume, drop the stale view, take writes again]
+ W --> O[Archive old document per retention,<br/>unless the quarantine keeps it]
+```
+
+## Stopping
+
+On SIGINT or SIGTERM (or when a transport stops: a supervised Mycelium daemon exits,
+or the libp2p or Tor transport ends; `A transport stopped`, error), the node stops
+accepting HTTP connections and drains the open ones (the dashboard's event streams end
+with an `offline` event; from then on its keepalives say it is leaving), sends its neighbours a farewell: a last STATUS saying it isn't
+ready and is leaving (one catching up from it picks another source, and each holds it
+dormant at once rather than after `dormant_after`; at most 2 s), writes the state file
+if the
+remembered members changed, appends what the document file lacks, and exits. A second
+signal exits at once. Nothing else is saved: the archive, the quarantine and all
+measurements are gone. A node that stops in the middle of a rejoin loses nothing its
+peers don't have, and starts the rejoin over when it returns.
+
+## Other phase machines
+
+* **A remake** goes `proposed` → `committed` or `aborted`, or `expired` when its
+ coordinator is gone: [`remakes.md`](remakes.md).
+* **A peer**, seen from one node, has a standing (stranger, admitted, member), a
+ liveness (unknown, active, dormant) and a handshake state: [`peers.md`](peers.md), "Liveness, dormancy
+ and HELLO state".
diff --git a/docs/measurements.md b/docs/measurements.md
new file mode 100644
index 0000000..2adc0f5
--- /dev/null
+++ b/docs/measurements.md
@@ -0,0 +1,299 @@
+# Measurements
+
+What a node measures about itself and its peers, where to read it, and what the
+numbers mean. Everything here comes from traffic and work the node has anyway; it
+sends no probes of its own and nothing is measured by anyone else, but for the
+dashboard's map (LINKS, only while someone looks at it).
+
+Four places to read:
+
+* `GET /admin/stats`: the node's figures as JSON, or as Prometheus text when asked
+ for it (`Accept: text/plain` or `application/openmetrics-text`). Prometheus names
+ are `continuum_…`; per-peer series carry a `peer` label (the node ID) and can be
+ joined with `continuum_peer_info` for the alias.
+* `GET /peers`: one entry per known peer, keyed by its node ID, with the per-peer
+ figures.
+* The log: incidents as they happen (a peer going dormant, a heal, a stalled catch-up),
+ at warn or info; `PUT /admin/log` changes the filter at runtime.
+* `GET /admin/dashboard` (builds with the `dashboard` cargo feature): a page with the
+ two above and a map of the swarm, live ([`dashboard.md`](dashboard.md)).
+
+All counters count since the process started; a restart resets them. Durations are
+whole seconds in JSON unless named `…Ms`; Prometheus has seconds throughout.
+
+## About the node
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `uptimeSecs` | `uptime_seconds` | Since the process started. |
+| `ready` | `ready` | The node holds the swarm's document and serves writes ([`lifecycle.md`](lifecycle.md)). |
+| `epoch` | `epoch_generation` | The local epoch; the generation counts remakes since genesis. |
+| `staleHelloReplies` | `stale_hello_replies_total` | HELLO_REPLYs to a HELLO from an earlier run of this node, ignored. A few after every restart are normal (a transport queued them, Mycelium's above all); a steady stream means a peer keeps answering an old HELLO. |
+
+## The document
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `document.heads` | `document_heads` | Heads of the local document now. 1 means every change has been merged into one line of history; more means concurrent writes not yet followed by a merging write. |
+| `document.maxHeads` | `document_max_heads` | The most heads seen right after applying a sync message. A high value shows how concurrent the swarm's writing gets; it also bounds keepalive size (above 32 heads, keepalives carry a digest and 32 of them). |
+| `document.savedBytes` | `document_saved_bytes` | The document as Automerge saves it (compressed): roughly what a joining node downloads and what a document file rewrite writes. Measured in the background, only after a change, at least a minute apart and at most 1% of the time (a document that takes 2 s to save is measured every 200 s at most); an export (`GET /admin/document`) counts as a measurement. `savedBytesSecsAgo` and `savedBytesTookMs` say how fresh and how costly. |
+| `document.lockWait.read`, `.write`: `count`, `sumUs`, `maxUs`, `buckets[]` (`leUs`) | `document_lock_wait_seconds{mode}` (histogram), `document_lock_wait_max_seconds{mode}` | How long takers of the document's lock waited for it, since start, by mode. Readers (the API's reads, topology, stats, generating sync messages, serving changes and deltas, saves) wait only for a writer; writers (applying sync messages and fetched changes, writes, epoch switches) wait for everyone. Buckets from 10 µs to 10 s, the last unbounded; not cumulative in JSON. |
+
+**Interpretation.** `savedBytes` growing without bound is the reason to compact
+([`remakes.md`](remakes.md)); `savedBytesTookMs` is a direct measure of what a switch, a rejoin and a
+document file rewrite cost in CPU.
+
+The node has one document, and nearly all work on it takes its lock, so `lockWait` says
+whether more cores would help: waits of a few microseconds mean the lock is free when
+asked for; write waits in the milliseconds (`sumUs` / `count`), growing with the number
+of neighbours, mean applying one peer's sync messages queues behind the others'. A
+high `maxUs` alone is a single long hold (a large sync message, a remake), not
+contention.
+
+## Catching up
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `catchUp.sources` | `catch_up_sources_total` | Catch-up sources chosen. 1 is normal; more means the first source was given up (quiet, not ready, refused, or stalled) and another took over. |
+| `catchUp.doneAfterSecs` | `catch_up_duration_seconds` | From start until the initial sync was done; absent until then, and on a node that was ready at once (genesis, document file, recovery). |
+| `catchUp.syncBytes` | `catch_up_sync_bytes` | Sync payload received until then, from every peer: what joining cost. |
+
+Compare `syncBytes` with `savedBytes`: much more received than the document is large
+means the node got the document from several peers before the source rule took hold,
+or caught up over many small messages.
+
+## Remakes
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `remakes.undecided` | `remakes_undecided` | Remakes of the local epoch not decided yet, expired ones included (`GET /admin/remakes`). |
+| `remakes.committed` | `remakes_committed` | Committed remakes of the local epoch: 1 for the moment between a commit and the switch. More than one is a double commit, on which no node switches by itself: alert on it. |
+| `remakes.rejoining` | `rejoining` | The node syncs a newer epoch from scratch ([`lifecycle.md`](lifecycle.md)). |
+| `remakes.catchUpAttempts` | `catch_up_attempts` | Attempts at catching up with a newer epoch; 0 when not catching up. A growing count means the node can't get the epoch its peers advertise. |
+| `remakes.quarantined`, `remakes.oldestQuarantineSecs` | `quarantined`, `oldest_quarantine_seconds` | Change sets set aside on a rejoin (`GET /admin/quarantine`). They live in memory only, so a restart loses them: alerting is how to learn of them. |
+| `remakes.archivedEpochs` | `archived_epochs` | Old epochs' documents kept in memory (`archiveRetention`). |
+
+## Sync and relay
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `sync.lastFromNeighbourSecs` | `last_sync_from_neighbour_seconds` | Since a sync message from any neighbour was applied. Growing without bound while others write: the node is cut off from the swarm's changes. |
+| `sync.divergedPeers` | `diverged_peers` | Peers whose heads differ from ours with no sync message in between. |
+| `sync.largestReceivedBytes` | `largest_sync_received_bytes` | The largest sync message received since start. Larger transfers come as deltas (blobs of 16 chunks or more, [`peers.md`](peers.md)), so this stays below 16 × `--blob-chunk` from nodes of this version. |
+| `relay.announceRounds` | `announce_rounds_total` | Announcement rounds: our new heads to the neighbours after applying their changes (partial mesh only). |
+| `relay.pulls` | `pulls_total` | Pulls of changes we lacked from a neighbour that announced them. |
+| `relay.pullsQueued` | `pulls_queued_total` | Announcements of heads we lacked that waited for the pull in progress. Many compared with `pulls`: changes arrive from several sides at once. |
+| `relay.blobRequestsDropped` | `blob_requests_dropped_total` | Blob requests not answered because too many were being answered; the requester asks again or asks another peer. Also logged as a warning, at most once a minute with the count. |
+
+## Topology
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `topology.k` | `neighbours_target` | `--neighbours`. |
+| `topology.view.members` | `members` | Registered members, this node included. |
+| `topology.view.reachable` | `reachable_members` | Members other than this node not judged unreachable (three unanswered HELLOs, or dormant). |
+| `topology.view.stable` | `stable_members` | Members whose registry entry says `stable`. |
+| `topology.view.neighbours` | `neighbours` | Current neighbours: peers this node keeps alive and syncs with on its own. At least `k` (links chosen by either end count); well above `k` on a stable member that many others chose, bounded at about 8 × k. |
+| `topology.view.stableNeighbours` | `stable_neighbours` | Of those, stable members. |
+| `topology.view.fullMesh` | `full_mesh` | Every reachable member is a neighbour (at most `k + 1` reachable members). |
+
+`view` is absent until the node is ready and knows the member list; until then
+everyone counts as a neighbour.
+
+**Interpretation.** `members − reachable − 1` is how many members are offline or
+unreachable from here; if it is half or more of the electorate, no remake can commit.
+`neighbours` far above `k` on one node means it is a hub (a stable member among few),
+which is by design but costs it bandwidth.
+
+## Stability
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `stability.tier` | `stable` | What this node published about itself: `stable` once connected at least 95% of a full day, `transient` again below 80%. |
+| `stability.connectedFraction` | `connected_ratio` | Fraction of the observed time (up to 24 h) with at least one live peer. |
+| `stability.observedSecs` | `connectivity_observed_seconds` | How much time the fraction is based on; a day at most. |
+
+A node that restarted keeps a stable tier only if a peer says it was away less than an
+hour; otherwise it starts over with no history, and `observedSecs` says so.
+
+## Propagation
+
+How long changes written elsewhere took to reach this node, from the writer's commit
+stamp to the apply here, for every change in every applied sync message once caught up.
+The apply is timed by the peers' clocks: this node's own offset from them
+(`clock.offsetMs`, Clocks) is taken off.
+A histogram with buckets at 100 ms, 250 ms, 500 ms, 1 s, 2.5 s, 5 s, 10 s, 30 s, 1 min,
+5 min, 1 h, and the rest.
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `propagation.count`, `.sumMs` | `propagation_delay_seconds_count`, `_sum` | For the mean. |
+| `propagation.buckets[]` | `propagation_delay_seconds_bucket{le}` | Per bucket (not cumulative in JSON; cumulative in Prometheus, as histograms are). |
+| `propagation.ahead` | `propagation_clock_ahead_total` | Changes stamped later than their arrival (by the peers' clocks): the writer's clock is ahead. Counted in the first bucket. |
+
+**Interpretation.** The delay includes the writer's clock skew (this node's own is taken
+off), so changes from a writer with a badly set clock show nonsense here; `ahead`
+growing is the sign, and that writer's own `clock.off` ([`trust.md`](trust.md), "What depends on
+clocks"). Writers aren't known by node (Automerge's actors are per run), so their skew
+can't be taken off too. With sane clocks: in a full mesh most
+changes land in the first buckets; in a partial mesh each hop adds about a round trip
+plus the 200 ms announcement delay. Mass in the minutes buckets means changes arrive by
+keepalive-triggered pulls rather than relay, i.e. announcements are being lost.
+
+## Clocks
+
+Each PONG says when its sender answered, by its clock, so every peer's clock is known
+to within half a round trip (`clock` per peer, in Per peer). This node's own clock is
+the odd one out when it differs from most of its active peers' by more than
+`--clock-skew-warn` (2 s by default), for sure: beyond their bounds.
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `clock.peers` | `clock_peers_compared` | Active peers whose clocks were compared (a PONG from them lately). |
+| `clock.offsetMs` | `clock_offset_seconds` | How far this node's clock is ahead of its peers' (behind if negative): the median over them. Absent with none compared. |
+| `clock.behind`, `clock.ahead` | — | Peers whose clocks are behind ours, and ahead, by more than `--clock-skew-warn` for sure. |
+| `clock.off` | `clock_off` | Whether this node's clock is off: more than half of them differ from it beyond the limit, the same way. |
+| `clock.limitMs` | — | The limit (`--clock-skew-warn`). |
+
+**Interpretation.** A peer or two `ahead` or `behind` are their clocks, which they warn
+about themselves; `off` is ours. While it is, the log warns every 10 minutes, and says
+when it agrees again, and `/health` says so in a second line (still `200`). A clock set
+anew (by NTP, a resumed machine, by hand) by more than the limit shows as a jump in the
+log: this node's (a warning) when most peers' clocks moved alike, else the peers' whose
+did. Two nodes alone can't tell whose clock is off: both warn. Clocks matter for alive
+stamps, remake deadlines and the propagation figures ([`trust.md`](trust.md), "What depends on
+clocks"); setting them is the operating system's job (NTP).
+
+## Traffic
+
+Messages and payload bytes by direction (`sent`, `received`, `refused`) and kind
+(`hello`, `helloReply`, `sync`, `status`, `statusReply`, `blob`, `identity` for WHO and
+ME, counted under the address they went to or came from, so only in the totals, and
+`paths` for PING and PONG: per peer, but the paths layer's own PINGs under the address,
+so only in the totals). Payload bytes are what a transport's message carries (the wire
+encoding): not the transport's framing (for Mycelium, its topic and the base64 of its
+HTTP API). `refused` is what the transports wouldn't accept to send (no path to the
+peer), not counted as sent.
+
+| JSON | Prometheus | Scope |
+|---|---|---|
+| `traffic` in `/admin/stats` | `messages_total{direction,kind}`, `payload_bytes_total{direction,kind}` | All peers, including ones no longer listed. |
+| `traffic` per entry in `/peers` | `peer_messages_total{peer,direction,kind}`, `peer_payload_bytes_total{peer,…}` | One peer. |
+
+**Interpretation.** Keepalives (`paths` for PINGs and PONGs, every third one `status` + `statusReply`) should be about two per neighbour per
+keepalive interval; much more means announcements (relay) or heals. `sync` bytes
+dominate while catching up and on big writes; a steady `sync` stream on an idle swarm
+means two peers disagree and keep healing. `refused` growing for one peer means no
+transport has a path to it while we still consider it a neighbour.
+
+## Transports
+
+How the transports find and reach peers, under `transports` in `/admin/stats`; each
+part is absent (`null`) where it doesn't apply.
+
+The identity layer (`transport::identity`, [`peers.md`](peers.md)), under `transports.identity`:
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `proven` | `identity_proven_nodes{transport}` | Nodes proven at an address now, by transport; a node on several counts on each. |
+| `relayed` | `identity_relayed_nodes{transport}` | Of those, the nodes the transport reaches only through a relay (libp2p), by transport; in JSON absent where none is. Many means hole punching doesn't get through (`libp2p.holePunches`). |
+| `hinted` | `identity_hinted_nodes` | Nodes known only from the member list or HELLO_REPLYs, proven nowhere yet. |
+| `proofsRejected` | `identity_proofs_rejected_total` | WHO and ME that didn't hold: a bad signature, or made for other addresses. Normally 0; anything else is someone replaying or forging proofs. |
+| `addressChanges` | `identity_address_changes_total` | Nodes that proved themselves at another address on a transport than before: a restart with a new libp2p key or Mycelium key. Frequent ones mean a node doesn't keep its transport key (`--libp2p-key-file`, `--mycelium-key-file`). |
+| `droppedUnproven` | `identity_dropped_unproven_total` | Messages dropped because no node proved itself at their address (the address is asked WHO). Some after a restart are normal; a steady stream means something keeps sending from an address that never proves itself. |
+| `refusedUnproven` | `identity_refused_unproven_total` | Sends refused because the peer hadn't proven where it is yet; retried once it has. |
+| `seedsUnanswered` | `identity_seeds_unanswered_total` | Seeds at which no node answered WHO in time. |
+
+The paths layer (`transport::paths`, [`peers.md`](peers.md)), under `transports.paths`: the
+paths to peers, measured by PINGs.
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `paths[]` (`address`, `rttMs`, `down`, `connection`) | — | Every path measured: those of peers this node chose among or sent to in the last 5 minutes, and every path its keepalive PINGs went over (a peer's only path too). |
+| `probesSent`, `probesAnswered`, `probesUnanswered` | `paths_probes_total{result}` | PINGs, the keepalive's and every 30 s (or every keepalive interval, if that is longer) per path in use that no keepalive PING went over in that time (a busy pair needs no keepalives, so its paths would go unmeasured), and their PONGs. Unanswered ones (no PONG within 15 s) on one path mean it doesn't deliver; three in a row make it down. |
+| `sendFailures` | `paths_send_failures_total` | Sends that failed on a path; each is tried at once at the peer's next path. |
+| `failovers` | `paths_failovers_total` | Sends that went over another path after one failed. |
+| `switches` | `paths_switches_total` | Changes of the path in use for one faster by a quarter (and 5 ms). Frequent ones mean two paths about as fast, with changing delays. |
+
+Per peer, in `/peers` (`destination.paths`), and as `peer_path_rtt_seconds{peer,transport}`
+and `peer_path_down{peer,transport}`: each path to it, best first. A path's `address` is
+where it goes now: for libp2p, the remote address of its connection, ending in the peer ID
+(just the peer ID while there is none); a Mycelium public key; an onion service.
+
+libp2p (`--libp2p-enable`), under `transports.libp2p`:
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `reachability`, `publicAddress` | `libp2p_reachability{status}` | What AutoNAT says: `unknown`, `private` (others can't dial this node: it needs a relay) or `public`, with the address it confirmed. |
+| `connections.direct`, `.relayed` | `libp2p_connections{kind}` | Open connections now, direct or through a relay. |
+| `relaySlots.held` | `libp2p_relay_slots` | Slots held on relays now, to be reachable through them. |
+| `relaySlots.paused` | `libp2p_relays_paused` | Relays not picked again for a while: 10 minutes after refusing a slot, 30 seconds after not granting it otherwise. |
+| `relaySlots.granted`, `.lost`, `.notGranted` | `libp2p_relay_slot_events_total{event}` | Slots granted (renewals not counted), lost after being granted (not when one moves: reserved again at once over the connection kept, as a duplicate connection to the relay closed), and asked for but never granted. `notGranted` growing: a relay is unreachable, or another peer answers at its address (a relay restarted without its libp2p key). |
+| `holePunches.succeeded`, `.failed` | `libp2p_hole_punches_total{result}` | Direct connections tried over a relayed one (DCUtR). A failure leaves the relay carrying the traffic. |
+| `dialFailures` | `libp2p_dial_failures_total{cause}` | Connections that couldn't be made: `unreachable` (refused, timed out, no route), `wrongPeerId` (another peer at the address: one restarted without its libp2p key, or a NATed peer's LAN address in a range ours uses too; that address isn't taken for the peer again), `noAddresses`, `other`. |
+
+A relay that is no node (`--relay-only`) has `/admin/stats` too: its `peerId`, its
+`addresses` (as `libp2p:` addresses, ready for `_continuum/relays`), `uptimeSecs`, and
+`transports.libp2p` as above (Prometheus: `relay_info{peer_id}`, `uptime_seconds` and
+the `libp2p_…` series).
+
+**Interpretation.** A node that should be dialable but shows `private` has a port
+closed or not forwarded. `relayed` connections that stay while `holePunches.failed`
+grows means the relays carry the traffic for good: fine within their limits, but it
+costs the relay. `wrongPeerId` and `addressChanges` together point at a node restarted
+without its keys.
+
+## Incidents
+
+Counted per peer, summed in `/admin/stats` over the peers currently listed (a
+forgotten peer's are gone).
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `incidents.replyTimeouts` | `peer_reply_timeouts_total` | Our sync messages the peer didn't answer in time (10 s, 30 s with Tor enabled, doubling to 30 times that); resent. Lost messages, or a slow peer. |
+| `incidents.heals` | `peer_heals_total` | Re-handshakes because our heads and the peer's differed for `resync_after` with no sync between ([`peers.md`](peers.md), "Keepalive and self-healing"). Rare in a healthy swarm; frequent means sync state keeps getting out of step. |
+| `incidents.dormancies` | `peer_dormancies_total` | Times the peer went dormant (nothing received for `dormant_after`). |
+| `incidents.dormantMs` | `peer_dormant_seconds_total` | Time it spent dormant, up to its last return. |
+
+## Per peer
+
+In `/peers`, and as `peer_…` series with a `peer` label:
+
+| JSON | Prometheus | Meaning |
+|---|---|---|
+| `status`, `dormantForSecs` | `peer_unknown`, `peer_dormant` | Unknown (nothing heard from it since this node started: known from the member list or another peer), active, or dormant, and for how long. |
+| `lastSeenSecsAgo` | `peer_last_seen_seconds` | Since any message from it. |
+| `neighbour` | `peer_neighbour` | Whether we initiate towards it. |
+| `destination` | `peer_destination_info{transport,proven,connection}` | Where messages to it go: the address (its transport), whether it proved itself there (else sends wait for its proof), and the connection there: libp2p's `direct` or `relayed`, Tor's `direct` (an onion circuit); none on Mycelium; while `relayed` after a failed hole punch, `punching`: punches failed in a row (`failed`) and when it's tried again (`nextInSecs`). |
+| `destination.paths[].rttMs`, `.down`, `.connection` | `peer_path_rtt_seconds{transport}`, `peer_path_down{transport}` | Every path to it, best first: its round-trip time (smoothed, from PINGs), whether it is down, and the transport's connection there (`direct` or `relayed`, where it has connections). The first is the one in use: the peer's round-trip time. |
+| `lastSyncSecsAgo` | `peer_last_sync_seconds` | Since a sync message from it was applied (or the epoch changed). |
+| `divergedForSecs` | `peer_diverged_seconds` | How long its heads have differed from ours with no sync message from it in between; absent while they match. Long values precede a healing re-handshake ([`peers.md`](peers.md)). |
+| `syncThroughput` | `peer_sync_throughput_bytes_per_second` | Sync bytes per second over exchanges of at least 32 KiB, round trip taken off, smoothed. Absent before the first large exchange; comes with its age (`secsAgo`) since large exchanges are rare. This is the rate the peer delivers at in practice (its apply time included), not the path's capacity. |
+| `clock.offsetMs`, `.withinMs` | `peer_clock_offset_seconds`, `peer_clock_offset_within_seconds` | How far its clock is ahead of ours (behind if negative), give or take. Each PONG says when the peer answered, by its clock, which lies between our PING going and the PONG coming: within half the round trip of its middle, however unevenly the two ways took. Of its paths and the last 5 minutes, the narrowest bound is kept; a sample it contradicts (a clock set anew) replaces it at once. Absent before a PONG from it. |
+| `epoch`, `sameEpoch`, `heads` | — | What it last advertised; `sameEpoch: false` means we don't sync with it. |
+| `handshakeComplete`, `hasPendingDelivery` | — | Session state, for debugging. |
+
+## Disk
+
+Per kind of the node's own files (`state`, `document`, `backup`, `remake`), since
+start: `reads`, `readBytes`, `writes` (whole files), `writtenBytes`, `appends`,
+`appendedBytes`, `errors`. Prometheus: `disk_…_total{file}`. The document file is the
+only one written steadily: `appendedBytes` over time is the write rate it costs the
+device; a rising `writes` count there means rewrites because the appended part keeps
+outgrowing the full save (busy swarm, or a `--document-rewrite` set low).
+
+## The dashboard
+
+In builds with the `dashboard` cargo feature, `GET /admin/dashboard` is a page showing
+`/admin/stats`, `/peers` and a map of the swarm, live: [`dashboard.md`](dashboard.md), and how it works
+underneath (events, LINKS) at its end. Its LINKS are the one thing a node sends only to
+measure: the links every member reports, while someone looks.
+
+## What is not measured
+
+* Nothing about other nodes but what they send us: no probes, no remote figures (but
+ the dashboard's LINKS).
+* Clock skew between nodes that aren't each other's peers: only peers' clocks are
+ compared (`clock`, per peer).
+* Remake durations and outcomes: the remake reports (`GET /admin/remakes`) hold them.
+* Mycelium's own routing, retransmissions and queueing: the daemon's API has them.
diff --git a/docs/operations.md b/docs/operations.md
new file mode 100644
index 0000000..fe09537
--- /dev/null
+++ b/docs/operations.md
@@ -0,0 +1,149 @@
+# Operations
+
+Running a swarm from day to day: adding and removing nodes, keys, backups, upgrades,
+what to alert on. How each part works is in the pages linked; this one says what to do.
+
+## Before the first node
+
+Settle on these, as they are hard to change later:
+
+* **A swarm key** (`--swarm-key-file`, made with `--swarm-key-create` on the first node,
+ copied to the others). Without one, any node that can reach a member may join
+ ([`trust.md`](trust.md)).
+* **A node key per node** (`--node-key-file`, `--node-key-create` on its first start).
+ The node key is the node: a new one is a new member.
+* **A state file and a document file per node** (`--state-file`, `--document-file`).
+ With them a node restarts on its swarm, with its data, ready at once; without them it
+ starts empty and has to join again ([`lifecycle.md`](lifecycle.md), What the files are for).
+* **The transports' keys** (`--mycelium-key-file`, `--libp2p-key-file`,
+ `--tor-state-dir`), so a node's addresses stay the same across restarts
+ ([`transports.md`](transports.md), Keys).
+* **A supervisor** that restarts the node: it stops when a transport stops (`A
+ transport stopped`).
+
+## Adding a node
+
+Give it the swarm key, a node key to create, its files, and a seed: any member's
+address (`addresses` in `GET /admin/stats` there), or nothing on a LAN with libp2p's
+`--libp2p-mdns`. It joins: `503` from the store until it is ready (`/ready` says
+`200` then), with `Still joining the swarm; trying on` in the log every five minutes
+while it isn't, saying why. Once ready it registers itself in the member list, and the
+others make it a neighbour where the topology says so ([`peers.md`](peers.md)).
+
+## Removing a node
+
+Stop it, then forget it from any node: `DELETE /peers/{node ID}`. That writes a
+swarm-wide forget record and removes its registry entry; every node drops it. A node
+that is forgotten but still alive cancels the record with its next HELLO and registers
+again, so stop it first.
+
+A node stopped but not forgotten stays a member: it is probed now and then, and it
+counts in the electorate of remakes until its alive stamp is older than
+`--electorate-window` (7 days by default; [`remakes.md`](remakes.md), Electorate and majority).
+Several of them can keep a remake from reaching a majority: forget those that won't
+come back.
+
+## Keys
+
+**The swarm key** can't be changed while the swarm runs: a node with another key is a
+stranger to the others (`Peer no longer proves the swarm key; dropping its
+membership`). Stop every node, give each the new key, and start them; with document
+files they resume with their data. Nodes started one by one with the new key form a
+second group that the old ones don't talk to, until all have it; they merge then, as
+both sides kept writing.
+
+**A node key** is the node's identity: a new one makes a new member. Start the node
+with the new key (its document file can stay: its data is the swarm's), and forget the
+old node ID.
+
+**A transport key** (Mycelium's, libp2p's, Tor's) only moves the node's address on
+that transport: peers learn the new one from its registry entry and the identity layer
+(`addressChanges` in the transports' figures, [`measurements.md`](measurements.md)). Seeds given as the old
+address go stale.
+
+## Backups
+
+`GET /admin/document` on a ready node exports the whole document with its history,
+naming its swarm and epoch ([`openapi.yaml`](openapi.yaml)). Every node has all the data, so one
+node's export is a backup of the swarm. A document file is a backup too, of its node,
+but it is being written: export instead.
+
+To restore:
+
+* **into a running swarm**: an `import` remake with the backup ([`remakes.md`](remakes.md), Import
+ and restoring a backup), which replaces the content for everyone;
+* **a swarm that is gone**: one node with `--recover-from FILE` (ready at once, on the
+ backup's swarm and epoch), the others joining it;
+* **its content as a new swarm**: `--genesis --remake import:FILE`.
+
+Quarantined writes (`GET /admin/quarantine`) and the archive of old epochs live in
+memory only, and aren't in any backup.
+
+## A whole swarm down
+
+Nodes with document files are ready at once on restart and merge with each other. If
+none has its data any more, they all join and wait for each other, and none becomes
+ready: start one with `--recover-from` and a backup, or with `--genesis` (with its state
+file: the same swarm again, empty), and the others join it ([`lifecycle.md`](lifecycle.md), A whole
+swarm restarting).
+
+## Upgrading
+
+One node at a time, restarting each with its files. What to know:
+
+* **The wire format.** Each change to it bumps a version byte, and nodes ignore
+ messages of another version. Across such a change, upgraded nodes and the others
+ don't talk: the swarm is split until every node is upgraded, both sides go on
+ writing, and they merge then. Mycelium logs it (`Peer speaks another wire version
+ (another build); ignoring its messages`, warn, once per peer); libp2p and Tor at debug
+ only (`Dropping a message that doesn't decode`). Until the first release, assume
+ every upgrade is such a change, and upgrade all nodes close together.
+* **Automerge.** A remake needs every node to build the same snapshot, byte for byte,
+ so the same Automerge version everywhere; a node with another one rejoins instead of
+ switching ([`remakes.md`](remakes.md), Limits). Don't remake while the swarm runs two versions.
+* **The dashboard** reloads by itself once its node serves another version of it.
+
+## What to alert on
+
+From the metrics (`GET /admin/stats` as Prometheus, [`measurements.md`](measurements.md)):
+
+| Metric | Alert when | Means |
+|---|---|---|
+| `continuum_ready` | 0 for longer than a join takes | the node can't join or rejoin (its log says why) |
+| `continuum_clock_off` | 1 | its clock is off from its peers' ([`trust.md`](trust.md), What depends on clocks) |
+| `continuum_remakes_committed` | above 1 | a double commit; no node switches on its own |
+| `continuum_quarantined` | above 0 | writes set aside by a rejoin, lost at the next restart |
+| `continuum_catch_up_attempts` | growing | the node can't get the epoch its peers advertise |
+| `continuum_reachable_members` | at most half of `continuum_members` | no remake can commit |
+| `continuum_peer_heals_total` | growing steadily | sync keeps getting out of step with a peer |
+
+And from the log, at warn or error:
+
+* `A transport stopped`: the node stops;
+* `Still joining the swarm; trying on`: with why (seeds unresolved, peers' states);
+* `Several remakes committed for this epoch; not switching on my own`, `Decisions on
+ this remake disagree; not switching on my own`, `Peers disagree about the newest
+ epoch; following the one most of them are on`, `More peers are on another epoch of
+ this generation than on ours`: two remakes committed for one epoch; look at the
+ quarantines that follow ([`remakes.md`](remakes.md), Troubleshooting);
+* `Quarantined writes the remake has no place for`;
+* `Couldn't write the state file`, `Couldn't rewrite the document file`: a full or
+ read-only disk;
+* `The … file is readable by others than its owner; chmod 600 it`;
+* `Peer speaks another wire version (another build); ignoring its messages`: a node on
+ another build.
+
+## What a node uses
+
+* **Memory**: the document with its history (Automerge holds more in memory than
+ `savedBytes` on disk), the archive of old epochs (`archiveRetention`, 2 epochs by
+ default), quarantined writes and per-peer state. Remaking (`compact`) drops the
+ history ([`remakes.md`](remakes.md)).
+* **Disk**: the document file, between the full save and that plus what was appended
+ since its last rewrite (`--document-rewrite`); the state file (small); Tor's state
+ directory.
+* **Network**: per neighbour, about two keepalives per `--keepalive` interval when
+ quiet ([`measurements.md`](measurements.md), Traffic), plus sync for every write, and LINKS while a
+ dashboard is open; a joining node downloads the document once (`savedBytes`, about).
+* **Processes and ports**: the HTTP API (`--bind-addr`); a supervised Mycelium daemon
+ and its overlay port; libp2p's listen addresses.
diff --git a/docs/peers.md b/docs/peers.md
new file mode 100644
index 0000000..95b4dab
--- /dev/null
+++ b/docs/peers.md
@@ -0,0 +1,358 @@
+# Peers: handshake, keepalive, dormancy and topology
+
+The timings named below (`dormant_after` and the like) derive from `--keepalive`
+([Timings](#timings), at the end).
+
+The phases a node goes through (starting, joining, ready, rejoining, stopping) are in
+[`lifecycle.md`](lifecycle.md); this page is the protocol between peers.
+
+## Node IDs and addresses
+
+A node is its node ID (`continuum:<hex>`, from its node key); a transport address only
+says where it is (`mycelium:<public key>`). The identity layer (`transport::identity`)
+between the node and its transports keeps the two apart, so everything below speaks of
+node IDs. A node may have several transports, and reaches a peer on one both have
+(module `transport::paths`). With several, it measures them: every 30 s (or every
+keepalive interval, if that is longer) each gets a PING (the keepalive's, or one of its own), answered by a PONG over the same path, which
+gives its round-trip time. A peer's only path is measured the same way once messages
+go over it (a busy pair sends no keepalives). The
+configured order decides until both are measured; then the node changes to another
+path when it is a quarter faster (and at least 5 ms), so it doesn't flap. A send that
+fails goes to the next path at once, and a path whose sends fail, or whose PINGs go
+unanswered three times in a row, is down: used only when no other works. A node on
+several transports bridges nodes that share none ([`transports.md`](transports.md), Several transports):
+the topology links it on each of its transports (below).
+
+```mermaid
+sequenceDiagram
+ participant N as New node
+ participant S as Seed
+ N->>S: WHO(N's node key, signature over N's and S's address)
+ S-->>N: ME(S's node key, signature over S's and N's address)
+ Note over N,S: each knows which node is at the other's address
+```
+
+A WHO asks who is at an address and says who is asking; a ME answers. Each carries the
+sender's public node key and its signature over the sender's address and the
+recipient's. The transport authenticates the sender's address, so a proof holds for
+that pair of addresses only. Once a node proved itself at an address, messages from
+there are that node's; messages from an address no node proved itself at are dropped,
+and the address is asked WHO (at most every 5 s), so after a restart the first message
+from a peer brings its proof back.
+
+A seed is an address (or something the transport resolves to one: a Mycelium public
+key, IPv6 address or host name); it is asked WHO, and its ME names the node. Where other
+nodes are comes from HELLO_REPLYs and the member list: hints, which are asked WHO before
+anything is sent there; a send to a node that hasn't proven itself anywhere yet is
+refused, and retried like any refused send. A node proven on some transports is also
+asked WHO at its hinted addresses on the others, at most once a minute each: its ME
+there adds a path to it (a second transport, Tor say), which is then measured and failed
+over to. No seed is needed for that: the member list carries every member's addresses.
+
+## Handshake and join
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant N as New node N
+ participant S as Node S (reached via a seed)
+ participant P as Peer P (known to S)
+
+ N->>S: HELLO(swarm ID if known, epoch, key proof)
+ Note over S: claimed swarm ID matches ours (or none claimed)<br/>N is member, Established, sync reset
+ S->>N: HELLO_REPLY(swarm ID, active established neighbours [P], epoch, key proof)
+ Note over N: swarm ID matches or is adopted<br/>S is member, Established, sync reset
+ N->>P: HELLO(swarm ID, epoch, key proof) - discovered via S
+ P->>N: HELLO_REPLY(...)
+ par sync with one ready peer on the same epoch, the catch-up source (here S)
+ S-->>N: sync messages
+ N-->>S: sync messages
+ end
+ Note over N: all of the source's heads applied: initial sync done<br/>then sync with every neighbour on the same epoch
+ alt S is on a newer epoch than N
+ Note over N: no sync (epochs differ)<br/>remake worker starts a rejoin
+ N->>S: BLOB request (snapshot change)
+ S-->>N: manifest, then chunks (also from other peers on the epoch)
+ Note over N: snapshot applied, then sync as above
+ end
+```
+
+One round trip makes both sides members of each other: the HELLO proves the sender's
+key, the reply the swarm ID and the responder's key. A new handshake resets the sync
+session on both sides; HELLOs carry the handshake's number, so a resent HELLO (its
+reply was lost) or a late copy is answered without resetting again. A node that doesn't know its swarm
+yet (it is joining itself) doesn't answer a HELLO; the sender retries.
+
+The reply lists at most 64 of the responder's active neighbours, each with the addresses
+it reaches them at (at most 8): if it has more, those the recipient would most likely
+pick itself (lowest pair score with it). The recipient takes at most 64 from a reply,
+only valid identities (node IDs), and, once it knows the member list, only
+its own neighbours; their addresses are hints for its transport, and each gets a
+HELLO.
+
+Every HELLO carries a random id of the sender's current run, and the HELLO_REPLY echoes
+it. A reply with another id answers a HELLO from an earlier run (delivered late, or
+queued in Mycelium while the node was down) and is ignored.
+
+**Stale messages.** Mycelium may deliver a message up to 5 minutes late, and an external
+Mycelium queues messages while our node is down (a restart drains that queue for up to
+2 s, but only to cut noise). The protocol therefore must not depend on where in a
+peer's message stream a node starts listening: unanswered HELLOs are retried, a HELLO
+resets the session on both sides, sync messages are idempotent and checked against the
+epoch, and lost or stale sync state is repaired by the reply timeout, the `need` rule and
+the heal. Keepalives only inform.
+
+Every message carries the sender's epoch, and with it how many times the sender changed
+epoch in its current run. A node records a peer's epoch only from a message whose count
+is at least the newest it has seen from that peer, so a message delayed from before
+the peer switched (or rejoined, or gave up a rejoin) can't flip its recorded epoch
+back. The count starts over when the peer restarts, which its HELLO or HELLO_REPLY
+shows by a new run ID.
+
+With a swarm key (`--swarm-key-file`), every HELLO and HELLO_REPLY carries
+`HMAC-SHA256(key, label, swarm ID, from, to)`. A peer is admitted by its first valid one;
+until then everything else it sends is ignored, and a handshake without a valid proof
+gets no answer at all.
+
+Retries: an unanswered HELLO is resent after 1 s, doubling up to 30 s. To an
+unreachable peer (dormant, or three unanswered HELLOs) it is a probe: after
+`dormant_probe`, doubling up to 6 × `dormant_probe` (or `--dormant-probe-max`, which is
+every wait if shorter); members get probes even when they
+aren't neighbours. A member a HELLO_REPLY lists among the sender's active neighbours is
+probed at once: the sender reaches it, so after a partition the first pair to find each
+other again brings the others' probes forward, and the sides reconnect within seconds
+rather than pair by pair. So are all unreachable members once this node becomes
+reachable where it wasn't (libp2p: a relay grants it a slot, or AutoNAT finds it
+public): two nodes behind NATs that restart together can't reach each other until they
+hold slots, and the second to get one reaches the first at once instead of at its next
+probe. Sync messages from a peer that isn't a member yet are
+dropped (the handshake resets the session anyway) and trigger our HELLO.
+
+After a sync reset, both sides may open the sync; crossed openings cost nothing between
+peers that share history. Only a ready node never opens towards a peer that isn't ready
+(HELLO, HELLO_REPLY, sync messages and keepalives all say whether the sender is): that
+peer's answer would carry empty heads, and the whole document would go out twice. The
+peer that isn't ready opens itself.
+
+While a node catches up (joining, or rejoining a newer epoch, until its initial sync is
+done), it syncs with one peer only, the first it tries: otherwise every peer would send it
+the whole document (or everything it lacks) at once. Messages from the others are applied
+but not answered until the initial sync is done; then they only exchange heads. The
+source counts as alive while any message from it arrives; after `catch_up_probe` of
+silence (keepalive / 30, between 1 and 5 s) it gets a STATUS. After three unanswered
+probes, or when it says it isn't ready, or twice that it wouldn't sync with us, the next
+peer takes over. Being alive isn't progress, though: the next peer also takes over when
+three of our sync messages in a row went unanswered and the source has sent no sync
+message for the transport's delivery limit (five minutes for Mycelium: by then it has
+delivered or dropped anything sent, however large); the source given up then isn't
+chosen again for as long,
+so a stalled source doesn't simply take the role back, and gets a fresh HELLO, whose
+reply brings its current peer list (the first one may have listed nobody: a peer lists
+only its neighbours, and a new member isn't one until its registration has spread). Every minute without a sync
+message from the source is logged as a warning.
+
+A sync message larger than 16 chunks (16 × `--blob-chunk`, 1 MiB by default) isn't
+sent: it would be all or nothing, sent again whole after a lost copy, and on a slow link
+may not arrive within the transport's delivery limit (Mycelium's five minutes). The sender takes it back and offers a
+*delta* instead: the changes in the history of its heads but not of the peer's (as far
+as it knows them). The peer fetches them as a blob, in chunks, from the sender and any
+other peer holding those heads (every such peer produces the same bytes), loads them,
+and syncs on; the rest goes in ordinary messages. Until the peer answers, the sender
+sends it nothing; if the answer is overdue, it offers again. This is how a join without a
+recent remake, or a return after much was written, gets the bulk of its changes.
+
+Towards a peer on a slow link the limit is lower: a message is offered as a delta if it
+would take more than 5 s at the throughput measured to that peer (`syncThroughput`,
+[`measurements.md`](measurements.md)), or if it is at least half as large as one that didn't make it to
+the peer in the last hour (its send failed, or it went unanswered; only those of more
+than two chunks count), but never below one chunk. A message too large for the link
+then goes chunk by chunk instead of failing and being sent again whole.
+
+Every sync message says whether its sender is ready. Only a ready peer can be the source,
+and only a message from one completes the initial sync: a peer that is itself still
+catching up has empty heads, which any document trivially contains.
+
+The two sides of a pair take turns sending keepalives; the one that leads the pair sends
+the first. The leader is chosen by one bit of `SHA-256(label, smaller key, larger
+key)`, so both sides agree without talking and each node leads about half of its pairs
+([`swarm/pair.rs`](../src/swarm/pair.rs)).
+
+## Liveness, dormancy and HELLO state (per peer)
+
+```mermaid
+stateDiagram-v2
+ direction LR
+ state Liveness {
+ [*] --> Unknown
+ Unknown --> Active: any message received
+ Unknown --> Dormant: node ready, a neighbour, nothing received for dormant_after
+ Active --> Dormant: node ready, a neighbour, nothing received for dormant_after
+ Active --> Dormant: its farewell (a STATUS saying it is leaving)
+ Dormant --> Active: any message received (after its farewell: from its next run)
+ }
+ state Handshake {
+ [*] --> NotSent
+ NotSent --> Sending: HELLO goes out
+ Sending --> AwaitingReply: sent (or failed)
+ AwaitingReply --> Sending: retry due
+ AwaitingReply --> Established: HELLO_REPLY
+ AwaitingReply --> NotSent: no longer a neighbour (unless probed while unreachable)
+ Sending --> Established: HELLO_REPLY
+ Established --> NotSent: heal, stall reset, peer reactivated,<br/>or it no longer proves the swarm key
+ }
+```
+
+- **Unknown** is a peer nothing was heard from since this node started (it is known
+ from the member list, or another peer named it). It is treated as active; it only
+ shows that nobody answered yet.
+- **Becoming dormant** resets sync with the peer, stops syncing and pinging it, and
+ probes it with a HELLO (after `dormant_probe`, doubling). A dormant member stays in
+ the electorate of remakes while its alive stamp is recent ([`remakes.md`](remakes.md)), so enough
+ of them make a majority impossible; an operator can forget them (`DELETE /peers/{peer}`,
+ a swarm-wide record that is cancelled if the peer turns out to be alive).
+- **Any message** from a dormant peer makes it active again and forces a fresh
+ handshake, whose reply resets sync on both sides.
+- Dormancy is only judged while the node is ready, so joining nodes keep fast retries.
+ A peer's farewell (it is shutting down: a STATUS with `leaving`) makes it dormant at
+ once, whatever our phase; it isn't counted as an incident (`dormancies`). What that
+ run still sends while it stops (sync messages, PINGs, a LINKS_REPLY) doesn't make it
+ active again: only a HELLO or HELLO_REPLY from its next run does.
+- **Strangers** (peers that never became members: listed to us but never answering, or
+ failing the handshake) are forgotten locally once silent for a day, so peers named
+ to a node can't grow its list without bound.
+
+## Member registry and neighbours
+
+`_continuum/members/<node ID>` lists every node that has joined, replicated with the
+document and carried into new epochs. An entry says where its node can be reached
+(`addresses`, kept up to date by the node), so members find each other without having
+met. It is a pure registry, not access control (that
+is the swarm key). A node registers itself as soon as it is ready and again whenever its
+entry is missing while it runs (checked every 5 s). Forgetting a peer (`DELETE /peers/{peer}`)
+removes its entry along with writing the forget record; a forgotten node that is alive
+cancels the record and registers again. Each node acts on a record as soon as it has
+it (and looks at the records again every 5 s), once: a peer it hears of again before
+the cancel arrives is kept.
+
+From the member list every node computes the graph ([`swarm/topology/`](../src/swarm/topology/)), leaving out
+members it can't reach (three unanswered HELLOs, or dormant, until they answer a probe;
+or with no address on any of its transports), and those whose alive stamp is older than
+the electorate window (`electorate-window`) that it hasn't heard from since it started
+(a member coming back sends its HELLO, and counts again from then):
+a ring by key hash, each member linked to the nearest reachable member
+in both directions, plus, per member, the `k − 2` reachable others with the lowest pair
+score; a link exists if either end chose it. With several transports, each has such a
+graph among the members on it (by the addresses in their entries), and a node's
+neighbours are its neighbours on each transport it is on: every transport's graph is
+connected, and a member on two transports is in both, so as long as some members are
+on several, the swarm is connected, and those have about `k` neighbours per
+transport. With 100 members of which 10 are online,
+the 10 form a full mesh; nodes that briefly disagree on who is reachable only add links. `k` is `--neighbours` (default 12), so swarms of up to 13 nodes are a
+full mesh. A node *initiates* only towards its neighbours (HELLO, keepalive, dormancy
+checks, sync openings, pushing its changes, epoch announcements, the heal, the peer list
+in HELLO_REPLY) and *answers* anyone. Changes travel over several hops; heads in
+keepalives tell a node when a neighbour has something it lacks. Until a node is ready
+and knows the member list, everyone counts as a neighbour, so joining works as before.
+A peer that proved itself a member of the swarm (its handshake) but isn't in the member
+list yet is a neighbour too, until it is: otherwise a node that joined before the
+genesis node registered itself, and registered itself in a document without the genesis
+node, would never sync with it, and neither registration would pass.
+So is a member that lists no address on the node's transports but reaches it anyway, as
+long as it says it syncs with the node (STATUS): it only connects out (a libp2p node
+without a listen address), so it is in no graph but its own, and the nodes it picked
+would otherwise only answer it, never send it their changes.
+The neighbours are recomputed as soon as the member list changes or a peer becomes
+unreachable or reachable again, and every 5 s for what has no event of its own
+(unreachability expiring); new ones get a HELLO and a sync opening, former ones a session
+reset.
+
+Each node judges its own stability ([`swarm/stability.rs`](../src/swarm/stability.rs)): the fraction of the last 24 h
+it had at least one live peer. It writes only a coarse tier into its registry entry
+(`tier`: `stable` after a day at ≥ 95%, back to `transient` below 80%), and only when it
+changes. After a restart, a stable node stays stable if a peer's HELLO_REPLY says it was
+away for less than an hour; otherwise it starts over as transient. In the neighbour graph,
+half of each member's scored slots prefer stable members, so long-running nodes form the
+backbone (with 20 stable of 250 members and k = 12: about 65 links per stable member).
+
+Changes travel through a partial mesh by announcement and pull ([`swarm/relay.rs`](../src/swarm/relay.rs)): a node
+pushes its own writes to its neighbours; after applying changes from one neighbour it
+sends its other neighbours a STATUS with its new heads, and a neighbour that lacks those
+heads pulls them (it sets them as the peer's heads in its sync state, so Automerge asks
+for what's missing). It pulls from one neighbour at a time (15 s timeout); neighbours
+that announce heads it lacks meanwhile wait their turn and are pulled from as soon as
+the current pull is answered, if they still have something it lacks. The regular
+STATUS (every third keepalive) triggers the same pull, so gaps close within a few
+keepalive intervals. A node
+that just caught up announces its heads once. In a full mesh nothing is announced: the
+writer reaches everyone (while a node doesn't know its neighbours yet, it announces).
+
+## Keepalive and self-healing
+
+```mermaid
+sequenceDiagram
+ participant A as Node A (its turn)
+ participant B as Node B
+
+ Note over A: B established and active, quiet for keepalive,<br/>no keepalive to B for keepalive, known for keepalive
+ alt no STATUS between them for 3 × keepalive
+ A->>B: STATUS(heads, epoch, ready, syncs with you)
+ Note over B: mark A seen (reactivate if dormant)<br/>record A's epoch, reset sync if it changed<br/>re-handshake if A was dormant or unknown
+ B->>A: STATUS_REPLY(heads, epoch, ready, syncs with you)
+ Note over A: mark B seen, record B's epoch
+ else otherwise
+ A->>B: PING(nonce)
+ Note over B: mark A seen (reactivate if dormant)
+ B->>A: PONG(nonce, B's time)
+ Note over A: mark B seen, round-trip sample
+ end
+ alt after a STATUS: same epoch, heads have differed for resync_after with no sync from it since,<br/>no answer due to our own sync message, last heal at least resync_after ago
+ A->>B: HELLO
+ B->>A: HELLO_REPLY
+ Note over A,B: sync reset on both sides, sync restarts
+ end
+ Note over A,B: after an epoch switch a node announces its epoch at once:<br/>ready peers already on it get its sync opening,<br/>all others a STATUS#59; a node that becomes ready<br/>sends every neighbour a STATUS
+```
+
+A quiet pair's keepalive is a PING, which the peer's paths layer answers at once with a
+PONG over the same path (`transport::paths`): both sides learn the other is alive, and
+the leader gets a round-trip time. Every third one is a STATUS instead, answered with a STATUS_REPLY: each
+side's state, for the heal and against lost announcements. A node's state also goes out
+when it changes: its heads (announcements, above), its epoch after a switch, and its
+readiness when it becomes ready (or ready again after a rejoin) and at shutdown.
+
+A STATUS lists the sender's heads (up to 32; more as a digest of them with 32 of
+them, another 32 each time, so a recipient pulls the listed ones it lacks), its epoch,
+whether it is ready, and whether it would sync with the recipient.
+
+The two sides take turns: after a keepalive from the peer, the next one is ours (the
+leader of the pair sends the first). On its turn a node sends once the pair has been
+quiet for a keepalive interval; off its turn only after 1.5 × keepalive, in case the
+other one doesn't, e.g. because a keepalive or its answer was lost (any message counts
+as heard, so sync traffic suppresses keepalives on both sides). If two keepalives
+cross anyway (the peer's arrives within half an interval after ours), the next turn is
+the leader's, so they don't keep crossing. One keepalive and its answer per pair and interval, each side sending
+every other one, so both measure the round trip, instead of keepalives from both sides
+that often cross.
+
+Inside sync, a lost message is resent after the reply timeout (10 s, 30 s with Tor
+enabled, doubling up to 30 times that). If the peer asks for a change we believe it has (the message carrying it was
+lost while later ones arrived), we forget which changes were sent, and the next message
+carries everything it lacks. A peer that got a change without the ones it builds on asks
+only for those, without saying what it has; Automerge would send just them, and the peer
+ask again for theirs, one round trip per missing change. So we answer as if it had
+nothing since what we share: everything since then goes at once (a blob, if large).
+
+## Timings
+
+Derived from `--keepalive` (`Timing::from_keepalive`, [`state/timing.rs`](../src/state/timing.rs)):
+
+| Name | Value | What for |
+|---|---|---|
+| `status_refresh` | 3 × keepalive | every third keepalive is a STATUS |
+| `dormant_after` | 4 × keepalive | silent this long, a peer is dormant (three keepalives lost in a row are tolerated) |
+| `resync_after` | 2 × keepalive, at least twice the first sync reply timeout (20 s; 60 s with Tor) | heads differing this long make a heal |
+| `dormant_probe` | 10 × keepalive | the first probe of an unreachable peer, doubling up to 6 × (or `--dormant-probe-max`) |
+| `catch_up_probe` | keepalive / 30, from 1 to 5 s | how soon a silent catch-up source is probed |
+| sync reply timeout | 10 s (30 s with Tor enabled: the longest of the transports'), doubling up to 30 × | resending a sync message nothing answered |
+
+The peer worker runs when the earliest of these is due for some peer (and when something may have made one due sooner: a HELLO sent, a sync message sent, a keepalive received, a new peer or new neighbours), at least every 5 s; the remake worker every second.
diff --git a/docs/remakes.md b/docs/remakes.md
new file mode 100644
index 0000000..efcc8e3
--- /dev/null
+++ b/docs/remakes.md
@@ -0,0 +1,410 @@
+# Remakes: an operator's guide
+
+A remake moves the whole swarm to a new document, the next *epoch*. Use one to:
+
+* **compact** (`compact`): same content, no history. The document shrinks to what it
+ holds now, and joining nodes download less.
+* **replace** (`replace`): new content you supply as JSON (the shape `GET /store`
+ returns), with rules for where writes made in the meantime go.
+* **import** (`import`): new content from an Automerge document, e.g. a backup made
+ with `GET /admin/document`. Unlike `replace`, Automerge types survive (text,
+ counters, bytes, timestamps, floats).
+
+Everything else is the same for all three kinds. All endpoints need the node's basic
+auth; examples leave it out.
+
+## How a remake runs
+
+1. **Proposal.** You `POST /admin/remakes` to any node; it becomes the *coordinator*.
+ It fixes the *electorate* and, for `replace` and `import`, the *base* (the
+ document's heads now, or `baseHeads`), writes the proposal into the document and
+ votes for it.
+2. **Vote.** Every member in the electorate votes for it once the proposal reaches it,
+ after a random wait of up to half a keepalive interval, unless it already voted for
+ a competing remake of the same epoch or the remake has its majority already (then a
+ vote would only be one more change for everyone). For
+ `replace` and `import`, a member first downloads the new document from the
+ coordinator and the members that have it, so a committed remake has its document on
+ a majority.
+3. **Decision.** The coordinator commits as soon as a majority of the electorate voted
+ for the remake. It aborts if that becomes impossible, if the voting period
+ (`settle`) ends without it, or on your request. A majority that arrives after
+ the voting period doesn't count, and an abort request or a competitor's commit that
+ arrives while the coordinator builds the new document still wins. Once the remake
+ has expired (below), the coordinator doesn't commit it any more. A compaction's
+ base is fixed now: the coordinator's heads as it starts building, recorded in the
+ decision.
+4. **Switch.** After the switch delay, every node builds the new document on its own,
+ checks that it matches the committed epoch, and carries over the writes made since
+ the base that it has, its own and other nodes'. Its old document goes into its archive.
+5. **Stragglers.** Nodes that were away, or whose build didn't match, notice peers on
+ the newer epoch and *rejoin*: they download the epoch's snapshot, sync the changes
+ made since, and carry over their own writes within the replay window
+ ([`lifecycle.md`](lifecycle.md), "Rejoining").
+
+Peers on different epochs don't sync with each other, so a node stays on its epoch
+until it switches or rejoins.
+
+### Electorate and majority
+
+The electorate is the set of members whose alive stamp in the member registry is
+recent: the window is the coordinator's `--electorate-window` (default 7d, at least
+7h). Every node publishes its window in its registry entry (`window`), and renews its
+stamp every seventh of the shortest window any member publishes (once a day by
+default), at most hourly: so a coordinator with a shorter window than the others still
+counts every live member. A stamp more than a renewal interval in the future (its
+node's clock is that far ahead) doesn't count. The coordinator is always included. A remake
+needs ⌊n/2⌋+1 of the n electorate members.
+
+Each member votes for at most one remake of its epoch, and votes again only once that
+remake is aborted, so two competing remakes, even on two sides of a network partition,
+can't both get a majority. Votes are kept in the votes register
+(`_continuum/votes/<node ID>`), one entry per member.
+
+What this means in practice:
+
+* Members that have been gone for longer than the window don't count, and don't block
+ anything.
+* Members that are offline but within the window count. While more than half of the
+ electorate is reachable, they don't block anything either.
+* If half or more of the electorate is offline, no remake can commit. Wait for nodes to
+ return, or forget members that are gone for good (`DELETE /peers/{peer}`), then
+ propose again.
+* With two nodes, both must vote; with three, one can be offline.
+
+Only electorate members vote. A node that joined after the proposal doesn't.
+
+## Proposing
+
+All fields are optional:
+
+```sh
+curl -X POST localhost:3000/admin/remakes -H 'Content-Type: application/json' -d '{
+ "kind": "compact",
+ "settle": "8m",
+ "switchDelay": "1m",
+ "replayWindow": { "epochs": 1, "age": "7d" },
+ "archiveRetention": { "epochs": 2, "age": "30d" }
+}'
+```
+
+| Field | Default | Meaning |
+|---|---|---|
+| `kind` | `compact` | `compact`, `replace` or `import`. |
+| `spec` | none | The kind's input (below). |
+| `baseHeads` | current heads | `replace` and `import` only: what the new document accounts for; writes after it are carried over. |
+| `settle` | 8 × keepalive | Voting period, a duration ([`configuration.md`](configuration.md), "Durations and sizes"). A short one only risks an abort for lack of votes. |
+| `switchDelay` | 1 × keepalive | Wait between commit and switch, so the commit reaches every node first. Shorter only with `?force=switchDelay`. |
+| `replayWindow` | 1 epoch, 7 days | How far behind a returning node may be and still have its writes carried over automatically. |
+| `archiveRetention` | 2 epochs, 30 days | How long nodes keep the old document. |
+
+A retention is kept until *every* limit it gives has passed.
+
+`?force=` overrides checks: `true` all of them, or a comma-separated list of
+`switchDelay` (accept a shorter switch delay), `supersede` (abort expired remakes of
+this epoch and propose in their place, see below) and `clock` (propose although this
+node's clock is off from its peers').
+
+Answers:
+
+* `202` with the report and a `Location` header. With `?wait=true`, the final report
+ once decided (`200`).
+* `409` if another remake of this epoch is still open on this node's view, if an
+ expired one still holds votes (unless `?force=supersede`), or if this node can't
+ vote for a new one (it voted for another remake of this epoch, or may have before it
+ restarted), or if this node's clock is off from most of its peers' ([`measurements.md`](measurements.md),
+ Clocks): the coordinator stamps and judges the deadlines by it (unless
+ `?force=clock`).
+* `400` for an invalid request; `503` if the node isn't ready.
+
+Dropping the HTTP connection doesn't abort the remake.
+
+### Replace
+
+```json
+{
+ "kind": "replace",
+ "spec": {
+ "document": { "accounts": { "alice": { "name": "Alice" } } },
+ "rules": [
+ { "from": "users", "to": "accounts" },
+ { "from": "users/admin", "drop": true },
+ { "from": "settings", "to": "settings" }
+ ]
+ }
+}
+```
+
+The rules decide where writes made after the base go: from the old epoch, and from
+nodes that rejoin later. Paths are slash-separated strings, or arrays of segments for
+keys that contain `/`. The longest matching `from` wins, and the rest of the path is
+appended to `to`. `"drop": true` discards matching writes. A write no rule covers is
+quarantined on the node that made it. `""` is the root: `{"from": "", "to": ""}` keeps
+every path.
+
+`_continuum` (protocol data) can't be replaced or targeted by rules.
+
+To replace with something derived from the current content, read it with
+`GET /store?heads=true`, transform it, and pass the heads from `x-continuum-heads` as
+`baseHeads`: then exactly the writes you didn't see are carried over.
+
+### Import and restoring a backup
+
+Take backups with:
+
+```sh
+curl -o backup.automerge localhost:3000/admin/document
+```
+
+The file is the whole document (history included). Response headers name the swarm,
+the epoch and the heads.
+
+Restore it, keeping writes made since at their paths:
+
+```sh
+curl -X POST localhost:3000/admin/remakes \
+ -H 'Content-Type: application/octet-stream' \
+ --data-binary @backup.automerge \
+ --url-query 'rules=[{"from":"","to":""}]'
+```
+
+With `application/octet-stream`, `rules`, `baseHeads`, `settle` and
+`switchDelay` go in the query; for the retention settings use the JSON form, with
+the document base64-encoded:
+
+```sh
+base64 -w0 backup.automerge |
+ jq -Rs '{kind: "import", spec: {document: ., rules: [{from: "", to: ""}]}}' |
+ curl -X POST localhost:3000/admin/remakes -H 'Content-Type: application/json' --data-binary @-
+```
+
+The coordinator keeps only the content: the backup's history and its `_continuum`
+(protocol data of whatever swarm it came from) are dropped. A backup of one swarm can
+be imported into another.
+
+If no node of the swarm is left, start one from the backup instead:
+`continuum --recover-from backup.automerge …` ([`lifecycle.md`](lifecycle.md), "Starting").
+
+### A new swarm with content
+
+A new swarm can start with content instead of empty: `--genesis --remake KIND:PATH`
+makes the genesis document the way a remake of that kind would make a new epoch.
+
+```sh
+continuum --genesis --remake import:backup.automerge … # a backup's content, of any swarm
+continuum --genesis --remake replace:seed.json … # a JSON document, the store root
+```
+
+As with an `import` remake, only the content is kept: the backup's history and
+protocol data (its swarm ID included) are dropped, so it starts a new swarm, not the
+old one. `compact` is accepted and changes nothing.
+
+Without rules, every write made after the base is quarantined. Leaving rules out is
+right when the backup is meant to win completely.
+
+## Watching and aborting
+
+```sh
+curl localhost:3000/admin/remakes/<id> # one report
+curl 'localhost:3000/admin/remakes/<id>?wait=true' # until decided
+curl localhost:3000/admin/remakes # all of them
+curl localhost:3000/admin/epochs # this node's epoch, archive, rejoin
+```
+
+A report has a `phase`:
+
+* `proposed`: voting.
+* `committed`: decided; nodes switch after the switch delay. It can't be undone.
+* `aborted`: the `decision.reason` says why; for missing votes it includes the tally,
+ e.g. `2 of 5 voted for it, 2 for a competing remake, 1 not yet; 3 needed`.
+* `expired`: no decision long after the voting period (`settle` plus the grace
+ of four of the coordinator's keepalives, written in the proposal as `graceMs`, so
+ every node judges it alike). The coordinator is gone, and won't commit it any more. Its
+ votes still count: the members that cast them can't vote for another remake of this
+ epoch until this one is aborted, which any node can do now (below).
+
+`votes` lists who voted, `proposal.electorate` and `proposal.quorum` who counts and
+how many are needed. `local` reports what happened on the node you asked: when it
+switched, how many paths it carried over, and the quarantine entry for writes it
+couldn't place.
+
+Abort while undecided:
+
+```sh
+curl -X DELETE localhost:3000/admin/remakes/<id> # this one
+curl -X DELETE localhost:3000/admin/remakes # whichever is in progress
+```
+
+The coordinator applies it within about a second (`202`, or the final report with
+`?wait=true`). If the node you ask doesn't reach the coordinator (it is in another
+partition, or gone but not yet expired), the request answers `409`: the coordinator
+could commit before the request reaches it. Ask a node that reaches it, or wait until
+the remake expires (`abortableAt`); `?force=coordinatorInactive` writes the request
+anyway. An expired remake has no coordinator to apply it, so the node you ask
+aborts it itself, under three conditions that keep it from crossing a late commit:
+
+* a whole grace period (the remake's, `graceMs`) must have passed since it expired; until
+ then only its coordinator may decide it, and the `409` says when (`abortableAt`);
+* if the node you ask sees the coordinator as an active peer, the coordinator is
+ stuck rather than gone (a different version, a bug: look at its log). Aborting then
+ needs `?force=coordinatorActive`;
+* the node you ask must have its clock in line with its peers' ([`measurements.md`](measurements.md),
+ Clocks): whether the remake expired is judged by it, and a node ahead would abort a
+ remake its coordinator may still commit. Otherwise `409`, unless `?force=clock`.
+
+A member that saw a remake expire before it voted for it says so in its log, once
+(`Didn't vote for a remake`, warn if its clock is off: it then misses remakes others
+still vote on).
+
+A committed or aborted remake answers `409`.
+
+### Superseding an expired remake
+
+Proposing while an expired remake of the epoch still holds votes answers `409` with
+what you need to decide: for each such remake its `id`, its `coordinator`, whether
+the node you asked sees that coordinator as an active peer (`coordinatorActive`),
+when it expired (`expiredAt`), from when it can be aborted (`abortableAt`) and its
+`tally`. Either abort it and propose again, or do both at once:
+
+```sh
+curl -X POST 'localhost:3000/admin/remakes?force=supersede' -H 'Content-Type: application/json' -d '{}'
+```
+
+The expired remakes are aborted ("superseded by remake …") in the same change that
+writes the new proposal, and their voters can vote for the new one. The same two
+conditions as for aborting apply; with an active coordinator, add `coordinatorActive`:
+`?force=supersede,coordinatorActive`.
+
+### Adopting a remake
+
+If a remake's coordinator is gone for good, another node can take over:
+
+```sh
+curl -X POST localhost:3000/admin/remakes/<id>/adopt
+```
+
+Only for an expired remake (or one adopted before, whose adopter is gone too). The
+node you ask becomes its coordinator (`adoptedBy` in the report). An adopted remake
+has no voting period: it commits once a majority voted for it, members that haven't
+voted yet can still do so, and it aborts only once a majority is out of reach or on
+your request.
+
+Adopt on one node only, and only when the coordinator is really gone: two nodes
+coordinating at once could decide differently.
+
+## Writes around a remake
+
+The swarm stays writable throughout.
+
+* **Writes on nodes that switch** are carried over into the new epoch, at their path
+ (compaction) or wherever the rules say.
+* **Writes on nodes that were away** are carried over when they rejoin, if the node
+ is within the replay window of the first remake it missed: at most `epochs` remakes
+ behind, or that remake was proposed at most `age` ago. Otherwise all of them are
+ quarantined, as are writes whose fate can't be determined.
+* **Last writer wins**: a write is carried over only if nothing newer touched its path,
+ a path above it or one below it, in the new epoch; newer by the times the writers'
+ clocks gave their changes. A carried-over write keeps the time it was made, so a
+ later carry-over judges by that. A write that loses is left out, logged (`Writes not
+ replayed: newer ones touched their paths`), or with `--superseded-writes quarantine`
+ kept in the quarantine. Clocks that disagree (beyond `--clock-skew-warn`) can make
+ the older write win.
+* **Granularity**: map entries are carried over per key. Lists and text are carried
+ over whole, so concurrent edits of the same list on two nodes around a remake end as
+ last-writer-wins.
+* **Who carries what**: a node that switches carries over every write made since the
+ base that it has, its own and other nodes'; a node that rejoins carries only its own
+ (the others' reached the new epoch through the nodes that switched). So a write is
+ lost only if no node that has it switches and its author can't rejoin with it: say,
+ it was made by a node cut off during the remake, synced only to nodes cut off too,
+ and its author restarted (keeping no document) before they rejoined.
+
+A rejoining node answers reads from its old document, marked with the
+`x-continuum-stale` header, and writes go into it; they are carried over into the new
+epoch when the rejoin ends, like the node's writes from before ([`lifecycle.md`](lifecycle.md),
+Rejoining).
+
+## Quarantine
+
+Writes a remake couldn't place stay on the node that made them, in memory:
+
+```sh
+curl localhost:3000/admin/quarantine # entries on this node
+curl localhost:3000/admin/quarantine/<id> # paths and values
+curl -o old.automerge localhost:3000/admin/quarantine/<id>/document
+curl -X POST localhost:3000/admin/quarantine/<id>/replay # write them anyway
+curl -X DELETE localhost:3000/admin/quarantine/<id> # discard
+```
+
+Replaying writes the values at their old paths as fresh writes (they win over what is
+there now). Quarantine doesn't survive a restart of that node, so look at it soon; the
+remake report's `local.quarantine` on that node names the entry.
+
+## Troubleshooting
+
+* **Aborted, "no majority in the voting period".** Too few electorate members voted in
+ time. Check which ones are missing (`proposal.electorate` minus `votes`). Retry with
+ a longer `settle` if they are only slow or briefly away; forget them if they are
+ gone for good.
+* **Aborted, "a majority is out of reach".** Members voted for a competing remake.
+ Wait until that one is decided, then propose again if still needed.
+* **Expired.** The coordinator stopped before deciding. If it restarts, it takes the
+ remake up again once ready and, the voting period being over, aborts it. If it is
+ gone for good, abort the remake, or supersede it with a new proposal
+ (`?force=supersede`).
+* **`409` on proposing.** A remake of this epoch is still open: wait for it, or abort
+ it. Or this node voted for another remake of this epoch: propose on a node that
+ didn't.
+* **A node stays on the old epoch.** It rejoins once enough ready members advertise
+ the new one ([`lifecycle.md`](lifecycle.md), "Rejoining"); `GET /admin/epochs` on it shows the
+ attempt and any error. Nodes keep trying.
+* **Several remakes committed for one epoch, or a contested remake** (`contested:
+ true`: a commit and an abort written concurrently, possible only if clocks disagree;
+ Limits, Clocks). No node switches to one on its own records (`not switching on my
+ own`, error), but it follows peers that did: the epoch most of them are on. Nodes on
+ the other epoch of that generation rejoin it sideways once they see themselves
+ outnumbered (`More peers are on another epoch of this generation than on ours`,
+ warn), and what was written there goes to quarantine ([`lifecycle.md`](lifecycle.md), Rejoining).
+ Look at those quarantines; report how it came about (Limits).
+* **Repairing records by hand.** Remake records live in the document under
+ `_continuum/remakes/<id>`. Old ones need no cleaning up: an epoch's document holds
+ the remakes proposed in it and the record of the remake that made it, and earlier
+ ones go with the previous epoch's document. Users listed in `protocol-writers` may change or delete
+ them through the store API, e.g. `curl -X DELETE …/store/_continuum/remakes/<id>`
+ to remove a broken one. Deleting a record doesn't free the votes cast for it (the
+ votes register, `_continuum/votes`, keeps them): abort it instead where you can. Every node acts on what it finds there, so such a write acts
+ on the whole swarm; it is logged. Other users get `403`, and epoch metadata
+ (`_continuum/epochs`) and the swarm ID (`_continuum/swarm`) are never writable.
+
+## Limits
+
+* **Clocks.** Voting periods, expiry and the replay window are judged by each node's
+ own clock ([`trust.md`](trust.md), What depends on clocks). With clocks further apart than the
+ grace period (four of the coordinator's keepalives), a remake aborted from another node after expiry
+ could also have been committed by its coordinator: a contested remake.
+
+* **Document size.** Large transfers go in chunks (`--blob-chunk`, default 64KiB)
+ from up to four peers at once: the new document of a `replace`/`import` remake (it
+ stays on the coordinator, and every member downloads it before voting), and the
+ epoch's snapshot for a rejoining node. One transfer is still a single message: the
+ changes made since the last remake, which reach a rejoining node in one sync
+ message. A transport drops messages it can't deliver within its delivery limit
+ (Mycelium: five minutes), which limits
+ that one to somewhere in the MiB range; remaking regularly keeps it small. Requests
+ are limited to 64 MiB.
+* **Two remakes committed for one epoch.** Majority voting rules it out while every
+ node sees the same registry and keeps its records, but three things get past it:
+ a coordinator cut off right after it committed, while the others aborted the remake
+ as expired and committed another (it switches to its own); a partition that outlasts
+ `--electorate-window`, after which each side counts only itself as the electorate;
+ and a node without `--document-file` that voted, restarted, and votes again on the
+ other side of a partition (its vote is forgotten with the records). Two epochs of
+ the next generation result; the swarm follows the one more nodes are on, and the
+ others' writes there are quarantined (Troubleshooting).
+* **Same Automerge version.** Every node must build byte-identical snapshots, so all
+ nodes need the same Automerge version. A node whose build differs rejoins instead.
+* **Lost votes.** Votes aren't stored outside the document. A node that voted, then
+ restarted before its vote reached anyone but the coordinator, doesn't see its vote.
+ So after a restart a node votes only for the one undecided remake older than its
+ start, if there is just one, and not at all while there are several. Should it still
+ vote twice, it counts for neither (the tally reports it as `for two`).
diff --git a/docs/transports.md b/docs/transports.md
new file mode 100644
index 0000000..8fb81d3
--- /dev/null
+++ b/docs/transports.md
@@ -0,0 +1,296 @@
+# Transports
+
+How nodes reach each other. A node is its node ID, from its node key; a transport
+only says where it is ([`peers.md`](peers.md), node IDs and addresses). Continuum has three
+transports, each a cargo feature (`--features mycelium,libp2p,tor`), any of them at
+once. Which a node uses is `--<transport>-enable`, and in which order
+`--transport-order` ([`configuration.md`](configuration.md), Transports); the options below are each
+transport's.
+
+| | Mycelium | libp2p | Tor |
+|---|---|---|---|
+| What it is | An encrypted overlay network, addressed by keys; a daemon per node (supervised, or one running elsewhere) | Direct connections, TCP (Noise, Yamux) and QUIC | An onion service per node (arti, embedded) |
+| Through NAT and firewalls | Yes: the overlay routes around them, through its peers | With help: a forwarded or UPnP-mapped port, a relay, hole punching | Yes: onion services need no open port |
+| Addresses | `mycelium:<public key>` | `libp2p:<multiaddr>/p2p/<peer ID>` | `tor:<onion>.onion:<port>` |
+| Encryption | End to end (the overlay) | Per connection, between the two nodes, through a relay too (Noise, QUIC's TLS) | End to end (Tor) |
+| Round trips | Those of the overlay's routes | The network's: the fastest on a LAN or VPN | A second or more |
+| Needs | Overlay peers to connect through (`--mycelium-peers`), or a daemon elsewhere | A listen address on at least one side of each pair, or a relay | Time to bootstrap; arti's onion services are still experimental |
+
+## Several transports
+
+A node's addresses, one or more per transport, are `addresses` in `GET /admin/stats`.
+A node on several transports reaches a peer on the first one in its order that both
+have, until the paths are measured, then on the fastest; a send that fails goes over
+the next at once ([`peers.md`](peers.md), node IDs and addresses). A node on several also carries
+the traffic between nodes that share none, through the document: it syncs with its
+neighbours on each side, and the changes travel through its copy. No node forwards
+another's messages: a node that bridges two transports holds the document.
+
+Within one transport, networks are bridged by that transport: a Mycelium daemon (one
+without continuum will do) routes for the overlay, and a libp2p relay (a node with
+`--libp2p-relay`, or continuum with `--relay-only`, which holds no document) carries
+connections between nodes that can't dial each other.
+
+## Mycelium
+
+Each node runs its own Mycelium daemon, supervised: started with the node, and
+stopped with it. `--mycelium-peers` names the overlay peers it connects through
+(`tls://host:9651`, not continuum peers; public Mycelium nodes are used only if listed
+there), and `--mycelium-tcp-listen-port` the port it listens on for overlay peers (a
+free one without). Its key is its identity on the overlay, and so its address:
+`--mycelium-key-file` keeps it across restarts (a new one each start without), and
+`--mycelium-key-create` makes it on the first start. `--mycelium-network-key-file` and
+`--mycelium-network-name` join a private Mycelium network instead of the public one.
+
+`--mycelium-rest-url` uses a daemon running elsewhere, through its REST API, instead
+of supervising one; then the options above don't apply: those of a supervised daemon
+can't be combined with it ([`configuration.md`](configuration.md), unsetting and conflicting options), and
+`--mycelium-peers` is left as it is but unused (that daemon has peers of its own). Seeds are a node's address,
+its Mycelium public key or IPv6 address, or a host name with an AAAA record for one.
+
+### When Mycelium doesn't work
+
+The supervised daemon's own output goes where the node's does, so its complaints are in
+the node's log, between the node's lines.
+
+* **`Starting the supervised Mycelium daemon`** with **``Running `mycelium` ``**: the
+ `mycelium` program isn't on the node's `PATH`. Supervising needs Linux (the keys are
+ handed over in memory, and the daemon dies with the node); elsewhere, run the daemon
+ yourself and give `--mycelium-rest-url`.
+* **`Querying local Mycelium identity (no answer within 30s)`**: the daemon didn't answer
+ its API: it failed to start (its output says why: a port taken, a bad key file), or
+ `--mycelium-rest-url` points elsewhere.
+* **Nobody reaches the node, nor it anyone** (seeds stay unanswered, `refused` grows in
+ the traffic, `Mycelium rejected outbound message`): the daemons aren't connected on
+ the overlay. A supervised daemon connects only to `--mycelium-peers` (by default
+ `tls://127.0.0.1:9651`, a daemon on the same machine) and to who connects to it
+ (`--mycelium-tcp-listen-port`, which must then be reachable); it doesn't discover
+ peers on the LAN. Nodes of a private network need the same name and key, or they
+ don't see each other.
+* **A peer's address changes at every restart** (`addressChanges` in the transports'
+ figures, [`measurements.md`](measurements.md)): it has no `--mycelium-key-file`, so its daemon gets a new
+ key each start. The node ID stays, so this costs a WHO round, but seeds given as its
+ Mycelium address go stale.
+* **`Discarded messages Mycelium queued for us before we started`** (info), and a few
+ `staleHelloReplies`: normal after a restart; Mycelium keeps messages for a while.
+* **The daemon exits**: the node stops (`A transport stopped`, error), to be restarted
+ by its supervisor.
+
+## libp2p
+
+libp2p connects directly, over TCP (Noise, Yamux) and QUIC: `--libp2p-listen` says
+where to listen (e.g. `/ip4/0.0.0.0/tcp/9661`), and the addresses it listens on go
+into the member list as `libp2p:<multiaddr>/p2p/<peer ID>`, loopback and link-local
+ones left out (unless there are no others). `--libp2p-announce` publishes other
+addresses instead, e.g. a public address forwarded to the listen port. A port forwarded
+on the router needs no announce: once AutoNAT finds the node dialable at the router's
+address (peers tell it where they see it, and dial it back there), that address is
+published next to the listen addresses. A node's entry in the member list takes its
+transports' addresses in turns (each one's best first), 16 at most, so many libp2p
+addresses don't crowd out its other transports'. Connected peers
+tell each other where they listen (libp2p's identify), so a node also learns where
+peers that dialled it can be reached. A node with no reachable listen address can still
+take part: it connects out, and its peers answer over that connection (each pair of
+peers needs one side that can be dialled). Two peers connected only through a relay
+(below) dial each other directly where they know an address for the other; that fails
+behind firewalls, so it's tried again after 30 seconds, then less often, up to every 10
+minutes. A directly connected peer's other addresses (from identify) are dialled too,
+for a better path: of several connections to a peer, one is kept 20 s after the last
+opened, a direct one before a relayed one, then the one with the shortest round trip
+measured since then (so a new ethernet path can win over a WLAN connection hours old).
+
+### Relays
+
+A node nobody can dial (behind NAT, or with its port closed) can be reached through a
+relay instead: a node of the swarm that others can dial, started with `--libp2p-relay`.
+A relay says so in the member list (each of its addresses again, ending in
+`/p2p-circuit`), and a node that needs a relay picks up to two of those by itself. It
+needs one when it has no address others could dial, or when AutoNAT (connected peers
+dialling it back) says it can't be dialled; once AutoNAT says it can, it lets them go.
+A slot lost is reserved again at once. A relay that refuses a slot (it has none left,
+doesn't allow us, isn't a relay, or another peer answers at its address) isn't picked
+again for 10 minutes; one that doesn't grant it otherwise (it doesn't answer, or the
+connection closed) isn't for 30 seconds. Give relays a `--libp2p-key-file` (with
+`--libp2p-key-create`, made on the first start): without
+one, a relay's peer ID changes with every start, and the addresses others have for it,
+and for the nodes behind it, are wrong until the member list catches up.
+`--libp2p-relays /ip4/203.0.113.7/tcp/9661` (with or without the relay's
+`/p2p/<peer ID>`) names relays to pick first, before members: used the same way, only
+while the node needs a relay. Any libp2p node that serves circuit relay v2 will do,
+but most keep circuits short (2 minutes and 128 KiB by default): enough to punch holes,
+not to carry traffic where that fails. A relay configured without its peer ID is
+connected to, to find it out, and again when it doesn't grant a slot.
+
+Relays for the whole swarm can be listed in the document instead, so no node needs
+them configured: a user in `--protocol-writers` writes their addresses (as `libp2p:`
+addresses, with or without `/p2p/<peer ID>`, at most 32) to `_continuum/relays`:
+
+```sh
+curl -u admin:secret -X PUT localhost:3000/store/_continuum/relays \
+ -H 'Content-Type: application/json' \
+ -d '["libp2p:/ip6/fd00::10/tcp/9661", "libp2p:/ip6/fd00::11/tcp/9661"]'
+```
+
+Every member picks among them as among configured relays, after those, and before
+members that relay; the list goes into new epochs with the rest of `_continuum`.
+
+A relay need not be a node: `--relay-only` runs continuum as a libp2p relay alone, for
+machines that should relay but hold no document (stable hosts on a VPN, say). It has
+no node key, joins no swarm and keeps no files but its libp2p key; nodes don't take it
+for one of them (it doesn't speak continuum's protocol), nor for a seed: a node given it
+as one says so (`Initial HELLO failed`, "a relay, not a node"); its seed is a node behind
+the relay (`<relay>/p2p-circuit/p2p/<peer ID>`, or `/p2p/<peer ID>` with the relay in
+`--libp2p-relays`). It needs `transports =
+["libp2p"]`, `libp2p-listen` and `libp2p-key-file` (a relay's peer ID must stay the
+same); options about a swarm or a document are errors with it. With `htpasswd`, its
+HTTP API has `/health`, `/admin/stats` (its addresses, ready to list, and its
+transport figures) and `/admin/log`; without, it has none.
+
+```toml
+relay-only = true
+[libp2p]
+enable = true
+listen = ["/ip6/::/tcp/9661", "/ip6/::/udp/9661/quic-v1"]
+key-file = "/var/lib/continuum/libp2p.key"
+```
+
+Then list it for the swarm (`_continuum/relays`, above), or configure it on the nodes
+that need it (`--libp2p-relays`). A node
+publishes its address through a relay (`…/p2p/<relay>/p2p-circuit/p2p/<peer ID>`) with
+the others. Peers that reach
+it through the relay then try to connect directly (hole punching, libp2p's DCUtR); where
+that fails, the relay carries the traffic. Hole punching needs both to listen, though
+the firewall may stay closed (`/ip6/::/udp/0/quic-v1`: QUIC sends from where it
+listens). Each offers where the relay saw it and where it listens, so nodes on one LAN
+meet there, rather than at their router's public address. A punch that fails is tried
+again after a minute, then after 5, then every 30 minutes, while the peers stay
+connected through the relay: what failed may have been passing. A relay takes at most 1024 circuits (64 per
+peer), each for up to an hour and 1 GiB, and doesn't limit how often they're asked for; a circuit that ends is made again on the next
+message. Only relays of the swarm are used, unless the node is told to use public
+infrastructure (below). How it goes (AutoNAT's verdict, connections direct or relayed,
+relay slots, hole punches, failed dials) is in `/admin/stats` under
+`transports.libp2p`, and each peer's connection in `/peers` ([`measurements.md`](measurements.md),
+Transports).
+
+### Address ranges
+
+With `--libp2p-ignore`, libp2p leaves address ranges out, e.g. `10.244.0.0/16` and
+`42::/16` for VPNs it should neither depend on nor take a detour through: addresses
+there are neither published (announced ones are, whatever they are) nor told peers
+that connect nor offered for hole punching nor dialled, and connections there are
+refused, whoever dials. Peers connected to the node forget its addresses there; others
+when they next connect. Addresses of peers there aren't kept either (nor shown, nor
+passed on), nor those through a relay reached there, as they're never dialled. libp2p still listens there, if told to listen
+on every interface: the log (at debug level) says which addresses are left out. A node
+listening on every interface (`0.0.0.0`, `::`) has many addresses, and a node
+publishes 4 direct ones at most (those the router or AutoNAT found first, then those in
+`--libp2p-prefer`),
+and 2 through relays: leaving VPN ranges out keeps the ones that matter. A relay reached
+only through a left-out range (or one without a route) can't be used then, and isn't
+tried.
+
+Ranges this node has no route to are left out alike, without being named: the
+operating system is asked whether it has a route to a peer's address (per /24 or /64,
+again every 30 s), and where it has none, such addresses aren't kept or dialled. A
+node without IPv6 to the internet thus leaves peers' public IPv6 addresses out; once
+it has a route again, they're taken again as they're heard of next. Each range that
+loses or gets its route is logged (at debug level).
+
+With `--libp2p-internet-hosts`, e.g. `one.one.one.one` (names or addresses; names are
+resolved every 30 s, so both families are covered), the node also asks whether it has a
+route to the internet at all, per IPv4 and IPv6: where it has none, it doesn't publish
+its own public addresses of that family, which nobody could get answers from; nor any
+public address of its own before the first answer (a few seconds after starting).
+Nothing is sent to those hosts; only the routing table is asked. Without it, or for a family
+none of them resolves to, this isn't asked, and every address is published.
+
+With `--libp2p-prefer`, the ranges that matter most come first, best first, e.g. the
+LAN's `10.23.0.0/16`: of the addresses a node listens on, those there are published
+first, and of the addresses a connected peer tells (identify), they are kept first
+(a node keeps 8 per peer). Without it, and after the preferred ones, public addresses
+come first (IPv4 outside the private, shared and reserved ranges; IPv6 in global
+unicast, `2000::/3`), then those through relays, then all others (container bridges,
+other LANs, overlays such as Mycelium's `400::/7`). Nodes on one LAN that are connected only through a relay find each
+other's LAN address this way, and dial it. A preferred range all inside an ignored one
+is refused (nothing there would be used); one with an ignored range inside it is
+taken, with a warning: the ignored part stays left out.
+
+### Through the router: UPnP
+
+With `--libp2p-upnp`, a node asks its router to forward its listen ports (UPnP, which
+most home routers offer, IPv4), and publishes where: the router's public address, next
+to its listen addresses. Nodes elsewhere then dial it directly, with neither a relay
+nor hole punching, which matters where the other side's NAT defeats punching (mobile
+networks' carrier-grade NAT). It needs a fixed port to be useful across restarts
+(`/ip4/0.0.0.0/udp/9661/quic-v1`), and different ones for nodes behind one router. The
+log says what the router did (`The router forwards to us`), or that no router answered;
+a router behind another NAT maps nothing useful, and the log says so too. The ports are
+open on the router for as long as the node runs; `--libp2p-announce` takes precedence.
+
+### Public infrastructure
+
+With `--libp2p-public`, a node uses public libp2p infrastructure: it joins the IPFS DHT
+(Kademlia, through the IPFS bootstrap nodes at `/dnsaddr/bootstrap.libp2p.io`), where it
+finds addresses of peers the member list doesn't have, and public peers that relay;
+those are picked after members that relay. Public relays keep circuits short (2 minutes
+and 128 KiB by default): enough to punch holes through NATs, not to carry a swarm's
+traffic for long. It also makes the node's peer ID and addresses known to the DHT, so
+to anyone. Without it, nothing public is used. (For Mycelium, public peers are in
+`--mycelium-peers`: none unless given.)
+
+### On the LAN
+
+With `--libp2p-mdns`, a node finds libp2p peers on its LAN (multicast DNS, IPv4): it
+notes their LAN addresses, and those that speak continuum are seeds while it joins, so
+nodes on one LAN find each other without `--seeds`. On a LAN with several swarms, a new
+node without a state file joins whichever answers first, unless a swarm key
+(`--swarm-key-file`) keeps it out of the others. Seeds for it are such
+multiaddrs, with or without the peer ID and with or without the `libp2p:` scheme
+(`/ip6/fd00::1/tcp/9661`, `libp2p:/ip6/fd00::1/tcp/9661/p2p/12D3…`); without the peer ID,
+the node connects to find out who is there. A peer ID alone (`libp2p:/p2p/12D3…`) is
+reached where the node knows it, or else through the relays it knows (`--libp2p-relays`,
+above): a seed behind a relay, without spelling out the circuit. It doesn't get through NAT yet, and never uses
+public libp2p infrastructure (bootstrap nodes, relays).
+
+## Tor
+
+Tor (`--tor-enable`, in builds with the `tor` cargo feature) is for reachability:
+every node runs an onion service (with arti, embedded), which other nodes reach from
+anywhere, without a forwarded port or a relay. It isn't for anonymity: a node's other
+addresses are in the member list next to its onion address. `--tor-state-dir` is where
+arti keeps its state and cache, the onion service's key among them, which makes its
+address: keep it with the node, like the node key. `--tor-port` is the onion service's
+port (9662). A node's address is its onion service, `tor:<onion>.onion:<port>`, which is
+also what a seed gives (one ending in `/<node key>`, as nodes published them before,
+still works). Arti bootstraps in the background, which takes a while: the node starts
+without waiting, sends over Tor wait for it, and a new onion service may take a minute
+or two to be reachable.
+
+Onion services prove themselves to who connects, but not the other way: at the start of
+every connection, both sides prove their node key in a challenge handshake bound to the
+onion address dialed ([`transport/challenge.rs`](../src/transport/challenge.rs)), and the dialer says which onion service
+is its own. The node dialed believes that once a dial there found the same node key: it
+dials back to check if it hasn't dialed there yet, once, and closes that connection
+again; a claim that failed that check is refused without dialing for 10 minutes. Nodes
+keep one connection per pair. Round trips over Tor take a second or more, so a peer on a
+faster transport too is reached there while it works (the paths layer picks by
+round-trip time). Arti's onion services are still experimental: expect them to be less
+reliable than the other transports.
+
+## Keys
+
+Each key file is read when the node starts; a missing one is an error, unless the
+option creating it is given (a new key, in a new file only its owner may read):
+
+| Key | File | Created with | A new one means |
+|---|---|---|---|
+| Node | `--node-key-file` | `--node-key-create` | A new node |
+| Swarm | `--swarm-key-file` | `--swarm-key-create` | Only on the swarm's first node: the others need a copy |
+| Mycelium | `--mycelium-key-file` | `--mycelium-key-create` | A new Mycelium address |
+| libp2p | `--libp2p-key-file` | `--libp2p-key-create` | A new peer ID |
+
+Tor's onion service key is in `--tor-state-dir`, made by arti.
+
+## Figures
+
+How the transports find and reach peers: [`measurements.md`](measurements.md), Transports.
diff --git a/docs/trust.md b/docs/trust.md
new file mode 100644
index 0000000..521a175
--- /dev/null
+++ b/docs/trust.md
@@ -0,0 +1,114 @@
+# Trust model
+
+Who can do what to a swarm, and what stands in the way. In short: the swarm key
+decides who is a member, and members are trusted with everything.
+
+## The boundaries
+
+**The network.** Every transport authenticates the sender of every message, each by
+its own key: Mycelium by the public key of the sending daemon, libp2p by the peer ID
+of the connection, Tor by a challenge handshake on each connection, in which both sides
+prove their node key ([`transports.md`](transports.md)). So a node always knows which address sent it something, though not
+who wrote what that message carries (changes are relayed). On top, a node proves its
+node ID at each address with a signature by its node key (WHO and ME, [`peers.md`](peers.md)), so a
+node is known by its node ID whichever transport and address it uses. A private Mycelium
+network (`--mycelium-network-key-file`, `--mycelium-network-name`) additionally keeps
+other Mycelium nodes from reaching ours at all; on libp2p and Tor, anyone who knows an
+address can connect, and the swarm key is what keeps them out.
+
+**The swarm key.** With `--swarm-key-file`, a node proves the key in the handshake
+before anything else it sends is acted on; until then it is a *stranger*. Without a
+swarm key, every node that can reach a member can join: a joining node doesn't need to
+know the swarm ID, and the swarm ID is no secret anyway. The key file should be readable
+by its owner only; a node warns at startup if others may read it.
+
+**Public infrastructure.** Nodes use none unless told to: Mycelium peers are those in
+`--mycelium-peers`, and libp2p reaches only seeds, members and the relays of the swarm.
+With `--libp2p-public`, a node joins the IPFS DHT and may use public relays: they see
+whom it talks to and how much (the traffic itself is encrypted end to end), and the DHT
+makes its peer ID and addresses public.
+
+**The node key.** `--node-key-file` holds the node's own key, from which its node ID
+(`continuum:<hex>`) derives: who the node is, whatever transport reaches it. It is a
+secret like the swarm key: readable by its owner only (a node warns otherwise; a key
+it creates is).
+
+**The HTTP API.** Every endpoint but the API description needs a user from
+`--htpasswd`. It listens on `127.0.0.1:3000` by default (`--bind-addr`) and speaks
+plain HTTP: to expose it, put a TLS proxy in front. All users may read and write user
+data and use the admin endpoints (remakes, forgetting peers, exports, log filters).
+There are no read-only users: whoever may open the dashboard may also edit and delete
+the document's data from it, and its writes reach the whole swarm.
+Only the users in `--protocol-writers` may write protocol data through the store; epoch
+metadata and the swarm ID are never writable. The `/debug` endpoints exist only with
+`--debug-api`.
+
+**The configuration.** Whoever can write a node's configuration file or its options can
+run commands as the node's user: a `cmd:` (or `command`) value runs at startup, and a
+`file:` value reads any file the node can ([`configuration.md`](configuration.md), "Importing values").
+Protect the configuration like the swarm key.
+
+## What members are trusted with
+
+Everything in the document. A member syncs changes to and from its peers, and every
+node acts on what the document says:
+
+* **User data**: any member can write or delete any path.
+* **Who wrote what** (`GET /admin/changes`): each change names its node and API user
+ as the writing node says; nothing signs that, so a member can name another. The
+ names of the users who write are in the document, and so on every node.
+* **The member registry**: each node writes its own entry, but nothing stops a member
+ from writing others'. The registry decides the topology and, through the alive
+ stamps, the electorate of a remake.
+* **Forget records**: any member can make the swarm forget a peer (until it says HELLO
+ again).
+* **Remakes**: any member can propose one, vote, and, as coordinator, decide. Votes
+ are not signed: the votes register says who voted, but any member could write any
+ entry. Every node switches to a committed epoch without counting the votes again.
+* **Epochs it advertises**: a rejoining node follows the epoch ready members
+ advertise, corroborated by two of them once it was ready (one if only one is active,
+ and one before it was ever ready), and downloads the epoch's snapshot from them,
+ checked against its hash.
+* **What it says about itself**: its keepalives and handshake replies say whether it is
+ ready, whether it would sync with the recipient, and how long it last heard from it
+ (which counts towards the recipient's stability tier). Nodes believe that: a member
+ claiming to be ready can become a joining node's catch-up source (though one with an
+ empty document can't make it ready: empty heads prove nothing), and one misreporting
+ absences can move a node between tiers.
+
+So a member that misbehaves, by a bug or on purpose, can rewrite or delete data,
+manipulate the registry and the electorate, forge votes, commit a remake without a
+majority, and make the swarm forget peers. Nothing but the swarm key protects against
+that; keep it to nodes you trust as much as your data.
+
+## What depends on clocks
+
+Nodes don't agree on time; each judges by its own clock: voting periods, when a remake
+expires and may be aborted by others, the replay window, alive stamps (and so the
+electorate). Keep the clocks within a grace period (four keepalives) of each other,
+e.g. with NTP. A node whose clock is further off can, together with an operator's
+abort, make a remake contested ([`remakes.md`](remakes.md), Limits). A node compares its clock with its
+peers' and warns when its own is off (`--clock-skew-warn`; [`measurements.md`](measurements.md), Clocks).
+
+## What a stranger can do
+
+Send messages. Before it proves the swarm key, a node ignores everything from it but
+the handshake, and doesn't sync with it. A stranger can still cost a node the work of
+reading and dropping its messages.
+
+## What protects against mistakes
+
+Not against a hostile member, but against honest ones getting in each other's way:
+
+* Majority voting with one vote per member and epoch: two remakes can't both commit
+ ([`remakes.md`](remakes.md)).
+* A snapshot every node builds itself and checks against the committed epoch, and
+ downloads checked against their hashes.
+* Protocol data writable through the API only by `--protocol-writers`, and logged.
+
+## Not yet
+
+Revoking a single member, or limiting what a member may do, needs per-node keys: a
+member list in the document, with keys that sign what each node writes about itself
+(its registry entry, its votes). See [`review-rejections.md`](review-rejections.md), "Author binding for remake
+votes and decisions".