eleven / messenger Public

docs: planet-scale.md — the running system distilled from #550 (#673)

The design doc Part 12 called for: home's dual-backend vault store, the
stateless ceremony layer, fleet backup as continuous replication, and
instance hibernation end to end (lease → restore → spawn → sleep, due-time
wakes, sidecar co-spawn, per-host flips, hibernate-by-default creates) —
documented as running code with PR references, the decision log updated
with the corrections the build taught (library-only lease, env ownership,
the never-woken reaper case), the 12 invariants, and the unbuilt parts
marked as roadmap. Plus a one-line CLAUDE.md architecture pointer.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fbd15230e6fabc987c5e9487d520965020cb8be9 parent d267aff
2 files changed, +397 −0
  • CLAUDE.md +4 −0
  • docs/planet-scale.md +393 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 8004ca4..1f3e93c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -124,6 +124,10 @@ in, so deploy is one file (no cgo).
forwards a signed-in user's request to it. `reaper.go` reclaims never-claimed
(zero-member) spaces so abandoned creates don't eat the cap, archiving to
`/data/trash` (recoverable) + a trash purge. See `docs/cloud-provisioner.md`.
+- `activator.go` + `internal/activator/` — instance **hibernation** (opt-in,
+ per-host): state parks in S3 (embedded Litestream + a single-writer lease)
+ and the router wakes/sleeps instance processes on demand. The running half
+ of the planet-scale design: `docs/planet-scale.md`.
- `web/pages/*.html` — served with `__GROUP_NAME__` substituted (HTML-escaped).
- `web/static/*` — vanilla ES modules, **no build step**, served via `go:embed`
with `Cache-Control: no-cache`.
diff --git a/docs/planet-scale.md b/docs/planet-scale.md
new file mode 100644
index 0000000..d1de754
--- /dev/null
+++ b/docs/planet-scale.md
@@ -0,0 +1,393 @@
+# 🤖 Planet scale — the running system
+
+This is the distillation of issue **#550** ("use an object store as the only
+database — planet-scale plan", twelve parts across two working sessions).
+Most of what that thread designed has since shipped; this doc describes the
+system as it **runs**, with the still-unbuilt parts clearly marked as roadmap.
+Where the issue and the code differ, the code wins, and the difference is
+noted. #550 stays open only as the historical record of how we got here and
+closes when this merges.
+
+## The one idea
+
+The E2EE rule did the hard part before any of this started. Home's server-side
+state is opaque client-encrypted ciphertext plus a few kilobytes of public-key
+metadata, with zero cross-user queries — so home scales by **removing the
+database**: an S3 bucket, used raw, is a strictly better fit than SQLite ever
+was. An instance's SQLite file is opaque blobs plus routing metadata the server
+already sees — so replicating it to a bucket is threat-model-free, and
+instances scale by **removing the idle process**: state parks in S3 as
+ciphertext, a process exists only while someone's talking, and creating a
+space becomes (nearly) a metadata write. Launch-day load lands on S3's
+capacity, not ours.
+
+## What runs today
+
+### Home: the store is a seam, and S3 is implementation #2
+
+`home/store.go` (PR #611) extracts **`vaultStore`**, home's whole persistence
+surface, shaped like an object store on purpose: point reads by key,
+conditional writes (CAS on an *opaque version string*, with the one universal
+sentinel `"0"` = "no blob yet"), keep-last-N blob versions, prefix deletes,
+and write-once pointers. SQLite is implementation #1 and stays the zero-config
+self-hoster default forever — dual backend is a feature, not migration debt.
+
+`home/s3store.go` (PR #627) is implementation #2: **one bucket, no database**,
+laid out exactly as the issue drew it —
+
+```
+blobs/<uid> the vault; S3-native versioning, version = the ETag
+users/<uid> one metadata JSON per user (credentials + devices),
+ every mutation an If-Match CAS loop
+creds/<credId> write-once pointer, body = uid
+tokens/<sha256> write-once pointer, body = uid
+```
+
+CAS rides S3 conditional writes (`If-Match` to replace; the `"0"` sentinel
+maps to `If-None-Match: *` for create-only). Pointers are never authoritative:
+resolving one re-loads `users/<uid>` and verifies the credential/token is
+still listed, so the stale pointer a revocation leaves behind denies by
+construction. Two open questions from the issue were settled at PR time,
+both worth recording:
+
+- **The client is a hand-rolled SigV4 signer** (`home/sigv4.go`), not the AWS
+ SDK — the surface home needs is six calls, and home keeps its tiny dependency
+ footprint. (The *activator* side, below, does carry `aws-sdk-go-v2`, brought
+ in by litestream — the two made opposite calls for good local reasons.)
+- **Keep-last-5 is enforced at the interface, not by the bucket**:
+ `listBlobVersions` caps at five; the bucket's lifecycle rule prunes
+ noncurrent versions asynchronously, days later, as a backstop. And S3 cannot
+ distinguish "a version string that was never ours" from a plain mismatch, so
+ the SQLite backend's `errBadVersion` (→ 400) is deliberately backend-specific;
+ on S3 both read as conflicts (→ 409).
+
+Selection is by env (`home/main.go`): `LCHAT_HOME_STORE` unset or `sqlite` is
+the default; `s3` requires `LCHAT_HOME_S3_BUCKET` + `LCHAT_HOME_S3_REGION`
+plus AWS credentials in the standard env vars, with optional
+`LCHAT_HOME_S3_PREFIX` (test isolation) and `LCHAT_HOME_S3_ENDPOINT`
+(path-style, for S3-compatible stores). Misconfiguration fails at boot, never
+limps.
+
+The wire contract was already ETag-opaque: PR #612 made the blob `version` a
+JSON string on the wire (clients tolerated both) and coarsened link-token
+`last_seen` to daily, so an object-store backend never pays a write per
+authenticated read.
+
+### Home is stateless
+
+`home/stateless.go` (PR #613) removed the last two things a home process
+remembered between requests. Ceremony proofs became **HMAC-signed tokens**
+(uid + expiry, 5-minute window) the client carries; in-flight WebAuthn
+sessions are **AES-GCM-sealed into the `?session=` query param**. Both keys
+derive from one server secret, `LCHAT_HOME_SESSION_KEY` — auth plumbing like
+the admin token, it cannot open a byte of any vault. Unset, an ephemeral key
+is minted at boot (with a logged warning): single-process self-hosters keep
+working with zero config, and ceremonies just don't survive a restart, which
+is exactly what the old in-memory map gave them. With the bucket backend and
+the stateless ceremonies together, home is a pure function from
+(request, bucket) → response — any replica behind a load balancer can finish
+what another began.
+
+### Fleet backup: replication as a property of normal operation
+
+`deploy/fleet-backup.sh` (PRs #637, #643) closed the actual gap the plan
+started from — instance DBs living on one box with no off-box copy. It runs on
+the router box, idempotent, regenerating `/etc/litestream.yml` from what's in
+`/data`: a CLI **Litestream** (0.5.14) streams every `/data/*.db` — instances
+plus home/push/cloud/registry — to the fleet-backup bucket
+(`eleven-prod-fleet-backup-…`, one prefix per database), under the
+`lchat-litestream` unit and a scoped IAM user that can touch objects but never
+versioning, lifecycle, or the bucket itself. Versioning + a 14-day noncurrent
+window is the ransomware posture: an overwrite or delete with the box's own
+credentials leaves recoverable versions behind. Continuous replication, not
+snapshots — seconds of RPO, point-in-time recovery, and it supersedes the
+parked R2-snapshot (`r2d2.sh`) plan entirely. The restore drill is one
+command, documented in the script's header; run it against a scratch path,
+never over a live file.
+
+One exclusion, added when hibernation went live (PR #658): **hibernating hosts
+are skipped** — the activator's embedded Litestream owns their replication (to
+the instances bucket, under the lease), and two replicators on one WAL would
+fight over checkpointing.
+
+### Instance hibernation, end to end
+
+This is Part 4 v2 of the issue, running. Four layers:
+
+**The lease** (`internal/activator/replica.go`, PR #630). Litestream is
+embedded **as a Go library** — pure `modernc.org/sqlite`, no cgo, so the
+single-binary property survives. Its S3 lease (`lock.json` acquired via
+conditional writes) is the distributed single-writer lock: a second claimant
+fails loudly with a typed `*litestream.LeaseExistsError`. This is a
+correction the plan earned by testing (recorded on #550): the issue assumed
+the lease was CLI config, but **the lease exists only in the library API — the
+CLI silently absorbs a second replicator**, orphaning the loser's writes with
+no error signal. Embedding it turned that from a footgun into the mechanism.
+TTL is 30 s, renewed every 10 s; a failed renewal means leadership is lost and
+the holder stands down — a wedged process is claimable by a healthy peer
+within ~30 s, which is the failover window of the whole design. Restores run
+*under* the held lease (restoring outside it could resurrect a stale copy that
+shadows the true holder's newer state).
+
+**The activator** (`activator.go`, PR #641). With `LCHAT_HIBERNATE_BUCKET`
+set, the router stops relying on systemd to keep flagged instances alive and
+runs them as its **own children, on demand** — "units are for the router,
+instances are its children", exactly as the issue put it. The per-host state
+machine is asleep → waking → awake → stopping:
+
+- **Wake** (a request for a host with no live process): claim the per-host
+ lease at `<prefix>/<host>/lock.json`; restore the DB from S3 only if the
+ local copy is gone (a present local DB is the warm cache — on one box, under
+ the lease, it can only be our own last checkpoint); spawn the instance
+ (exec of the same versioned `live/<host>` binary its systemd unit would run,
+ same env file); start replication under the lease. Concurrent requests
+ coalesce on one wake (singleflight); lease contention and wake timeouts are
+ 503 + `Retry-After` — "sleeping spaces are slow to wake", never a 5xx
+ cascade.
+- **Sleep** (idle past `LCHAT_HIBERNATE_IDLE`, default 15 m — idleness is
+ in-flight-count *and* time, so one open WebSocket holds an instance awake):
+ probe the next due time, SIGTERM the child (its own graceful drain is the
+ final write), final checkpoint to S3, release the lease, forget the entry.
+ The local DB stays as the warm cache.
+- **Failure is the wake path**: a dead child or a lost lease stands the entry
+ down, and the next request re-wakes it. S3 blips leave warm instances
+ serving (local SQLite is authoritative; replication queues and drains) —
+ only cold wakes stall.
+
+**Due-time wakes** (PR #653). Scheduled deliveries (future messages,
+reminders) fire on time while their instance sleeps: at sleep the stop path
+asks the instance for its earliest pending delivery (`GET /api/next-due`,
+admin-gated, over the socket) and records it in the registry
+(`routes.next_due` — persisted, so it survives router restarts); the sweeper
+wakes any sleeping host whose time has arrived, no client request needed.
+Worst-case lateness ≈ one sweep interval (≤30 s) + spawn + one scheduler tick
+(≤5 s). One honest gap the issue glossed: **member-bot charter schedules run
+in the sidecar** (`docs/member-bots.md`), so they don't wake a sleeping
+instance — the index covers server-side schedules only.
+
+**Sidecar co-spawn** (PR #670). A host with a bot sidecar staged
+(`/data/<host>-bot.env` — the same file `lchat-bot@.service` reads) gets its
+`bot run` child spawned right after the instance turns healthy and SIGTERMed
+first at sleep: the sidecar exists exactly while its instance is awake, so its
+lifecycle solves itself — the issue's "co-located over loopback" default,
+built. The subtlety that makes it work: the sidecar dials its instance's
+public URL back through this very router, so it stamps every request
+(`X-Lchat-Sidecar`, `internal/bot/sidecarmark.go`) and the activator makes
+that traffic **activity-invisible** — no idle-clock credit while awake, a 503
+(never a wake) while asleep. Without that, the pair could never sleep and an
+orphaned sidecar's reconnect loop would wake its instance right back, forever.
+Spoofing the marker only downgrades the sender's own requests. Supervision is
+sweep-driven Restart=always with capped exponential backoff; a sidecar that
+won't start never fails the wake — members before bots.
+
+### The flip, per host, and creates that are born asleep
+
+Opt-in is **per host** (`routes.hibernate` in the registry, PR #659), which is
+what makes a production canary safe: `LCHAT_HIBERNATE_BUCKET` ships fleet-wide
+as a no-op, then
+
+```sh
+lchat hibernate <host> --on # stop+disable its unit; the router wakes it per request
+lchat hibernate <host> --off # activator releases (checkpoint + lease back), unit restarts
+lchat instances # HIBERNATE column shows who's activator-managed
+```
+
+migrates exactly one instance, and back. The flip's ordering is deliberate
+(flag → stop unit → reload router → warm request) so the host is never left
+unserveable, and `--off` waits for the child to actually release the socket
+before the unit starts (an instance refuses to clobber a live listener). A
+staged bot sidecar hands over both ways in the same flip.
+
+One lesson found live, now baked in: **env-file ownership.** Systemd reads a
+root-written `EnvironmentFile` as root; the activator reads it as the service
+user — the canary's first wake died on permission denied. `add-instance` and
+both flips now chown the env (and bot session dirs) to match the data dir.
+
+Creation splits into **register** (the row: host, minted-or-imported token,
+placement fields — pure metadata, no capacity) and **materialize** (env file,
+staged binary, unit, health check, router reload, setup invite) — PR #625,
+exactly the seam the issue asked for. A `--hibernate` create (and, behind
+`LCHAT_CONTROL_HIBERNATE_CREATES=1` on the control daemon, **every
+provisioner-created space** — PR #663) skips only the systemd half: no unit,
+no resident process, no DB — the first wake finds no replica in S3 and boots
+fresh. Capacity for hibernating rows is a row ceiling
+(`LCHAT_MAX_REGISTERED`, default 10× `LCHAT_MAX_INSTANCES`), not a process
+cap. Honest delta from the issue's "create = a registry row + an S3 prefix,
+microseconds, no box contacted": today's hibernating create still writes the
+env file, stages the binary, reloads the router — and its readiness probe *is*
+the first wake, because minting the setup invite needs a running instance to
+pre-seed the admin user. Cheap, but not yet free; the fully-deferred
+materialization remains roadmap.
+
+The **reaper** got the property Part 5 promised — reclaiming never-claimed
+spaces **without waking anything**. A hibernating space's stats
+(`setup_complete` / members / `has_content`) are read straight off its local
+DB file, read-only — wake-free because the warm cache is the newest truth on
+one box. The case the code had to add: a hibernating space that has *never
+woken* has no DB at all, and before the fix that read as a stats error — and
+a stats error is a skip-never-reap, so register-only spaces would have been
+immortal. The stats handler now answers those **definitionally from the
+registry row** (0 members, never set up, no content); a missing DB on a
+*non*-hibernating row stays a 404, meaning a failed create, skipped as before.
+
+### The seams cut for what's next
+
+Small PRs that exist so later phases are config, not surgery:
+
+- **Attachments** sit behind a `blobStore` interface (`blobstore.go`, PR
+ #636): write-once immutable bytes, no CAS or versioning needed — sized and
+ thread-tagged in SQLite, ciphertext in the store. Disk is implementation #1;
+ the object-store implementation (R2 for zero-egress media, per the issue) is
+ roadmap.
+- **The cert cache** is behind `autocert.Cache` (`certcache.go`, PR #661):
+ `LCHAT_CERT_CACHE` unset/`dir` is today's `/data/acme` layout; `s3` is
+ reserved for the pooled-router future and **refuses to start** until it
+ exists — failing loudly beats minting certs into a local dir the rest of a
+ pool can't see.
+- **Placement fields**: the registry row carries `home_region` and `provider`
+ (PR #625), single-valued and inert today — the geo (Part 7) and co-op
+ (Part 10) seams, as schema.
+- **Billing rails** (PR #628): the cloud ledger's spaces table holds exactly
+ Part 11's shape — `stripe_customer_id` (an opaque pointer into Stripe) and
+ an `entitlement` flag — no email, name, or card detail anywhere in our
+ stores. Dormant until the Stripe env is set.
+- **The release-feed mirror** (PR #662, `deploy/release-server.sh` +
+ `updater.go`): the fleet self-updates from GitHub, whose CDN has served
+ stale manifests — the Part 9 SPOF. Publishing now mirrors the signed feed
+ and binaries to S3 (`LCHAT_RELEASE_MIRROR_BUCKET`), and updaters fall back
+ (`LCHAT_RELEASE_MIRROR_URL`) when the primary fails *or verifies but is
+ stale* against the newest feed the box ever accepted. Signature verification
+ is identical on both paths: the mirror adds availability, never trust.
+- **The secrets inventory** (`docs/secrets.md`, PR #639): every server-side
+ secret, blast radius, and rotation story — Part 12's operability item 11.
+- **Infrastructure as code** (`infra/`, PRs #609, #637, #638, #658): OpenTofu,
+ managing the static skeleton only — dev and prod as separate states. Dev
+ holds the proof-build buckets and a `t4g.small` box; prod holds the
+ fleet-backup and hibernation buckets (versioning, lifecycle, Block Public
+ Access, scoped IAM users whose keys never enter tofu state) plus CloudTrail.
+ The boundary test from the issue holds and is written into `infra/README.md`:
+ **if the IaC needs to know an instance hostname, the design is wrong.**
+
+## Decision log
+
+Part 12's table, one line per settled call, updated with what shipped. "Held"
+means the decision stands with nothing built against it yet.
+
+| # | decision | chosen | status |
+|---|---|---|---|
+| 1 | home store | S3 as the only database (over R2-sole / DynamoDB) | **Shipped** #611/#627 — dual backend, SQLite stays the self-hoster default; minimal SigV4 client, not the SDK |
+| 2 | home runtime | stateless Go binary, Lambda-able later (over a Workers rewrite) | **Shipped** #613 — statelessness done; Lambda step not taken |
+| 3 | multi-region writes | single write region + CRR warm standby (over active-active, which breaks per-region CAS) | Held — single region today, no CRR yet |
+| 4 | metadata at rest | sealed bucket: HMAC(pepper) keys + pepper-encrypted metadata | **Not built** — object keys are plain uids; bucket stays private (Block Public Access) |
+| 5 | public bucket | design as if public; the flip is its own reviewed decision (takedown process prerequisite) | Held — posture unchanged |
+| 6 | instance runtime | hibernating processes over S3: local SQLite + Litestream, lease = single-writer lock (over Lambda / S3-as-only-DB / cells / renting DO-Fly) | **Shipped** #630/#641 — with the correction: the lease is Go-library-only; the CLI silently absorbs a second writer, so the activator embeds litestream |
+| 7 | placement | rendezvous hash as hint, S3 lease as truth (over a routing table) | Lease half **shipped**; gossip/HRW is roadmap — one box, hash picks self |
+| 8 | instance data | one SQLite per space, forever (shared DB tier is the anti-goal) | Held, load-bearing |
+| 9 | attachments | blob interface → R2 from day one | Seam **shipped** #636; disk impl only, R2 roadmap |
+| 10 | provisioning | create = register only; materialize at first wake; controld dissolves | Split **shipped** #625, hibernate-by-default creates #663; full deferral + controld dissolution roadmap |
+| 11 | bootstrap | thin golden AMI + `lchat bootstrap`; release channel owns versions | `bootstrap` + channels predate #550; AMI roadmap |
+| 12 | migration posture | dual plane — Hetzner as-a-self-hoster + Eleven Cloud on AWS; origin-preserving moves | Held — production is the Hetzner plane; AWS holds buckets + a dev box |
+| 13 | custom domains | CNAME → `<sqid>.11ch.at`, per-host autocert, cert cache → S3 | Cache seam **shipped** #661 (`s3` reserved, refuses); rest roadmap |
+| 14 | IaC | OpenTofu, static skeleton only (over Terraform/Pulumi/CFN) | **Shipped** #609/#637/#638/#658 |
+| 15 | capacity | ASG target-tracking on warm-set RAM; one shared drain path; Spot slice | Roadmap |
+| 16 | geography | per-space `home_region`; relocate while hibernated | Field **shipped** #625, single-valued and inert; behavior roadmap |
+| 17 | push relay | small pool of long-lived processes; self-serve relay tokens | Relay predates #550; self-serve registration roadmap |
+| 18 | co-op | commons/provider seam; delegation unit = the space; ciphertext escrow | `provider` field **shipped** #625, inert; rest roadmap |
+| 19 | billing | Stripe holds all customer data; our record = opaque pointer + entitlement flag; public ledger | Rails **shipped** #628 (dormant until env set); public ledger roadmap (`docs/tier-economics.md` is the cost record) |
+
+Corrections the build taught us, beyond the table: the lease-is-library-only
+finding (#6 above); env-file ownership must follow the reader (root-written
+files, service-user activator — a wake died on it live); a never-woken
+hibernating space has no DB, so reaper stats answer from the registry row or
+register-only spaces become immortal; keep-last-N on S3 is an interface cap
+with the lifecycle as backstop, not a bucket guarantee; and `errBadVersion`
+is a SQLite-only nicety S3 cannot express.
+
+## The invariants — the review checklist
+
+Part 12's twelve lines, refined where building sharpened them. Every future
+PR that touches this territory gets checked against these:
+
+1. The server — and any future *provider* — only ever holds ciphertext plus
+ auth plumbing; never a usable key. (The root rule; everything below is it,
+ applied.)
+2. No cross-user state in home — the zero-join property *is* the scalability.
+3. Never consolidate spaces into a shared DB tier — per-space isolation is the
+ sharding, security, and migration story at once.
+4. DB migrations are additive-only — this is what makes wake-onto-new-binary
+ and channel-following safe.
+5. The IaC layer never learns an instance hostname.
+6. Wire contracts stay backend-opaque — clients never learn (or depend on) the
+ storage backend; versions are opaque strings, `"0"` the one sentinel.
+7. The wildcard TLS private key never crosses a trust domain.
+8. A space's hostname survives every migration — re-keying is never the cost
+ of moving (region, plane, or provider).
+9. The bot sidecar remains a pure outbound client and bot code never runs in
+ the server process — co-spawn makes it a router *child*, but still a
+ separate process whose traffic is marked, activity-invisible, and never a
+ wake source.
+10. No cross-provider protocol, ever — spaces are islands; if a feature seems
+ to need one, the design is wrong.
+11. The 18 KiB vault pad/cap and the no-per-user-analytics / no-email absences
+ move only by deliberate decision.
+12. No PII in our stores, ever — customer identity lives in Stripe; we hold
+ opaque pointers and entitlement flags. Income publishes aggregated;
+ expenses and disbursements publish itemized.
+
+## Roadmap — designed, not built
+
+Everything below is settled design from #550 with no code behind it (or only
+a seam). Build triggers are events, not dates.
+
+- **The activator mesh** — the "second box" work, in progress: membership
+ (gossip or S3 heartbeats), rendezvous hashing as the placement hint, peer
+ proxying to the hash-owner. The lease already guarantees split-brain can't
+ happen; the mesh only decides who *tries* first. Today's single router box
+ is the degenerate case — membership of one, hash picks self.
+- **Multi-region / `home_region`** — one write region per space, wakes
+ deterministic, relocation *while hibernated* (copy the prefix, flip the
+ field, next wake claims the lease elsewhere). The field exists; nothing
+ keys on it. Trigger: member-latency data from a real non-EU/US cohort.
+- **The co-op / provider seam** — `provider` as a real interface with
+ ourselves as provider #1, the Hetzner box formalized as #2, escrow replicas
+ in storage the provider doesn't control. Federation without a federation
+ protocol.
+- **Lambda-able home / autoscaling runtime** — statelessness (the
+ prerequisite) is done; the same binary on an autoscaling platform is the
+ next rung, an edge rewrite deliberately skipped.
+- **Sealed bucket, then the public-bucket decision** — pepper-keyed object
+ names and pepper-encrypted metadata first (hardens the private design too);
+ flipping public-read stays its own reviewed decision with a written
+ takedown process as prerequisite.
+- **Attachments to an object store** — the `blobStore` seam is cut; R2 for
+ zero-egress media is the intended implementation #2.
+- **S3 cert cache** — `LCHAT_CERT_CACHE=s3` is reserved and refusing; needed
+ the day two boxes share issuance.
+- **CRR + second-provider mirror** — the region checkbox and the Ente-style
+ read-only mirror; everything is ciphertext, so copies are threat-model-free.
+- **The AWS front plane** — NLB (TCP passthrough), ASG on warm-set RAM, Spot
+ slice, golden AMI: Part 7's ~$35/mo shape. The account, state buckets, and
+ IAM posture exist; the compute plane doesn't. Trigger: warm-RAM pressure or
+ a launch date.
+- **Observability gauges** — wake latency, lease conflicts, replication lag
+ are loud log lines today, not metrics; Part 9's E2EE-safe metrics discipline
+ is still owed.
+- **The public ledger** — Part 11's append-only books repo + static viewer.
+
+## The historical record
+
+The full twelve-part design conversation — including the superseded "cells"
+Part 4, the cost math, the rejected alternatives in their original words, and
+the lease experiment writeup — is issue
+[#550](https://github.com/elevenmessenger/messenger/issues/550). Related
+docs: `docs/instance-router.md` (the router this grew inside),
+`docs/cloud-provisioner.md` (create-a-space), `docs/server-releases.md` (the
+signed feed + mirror), `docs/secrets.md` (the key inventory),
+`infra/README.md` (the IaC skeleton), `docs/bots.md` / `docs/member-bots.md`
+(the sidecar model co-spawn inherits).
+
+One sentence, carried over intact because it survived contact with the build:
+**the E2EE rule did the hard part everywhere — home scales by removing the
+database, instances scale by removing the idle process, the fleet scales by
+removing the operator, the co-op scales by removing us — and the books
+balance in public.**