carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Eleven → Carloku: customer-fleet migration
2
3**Date:** 2026-08-29
4**Status:** Approved design (Paul). Revised 2026-08-30 twice: first after an
5adversarial review (Fable) found three Criticals in the original wave 1, then
6again after Paul reframed the work into **two phases — prove it greenfield,
7then move production hosts one at a time**. That reframe dissolves most of what
8the review found, so this document supersedes the box-conversion plan entirely.
9
10**Refreshed 2026-09-05** against main, after the spec sat unmerged on branch
11`eleven` for a week while the platform moved 269 commits. Three passages had
12gone stale and are amended in place, each marked and dated where it sits:
13phase 5's tick-cadence argument (void — #371 landed), gap 3 (demoted), and
14gap 6 (closed — the file it names was already deleted). Nothing else changed;
15the design and its decisions stand as approved.
16
17**Account:** `eleven` (sqid `bdr`) on `https://console.carloku.com`
18**Supersedes:** `infra/eleven-fleet.md`, which was factually wrong about
19Eleven's starting state (see "The reframe"). That runbook was deleted from main
20in the open-source redaction pass (`2adc71f`, 2026-08-27), so this document is
21now the only account of where Eleven starts.
22
23## The reframe this spec exists for
24
25`infra/eleven-fleet.md` says Eleven "moves from Tito's own self-hosted CARLOS
26install onto Carloku's console" — a control-plane re-point, "not a data
27migration: there is no data to migrate." The first half is wrong.
28
29**Eleven is not on CARLOS at all.** Checked 2026-08-29:
30
31- `home.elevenmessenger.com` resolves to `89.167.122.227` and serves 200 with
32 **no `X-Carlos-Version`** header. It is not behind a CARLOS edge.
33- The repo carries a complete operator surface of its own: `deploy/ops` with
34 build/sign/publish, `edge|beta|stable` channels, fleet sweeps, canaries and
35 a dev mesh; `lchat router` with its own cert cache and wildcard DNS-01;
36 `activator.go` with its own hibernation, litestream replication and
37 single-writer lease.
38- CARLOS's equivalents were **ported from that code** — commit `845ffd4`,
39 "activator, releases/channels, fleet ops (ported from Eleven + titogo)".
40
41So this is a first-time onboarding onto a control plane that is Eleven's own
42machinery, generalised. That is why the overlap is total rather than partial,
43and why most of the work is *deleting* Eleven's operator surface rather than
44translating it.
45
46The second half of the old runbook's claim survives, for a different reason
47than it gave, and it is now verified rather than inherited — see D4.
48
49## Verified starting state (2026-08-29)
50
51Every line here was checked, not assumed.
52
53| Fact | How it was checked |
54|---|---|
55| Account `eleven` (`bdr`) exists on Carloku | `carlos auth whoami` |
56| It has **zero apps** | `carlos instances list -account eleven` → "account eleven has no apps" |
57| It has **no fleets** | `carlos fleets ls -account eleven` → "no fleets" |
58| Production is two boxes: one Hetzner, one Fasthosts, neither EC2 | Paul, 2026-08-29 |
59| Boxes serve directly; no CARLOS edge in front | `curl -sI https://home.elevenmessenger.com` |
60| Eleven's AWS account is `427880590669` | `sts get-caller-identity` on the vault's dev-agent key |
61| Hibernate bucket `eleven-prod-instances-427880590669`, prefix `prod/` | vault item note + `s3api list-objects-v2` |
62| Layout is `prod/<host>/` by FQDN, plus `prod/mesh/` | `s3api list-objects-v2 --delimiter /` |
63| **31 instances**: 23 `*.11ch.at`, 5 `*.luan.chat`, 2 `*.elevenmessenger.com`, 1 `chat.paulca.com` | same listing |
64| All four zones are on **DNSimple** | `dig NS` on each |
65| Fleet API surface (#73) merged | PR #145 MERGED 2026-08-11 |
66| `region-remote-host`'s `deployment_bucket_arn` is optional | `infra/modules/region-remote-host/variables.tf:11` |
67| `carlos apps place` / `apps create --place` exist | `apps.go:111`, `apps.go:382` |
68| Sidecar members **not merged** | PR #223 OPEN, last updated 2026-08-18; re-checked 2026-09-05 — still unmerged, no sidecar package on main |
69| Eleven already runs a tofu-managed AWS node: EC2 t4g.small arm64, EIP, S3 bucket, IAM role | `infra/dev-mesh/{compute,storage,route53,dns}.tf` |
70| `dev.11ch.at` is delegated from DNSimple into a Route53 zone, live at `54.154.95.205` | `aws_route53_zone.dev` + `dnsimple_zone_record.dev_delegation`; `dig NS` |
71| `https://amadan.net/eleven/infrastructure` exists and is empty | `git ls-remote` → exit 0, no refs |
72| `lchat relocate` is mesh-internal and cannot target a CARLOS box | `relocate.go:1-30` (`home_region`, `fanOutMeshRoute`, `candidatesFor`) |
73| Status bucket `eleven-status-427880590669` is 521 objects / ~270 MB, of which `prompts/` is 422 / ~270 MB — the site itself is ~620 KB | `aws s3 ls --recursive --summarize` |
74| `carlos ledger` provides append / blob / publish / verify, credential-free verification | `carlos ledger -h` |
75
76So every precondition `infra/eleven-fleet.md` lists as "pending merge" has
77landed, and the two it assumes are already met — apps created, fleet
78existing — are both false.
79
80## Decisions
81
82| # | Decision | Rationale |
83|---|---|---|
84| **D1** | **Fresh boxes, never converted in place.** The two existing production boxes are retired when empty, not migrated. | Converting a live box in place carried the review's worst findings: a shared registry file, a fleet-wide lease drain, and a ports 80/443 handover under production traffic. A fresh box has none of them. |
85| **D2** | **Sidecars are proven in phase 1 and hardened in phase 3.** | Sidecars do not spin up today (Paul) and the reason is unknown, so a greenfield box is where the answer is cheapest to find — and no production behaviour can regress while looking. |
86| **D3** | Sidecars run on a **third fleet box with `CARLOS_INBOUND=none`**, on ordinary outbound-only networking — no NAT gateway, admin access retained. | Eleven's own `docs/bots.md`: the bot host transiently holds thread plaintext and keys, so "root on the chat server reads every bot thread." Moving it off the serving boxes restores the guarantee. The full private-subnet posture is not needed for that and costs the break-glass channel. |
87| **D4** | **Boxes serve directly** in phases 1-2. Carloku is control plane only. | A fleet box with no deployment bucket has its cert issuance gate cleared and "issues and keeps its own certs, as before" (`internal/edge/certsync.go:117`). No Eleven traffic transits Carloku, and no metadata leaves Eleven's boxes. |
88| **D5** | **Edge-fronted ingress is phase 4**, gated on two written entry conditions. | Fleet routes cannot be steered today, and the WebSocket hop is unmeasured. See "Phase 4". |
89| **D6** | The **sidecar box goes in Eleven's AWS account** (`427880590669`). | It is an ordinary `hashicorp/aws` EC2 instance, it sits beside the buckets that plane already holds, and `region-remote-host` is close to the right module with `deployment_bucket_arn` omitted. |
90| **D7** | **Two phases.** Phase 1 proves the whole product greenfield on a throwaway namespace; phase 2 moves the 31 production hosts. | Phase 1 turns the review's unknowns into rehearsals at zero risk: it proves release delivery, teaches the manifest shape at four hosts instead of 31, and answers why sidecars do not spin up today. |
91| **D8** | Phase 2 moves hosts **one at a time by DNS**, keeping each address identical. | `docs/multi-edge.md`: the address is the identity, and changing it re-keys every member. A per-host A record overrides the wildcard, so hosts cross individually and reversibly. `lchat relocate` cannot do this — it is mesh-internal (`home_region`, `fanOutMeshRoute`, `candidatesFor`) and a CARLOS box is not a mesh peer. |
92| **D9** | All new boxes are **AWS EC2 in Eleven's account**, tofu-managed from a root at `https://amadan.net/eleven/infrastructure`. | Tito covers the bill for now (Paul, 2026-08-30). The tofu root is Eleven-owned and lives outside the platform repos, per the customer-fleet rule. The repo exists and is empty. |
93
94## Topology
95
96DNS stays pointed at Eleven's boxes. Each box runs `carlos agent` — edge plus
97activator — issues its own wildcard certificates, and serves directly.
98Carloku is reached outbound over the tunnel and carries control only:
99releases, channels, config, placement, fleet visibility, wake.
100
101**Certificates.** A fleet box carrying no deployment bucket of its own has
102`armCertIssuance` clear the issuance gate, because there is nothing to publish
103to and nothing to mirror from; it issues for itself. Wildcards come from
104`newWildcardFromEnv` under the same gate.
105
106**DNS provider — a constraint, not a coincidence.** `internal/edge/dns.go`
107selects exactly one provider per box from `CARLOS_DNS_PROVIDER`. Eleven serves
108four zones (`11ch.at`, `elevenmessenger.com`, `luan.chat`, `paulca.com`) and
109**all four are on DNSimple**, so `CARLOS_DNS_PROVIDER=dnsimple` plus
110`CARLOS_DNSIMPLE_TOKEN` covers the whole estate. Had any zone been elsewhere,
111a directly-serving box could not have issued both wildcards and D4 would not
112have been available. Record this: adding a zone at a second DNS provider
113breaks the direct-serve path.
114
115The token is the vault's `lchat DNSimple DNS-01 token (11ch.at — Eleven
116Messenger acct 176940)` — password slot, per `deploy/vault-items.sh`.
117
118**Three wildcards, not four — deliberately.** `CARLOS_WILDCARD_DOMAINS` lists
119`11ch.at`, `elevenmessenger.com` and `luan.chat`. It omits `paulca.com`,
120because Eleven serves exactly one host there (`chat.paulca.com`) and a
121wildcard would grant the box authority to mint certificates for the whole of
122a personal domain it has no other business in. An exactly-named route gets an
123exact certificate through the ordinary registry host policy, which is the
124correct outcome. Any future member custom domain follows the same rule: one
125host means an exact name, a zone of instances means a wildcard.
126
127## Phase 1 — the greenfield proving ground
128
129A CARLOS-native Eleven, standing beside production and touching none of it.
130Everything here is throwaway: a namespace nobody's keys are bound to, on a box
131that can be destroyed and rebuilt.
132
133### Amended 2026-09-05 — phase 1 starts on the shared fleet, not a fresh box
134
135**Paul, 2026-09-05:** *"Since Eleven is decentralised anyway, I think the first
136goal will be getting a running instance on `https://console.carloku.com/a/eleven/`
137and then we go from here migrating the other apps."*
138
139This is a smaller and better first step than the box below, and it reorders
140phase 1 rather than replacing it. The original phase 1 opens with
141infrastructure — port `infra/dev-mesh` into a new tofu root, stand up an EC2
142box, point it at `carlos agent` — and only then proves the product. But **none
143of phase 1's five proofs actually need Eleven to own the box.** Account
144`eleven` (`bdr`) has no fleet, so an app created in it lands on the **shared
145Carloku fleet**, which already exists and is already running. That defers the
146tofu root, the AWS spend and the `eleven/infrastructure` repo out of the
147critical path entirely.
148
149It also matches the product: Eleven is decentralised, one instance per space,
150so a single instance *is* a working Eleven — not a stub standing in for one.
151There is no all-at-once cutover to rehearse.
152
153**Two findings from 2026-09-05 that make this cheap:**
154
1551. **The launch contract already matches, exactly.** CARLOS spawns an app as
156 `exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)`
157 (`internal/activator/backend_exec.go:691`). Eleven spawns its own instances
158 as `exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)`
159 (`messenger/activator.go:1218`) — the same line, because CARLOS's activator
160 was ported from Eleven's. `lchat` needs **no code change** to be launched by
161 a CARLOS box.
162
1632. **Verified by running it.** `lchat --socket <path> --db <path>` on an empty
164 directory boots, creates its SQLite database, prints an admin link, logs
165 `lchat listening on unix:<path>`, and answers **HTTP 200** with the app
166 shell over that socket. From nothing, with no config and no environment.
167
168**The one mismatch found: architecture.** `carlos ship --target` defaults to
169`linux-arm64` and the shared fleet is Graviton, but the binary committed to
170Eleven's repo is `lchat-linux-amd64`. Phase 1 needs an arm64 build. This is a
171build-matrix line, not a design problem.
172
173**So phase 1 now runs in two steps.** Step A — one instance on the shared
174fleet, which proves release delivery (proof 1), the manifest shape (proof 2)
175and that the product works on CARLOS (proof 3), with no infrastructure at all.
176Step B — the box below, which is still needed for the proofs step A cannot
177reach: sidecars (proof 4) and the disposition of `lchat router`'s hooks and
178links (proof 5), both of which want a box Eleven controls. Everything from
179"The infrastructure already exists in the right shape" onward is step B.
180
181### Step A is done — 2026-09-05
182
183Eleven runs on Carloku. `proving.bdr.oncarlos.com` serves Eleven's app shell
184over HTTPS from the shared fleet, on a binary shipped through `carlos`.
185
186The whole path, for the record:
187
188 ARCH=arm64 ./deploy/build-linux.sh # in Eleven's repo → lchat-linux-arm64
189 carlos apps create --account eleven --app proving
190 carlos deploy --account eleven --app proving --target linux-arm64 \
191 --version ffd3de0 lchat-linux-arm64
192 carlos instances enable --account eleven --app proving
193 carlos instances create --account eleven --app proving \
194 --host proving.bdr.oncarlos.com --channel edge
195
196**What that proves.**
197
198- **Proof 1, release delivery, end to end.** `deploy` shipped `ffd3de0` and
199 promoted it to `edge`; the box's reconcile pass materialised the route and
200 the adopt installed the binary. The edge stamps `x-carlos-version: ffd3de0`.
201- **Proof 2, the manifest shape.** `instances enable` pinned the app to
202 `bdr.oncarlos.com` and the one-label rule held: `proving` is one label under
203 the account's platform subdomain, and the console checked the host against
204 the manifest before minting the record.
205- **Proof 3, the product runs on CARLOS.** The box logs
206 `lchat listening on unix:/run/carlos/proving.bdr.oncarlos.com.sock
207 (db: /data/proving.bdr.oncarlos.com.db)`, and the served page carries
208 Eleven's own CSP — `connect-src` naming `home.elevenmessenger.com`, the
209 giphy and youtube-nocookie sources — not a platform placeholder. Signing in
210 and creating a space is the remaining half of this proof and needs a person.
211- **Hibernation works, unprompted.** The record read `Setting up`, then served,
212 then went to `Asleep` on its own. The instance woke for a request and slept
213 again with nothing configured for it.
214
215**Two findings worth carrying.**
216
2171. **The admin link names the wrong host.** On first boot lchat prints
218 `http://localhost:1747/admin#token=…` because it has no idea what hostname
219 fronts it — it only ever knew a socket. An operator has to hand-edit the
220 host before the link works. Harmless once, tedious across 31 hosts, and a
221 trap for anyone who pastes it as printed. Worth a fix in Eleven: derive the
222 link's origin from a public-host env var when one is set.
223
2242. **The architecture default is a real trip hazard.** `carlos ship` and
225 `carlos deploy` default `--target linux-arm64`; Eleven's committed binary is
226 `lchat-linux-amd64` and its `build-linux.sh` defaults `ARCH=amd64`. The two
227 defaults disagree, and nothing catches it until the box tries to exec the
228 binary. Eleven's build matrix should produce arm64 for Carloku.
229
230### Step B — the proving box
231
232**The infrastructure already exists in the right shape.** `infra/dev-mesh/` in
233Eleven's repo is complete tofu for exactly this box — EC2 `t4g.small` arm64
234(commented as "the sizing floor for a node that runs a sidecar"), security
235group, IAM role and instance profile, EIP, a versioned S3 bucket with a
236lifecycle rule, and `dev.11ch.at` delegated from DNSimple into a Route53 zone.
237`ops dev rebuild` already destroys and recreates it from nothing. Phase 1 does
238not design new infrastructure; it ports that shape into
239`https://amadan.net/eleven/infrastructure` and points the box at `carlos agent`
240instead of `lchat router`.
241
242**Use a fresh namespace, not `dev.11ch.at`** — the existing dev mesh keeps
243working, and the two must not contend for one zone.
244
245**DNS provider.** The phase-1 zone is on **Route53**, so this box runs
246`CARLOS_DNS_PROVIDER=route53`. Production is on DNSimple. Since the setting is
247per box, the two coexist with no conflict — the single-provider constraint bites
248only within one box.
249
250### What phase 1 must prove
251
252Each of these is a review finding converted from an unknown into a rehearsal:
253
2541. **Release delivery end to end** (the review's C1) — `carlos ship` Eleven's
255 binary, set channel pointers, and watch a box-side adopt install
256 `/opt/carlos/versions/…` and mint the live links. This is also where the
257 **signing-key migration** gets settled: Eleven signs its own releases today
258 (`deploy/ops publish`, key from Bitwarden), and from here on the authority is
259 Carloku's release key.
2602. **The manifest shape** (C3) — author an app and its manifest as a Carloku
261 operator (`carlos instances enable -domain <zone>`), then mint instances
262 through the ordinary door. Learn the one-label rule at small scale.
2633. **The product actually works on CARLOS** — log in to home, create a space,
264 invite a member, exchange messages. This is the part no amount of code
265 reading can establish.
2664. **Sidecars** (Open Q7) — provision a bot from nothing. Today's failure is
267 *unexplained*, and a greenfield box is where the explanation is cheapest to
268 find. If #223 has not landed, this is also where its `args` field and the
269 `<host>-sidecar.env` rename get exercised.
2705. **What of `lchat router` CARLOS cannot replace.** Hooks (`in.<host>`) and
271 links sit behind the router today, and `in.<host>` is two labels — which
272 `ValidHost` refuses. Phase 1 is where their disposition is decided, with
273 nothing depending on the answer.
274
275Phase 1 is finished when a person can sign in, make a space, talk in it, and
276watch a bot answer — with `carlos` the only control plane involved.
277
278## Phase 2 — move the production hosts, one at a time
279
280Fresh CARLOS boxes in Eleven's AWS account. The two existing boxes keep serving
281until they are empty, then retire. **No box is ever converted in place**, which
282is what removes the registry collision, the fleet-wide lease drain and the
283ports 80/443 handover from this plan altogether.
284
285**The state does not move, again.** Old and new boxes point at the *same*
286hibernate bucket and prefix, and `HibernatePrefix(prefix, host)` is `prod/<host>`
287on both sides — verified against the live bucket. A moved host is restored by the
288new box from the keys lchat already wrote. There is no database copy in this
289plan, and `docs/instance-migration.md`'s copy step is not needed here.
290
291**The lease makes it safe by construction.** Both sides use the same litestream
292`lss3.Leaser`, per host, at `<prefix>/<host>/lock.json`, TTL 15 minutes. Only one
293box can hold a host's lease, so a half-moved host cannot be served twice. What
294the TTL costs is a **bounded per-host wait**, scheduled deliberately, rather
295than the fleet-wide outage that in-place conversion would have caused.
296
297### Per-host move
298
299For each of the 31 hosts, in an order you choose (start with the least busy):
300
3011. **Create the instance record** on the right app for that host's zone (per
302 phase 1's manifest work). One host at a time; there is no bulk import.
3032. **Let the host sleep**, or put it to sleep, so its lease is not held.
3043. **Drop its route on the old box**, so lchat will not wake it again.
3054. **Flip that host's A record** to the new box. A specific record beats the
306 `*.11ch.at` wildcard, so this moves exactly one host and nothing else.
3075. **Wake and verify** — the new box restores from `prod/<host>/` and serves.
308 Allow for the lease TTL if the old box held it recently.
3096. *Rollback:* flip the A record back and restore the old box's route. The
310 bucket state is shared, so nothing is stranded.
311
312**The wildcard moves last**, once every host has an explicit record, and the
313old boxes come out of DNS entirely before they are destroyed.
314
315**Two box-side publishers retire with the old boxes** (phase 5):
316`deploy/status-publish.sh` generates `fleet.json` every five minutes, and
317`deploy/ssh-ledger-collect.sh` publishes the SSH access ledger on its own
318timer. Both stop when the boxes are destroyed, and the public status page
319decays silently rather than loudly. Resolve their rehoming **before** the last
320box is destroyed, not after.
321
322**The sibling services move too.** Hooks and links are router-fronted on the
323production boxes, so retiring `lchat router` retires them. They are part of
324phase 2's surface — not, as an earlier draft claimed, out of scope — and
325`in.<host>` needs the disposition phase 1 decided.
326
327### What phase 2 still carries
328
329Fresh boxes dissolve the registry collision (C2), the ports 80/443 handover and
330its reboot race, and the fleet-wide lease drain. What remains real:
331
332- **Release delivery** must work on the new boxes (proven in phase 1).
333- **Manifests and 31 instance records** still have to be authored.
334- **`CARLOS_POOLS` stays unset.** `inScope` gates pool first
335 (`reconcile.go:212`) and an empty `Pools` serves only the `""` pool
336 (`reconcile.go:1006-1011`) — correct only while every Eleven manifest carries
337 `Pool: ""`. Pin it.
338- **Do not pass `--region` to `instances create`.** App placement overrides the
339 request body (`apiinstances.go:558-561`), so the flag does nothing here.
340
341## Phase 3 — sidecars
342
343**Precondition: PR #223 merged and deployed to Carloku.** It is 55 commits and
344+15.7k lines and has not moved since 2026-08-18. It does not go onto the
345critical path of phases 1-2 (D2).
346
347**Why sidecars will not spawn AFTER the migration — and why that is a
348different question from why they do not run today.** An earlier draft claimed
349the `sidecar run` hardcode explained Paul's "sidecars don't spin up today".
350That was wrong, and the review caught it: today's boxes run **Eleven's own**
351activator, which spawns `bot run` from `/data/<host>-bot.env` and is a working
352mechanism (messenger `activator.go:19-27,243`). Whatever stops sidecars today
353is not this, and remains unexplained — establish it on a live box rather than
354carrying a guess forward.
355
356What the hardcode does explain is a **post-migration** failure. CARLOS's exec
357backend spawns `<release binary> sidecar run` (`backend_exec.go:826`) when
358`/data/<host>-sidecar.env` exists, while Eleven's binary dispatches `bot`
359(`main.go:190`) — so after the flip the child would exit on a usage error.
360PR #223 closes it: a sidecar record carries a required `args` argv field, so
361`carlos sidecars enable -app <app> -args 'bot run'` names the command instead
362of hardcoding it.
363
364There is a second, quieter mismatch on the same path. CARLOS reads
365`<DataDir>/<host>-sidecar.env` — its own comment calls this "Eleven's
366`<host>-bot.env`, renamed" (`backend_exec.go:798`). Eleven's existing staged
367env files are therefore **invisible** to CARLOS even after #223's `args` fix.
368Phase 3 needs an explicit restage or rename step for every sidecar env file —
369and phase 1 is where that is discovered rather than debugged in production.
370
371**Steps.** Provision the third box in account `427880590669` via tofu; attach
372it as a fleet box with `CARLOS_INBOUND=none`; enrol the bot sidecars with
373`-args 'bot run'` and `-placement no-inbound`. #223 refuses to mint routed
374instances on a no-inbound box, so the isolation is platform-enforced rather
375than conventional.
376
377**The hooks sidecar does not belong here.** `lchat hooks run` serves
378`in.<host>` behind the router — it is **inbound**. #223's sidecar class is
379defined as "a serving class that nothing can dial", so modelling it as a #223
380sidecar would make it permanently unreachable. But it cannot simply become an
381ordinary instance with a route either: `in.<host>` is **two labels** under its
382zone and `ValidHost` accepts only one. Its disposition is decided in phase 1,
383where nothing depends on the answer.
384
385## Phase 4 — edge-fronted ingress (deferred, gated)
386
387The eventual goal is members reaching a nearby edge. It is deferred behind two
388entry conditions, both of which must be written down as met before any DNS
389moves:
390
3911. **Fleet-placed routes can be steered.** They cannot today.
392 `internal/steering/steering.go:313` excludes non-shared-pool routes
393 defensively — "the door refuses them" — and the CLI says `steering …
394 latency|off (shared pool only, v1)`. Steered names must also be in
395 `PLATFORM_APPS_DOMAIN`'s zone; a custom domain reaches steering by CNAME to
396 `edge.<apps-domain>`.
3972. **The WebSocket hop is measured against direct-serve** on a real
398 transatlantic path. Eleven is WebSocket fan-out (`hub.go`). Nearest-edge
399 wins the TLS and TCP handshake once; thereafter every message pays
400 edge → tunnel → box for the life of the connection. With boxes in
401 Germany/UK and edges in IE/US/UK/AU, that is plausibly a net loss in steady
402 state. Measure it; do not assume it either way.
403
404Two things this phase is **not**:
405
406- It is not Eleven's multi-edge. `docs/multi-edge.md`'s north star is "a space
407 live on several edges at once", built on the append-only-log-is-a-CRDT
408 property. CARLOS routes to the one box holding the lease; it does not
409 replicate instance state and should not learn to. Replicated spaces stay
410 Eleven's.
411- It is not delivered by `instances set-upstreams`, which is health-probed
412 failover, not geo-routing.
413
414Direct-serve remains the rollback path in every case.
415
416## Phase 5 — the public record: status site and prompt registry
417
418Both live in one S3 bucket today (`eleven-status-427880590669`, served at
419`status.elevenmessenger.com` via CloudFront), but measurement shows they are two
420unrelated problems:
421
422| | Objects | Bytes |
423|---|---|---|
424| Whole bucket | 521 | 283,396,762 (~270 MB) |
425| `prompts/` alone | 422 | 282,761,414 (~270 MB) |
426| **Everything else** | **99** | **~620 KB** |
427
428**99.8% of the bucket is the prompt corpus.** The status site proper is 620 KB.
429
430### The status site → a CARLOS static app
431
432620 KB across 99 objects is a straightforward static app, and `carlos deploy`
433serves static natively. But hosting is the easy half. `docs/status.md` describes
434two kinds of content and only one is static:
435
436- **Committed data** — `carbon.json`, `economics.json`, `contributors.json`,
437 `music-*.json`, `ssh/*.json`. Authored, reviewed, pushed with `ops pages
438 push`. Moves cleanly to a static app; `ops pages push` retires with it.
439- **Generated data** — `fleet.json` and friends, produced **every five minutes
440 by `deploy/status-publish.sh` on the router box**: instance counts, awake vs
441 hibernating, version distribution, shared-service health, replication health.
442
443**The generator is the dependency, and it is a phase 2 problem.** That script
444runs on a box phase 2 retires, so the status page starts decaying the moment the
445last host crosses unless the generator has somewhere to go. Sequence this
446accordingly: the static half can move early, but the generated half must be
447resolved before the old boxes are destroyed.
448
449**The generator becomes a scheduled CARLOS app** (Paul, 2026-08-30). `carlos
450schedule set --app <a> --name fleet --every 30m --path /generate` declares it,
451and a box's runner "wakes a hibernating instance when its tick is due" — so a
452sleeping generator is the supported path, not a trick. `--every` has a
453**1-minute floor**, so any cadence under discussion is legal.
454
455**Amended 2026-09-05: the cadence argument this spec carried is void.**
456The original text said "use 30 minutes, not 5 — because at 5 it never sleeps",
457reasoning that a 5-minute tick resets `lastActive` three times per 15-minute
458idle window and pins the instance awake. That was true when written and is not
459true now. `Scheduled ticks must not hold an instance awake` (#371, on main
4602026-08-31) added `Service.ServeScheduled` and `beginScheduledRequest`
461(`activator.go:1261`), which wakes a sleeping instance and holds the in-flight
462count for the tick's duration but releases **without** stamping `lastActive`;
463`activatorDeliverer.Deliver` calls it instead of `ServeHTTP`
464(`schedule_wiring.go:349`). A tick now buys no residency at all.
465
466**So pick the cadence from what the page needs, not from the idle window.**
467The status page's own contract is a five-minute `fleet.json`, and five minutes
468is now affordable: the instance wakes, generates, and sleeps again one idle
469window after the last *real* request. Match the existing cadence unless
470something else argues against it.
471
472**Where it runs still matters, for a smaller reason than before.**
473`CARLOS_HIBERNATE_IDLE` is still read once from the **box** environment
474(`activator.go:92`) with no per-route or per-app override — gap 3 below stands.
475But #371 removes the sharp edge that made placement urgent: the generator no
476longer needs a short idle to sleep, so it no longer wants a box configured
477against its neighbours. Prefer the **shared Carloku fleet** anyway — a
478generator reading the console API needs no fleet-local knowledge, it keeps
479Eleven's boxes for Eleven's members, and it is the cheaper rung — but this is
480now a preference, not a constraint.
481
482Worth exploring rather than assuming: **CARLOS already knows much of what
483`fleet.json` reports** — instances, awake state, versions, heartbeats are all
484console-API facts. Part of the page may become an API consumer rather than a
485box-side generator, which would remove the box dependency entirely. What CARLOS
486does *not* know is Eleven's own aggregate vocabulary (allocation band words,
487fleet-backup replication health), so some generator is likely to survive; the
488question is what it runs on and what it reads.
489
490**The privacy rules are non-negotiable and must survive the move.**
491`docs/status.md` states them as holding *by construction* in the generator —
492aggregates only, never a hostname. A rehomed generator must carry the same
493boundary, and the existing contract test (`test/status-contract.test.mjs`) is
494the thing that proves it. Port the test with the generator, or the guarantee
495becomes a claim.
496
497### The prompt registry → `carlos ledger`, not git
498
499Amadan keeps its prompt registry in git because it is small. Eleven's is **270
500MB and grows per session, per contributor**, in a versioned bucket — Eleven's own
501docs say why it is not committed: *"far too large to commit."* Git would store
502every revision forever and every clone would pull all of it. Three orders of
503magnitude separate the two cases; the same answer does not fit both.
504
505`carlos ledger` already provides the right primitives, and per the open-ledgers
506work it was designed from Eleven's redaction rules in the first place:
507
508- `blob` — content-addressed, create-only uploads (re-uploading identical bytes
509 is a no-op), which is exactly the corpus's shape.
510- `append` — hash-chained entries for the session index.
511- `publish` — the public manifest, with a title.
512- `verify` — walks a **published** chain over plain HTTPS and re-hashes every
513 entry, needing **no credentials**. Anyone can check it.
514
515**On rewriting for redaction.** Git's rewritability looks like an advantage here
516and is not the property to want: a force-push makes tampering *silent*, while a
517hash chain with public `verify` makes it *detectable*. Redaction belongs
518**before** append — which is what `tools/prompt-registry.mjs` already does,
519redacting credentials and annotating each redaction in place. If something slips
520through, both models require a repair; only the ledger makes that repair
521visible. Treat immutability as the feature and the pre-append redaction pass as
522the control.
523
524**The hybrid, made concrete** — it is the split Eleven already uses, rehomed:
525`prompts.html` is committed *code* and belongs in the static site's repo; the
526corpus is *data* and belongs in ledger blobs. They compose, because `ledger
527verify --url <status-host>` points at exactly the host the static app serves.
528
529### Open for this phase
530
531- **How much of the generator collapses into console-API reads** once it is a
532 scheduled app. Where it runs is settled (shared fleet, `--every 30m`); what
533 it still needs a box-side view for is not.
534- **What one ledger entry is** — a session, a batch, a contributor-day — and how
535 a contributor appends without holding broad credentials.
536- **Whether the music and SSH ledgers follow the same path.** Both are accrued
537 public records with the same shape; the SSH ledger additionally has a box-side
538 collector with the same retirement problem as the fleet generator.
539- **What happens to the existing corpus** at cutover: re-uploaded as blobs
540 (content-addressing makes it idempotent) or left in the old bucket behind a
541 redirect.
542
543## What stays Eleven's, permanently
544
545- **Mesh replication** (`meshreplicate.go`, `prod/mesh/`). CARLOS deliberately
546 has no mesh prefix — `internal/activator/activator.go:117` records the seam.
547- **The reaper** (`reaper.go`), reclaiming never-claimed zero-member spaces.
548 CARLOS has no equivalent and needs none.
549- **Self-serve space creation** (`controld.go`, `cloud.go`). This becomes a
550 console API caller holding a **service credential** (#220/#227, merged)
551 rather than a local root daemon — but the quota, slug and capacity policy
552 stay Eleven's.
553- **The entire E2EE layer**, which CARLOS never sees and must never need to.
554
555## Capability map
556
557| Eleven today | CARLOS | Verdict |
558|---|---|---|
559| `ops publish` — build + sign | `carlos ship`, `release-keygen` | replace |
560| `edge\|beta\|stable`, `ops promote` | `carlos promote` / `pipeline` / `channels` | replace |
561| `ops ship` sweep, `AUTO=pinned` | `carlos adopt`, `instances set-channel` | replace |
562| `lchat router` subdomain routing | edge + registry routes | replace |
563| certcache + wildcard DNS-01 | edge wildcard manager, DNSimple backend | replace — same design |
564| `activator.go` hibernate + lease | CARLOS activator | replace — *ported from Eleven* |
565| Bitwarden vault contract | `carlos secrets` | replace with care — the vault contract is deliberate |
566| `controld` / `cloud.go` | console API + service credential | rehost, keep policy |
567| `reaper.go`, `meshreplicate.go` | — | **stays Eleven's** |
568| bot sidecars | PR #223 | **phases 1 and 3** |
569| hooks sidecar (`in.<host>`) | *neither* | **not a #223 sidecar, and not an instance record either**: two labels fails `ValidHost`. Disposition decided in phase 1. |
570
571## Gaps to file against the platform
572
5731. **The reconcile dry-run has no operator door.** `ReconcileConfig.DryRun`
574 exists and is documented as design §4.3's backfill mitigation, but nothing
575 outside `internal/provision` sets it; the only CLI-reachable `DryRun` is
576 `status.DryRun`, the unrelated leak check. An operator cannot ask a box
577 "what would you adopt, and what does adopting it let you later delete?"
578
579 **The fresh-box strategy (D1) demotes this from blocking to wanted.** A box
580 that boots with an empty registry and mints its own records is the ordinary
581 path, not a backfill — the dangerous case was adopting a live box's
582 pre-existing lchat rows, and no box is converted in place any more. Still
583 worth building: the question is the right one to be able to ask, and the
584 next customer arriving with an existing estate will be a genuine backfill.
585
5862. **No bulk instance-record import.** Phase 2 is 31 hand-authored
587 `instances create` calls. Tolerable once at this size; it is a real gap the
588 moment a second customer arrives with an existing estate, and it is worth
589 filing now while the pain is concrete.
590
5913. **No per-app hibernation idle.** `CARLOS_HIBERNATE_IDLE` is box-wide
592 (`activator.go:92`) with no route- or app-level override. Scheduled-work
593 apps want a short idle (wake, work, sleep); interactive apps want a long one
594 (do not make a returning member pay a cold wake). One box cannot serve both
595 well today. **Amended 2026-09-05: this is no longer urgent.** #371 means a
596 tick does not stamp `lastActive`, so a scheduled app sleeps on the box's own
597 idle without needing one of its own — which was the case that made this
598 gap bite. It stays a real gap for apps whose *own* traffic wants a different
599 idle from their neighbours', but phase 5 no longer depends on it. Still
600 worth a `Route.Idle` override.
601
6024. **Fleet steering.** Blocks phase 4. Latency steering is shared-pool only in
603 v1; fleet-placed routes are refused at the door and excluded by the
604 converger.
6055. **No "list an account's apps" verb.** `carlos instances list -account X`
606 answers only "account X has no apps"; there is no listing for an account
607 that *has* apps without naming one. This is a product gap under AGENTS.md's
608 API-and-CLI rule, found while establishing the starting state.
6096. ~~**`infra/eleven-fleet.md` is wrong.**~~ **Closed 2026-09-05: the file no
610 longer exists.** It was deleted from main in the open-source redaction pass
611 (`2adc71f`, 2026-08-27) — two days *before* this spec was written, so the
612 spec's "Supersedes" line described a file that was already gone. Nothing to
613 rewrite; this document is the only account of Eleven's starting state.
6147. **Multi-provider wildcards.** One `CARLOS_DNS_PROVIDER` per box. Not
615 blocking today (all four zones are DNSimple), but it is a latent constraint
616 on any directly-serving box whose zones are split.
617
618## Open questions
619
620The two-phase reframe settled most of the previous list — the registry
621disposition, the lease-drain window and the front-door handover are all gone
622with in-place conversion. What genuinely remains:
623
6241. **Which of the 31 hosts belongs to which app**, and therefore how many apps
625 there are. Needs an inventory before phase 2 starts; phase 1 establishes the
626 shape at four hosts first.
627
6282. **The disposition of `in.<host>`** (hooks). Two labels under the zone, which
629 `ValidHost` can never accept — so it is neither a #223 sidecar nor an
630 instance record. Decided in phase 1.
631
6323. **The signing-key migration.** Whether Eleven's release key is retired at
633 cutover or cross-published, and what happens to releases already signed with
634 it. Phase 1 settles it in practice.
635
6364. **Why do sidecars not spin up today?** Eleven's own activator has a working
637 `bot run` path (`activator.go:19-27,243`), so the previously-assumed
638 explanation is void. Phase 1 answers this from nothing.
639
6405. **What does a hibernating bot sidecar persist across wakes?** The Go sidecar
641 writes a `session.json` byte-compatible with the Node one. Whether that lives
642 on local disk, in a bucket, or is re-minted per wake decides whether the
643 sidecar box needs a data bucket of its own.
644
6456. **What the old boxes cost to keep running during phase 2**, and therefore how
646 long the parallel window can be. Both estates run at once until the last host
647 crosses.
648
6497. **Where the estate lives long-term.** AWS is the phase-2 answer with Tito
650 covering the bill (D9), but `docs/planet-scale.md` treats Hetzner as the
651 production plane deliberately, on cost. Worth revisiting once real running
652 costs are visible.
653
654## Security finding (out of scope here; file separately)
655
656The vault item *"AWS dev account (Eleven free tier) — dev-agent access key"*
657resolves to `arn:aws:iam::427880590669:user/dev-agent` carrying
658`AdministratorAccess` — on the same account that holds every production
659instance's parked state. The name describes something much smaller than the
660grant. The contrast is in the same vault: `activator-prod` is properly scoped
661and refuses `s3:GetBucketLocation`. Narrow the grant or rename the item; it is
662unrelated to this migration but was found by it.
663
664## What this spec does not do
665
666- It does not move Eleven's data. Old and new boxes share one hibernate bucket
667 and prefix, and `HibernatePrefix` produces the same keys on both sides — a
668 moved host is restored in place, not copied.
669- It does not convert any existing box. The two production boxes serve until
670 empty, then retire.
671- It does not change any space's address. Phase 1 runs on a throwaway
672 namespace precisely because an address is an identity, and phase 2 moves
673 hosts without renaming them.
674- It does not build replicated spaces. That stays Eleven's mesh work.
675- It does not change Eleven's E2EE model in any way.
676