eleven / messenger Public

docs: fleet guide — region placement, region-neutral minting, lchat relocate (#1172)

The multi-edge Phase 1 machinery (region-scoped placement + region-neutral
addressing + relocation) is merged and running on the dev mesh, but the fleet
guide still listed home_region placement as "designed, not built". Bring it
current:

- mesh.html: a new "Region-scoped placement" section — LCHAT_MESH_REGION +
  home_region placement, the region-neutral address rationale
  (LCHAT_CLOUD_REGION / --home-region / LCHAT_CLOUD_ID_LEDGER /
  LCHAT_DNS_PROVIDER=route53), and `lchat relocate` with the single-writer-lease
  data-safety argument. Badged "running on the dev mesh" (on main, not yet on
  prod, which is still one box).
- mesh.html honest-limits + roadmap: promote region placement out of "designed,
  not built"; disclose that the cross-region store copy is exercised only as a
  no-op (dev shares one bucket) and list the three gaps before prod use.
- reference.html: add LCHAT_DNS_PROVIDER / LCHAT_MESH_REGION / LCHAT_CLOUD_REGION
  / LCHAT_CLOUD_ID_LEDGER rows and the `lchat relocate` command + --home-region
  flag. (LCHAT_BOX_REGION stays — it's the distinct dashboard-display string.)

Not published to the public site mirror yet — repo copy only.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell 52cabe522a8effed3ff1b508e92823b2d72f5797 parent 725833f
2 files changed, +58 −3
  • docs/planet-scale-guide/mesh.html +51 −2
  • docs/planet-scale-guide/reference.html +7 −1
diff --git a/docs/planet-scale-guide/mesh.html b/docs/planet-scale-guide/mesh.html
index b670437..8eb76ab 100644
--- a/docs/planet-scale-guide/mesh.html
+++ b/docs/planet-scale-guide/mesh.html
@@ -126,15 +126,64 @@ LCHAT_MESH_TOKEN=&lt;the same shared secret as every other box&gt;
</figure>
<p>The recovery numbers, verified by live tests in both directions: after a <em>graceful</em> drain, the survivor is serving the drained box&rsquo;s spaces about <strong>two seconds</strong> later; after a <em>hard kill</em> (no drain, heartbeat left to rot), within about <strong>the lease lifetime</strong> — thirty seconds when those tests ran, <strong>fifteen minutes</strong> since #836 stretched the lease to tame its S3 request bill — with data intact in both cases (up to the space&rsquo;s <a href="hibernation.html#replication-classes">replication class</a>&rsquo;s window), because writes had been syncing to the bucket all along.</p>
+<h2 id="region-placement">Region-scoped placement: pinning a space to a place, and moving it</h2>
+<p>The mesh so far spreads spaces across boxes for capacity and failover, treating every box as interchangeable. <strong>Region placement</strong> adds a second axis: a space can declare where it wants to live, and the mesh honours it. This is the machinery behind data residency (&ldquo;this space stays in the EU&rdquo;) and, eventually, latency (&ldquo;serve this space near its members&rdquo;) &mdash; and it is <span class="status built">running on the dev mesh</span> today, proven across a two-region (Dublin + Helsinki) fleet. It is on <code>main</code> but not yet deployed to production, which is still a single box.</p>
+<p>Two fields carry it, one on each side of the pairing:</p>
+<dl>
+ <dt><code>LCHAT_MESH_REGION</code> — a box&rsquo;s region</dt>
+ <dd>An opaque label (e.g. <code>ie</code>, <code>fi</code>) each box advertises in its heartbeat <em>body</em> (not the key, which stays the peer URL). Absent means the box is region-agnostic and takes part in placement exactly as before; setting it costs nothing until a space actually names a region.</dd>
+ <dt><code>home_region</code> — a space&rsquo;s desired region</dt>
+ <dd>A replicated registry column on the route. When set, <a href="#placement">rendezvous hashing</a> is scoped to only the live boxes whose <code>LCHAT_MESH_REGION</code> matches it — so the space is owned by a box in its region, and a box in the wrong region forwards to the right one. Empty (every space today) means the whole fleet is eligible, byte-for-byte the pre-region behaviour. If a space names a region no live box serves, placement falls back to the whole fleet rather than stranding it — availability wins over residency.</dd>
+</dl>
+<div class="plain">
+ <span class="label">In plain words</span>
+ <p>Each box wears a region badge; each space can carry a note saying which badge it prefers. The same hashing arithmetic runs, but only over the boxes wearing the right badge. A space with no preference is placed as before, and a space whose region has no box up right now is still served somewhere rather than going dark.</p>
+</div>
+
+<h3 id="region-neutral">The address stays put: region is a property, not a place in the name</h3>
+<p>Because a space&rsquo;s <em>address is its identity</em> (invite links and per-origin browser keys bind to it), region cannot live in the hostname — changing region would change the name and break every member. So a region-placed fleet mints spaces at a single region-neutral domain (<code>&lt;id&gt;.example.com</code>, one wildcard) and the region rides in the <code>home_region</code> field instead. Three variables make that safe at fleet scale:</p>
+<dl>
+ <dt><code>LCHAT_CLOUD_REGION</code> + <code>add-instance --home-region &lt;cc&gt;</code> — stamping the region at birth</dt>
+ <dd>The provisioner stamps each new space&rsquo;s <code>home_region</code> from <code>LCHAT_CLOUD_REGION</code> (or the flag, for a hand-run create), so a space is placed in its region from its first wake.</dd>
+ <dt><code>LCHAT_CLOUD_ID_LEDGER</code> — one id space across regions</dt>
+ <dd>With a single region-neutral namespace, two regions minting at once must never collide on a hostname. The ledger is a small, shared, S3-backed monotonic counter (block-allocated, so roughly one S3 write per hundred creates) that every provisioner draws from — <code>&lt;id&gt;</code> is globally unique regardless of which region minted it. Off by default (a lone provisioner keeps its local ids); an explicit opt-in, because switching it on for a namespace that <em>already</em> has spaces needs the counter seeded above the existing maximum first, or fresh ids would re-mint live hostnames.</dd>
+ <dt><code>LCHAT_DNS_PROVIDER=route53</code> — a wildcard cert without a token on the box</dt>
+ <dd>Every region&rsquo;s box must terminate TLS for any region-neutral host (it may own the host, or forward to the box that does), which needs a <em>shared</em> wildcard certificate — and a wildcard is DNS-01 only. The DNS-01 solver is pluggable: <a href="router.html#dns01">DNSimple</a> stays the default, and Route53 is an additive off-ramp that authenticates with the box&rsquo;s own AWS role/user (scoped to one hosted zone) so no DNS token sits on disk. It never replaces DNSimple — it is chosen only by this variable.</dd>
+</dl>
+
+<h3 id="relocate">Relocating a space between regions</h3>
+<p><code>lchat relocate &lt;host&gt; --to &lt;region&gt;</code> is the deliberate move — an admin changing where a space lives, without changing its name. It runs three steps, and the <a href="hibernation.html#the-lease">single-writer lease</a> is what makes it safe:</p>
+<figure class="cmd">
+<figcaption>Run on a mesh box (with /data/router.env sourced), for a HIBERNATING space</figcaption>
+<pre><code>lchat relocate 8oe.example.com --to fi
+<span class="out">relocated 8oe.example.com: ie → fi
+ store: no copy — source and destination share the same bucket (the shared-bucket case)
+ home_region flipped + route re-fanned-out to peers — the mesh now places 8oe.example.com on a fi box</span></code></pre>
+</figure>
+<ol>
+ <li><strong>Store copy.</strong> The destination box wakes the space by restoring its DB from <em>its</em> region&rsquo;s object store, so the replica must be there first. When both regions share one bucket the bytes are already visible and this is a no-op; with per-region buckets it is a real prefix-to-prefix copy (deliberately excluding the lease object, so the destination takes a fresh lease).</li>
+ <li><strong>Flip <code>home_region</code> + re-fan-out.</strong> The registry column is updated and the route re-replicated to peers, so every box now hashes the space onto a destination-region box.</li>
+ <li><strong>(Optional) move the A record.</strong> Repointing DNS at the destination avoids the forwarding hop, but is not required for correctness — the wildcard + the mesh&rsquo;s forward-to-owner already route through the DNS-TTL window. Off unless <code>--move-dns</code> (Route53 only).</li>
+</ol>
+<div class="why">
+ <span class="label">Why it can&rsquo;t split-brain</span>
+ <p>Region and DNS are only <em>hints</em> that steer placement; the <a href="hibernation.html#the-lease">S3 lease</a> is the one truth of who may write. Relocation never creates a second writer: on a shared bucket both regions contend on the same lease object, so if the source is briefly still awake the destination&rsquo;s wake loses the lease race and retries once the source sleeps; with per-region buckets the copy runs while the source sleeps (lease released, replica flushed) and the destination takes a fresh lease in its own bucket. Re-running a completed relocation is a no-op — once the space is already home the destination holds the authoritative copy, so it never re-copies stale source bytes over newer ones.</p>
+</div>
+<div class="warn">
+ <span class="label">Only hibernating spaces relocate</span>
+ <p>A live, systemd-managed instance is pinned to its box — its database is on that box&rsquo;s local disk, so no other box can restore it. Relocation (and the automatic failover the mesh already does) works only for <a href="hibernation.html">hibernating</a> spaces, whose state lives in the bucket. Convert a live instance first with <code>lchat hibernate &lt;host&gt; --on</code>.</p>
+</div>
+
<h2 id="mesh-limits">The honest limits: what stands between here and a true multi-box fleet</h2>
<p>Two gaps remain. Neither is speculative — each corresponds to a concrete piece of code that is reserved or not yet written. (Two things this section used to list — registry replication and fleet-wide status aggregation — have since shipped and are covered above and in <a href="operations.html#observability">part 08</a>.)</p>
<ul>
- <li><span class="status partial">partially built</span> <strong>Route mutations after creation do not replicate.</strong> The initial fan-out (<a href="#mesh-replication">above</a>) and <code>mesh-seed</code> cover creation and joining, but a later rename, removal, or hibernation-flag flip on one box is not currently propagated to its peers — those must still be repeated by hand on every mesh member. Fine for a pool of auto-provisioned, rarely-renamed spaces; worth closing before mesh placement carries more than that.</li>
+ <li><span class="status partial">partially built</span> <strong>Route mutations after creation do not fully replicate.</strong> The initial fan-out (<a href="#mesh-replication">above</a>) and <code>mesh-seed</code> cover creation and joining, and <a href="#relocate"><code>lchat relocate</code></a> now explicitly re-fans-out the one route field it changes (<code>home_region</code>). But a later rename, removal, or hibernation-flag flip on one box is still not propagated to its peers — those must be repeated by hand on every mesh member. Fine for a pool of auto-provisioned, rarely-renamed spaces; worth closing before mesh placement carries more than that.</li>
+ <li><span class="status partial">built, not yet proven in production</span> <strong>The cross-region store copy is exercised only as a no-op.</strong> <a href="#relocate">Relocation</a>&rsquo;s per-region-bucket copy path is written and unit-tested, but the dev mesh shares one bucket, so live proof only covers the no-op skip path. Before a real cross-region move in production it needs: the source quiesced (asleep) before the copy so a live-writing source can&rsquo;t be copied mid-write; the copy client signed for the destination bucket&rsquo;s region; and a download+reupload variant for cross-account or R2 destinations (<code>CopyObject</code> covers only same-account, same-provider). All three are noted in <code>docs/multi-edge.md</code>.</li>
<li><span class="status partial">partially built</span> <strong>The certificate cache is per box.</strong> The wildcard certificate makes mesh domains workable (each box independently obtains its own copy), but per-host certificates for domains outside the wildcard cannot follow a space between boxes; the S3-backed shared cache that would fix this is reserved (<code>LCHAT_CERT_CACHE=s3</code>) and deliberately refuses to start. See <a href="router.html#tls">part 02</a>.</li>
</ul>
<div class="roadmap">
<span class="label">Designed, not built: what the mesh grows into</span>
- <p>The written design (<code>docs/planet-scale.md</code>) carries the mesh further than any current code: per-space <em>geographic</em> placement using the registry&rsquo;s <code>home_region</code> field (relocating a space between regions while it sleeps, by copying its bucket prefix and flipping the field); an autoscaling group of boxes behind a network load balancer, sized on warm-set memory; and a co-operative &ldquo;provider&rdquo; seam in which spaces could be delegated to operators other than the product&rsquo;s own. Each of these has a schema field or an interface already cut for it, and none has operable behaviour today.</p>
+ <p>The written design (<code>docs/planet-scale.md</code>, <code>docs/multi-edge.md</code>) carries the mesh further than any current code. <a href="#region-placement">Region-scoped placement</a> and <a href="#relocate">relocation</a> have since shipped and moved up to their own section above; what remains designed-only is: <em>multi-edge within a region</em> — several boxes replicating one space&rsquo;s log for latency and true failover, rather than one owner at a time; an autoscaling group of boxes behind a network load balancer, sized on warm-set memory; and a co-operative &ldquo;provider&rdquo; seam in which spaces could be delegated to operators other than the product&rsquo;s own. Each has a schema field or an interface already cut for it, and none has operable behaviour today.</p>
</div>
</div>
diff --git a/docs/planet-scale-guide/reference.html b/docs/planet-scale-guide/reference.html
index 513ab35..884f78b 100644
--- a/docs/planet-scale-guide/reference.html
+++ b/docs/planet-scale-guide/reference.html
@@ -29,7 +29,7 @@
<dl>
<dt><code>lchat bootstrap</code></dt>
<dd>One-time box setup: writes every systemd unit file, creates the <code>lchat</code> system user and the <code>/data</code>, <code>/run/lchat</code>, and certificate-cache directories, mints the root-only control token, enables and starts the router and the hourly update timer. Idempotent — safe to re-run to refresh unit files. Explained in <a href="deploys.html#bootstrap">part 04</a>.</dd>
- <dt><code>lchat add-instance &lt;host&gt; [--admin-token &lt;tok&gt;] [--hibernate] [--app-store-url &lt;url&gt;] [--home-origin &lt;url&gt;] [--push-relay-url &lt;url&gt;] [--push-relay-token &lt;tok&gt;]</code></dt>
+ <dt><code>lchat add-instance &lt;host&gt; [--admin-token &lt;tok&gt;] [--hibernate] [--home-region &lt;cc&gt;] [--app-store-url &lt;url&gt;] [--home-origin &lt;url&gt;] [--push-relay-url &lt;url&gt;] [--push-relay-token &lt;tok&gt;]</code></dt>
<dd>Creates a space: mints (or, with <code>--admin-token</code>, imports) the admin token, writes <code>/data/&lt;host&gt;.env</code>, registers the route, points the version link, starts and health-checks the unit (skipped with <code>--hibernate</code>), reloads the router, warms the certificate, and prints the one-time setup link and the admin link. Explained in <a href="spaces.html#adding-manual">part 05</a>.</dd>
<dt><code>lchat add-service &lt;host&gt; --socket &lt;path&gt;</code></dt>
<dd>Registers a <code>service</code>-kind route (home server, push relay, cloud provisioner, or any third-party backend) after verifying the socket answers. No unit is created and no version is tracked — the router only forwards. Explained in <a href="processes.html#sibling-services">part 03</a>.</dd>
@@ -47,6 +47,8 @@
<dd>Prints the registry: HOST, KIND, STATUS, VERSION, HIBERNATE, REPLICATION, SOCKET, plus a <code>last error</code> line for any failed row. The first stop for &ldquo;what does this box think it is serving?&rdquo;.</dd>
<dt><code>lchat mesh-seed &lt;peer-url&gt;</code></dt>
<dd>Run on an existing mesh box, pointed at a newly joined one: bulk-replicates every in-scope hibernating route to the peer in one pass, authenticated by <code>LCHAT_MESH_TOKEN</code>. The one-time catch-up step of <a href="mesh.html#add-box">adding a box to the mesh</a>. <a href="mesh.html#mesh-replication">Part 07</a>.</dd>
+ <dt><code>lchat relocate &lt;host&gt; --to &lt;region&gt; [--dst-bucket &lt;b&gt;] [--move-dns --dns-target &lt;ipv4&gt;]</code></dt>
+ <dd>Move a hibernating space to another region without changing its address: copies its store to the destination region (a no-op on a shared bucket), flips <code>home_region</code>, and re-fans-out the route so the mesh re-homes it. The <a href="hibernation.html#the-lease">lease</a> keeps a single writer throughout. Run on a mesh box with <code>/data/router.env</code> sourced. <a href="mesh.html#relocate">Part 07</a>.</dd>
<dt><code>lchat admin-link &lt;host&gt;</code> / <code>lchat setup-invite &lt;host&gt;</code></dt>
<dd>Recover the admin link (from the environment file — the only plaintext copy of the token) / mint a fresh one-time setup invite.</dd>
</dl>
@@ -94,12 +96,14 @@
<tr><td><code>LCHAT_FALLBACK_URL</code></td><td>unset (404)</td><td>Redirect target for unknown hostnames.</td></tr>
<tr><td><code>LCHAT_WILDCARD_DOMAINS</code></td><td>unset</td><td>Comma list of domains to hold wildcard certificates for (needs the next variable). <a href="router.html#dns01">Part 02</a>.</td></tr>
<tr><td><code>LCHAT_DNSIMPLE_TOKEN</code></td><td>unset</td><td>DNS-provider API token used for wildcard issuance.</td></tr>
+ <tr><td><code>LCHAT_DNS_PROVIDER</code></td><td><code>dnsimple</code></td><td>DNS-01 backend for wildcard issuance: <code>dnsimple</code> (default, needs the token above) or <code>route53</code> (additive AWS off-ramp — authenticates via the box&rsquo;s own AWS role/user scoped to one zone, no token on disk). <a href="mesh.html#region-neutral">Part 07</a>.</td></tr>
<tr><td><code>LCHAT_HIBERNATE_BUCKET</code></td><td>unset (activator off)</td><td>The hibernation bucket — the activator&rsquo;s master switch. <a href="hibernation.html#activator-config">Part 06</a>.</td></tr>
<tr><td><code>LCHAT_HIBERNATE_REGION</code> / <code>LCHAT_HIBERNATE_PREFIX</code></td><td>empty</td><td>Bucket region / key prefix.</td></tr>
<tr><td><code>LCHAT_HIBERNATE_IDLE</code></td><td><code>15m</code></td><td>Idle window before sleep (a duration such as <code>15m</code> or <code>90s</code>; must be positive).</td></tr>
<tr><td><code>LCHAT_MESH_SELF</code></td><td>unset (mesh off)</td><td>This box&rsquo;s peer-reachable base URL — the mesh&rsquo;s master switch. <a href="mesh.html#mesh-config">Part 07</a>.</td></tr>
<tr><td><code>LCHAT_MESH_DOMAINS</code></td><td>unset</td><td>Comma list of domains whose hosts participate in mesh placement. Required whenever <code>LCHAT_MESH_SELF</code> is set.</td></tr>
<tr><td><code>LCHAT_MESH_TOKEN</code></td><td>unset</td><td>Shared secret authenticating route replication between mesh members. Required whenever <code>LCHAT_MESH_SELF</code> is set; identical on every box. <a href="mesh.html#mesh-config">Part 07</a>.</td></tr>
+ <tr><td><code>LCHAT_MESH_REGION</code></td><td>unset (agnostic)</td><td>This box&rsquo;s placement region label (e.g. <code>ie</code>) — advertised in the heartbeat body; the mesh owns a space on a box whose region matches its <code>home_region</code>. Distinct from <code>LCHAT_BOX_REGION</code> (that is the dashboard display string). <a href="mesh.html#region-placement">Part 07</a>.</td></tr>
<tr><td><code>LCHAT_BOX_REGION</code></td><td>unset (omitted)</td><td>This box&rsquo;s coarse continent + country string, published to peers over the mesh stats channel for the Fleet dashboard&rsquo;s <code>region</code> field (e.g. &ldquo;EU &middot; Finland&rdquo;) — never a city, datacenter, IP, or hostname. <a href="operations.html#observability">Part 08</a>.</td></tr>
</table>
</div>
@@ -136,6 +140,8 @@
<tr><th>Variable</th><th>Default</th><th>Meaning</th></tr>
<tr><td><code>LCHAT_ADMIN_TOKEN</code></td><td>required</td><td>Gates the caller-token minting API.</td></tr>
<tr><td><code>LCHAT_CLOUD_BASE_DOMAIN</code></td><td>required</td><td>Domain new spaces are created under (needs wildcard DNS).</td></tr>
+ <tr><td><code>LCHAT_CLOUD_REGION</code></td><td>unset (agnostic)</td><td>Stamps each newly-provisioned space&rsquo;s <code>home_region</code>, so it is placed in this region from first wake. <a href="mesh.html#region-neutral">Part 07</a>.</td></tr>
+ <tr><td><code>LCHAT_CLOUD_ID_LEDGER</code></td><td><code>0</code> (local ids)</td><td>When truthy, mint hostnames from the shared S3-backed global id counter (so region-neutral names never collide across regions) instead of the local DB primary key. Explicit opt-in: enabling it for a namespace that already has spaces needs the counter seeded above the current max first. <a href="mesh.html#region-neutral">Part 07</a>.</td></tr>
<tr><td><code>LCHAT_CLOUD_QUOTA</code></td><td>3</td><td>Free spaces each requesting user may hold at once.</td></tr>
<tr><td><code>LCHAT_CLOUD_MAX_CREATES_PER_DAY</code></td><td>0 (uncapped)</td><td>Fleet-wide daily creation circuit breaker.</td></tr>
<tr><td><code>LCHAT_CLOUD_REAP_AFTER_DAYS</code></td><td>0 (reaper off)</td><td>Age before a never-claimed space is reclaimable. <a href="spaces.html#reaper">Part 05</a>.</td></tr>