open-standards/wai/wai-fleet/README.md
Transaction Science d82b136176 wai-fleet: a fleet as a service, enrolments and records kept append-only, reports signed and kept with their bundles
The HTTP API of extensions/fleet.md §9 over a directory it owns (two
append-only logs, reports written whole, a lock, everything verified again on
start), and wai-fleet collect, which gives each line of a node's summary file,
a path or an http(s) address, to a running service. Its tests sign the
corpus's report byte for byte, recover the directory, cut off a line a crash
cut short, read every corpus summary back through the reference relay's own
types, and collect records the relay's own code signed from a file over HTTP;
smoke.sh checks the API over HTTP and the bundle with fleet_verify.py.

wai-rs: Held checks a record or an enrolment before it is kept, so the
service keeps one only once it is on disk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:31:13 -04:00

100 lines
5.9 KiB
Markdown

# wai-fleet
A WAI fleet ([`extensions/fleet.md`](../extensions/fleet.md)) as a service. An organisation enrols
the nodes it runs by their signing keys: relays, compositors, ingest gateways, room services,
ledgers and audit collectors. Each node contributes the records it already signs, as it signs
them: a relay's session summaries, and every hop's per-hop records with their ledgers, with the
room descriptors that place those in time. The service verifies each record against its node's
enrolled key, keeps it append-only, and for a period signs the **fleet report** with the
organisation key. The report has one line per node and one per room, with sessions, bytes and
energy, and each line cites the records it came from. Energy keeps its acquisition class: one row
per scope and class, never summed across either. An unmetered figure is counted as unmetered,
never as zero joules, and a total over measured and unmetered figures says it is partial.
Every report is kept with its **bundle**: the report, its enrolments and its records. Any verifier
of the extension checks a bundle without this service, the corpus's second implementation among
them:
```sh
python3 -I ../live-conformance/fleet_verify.py --bundle week.wai-fleet.json
wai verify week.wai-fleet.json
```
Accounts, members, sign-on, quotas and billing are not here.
## Running it
```sh
cargo build --locked --release
WAI_FLEET_DIR=/path/to/fleet WAI_FLEET_KEY=/path/to/organisation.key WAI_FLEET_ADDR=127.0.0.1:8252 \
./target/release/wai-fleet serve
```
| variable | meaning |
|---|---|
| `WAI_FLEET_DIR` | the directory the service owns (required); created when absent |
| `WAI_FLEET_KEY` | the organisation key's file: 32 bytes, the Ed25519 seed; made with mode 0600 when absent |
| `WAI_FLEET_ADDR` | the address it listens on (default `127.0.0.1:8252`) |
`wai-fleet key` prints the organisation key of `WAI_FLEET_KEY`. `wai-fleet verify FILE` prints a
bundle's outcome. Enrolments are signed with the organisation key, by `wai enrolment create`, and
given to the service; the service signs none itself.
`wai-fleet collect URL SOURCE` reads a node's summary file and gives each line to the service at
`URL` as a summary record: `SOURCE` is a path (a relay's `summary.out_path`) or an `http` or
`https` address that answers with the file. It prints how many lines were held and how many were
refused, by reason. Collecting the same file again holds nothing twice: a line held already is
refused `record_duplicate`.
The directory:
```text
LOCK held exclusively while a process serves the directory
enrolments.jsonl {"enrolment": hex}, one a line, in the order admitted
records.jsonl each record's carriage, one a line, in the order admitted
reports/<digest>.wfr a report
reports/<digest>.wai-fleet.json its bundle
```
The two logs are append-only: a line is written whole and the log synced before the request that
admitted it is answered, and a line a crash cut short, never answered, is cut off on start. Reports
are written whole (to a temporary name, synced, renamed into place). On start the service takes the
lock (a second process on the same directory is refused), then reads every enrolment, record and
report back and verifies each again, and refuses to start on any that does not.
## Endpoints
| | |
|---|---|
| `GET /v1/fleet` | the service, its organisation key and its limits |
| `GET /v1/fleet/health` | `ok` |
| `POST /v1/fleet/enrolments` | `{"enrolment": <hex>}`: an enrolment the organisation key signed, read and verified, for a node not enrolled yet; `201` with its node, role and digest |
| `GET /v1/fleet/enrolments` | every enrolment, with its node, role, label, span, digest and the records held of it |
| `POST /v1/fleet/records` | `{"records": [...]}`: each record in its carriage, in order, verified against its node's enrolment and every record held; `200` with how many were held and refused, and each one's kind and identity or its reason |
| `GET /v1/fleet/rooms/{room}/hops` | the per-hop records held of a room, each `record` and `ledger`, as a receipt ledger's room source holds them |
| `POST /v1/fleet/reports` | `{"start": <µs>, "end": <µs>}`: the report of the period over the enrolments whose span meets it and the records held in it, signed, verified and kept; `201`, its digest, its outcome and its bundle |
| `GET /v1/fleet/reports` | every report kept, with its period |
| `GET /v1/fleet/reports/{digest}` | a report's bundle and outcome |
| `POST /v1/fleet/verify` | a bundle: its outcome |
A refusal is `422` with `{"outcome": "refuse", "reason"}`, the extension's reason; a record refused
in a batch is named in the batch's answer. A body that is not what the endpoint takes is `400`; an
unknown report is `404`. A request body is at most 32 MiB and 10 000 records. Requests that change
the fleet run one at a time; `POST /v1/fleet/verify` verifies at most two bundles at a time.
The service has no accounts. It holds the organisation key because it signs reports. It accepts an
enrolment only when the organisation key signed it and a record only when it verifies under an
enrolled node's key, whoever submits either. It listens on a loopback address unless told
otherwise: who may reach it is decided in front of it.
## Checks
```sh
cargo test --locked # the corpus's report signed byte for byte, the directory
# recovered after a restart, a line cut short cut off, one
# process per directory, the corpus's summaries read and
# written again by the relay's own types, and records the
# relay's own code signs collected from a file over HTTP
bash smoke.sh http://127.0.0.1:18252 # over HTTP, against an instance started with the corpus's
# organisation key (see .github/workflows/wai-fleet.yml)
```