eleven / messenger Public

docs: multi-edge — region as a property, relocation, replicated spaces (#1087)

Plan of record for the direction agreed 2026-07-18: a space's region is a
mutable property (home_region), never encoded in its address, so the address
stays stable identity while region is free to change. Three phases:

1. Stable-address relocation — admin moves a space between regions; address,
   invite links, and keys unchanged. Reuses hibernation + single-writer lease +
   proxy-to-owner; the DNS propagation window is bridged by the proxy (no live
   replication needed); dynamic DNS keeps the name fixed while the A record moves.
2. Multi-edge within a region — replicate the log across a region's boxes;
   resilience/latency with no residency promise touched.
3. Cross-region multi-edge — the north star, as an explicit owner mode.

The load-bearing rationale: our data is an append-only log of immutable, opaque,
id'd events — formally a CRDT — so merging edges is union+dedup+sort, removing
consensus / write-conflict / content-merge entirely. What remains is
eventual-consistency plumbing (ordering, ephemeral fan-out, a few watermarks).
Names the residency-vs-multi-edge tension (two owner modes, not both at once) and
that this supersedes the country-in-host dev scheme (reusing its per-box
advertisement plumbing, keyed on region not host-domain). Cross-linked from
planet-scale.md's home_region roadmap bullet.

Design only — no code. Build order = the three phases, each its own slice.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell a410227cf5e211bc6992307c2d9d6f4acd82ee30 parent 24e8da6
2 files changed, +200 −1
  • docs/multi-edge.md +195 −0
  • docs/planet-scale.md +5 −1
diff --git a/docs/multi-edge.md b/docs/multi-edge.md
new file mode 100644
index 0000000..5e539a8
--- /dev/null
+++ b/docs/multi-edge.md
@@ -0,0 +1,195 @@
+# 🤖 Multi-edge — region as a property, relocation, and replicated spaces
+
+Status: **direction agreed 2026-07-18** (Paul). This is the plan of record for
+how a space is *placed* (which box serves it), *relocated* (moved between
+regions), and eventually *replicated across edges* (served from several boxes at
+once). It builds on `docs/planet-scale.md` (the running single-owner system) and
+supersedes the "country in the hostname" scheme the dev mesh trialled
+(`docs/dev-mesh.md`, PR #1043; described today by the `docs/planet-scale-guide/`
+proving-ground page); see [What this supersedes](#what-this-supersedes).
+
+The north star is **multi-edge**: a space live on several edges at once, so every
+member reaches a nearby one. Two interim steps stand on their own and lay the
+groundwork: **stable-address relocation** and **multi-edge within a single
+region**.
+
+## The one idea: region is a property, not an identity
+
+A space's **address is its identity** — the name members type, the host its
+invite links carry, the origin its per-thread encryption keys are bound to.
+Identity must never change, because changing it re-keys every member.
+
+Where a space *lives* — its **region** — must be free to change: auto-assigned at
+creation, movable later by the space's admin, and (the endgame) spread across
+several places at once. So region **cannot be encoded in the address**. It is a
+separate, mutable property of the space — a `home_region` field — and the address
+stays region-neutral and permanent (`<sqid>.11ch.at`, one wildcard, no country
+label).
+
+This is the `home_region` model `docs/planet-scale.md` always pointed at:
+placement and residency are properties the infrastructure moves a space *to*,
+never facts baked into its name.
+
+## The enabling property: append-only + content-blind is a CRDT
+
+Everything ambitious below is tractable for one reason, and it is worth stating
+plainly because it is load-bearing: **our data model is an append-only log of
+immutable, opaque, id'd events.** Messages, edits, deletes, and reactions are all
+*events* — a delete is a new event, never an in-place row mutation; a reaction
+rides inside an event. The server stores and relays only ciphertext it cannot
+read.
+
+An append-only set of id'd events is, formally, a **CRDT** (a grow-only set).
+Merging two boxes' logs is *union → dedup by id → sort by (timestamp, id)*. There
+is no conflict to resolve, no data to lose, no coordination to run, and it
+converges regardless of the order events arrive in. The server was never going to
+do content-based merging — it cannot see the content — and it does not need to.
+
+This is what removes the **hardest categories** of distributed-systems work from
+the table entirely:
+
+- no consensus / leader election for writes,
+- no write-conflict resolution,
+- no content merges or last-writer-wins data loss.
+
+What is left is *eventual-consistency plumbing* — a much lower tier of
+difficulty. That is the whole reason multi-edge is a road we can walk rather than
+a moonshot.
+
+## Phase 1 — stable-address relocation
+
+An admin moves a space to another region; its address, invite links, and keys do
+not change. Members notice nothing.
+
+The mechanism reuses parts that already exist:
+
+1. **Copy** the space's store to the destination region's object store. (In the
+ dev mesh there is one shared bucket, so this is free; in production each
+ country has its own store, so this is a prefix-to-prefix copy — done while the
+ space sleeps, per `docs/planet-scale.md`.)
+2. **Flip** `home_region`, which replicates to every box the same way routing
+ rows already do (`meshreplicate.go`).
+3. **Update DNS** — the space's A record now points at the destination box (see
+ [Dynamic DNS](#dynamic-dns-and-the-propagation-window)).
+
+### Dynamic DNS and the propagation window
+
+Keeping the *name* fixed while the *serving box* changes is exactly what
+API-driven dynamic DNS is for (we already use it for DNS-01 wildcard certs): the
+host stays `<sqid>.11ch.at`, only its A record's target moves.
+
+The DNS TTL means a propagation window where some clients still resolve to the
+old box. **No live replication is needed to cover it** — the
+[single-writer lease](planet-scale.md) plus the mesh's proxy-to-owner already do.
+A request that lands on the old box finds `home_region` now points elsewhere and
+**proxies to the new owner**, exactly as it already does for any wrong-box
+request. The lease guarantees only the new box ever writes, so there is no
+dual-writer and no split-brain — the window costs at most a proxy hop, not
+correctness.
+
+So Phase 1 is mostly existing machinery (hibernation + lease + proxy + the
+route-replication plumbing) plus the `home_region` field, region-neutral
+addressing, and placement-by-region. It is the smallest change that delivers the
+stable-address promise.
+
+## Phase 2 — multi-edge within a region
+
+Replicate a space's log across the boxes of *one* region. Same-jurisdiction, so
+**no residency promise is affected** — this is pure resilience and latency, and
+it is where the merge/gossip layer gets validated on the easy case (all edges in
+one country, no cross-border concerns).
+
+Each edge accepts appends locally and propagates them to its peers; the CRDT
+merge means every edge converges without coordination. The single-writer lease —
+which today exists to stop two processes corrupting one SQLite file — relaxes
+into **per-edge logs that merge**, rather than one file one writer. A box failing
+no longer strands its spaces mid-write; its peers already hold the log.
+
+## Phase 3 — cross-region multi-edge (the goal)
+
+The same replication, now spanning regions: a space served from edges in several
+countries, each member reaching the nearest. This is the north star — and the one
+place a real trade-off surfaces, below.
+
+## What append-only does *not* solve
+
+Honest remaining work — the eventual-consistency plumbing the CRDT leaves us:
+
+- **Ordering / causal consistency.** Union+sort converges, but "a reply appears
+ after its parent" and "read-your-writes" want causal metadata (a per-sender
+ sequence or a parent ref) carried *inside* the encrypted event. Today there is a
+ real server-assigned monotonic sequence (the subscription watermark); multi-edge
+ moves that to a partial order (timestamp+id, or Lamport clocks) instead of one
+ counter. Chat tolerates weak ordering well, so this is manageable, not scary.
+- **Ephemeral fan-out.** Typing, presence, and relay-only frames are *not* log
+ entries — they are live signals, and the WebSocket hub is per-box today.
+ Multi-edge needs a cross-edge pub/sub for them. New, but small.
+- **Real-time delivery.** A message appended at edge A must reach a subscriber on
+ edge B *now*, not at anti-entropy time — so edges want fast push to each other,
+ not only periodic reconciliation.
+- **Server-side watermarks.** Read-marks are already forward-only max-wins
+ (CRDT-friendly). Scheduled future-messages (the sweep goroutine, `miniapps.go`)
+ need one edge to own the timer or idempotent delivery. A handful of these, each
+ solvable.
+- **Cost.** N copies of the data and gossip traffic — a real but bounded price.
+
+## Residency ⟂ multi-edge — two modes, not both
+
+Multi-edge and data residency pull in opposite directions: multi-edge **spreads**
+the ciphertext across edges (and countries) for reach and resilience; residency
+**pins** it to one country. They cannot both hold for the same space at once.
+
+So they are **modes a space's owner chooses**, not a global setting:
+
+- **Residency** — one region, data stays put; the guarantee a country subdomain
+ can make.
+- **Multi-edge within a jurisdiction** — the sweet spot: resilience and lower
+ latency with *no* residency promise broken.
+- **Cross-region multi-edge** — maximum reach, explicitly trading the residency
+ guarantee for it.
+
+Relocation (Phase 1) is available in every mode — it is just "change where the
+one copy lives" in the residency/single-edge case.
+
+## What this supersedes
+
+The dev mesh trialled **country in the hostname** (`<sqid>.<cc>.dev.11ch.at`,
+per-country wildcards, placement scoped by the host's country label — the
+domain-scoped mesh in `mesh.go`/`heartbeat.go`/`meshreplicate.go`). That made
+region an *immutable identity*, which is precisely what the stable-address
+requirement rules out. It was a useful stepping stone; this model replaces it:
+
+- **Addresses go region-neutral** (`<sqid>.11ch.at`, one wildcard). The country
+ leaves the name.
+- **Placement keys on `home_region`, not the host label.** The per-box
+ advertisement plumbing that domain-scoping added (each box publishing its
+ capability via a sibling S3 object) is **reused** — a box now advertises its
+ *region* instead of its host-domains, and placement/replication scope to the
+ box(es) whose region matches the space's `home_region`.
+- `docs/dev-mesh.md` and the `docs/planet-scale-guide/` "proving ground" page
+ describe the country-in-host scheme that runs on the dev mesh *today*; they stay
+ accurate until the phases below land, then are reworked in step.
+
+## Status and build order
+
+Nothing here is operable yet; the pieces it reuses are.
+
+- The **append-only, content-blind data model** it rests on: *running today* — it
+ is how every space already works.
+- **Hibernation + single-writer lease + proxy-to-owner** (the machinery Phase 1
+ reuses): *running today* (`docs/planet-scale.md`).
+- **Region-neutral addressing + `home_region` placement + relocation** (Phase 1):
+ *designed, not built*.
+- **Multi-edge within a region** (Phase 2) and **cross-region multi-edge**
+ (Phase 3): *designed, not built*.
+
+Build order, each its own reviewable slice:
+
+1. **Phase 1 — stable-address relocation.** `home_region` field, region-neutral
+ addressing, mesh places by region, relocation via lease + proxy + cross-region
+ copy + dynamic DNS. Mostly existing parts; the foundation everything else sits
+ on.
+2. **Phase 2 — multi-edge within a region.** The log-merge / gossip layer,
+ validated where there are no residency concerns.
+3. **Phase 3 — cross-region multi-edge.** The same, spanning regions, as an
+ explicit owner mode.
diff --git a/docs/planet-scale.md b/docs/planet-scale.md
index 52ddcb9..8b69828 100644
--- a/docs/planet-scale.md
+++ b/docs/planet-scale.md
@@ -400,7 +400,11 @@ a seam). Build triggers are events, not dates.
- **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.
+ keys on it. Trigger: member-latency data from a real non-EU/US cohort. The
+ full design that builds on this — region as a *mutable property* (not encoded
+ in the address), stable-address relocation, and multi-edge replication, with
+ the append-only/CRDT reasoning that makes it tractable — is
+ **`docs/multi-edge.md`** (agreed direction, 2026-07-18).
- **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