Skip to main content

venue playbook

This is the operator's and tester's companion to docs/mesh-federation.md. That page is the design; this one is what to build, what to run, in what order, and what number has to come out of each run before the next one is worth doing. It ends with the go/no-go list for the conference and the runbook for the days it is on.

The honest summary first. What is proven is the seam: a Neutrino node and a Spindle federate in both directions in an MSC4242 room, with every event signed and verified, and a hundred Neutrino nodes converge on one machine when joins are spread out. What is not proven is the radio and the crowd: nothing here has run over real Bluetooth with real phones, and nothing has run a gateway tier against a Spindle at more than one node a side. The ladder below is ordered so that each rung answers the cheapest unanswered question, and the go/no-go list refuses the conference until the rungs that involve phones have been climbed.

1. The parts​

PartWhereRevision to use
Spindletuna-os/spindlemain at or after #372 (MSC4242 rooms, [federation] peers)
Neutrino forkhanthor/neutrino, branch e2ee-key-transport3fb6945 plus contrib/neutrino/0001-gateway-federation.patch
Neutrino LAN and BLE buildshanthor/neutrino-irohhead, built against the patched fork
Companion app and harnesshanthor/indiafoss-companionhead; tools/neutrino-probe is the swarm harness
Chat apphanthor/indiafoss-chat-androidhead

Three roles run this software (docs/mesh-federation.md, "The system"):

  • Phones run Neutrino over BLE and Wi-Fi and federate with each other and with gateways. They never federate with the Spindle.
  • Venue gateways are Neutrino nodes on machines with the venue's uplink: a laptop or small computer per hall, three to five in all. They are the Spindle's only federation peers and the only mesh nodes that dial a hostname.
  • The conference Spindle is the room's home for everyone with internet. It creates the session rooms under org.matrix.msc4242.12 and owns the aliases.

2. Setup​

2.1 Build​

# Spindle
git clone https://github.com/tuna-os/spindle && cd spindle
cargo build --release -p spindle-server # target/release/spindle

# The fork, with the gateway patch
git clone -b e2ee-key-transport https://github.com/hanthor/neutrino
git -C neutrino checkout 3fb6945 -b gateway-federation
git -C neutrino am ../spindle/contrib/neutrino/0001-gateway-federation.patch

# The LAN/BLE node, pointed at the patched fork (local build only)
git clone https://github.com/hanthor/neutrino-iroh
cat >> neutrino-iroh/Cargo.toml <<'TOML'
[patch."https://github.com/hanthor/neutrino"]
neutrino-ffi = { path = "../neutrino/crates/neutrino-ffi" }
neutrino-main = { path = "../neutrino/crates/neutrino-main" }
TOML
cargo build --release --manifest-path neutrino-iroh/Cargo.toml -p neutrino-ffi-ble --bin neutrino-lan

The patch is what lets a gateway reach the Spindle by name and sign its requests; without it the mesh side of every test below fails at the first hop. When the fork carries the change itself, drop the [patch] block.

2.2 The conference Spindle​

One host with a public name (conf.example below), a TLS terminator in front of 8448 for the public internet, and a plain-http listener the venue network can reach. The venue-facing parts of spindle.toml:

[server]
name = "conf.example"

[federation]
# The gateways, by node id (the 64-hex key each one prints at start).
# Plain http on the venue LAN, and an hour's patience for one that is dark.
peers = {
"<gateway-1 node id>" = { url = "http://10.20.0.11:8008", max_backoff_ms = 3600000 },
"<gateway-2 node id>" = { url = "http://10.20.0.12:8008", max_backoff_ms = 3600000 },
"<gateway-3 node id>" = { url = "http://10.20.0.13:8008", max_backoff_ms = 3600000 },
}
# The venue's range, so those literal addresses are allowed.
allow_internal = ["10.20.0.0/16"]
retry_base_ms = 1000

[ratelimit]
# Ring pushes are the expensive notification; ten a minute per user.
rings_per_minute = 10

[metrics]
# Scrape from the operations laptop; the exposition names the peers.

[rtc]
# A LiveKit SFU on the venue LAN, advertised as a focus (docs/matrix-rtc.md).

