Shoal exists because my local-first apps (mnemonic, habit-tracker, scalidraw) needed to move between devices without an account or a cloud I have to trust. The server stores an append-only, per-user log of encrypted operations, assigns each one a sequence number, and serves everything after a cursor. Every merge decision is made on the client. A server that only orders ciphertext has nowhere for app-specific logic to accumulate, so it has not changed as apps were added.
architecture
the server orders ciphertext and pokes subscribers. every merge decision is made on the client, so the server never needs to change when an app is added.
the only secret is the 12-word phrase. the same words on a new phone derive the same keys and the same user id, and a sync() replays the log. key loss is data loss, and that trade is documented rather than hidden.
The server is an axum router with four routes: POST and GET /v1/ops, POST /v1/compact, and GET /v1/poke for server-sent events, plus a health check. Auth verifies an ed25519 signature over three X-Shoal-* headers with a 300 s clock-skew window. Storage is SQLite through rusqlite in WAL mode, two tables, with migrations keyed on PRAGMA user_version. About 1,900 lines of Rust.
The client has five modules: keys (BIP39 to keys, AEAD, request signatures), a hybrid logical clock, the sync engine (outbox drain, pull and merge, poke loop, backoff, compaction), a Dexie storage adapter over three tables inside the app’s own IndexedDB database, and errors.
the path of one write
- The app calls
record("habit/<id>", state)with the full record. The client stamps a clock value, generates a UUID op id, and enqueues plaintext into the outbox table. No network and no crypto yet. Oversized records are refused here so an undeliverable op cannot block the FIFO outbox. - On push, each payload is encrypted with XChaCha20-Poly1305, nonce prepended, with the collection and record id as associated data, then batched under both an op-count and a byte budget.
- The server verifies the signature, decodes the payload to raw bytes so malformed data returns 400 instead of persisting, and appends inside one transaction that also enforces caps and assigns
seq. A duplicate op id returns the existing seq, which makes retries idempotent. - The server broadcasts a poke carrying only
{head: N}to that user’s SSE subscribers. A poke is a hint, never a data channel. - The other device pulls
?since=cursor, folds each remote clock into its own, skips its own echoes (an equal stamp is its own write), decrypts, and hands the winner to the app.
The server sees the collection name, record id, clock (which includes a device-stable node id), timestamps, payload sizes, and the public key. That list is written down in the protocol doc under “what the server can see”. It cannot read contents, forge ops, or move an op between records, because the associated data binds each ciphertext to its record. It can withhold ops or lie about head. There is no hash chain, and the docs say so.
technical decisions
- An op log of full record states, not a CRDT. Automerge and Yjs solve collaborative text. A playlist name or a habit completion needs a winner. Full-state ops make merge order-insensitive and make compaction trivially sound.
- Last-writer-wins on a hybrid logical clock. The clock is encoded as 12 hex digits of milliseconds, 4 of counter, 8 of node id, so lexicographic comparison is causal comparison and a merge is a string compare. Tables can opt into append-only instead, which is set union on unique ids.
- Keys from 12 words. BIP39 seed, then HKDF-SHA256 with
shoal/v1/signfor the ed25519 seed andshoal/v1/encfor the encryption key. The consequences are documented: no forward secrecy, and key loss is data loss. - No accounts and no cookies. Every request signs the method, the path with query, a timestamp, and the SHA-256 of the body. There is no ambient authority, which is why CORS defaults open. Users are created implicitly by the first valid signed push.
- SQLite, with
headandop_countstored. Compaction leaves gaps, soMAX(seq)would overstate andCOUNT(*)would be an O(ops) scan per push. - HTTPS and SSE only.
EventSourcecannot set headers, so the client frames SSE by hand fromfetchand signs that request like any other.
hard problems
- Compaction is scoped to one collection and capped at the client’s own cursor. It keeps the highest-clock op per record and leaves
headuntouched, so older cursors stay valid andseqis never reused. Tombstones survive it, so a device that went offline before a delete still learns about the delete. - An op that fails to decrypt records its clock and fires
onUndecryptableinstead of wedging the whole sync. - Backoff reads stream lifetime as a health signal: a poke stream that stayed up resets to the floor, one that dies at once keeps escalating.
- A 413 halves the batch and retries instead of stalling the outbox.
- Concurrent
sync()calls join the in-flight round and schedule exactly one more, so a poke landing mid-sync is never lost. - Schema migrations run in place inside a transaction, including a v0 to v1 rewrite of payloads from base64 text to blobs that rolls back leaving the file untouched.
numbers
| measure | value |
|---|---|
| rust tests | 26 unit, 22 integration |
| client tests | 58 across 8 files |
| payload cap | 256 KiB per op, 16 MiB per request body |
| batch | 1,000 ops accepted, the client pushes 500 |
| rate limit | 120 requests per minute per user |