Register the organiser accounts, create each session room with room_version: org.matrix.msc4242.12, and set its alias (#session-<id>:conf.example). The app's messaging.aliasServer is conf.example, so every attendee resolves the same room.

Check it stands: /_matrix/key/v2/server answers over both listeners, /_matrix/client/v3/capabilities lists the state-DAG version as unstable, and spindle_build_info is on the metrics port.

2.3 A gateway​

neutrino-lan --bind 127.0.0.1:8008 --storage /var/lib/gateway \
--fed-port 8448 --peer <another gateway id>@<its ip>:8448

The first line it prints is its server name, the node id: put it in the Spindle's peers. Two flags are load-bearing and the Companion project's docs/test-gateway.md explains why: --fed-port puts federation on the CoAP sidecar over the iroh link, and --storage holds the identity, so back the directory up before touching it -- deleting it renames the gateway and orphans every room it is in. Run it under systemd with lingering, as the test gateway is.

A gateway joins every session room through the Spindle (/join/<alias>?server_name=conf.example) before the doors open, so the mesh has a copy to join from.

2.4 The app bundle​

Point messaging.aliasServer at the Spindle's name. Keep the two client rules the loopback swarm made non-negotiable (Companion docs/neutrino-scale.md): a random delay of a few seconds before joining a room a crowd opens at once, and rooms per session rather than per track, with a hundred members as the design ceiling on the mesh.

3. The test ladder​

Each rung names what it exercises, the command, the number that has to come out, and what a failure means. Do not skip a rung: the ones above assume the ones below.

Rung 0: the code, on one machine​

What: the seam's mechanics with no network at all.

cargo test -p spindle-core --lib version
cargo test -p spindle-server --test state_dag_rooms --test federation_fork --test federation_peers

Pass: all green. A failure here is a regression in either repository and stops everything.

Rung 1: one node, one Spindle, loopback​

What: every federation surface the seam uses, in both directions.

NEUTRINO_LAN=neutrino-iroh/target/release/neutrino-lan \
SPINDLE_BIN=target/release/spindle scripts/neutrino-interop.sh

Pass: every probe, as recorded in docs/mesh-federation.md: invites both ways accepted, the mesh user joined through make_join and send_join, messages crossing both ways, the Spindle joined a mesh room seeded from its state DAG, the alias resolved, and the six encryption probes -- keys found and claimed in both directions, a device change announced, to-device messages delivered both ways. A failure here is a version or signing mismatch; the rig prints the peer's refusal.

What: the mesh's own behaviour on links that look like the venue's, using the Companion harness, which shapes delay, jitter, loss and bandwidth per node and can cut and heal a link.

export NEUTRINO_BIN=.../neutrino-lan
pnpm --filter @indiafoss/neutrino-probe swarm -- --size 24 --profile ble
pnpm --filter @indiafoss/neutrino-probe swarm -- \
--size 50 --profile ble --gateways 3 --gateway-profile wifi --stagger 250
pnpm --filter @indiafoss/neutrino-probe swarm -- \
--size 100 --profile wifi --gateways 3 --stagger 250

Pass: every node joins, one message reaches every node, and p90 delivery is under the profile's expectation: under a second on wifi, single-digit seconds on ble. A failure here is the mesh, not the seam; the number to keep is p50/p90 delivery per profile per size, in a table beside the loopback one.

Rung 3: the swarm's gateways federated with a Spindle​

What: the whole path end to end in software: a phone on a shaped BLE link, a gateway on Wi-Fi, a Spindle on the far side of a WAN profile.

Today the harness has no Spindle in it; this rung is done by hand until runSwarm grows a --spindle flag (the next tool change, and the one this playbook asks for). By hand: start a Spindle as in §2.2 with the swarm's gateways in peers (their node ids print in the harness log), create a room on the Spindle, have each gateway join it via the Spindle, then have the swarm's phones join through their gateway and send.

Pass: a message from a Spindle account reaches every phone, and a message from a phone reaches the Spindle account, within the Rung 2 budget plus one WAN round trip. A failure here isolates to one hop: Spindle-gateway (check spindle_federation_queue_depth for the gateway's name), or gateway-phone (the harness's own delivery table).

Rung 4: the Spindle at conference load, alone​

What: the side that carries everyone with internet: three thousand accounts, three thousand sync waiters, the announcements room, push.

Spindle's benchmark tooling already runs sittings against a server (scripts/api-benchmark.py, scripts/sitting.py, docs/benchmarks.md); use it with a 3,000-account, one-big-room population: every account long-polling /sync, one announcement a minute, a ring to every account once. Watch spindle_sync_lag_seconds, spindle_append_duration_seconds and spindle_http_request_duration_seconds.

Pass: sync lag stays under a second at 3,000 waiters, the announcement reaches all waiters within a few seconds, and ring delivery obeys rings_per_minute rather than melting the pusher. A failure here is capacity on the box: more cores or a second instance, both of which are ordinary operations questions.

Rung 5: the radio, with real phones​

What: the two numbers no server test can produce: bytes per second per BLE link, and hop latency, in a room full of people and bodies.

Ten to twenty phones with the app, one room, one message per second from one phone for five minutes, then the same with a gateway laptop in the room joined to a Spindle room. Read the app's mesh peers list and the gateway's journal.

Pass: messages arrive on every phone within ten seconds at twenty phones, and the gateway relays a Spindle message to the room within the same budget. Record the peer count each phone sees. A failure here is the transport, and it changes the design: fewer members per mesh room, more gateways, or Wi-Fi as the primary hop with BLE as the fallback.

Rung 6: a dress rehearsal​

What: a meetup or a day at the office with a hundred or more people, the full stack, the real app bundle pointed at a staging Spindle.

Pass: the day passes with the runbook (§6) needing nothing but its first line. Everything it needed beyond that becomes a rung above.

4. What 3,000 attendees means, in numbers​

The population is not one room. It is:

  • The announcements room, everyone in it, unencrypted. This lives on the Spindle, and the Spindle is built for exactly this shape: one append, three thousand sync waiters woken. Rung 4 measures it. Attendees with any internet, including the venue Wi-Fi, read it from the Spindle directly.
  • Session rooms, tens to low hundreds each, one per talk, booth and hall, unencrypted, so a phone that joins late reads them without a key exchange over Bluetooth. These are the mesh's rooms. A hundred is the ceiling the loopback swarm converged at when joins were spread over seconds, and the join storm is the failure mode: the app's join jitter is what keeps it a ceiling and not a cliff.
  • Direct messages and the groups people make, encrypted by default. Two nodes for a DM, and free; a group is a small session room with a key exchange on top. The key material crosses the seam through the federation key endpoints and to-device EDUs (docs/mesh-federation.md, "Encryption"), which rung 1 exercises in both directions. A gateway relays ciphertext and keys and never sees plaintext.

The open question, and the only one that could sink the announcements room for the offline, is announcements to phones with only BLE. A gateway holds the room and fans each announcement out to every phone member it can see, one transaction per phone, over a shared radio. The arithmetic is bytes per announcement, times phones per gateway, divided by the per-link throughput Rung 5 measures. If a two-kilobyte announcement to two hundred phones on one gateway takes longer than a minute, the answer is more gateways or a smaller mesh copy of that room, and Rung 5 is where that number comes from. Until it exists, plan for announcements reaching offline phones late, not not at all: the gateway keeps the outbox and delivers when the phone is in range, which is what max_backoff_ms is for.

5. Go/no-go​

Go only when every line holds.

  • Rungs 0 to 4 green on the revisions in §1, with their numbers recorded in this repository (a table under docs/evidence).
  • Rung 5 run at least once with twenty phones; p90 under ten seconds.
  • Rung 6 run once with a hundred people, and its incident list folded back into the ladder.
  • Three or more gateways placed, each on a wired uplink, each listed in the Spindle's peers, each joined to every session room.
  • The Wi-Fi isolation check passed: a phone on venue Wi-Fi can reach a gateway's federation port even when it cannot reach another phone (Companion #163; gateways on wired uplinks are the answer).
  • Gateway identity directories backed up, and one restore drilled.
  • Spindle backup drilled (docs/lifecycle.md) and the restore brought the rooms back with their aliases.
  • The app bundle's aliasServer is the Spindle's name, join jitter is on, and session rooms are per session.
  • Metrics scraped from the operations laptop, with the three panels in §6 on screen.
  • The two known gaps below are either fixed or accepted in writing.

6. The runbook for the event​

The panels. spindle_federation_queue_depth per peer (a gateway that is dark shows as a rising queue and nothing else), spindle_sync_lag_seconds (the announcements room's health), and spindle_http_request_duration_seconds by route. On each gateway, journalctl --user -u indiafoss-gateway -f.

A gateway goes dark. Nothing to do for a while: the Spindle's outbox keeps its rows and retries on the patient schedule; the phones near it keep federating with each other and with other gateways in range. If it does not come back in an hour, restart it on the same storage directory; the outbox redelivers. Never start it on a fresh directory.

The uplink goes. The mesh keeps working among itself; the Spindle keeps working for the internet. When the uplink returns the gateways' outboxes drain into the Spindle and the Spindle's into the gateways. Expect the announcements room to catch up in the order the gateways reach it.

A join storm (a talk starts and a hall opens the room at once): the symptom on a gateway is 504 M_UNKNOWN: timed out applying room state. The fix is the client jitter that should already be on; if it is not, nothing on the server side helps and the room converges in minutes rather than seconds.

The Spindle restarts. Sub-second on a warm box; sync waiters reconnect. If it does not come back, restore from the last backup; the gateways' outboxes redeliver what it missed.

Two gateways disagree about a session room (a state-DAG fork that neither side names the other's event in). The Spindle sets the contested key aside and keeps taking writes; the gateways run state resolution and may pick the other branch until the resolver lands on Spindle. Expect a room name or topic to differ between sides, never a lost message.

7. Known gaps​

  1. State-DAG fork resolution on Spindle. A contested key is set aside rather than resolved (docs/divergence.md, #16). Two gateways cannot produce a fork the Spindle cannot fold unless they write the same key while partitioned from each other; harmless for a conference room, and the next piece of work regardless.
  2. Inbound request verification on the gateway. The fork reads the X-Matrix origin and does not check its signature. On a venue LAN the operator runs, that is a network-layer trust the design accepts; on a gateway reachable from the internet it is not. The next Neutrino patch, and the reason a gateway's federation port faces the venue network only.
  3. No Spindle in the swarm harness. Rung 3 is manual until runSwarm grows a --spindle flag.
  4. No radio numbers. Rung 5 has not been run. Everything in §4 that depends on BLE throughput is arithmetic waiting for its inputs.