carlos / platform Public

docs: backhaul rollout notes

Paul Campbell pushed by paul@keymail.dev a2943cba2a5950e9d3ff72142c9b04e20b849ab9 parent 67de75f
2 files changed, +281 −6
  • docs/superpowers/plans/2026-08-22-multi-region-backhaul.md +238 −4
  • docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md +43 −2
diff --git a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
index 34741bc8a..743c01469 100644
--- a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
+++ b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
@@ -29,7 +29,11 @@ as the house idiom for bucket-fed edge state.
- Spec: `docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md`
Part 3. Key shapes verbatim: `control/edges/<box>.json`,
`control/edge-mesh-key`. Hop headers verbatim: `X-Carlos-Hop: 1` and
- `X-Carlos-Hop-Auth: <hex hmac>`.
+ `X-Carlos-Hop-Auth: <unix>:<hex>` (**amendment, 2026-08-22, T7**: this
+ line originally read `<hex hmac>`, omitting the leading unix timestamp
+ `signHop` actually prepends — File Structure's own Task 2 Interfaces
+ section below had the right shape all along; ledgered as a Task 2 minor
+ finding, fixed here rather than left to drift from the code).
- **Nothing writes `control/serial`.** Task 1 amends the spec's Part 3
line that said the edges file is serial-bumped (same correction the
certs took in stage 1-2): edges files ride their own 30s poll.
@@ -455,15 +459,245 @@ func TestHomeMapFoldsAndExcludesOwnBox(t *testing.T) {
- Modify: the spec (Part 3 rows → implemented-dormant with commits);
this plan (rollout notes below).
-- [ ] Fill the rollout notes: stage-5 arming now needs — per-box edges
+- [x] Fill the rollout notes: stage-5 arming now needs — per-box edges
files verified present for every armed box BEFORE steering (plan 3)
ever points DNS at a second edge; mesh-key presence check; the
backhaul path exercised on the dev/Kass deployment before flagship AU
(first two-real-boxes deployment); #244's bare-edge note extends to
the mesh key (a bare edge with a bucket can read it — same trust
envelope as certs, record it).
-- [ ] Full gate; commit `docs: backhaul rollout notes`.
+- [x] Full gate; commit `docs: backhaul rollout notes`.
## Rollout notes (filled by Task 7 at execution)
-- (filled at execution)
+Synthesized from the SDD ledger (`progress.md`) across all six
+implementation tasks and their reviews. This plan ships stages 1–3 (the
+spec's Part 3 rows) dormant on single-box deployments; nothing below is
+armed by merging this branch — it is the checklist and the open-questions
+list for the rollout that follows, including the stage-5/6 arming this
+plan's own spec Part 4 describes.
+
+### Stage-5 arming checklist (before flagship AU, before any real second box)
+
+1. **Per-box edges files verified present for every armed box, BEFORE
+ plan 3 (steering) ever points DNS at a second edge.** The home map is
+ built by folding `control/edges/<box>.json` for every box the mirror
+ can list (`internal/edge/homemap.go`, Task 3); a box that never
+ published one is invisible to every other edge's map, so a route
+ homed there is neither served locally by strangers' hellos (fine) nor
+ backhaul-reachable (NOT fine, once steering can legitimately land
+ traffic on a box that has nothing to route it with). No CLI/console
+ surface exists for this today — same box-local-only shape as the
+ registry-snapshot check plan 1's rollout notes carry — so it is a
+ direct-bucket list, consistent with CLAUDE.md's operate-via-API-and-CLI
+ carve-out for platform-internal work:
+
+ ```bash
+ aws --profile <profile> s3 ls s3://<control-bucket>/control/edges/
+ ```
+
+ Expect exactly one `<box>.json` per box that is supposed to be armed.
+ A missing entry means that box's `edgesFileLoop` has not ticked yet
+ (5min + jitter cadence) or is not running — resolve before arming
+ steering, not after.
+2. **Mesh-key presence check.** `control/edge-mesh-key` must exist and
+ every armed box's mirror must have it loaded before any hop can verify
+ (`internal/edge/mesh.go`, Task 2). Same direct-bucket check:
+ `aws s3 ls s3://<control-bucket>/control/edge-mesh-key`. Absence means
+ no box has yet won the housekeeper election with `mintMeshKeyLoop`
+ armed — check the housekeeper's acquisition log line
+ (`internal/host/housekeeper.go`) on whichever box should hold it.
+ **Rotation runbook pointer:** there is no standalone rotation runbook
+ document yet — the only written procedure is this plan's own Task 2
+ Global Constraints section above (`control/edge-mesh-key.next`,
+ dual-accept read on both `verifyHop` callers, promotion = operator
+ copies `.next` over the key and deletes `.next`). File a real runbook
+ doc before the first live rotation rather than pointing operators at a
+ plan file; until then, this paragraph IS the pointer. **Propagation
+ window is ≤1 poll** (30s, the home-map/mesh-key mirror's shared
+ cadence) per Task 2's ledgered honest-header fix (`0c07490`) — during
+ that window some edges verify hops under the old key, some under
+ `.next`; dual-accept on both is what makes that safe, not a race to
+ avoid.
+3. **Exercise the backhaul path on dev/Kass before flagship AU.**
+ Flagship AU is the spec's designated first target because a steering
+ mistake there "burns nobody's ticket sales" (spec Part 1 decisions) —
+ but nothing in this branch has run across two REAL boxes; Task 6's
+ e2e (`67de75f`) is in-process with edges files written directly into a
+ fake store (see the spec's Part 3 residual note, and Task 6's report
+ §4.1). The dev/Kass deployment is the first two-real-boxes proving
+ ground: arm a second box there, verify items 1–2 above hold live (not
+ just in a fake store), drive one real cross-box hop with `curl
+ --resolve` against the far box's EIP for a host homed on the near box,
+ and confirm the marker/XFF/one-hop behaviour Task 6 pinned in-process
+ also holds over a real network hop — before repeating any of this on
+ flagship.
+4. **#244's bare-edge note extends to the mesh key.** Plan 1's rollout
+ notes (item 5) ruled that a standalone `carlos edge` run outside the
+ agent on a bucket-backed box is ungated and publishes to
+ `control/certs/**` unconditionally — "don't run a bare `carlos edge`
+ against a deployment bucket on a multi-edge deployment," tracked as
+ issue #244. The same box, run the same way, also mirrors
+ `control/edges/` and `control/edge-mesh-key` (Task 3's home-map mirror
+ has no issuer gate — it is read-only bucket state, not the issuance
+ path) — so a bare edge can read the mesh key and the whole
+ deployment's home map exactly as it can read certs. This is not a new
+ hole: it is the same trust statement Part 2 already makes explicit
+ ("any box with deployment-bucket read already serves this deployment's
+ traffic... holding TLS keys does not extend what a compromised box can
+ do") extended to the one new key type Part 3 adds. Recorded here so
+ #244's operational rule ("don't run a bare `carlos edge` against a
+ deployment bucket on a multi-edge deployment") is read as covering the
+ mesh key too, not just certs.
+
+### Open ruling requested for Paul
+
+5. **T7 egress ruling — under-metering of backhauled egress, including
+ an abuse case.** By Task 3's hard constraint (T3 review) the ONLY
+ usage meter (`edge.go:840`) sits below a successful local
+ `lookupUpstream`; a backhauling edge misses it by construction, so a
+ route's cross-region backhaul traffic is billed (AWS egress) on the
+ SENDING box's account but never metered (CARLOS usage/billing) on
+ either box — the bytes leave the sending box's NIC twice as expensive
+ as a locally-served request and the platform's own metering sees none
+ of it. Task 4 (`bdcf6f6`..`0193912`) confirmed this is not merely a
+ missed-revenue gap: it is an **abuse surface**. Backhaul dials any
+ host the home map names, on an unverified hello, before any hop trust
+ check applies to the INBOUND request that triggered the dial — so a
+ scanner sending forged `Host:` headers at a non-home edge for hosts
+ that happen to be real elsewhere in the deployment generates real
+ cross-region egress cost on the sending box's AWS bill, with no
+ member, no account, and no CARLOS usage row to attribute it to.
+ **Containment option on the table, not yet decided:** a per-home-box
+ in-flight semaphore bounding concurrent backhaul dials toward any one
+ home box — cheap (one counter, no new storage), does not fix
+ attribution, but bounds the blast radius of a scanning flood to a
+ fixed number of concurrent cross-region dials rather than unbounded.
+ This needs a ruling before Part 4 (steering) sends real, sustained
+ traffic through this path — today it is dormant/low-volume by
+ construction (no steering, no DNS pointing anywhere but the home
+ region), which is the window in which to decide, not after. Cross-refs
+ unit-economics (the meter placement is `internal/usage`'s territory,
+ not `internal/edge`'s to relocate unilaterally) and the object of this
+ ruling should be recorded as a numbered issue once Paul rules on the
+ containment approach.
+
+### Constraint for Part 4 / plan 3 (steering) to honor
+
+6. **Pool narrowing: steered non-shared-pool routes must not get
+ out-of-pool latency records.** Task 5 (`15c3673`..`af006be`) confirmed
+ `inScope` tests `poolServed` FIRST and unchanged — the everywhere-class
+ widening in this plan only ever widens the REGION axis, never the pool
+ axis, so a `tito`-pool static record is still refused by a box serving
+ a different pool. Part 4's Route53 record generation must respect the
+ same boundary: a steered route in a non-shared pool must only get
+ latency records for the armed edges that actually serve its pool, not
+ every armed edge in the deployment — otherwise DNS would advertise a
+ path to a box that has never adopted the route and cannot serve it
+ (the everywhere-class widening's pool refusal would make that box 404
+ or refuse the hop, not backhaul it, since the route never landed
+ there). Stated as a plan-3 design constraint, not implemented here.
+
+### Operator/dashboard notes at second-region arming
+
+7. **`DNSStatus: "elsewhere"` false-alarm risk at second-region arming.**
+ `dnsStatus` (`internal/domains/reconcile.go:465`) classifies a route's
+ resolved IPs against `cfg.EdgeIPs` — the deployment's edge address list,
+ sourced from `PLATFORM_EDGE_IPS` per box (`internal/host/agent.go:1371`).
+ Each box runs its OWN `domains.Reconcile` pass and stamps its OWN
+ registry. Once Task 5's everywhere-class adoption puts the SAME static
+ routes on every box (not just the home box), every armed box now
+ independently DNS-checks those hosts — and any box whose own
+ `PLATFORM_EDGE_IPS` has not been updated to include the new region's
+ EIP will read a real, correctly-resolving DNS answer (still pointing
+ only at the original region, since Part 4 steering has not run yet) as
+ `"elsewhere"`, because the resolved IP is not in that box's edge set.
+ **Rollout action:** update `PLATFORM_EDGE_IPS` on every box to the
+ full deployment-wide edge IP list at the same time a new region is
+ armed, not after — otherwise the fleet dashboard (which aggregates
+ per-box readings) shows real DNS noise for every everywhere-class host
+ the instant the second box starts reconciling them, indistinguishable
+ at a glance from an actual DNS problem.
+8. **Legacy public-IP single-`Addr` rows: enumerate before regions
+ apply.** Task 5's `publiclyDialable` gate reclassifies any addr record
+ whose upstreams are ALL public IP literals as everywhere-class the
+ moment a second box exists — no code deploy required, just another
+ box appearing. Loopback and RFC1918/ULA private addrs are excluded by
+ design (home-boxed, unchanged), but a legacy single-`Addr` row that
+ happens to carry a real public IP literal would silently start being
+ probed and served from every armed box the day regions apply. Expected
+ empty in both production deployments today, but unverified — enumerate
+ before the first regions-apply:
+ ```sql
+ -- against each deployment's registry (read-only)
+ SELECT host, addr FROM routes WHERE addr <> '' AND kind = '';
+ ```
+ then eyeball each `addr` against `publiclyDialable`'s rule
+ (`net.ParseIP` non-nil, `IsGlobalUnicast()`, not private/loopback/
+ link-local) — any hit is a route to look at BEFORE arming, not after.
+9. **Edges-file `role: "home"` over-claims for static hosts — wants a
+ `"replica"` role before Part 4 leans on the map.** Task 5's own
+ concern (report §4.1): once static routes are everywhere-class, EVERY
+ box that adopts one publishes it with `role: "home"` in its edges
+ file (Task 1's schema has only ever written `"home"`), so the home map
+ folds multiple boxes all claiming to home the same static host. The
+ fold's duplicate rule (first box in sorted order wins) makes this
+ harmless TODAY, because static routes never backhaul (Part 3's table:
+ Everywhere class is "adopt and serve locally... zero backhaul") — the
+ role is never consulted for a routing decision on this class. It stops
+ being harmless the moment Part 4's read-local anticipation (spec
+ "Anticipated, not built" §1) starts reading the `role` field for
+ real — a reader that cannot distinguish "the box that actually homes
+ this process" from "a box that adopted a static copy" cannot implement
+ read-local correctly. The role vocabulary already has the room (spec:
+ `{"role":"home"}` object form, not a bool) — this is a note for
+ whoever designs Part 4's writer change, not a code change here.
+10. **Domains: a static app's apex row appears on far boxes — operator-
+ surprise note, not a DNS-writer change.** Task 5 confirmed (report
+ §4.1, item 3) that `domains.Reconcile` mints the platform apex on a
+ far box too, since Task 5 makes its source route exist there. DNS
+ itself still points only at the home region until Part 4 steering —
+ **this is NOT a new DNS writer and does not change what answers a
+ real client sees**, disproving any read of it as a DNS-authority
+ change. It is purely a local-registry/console-visibility surprise: an
+ operator inspecting a far box's own domain rows will now see an apex
+ entry for an app they may not expect that box to know about at all.
+ Worth a line in the fleet dashboard / `carlos apps` docs before
+ stage 5, not a blocker.
+
+### Cross-referenced issues
+
+11. **#250 — everywhere-class rows with operator-touched
+ Backing/Tunnel/Caps survive record deletion on far boxes ("far-box
+ survivors").** Filed during Task 5's review round 1 (fix commit
+ `af006be`). Since `15c3673`, a static or public-addr record is
+ materialized on every platform box, and the removal phase reads the
+ class off the ROW (`everywhereRoute`), which deliberately answers
+ "home-boxed" for any row carrying `Backing`, `Tunnel` or `Caps` — a
+ conservative choice that also means: mint a static record on a far
+ box, `carlos route -grant`/`-backing`/`-tunnel` that far copy, delete
+ the record — the home box retires its route, the far box keeps
+ serving the retired site forever with its grants intact and no record
+ anywhere to explain why. The gate stays conservative on purpose (the
+ alternative hands an unattended bucket-record deletion the
+ capability-grant purge's human-gated contract), so the fix is
+ visibility + a supported cleanup verb, not a gate change. Relevant to
+ this rollout because everywhere-class adoption (this plan) is what
+ makes far-box copies routine for the first time — #250 was inert
+ before this branch.
+12. **#179 — deployment-wide secrets key, tracked as a mesh blocker.**
+ Task 5's concern (report §4.1, item 2): `appconfig.Sync` now renders
+ an everywhere-class app's `<host>.env` on every box that adopts it,
+ which is more copies of a tenant's rendered config at rest — not a
+ new READ capability, since every box already holds the
+ deployment-wide secrets key and could always read the whole store,
+ the exact coupling #179 tracks. #179 is a pre-existing blocker (also
+ surfaced on the trust-placement branch's mesh design work), not
+ something this branch introduces; recorded here as the cross-ref
+ Task 5's review asked for, not as new scope for this plan.
+
+**Full gate** (env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod
+GOPROXY=off`): `go build ./...`, `go vet ./...`, `GOOS=darwin go build
+./...`, `go test ./... -count=1`, `gofmt -l` on touched files — all
+clean; this task's changes are docs-only (spec + this plan file), so
+the gate is trivially green (no `.go` file in the diff).
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
index 8ce135c34..fa911ee7c 100644
--- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
+++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
@@ -74,8 +74,8 @@ in the plan's rollout notes, not code.
| `releases/**` retention prune | every box | moves behind the **housekeeper lease** — five concurrent pruners racing a promote is a bug class we refuse to have. **Implemented, dormant** (`45a2ea6`..`aadd18e`) |
| usage metering day objects | the (single) edge | **audit**: if day-object keys are not box-scoped, they become `…/<box>/…` in the same shape as snapshots. **Confirmed already box-scoped** (`usage/edge/<box>/days/`, bucket-writer audit) |
| `control/certs/**` (new; carries HTTP-01 tokens as `<token>+http-01` and tls-alpn-01 token certs as `<host>+token` — no separate acme-challenges prefix) | housekeeper | Part 2 — **implemented, dormant** (`7eff14b`..`f7e67fe`) |
-| `control/edges/<box>.json` (new) | each box, own key only | Part 3 |
-| `control/edge-mesh-key` (new) | housekeeper mints if absent (create-only CAS) | Part 3 |
+| `control/edges/<box>.json` (new) | each box, own key only | Part 3 — **implemented, dormant** (`23a2b0c`..`e964df3`) |
+| `control/edge-mesh-key` (new) | housekeeper mints if absent (create-only CAS) | Part 3 — **implemented, dormant** (`fc2bad7`, `0c07490`) |
Stage-1 audit deliverable: an enumerated list of EVERY bucket writer in
the codebase with its ruling from the table above, checked into the spec's
@@ -173,8 +173,49 @@ over with identical machinery. Distributed certs keep every edge serving
through the gap; a dead issuer costs nothing until the earliest renewal
window ~30 days out.
+**The serving chain, and the last layer's boot-outage rationale.**
+`GetCertificate` on a non-issuer edge tries, in order: the wildcard
+handler, then this mirror, then the box's own local autocert manager,
+then — last — a direct read of that box's own on-disk autocert cache
+(`internal/edge/certsync.go`, `localCertificate`). That last layer exists
+because it is not redundant with the mirror: it is what a box serves when
+the bucket is unreachable at boot (an empty mirror) but its own disk
+still holds yesterday's certs, and what covers the ~20 minute window
+after a boot or a crash while the housekeeper election has not yet
+resolved. **Amendment (2026-08-22, T7):** that last-resort read is
+negatively memoized for `localCertTTL` (30s) — a name whose disk lookup
+comes back empty is not re-read from disk for 30s. Harmless in practice:
+the mirror is consulted first and answers ahead of this layer whenever it
+has anything, so a memoized nil here only matters in the narrow window
+where BOTH the mirror and this box's own disk have nothing for the name —
+it cannot cost a handshake the mirror could have served. Recorded because
+Task 6's two-edge e2e (`67de75f`) is the first thing to demonstrate the
+negative memoization directly (its P1 subtest), not because it changed
+behaviour.
+
## Part 3 — serving classes and backhaul
+**Implementation status (2026-08-22): implemented, dormant.** The per-box
+edges file (commits `23a2b0c`..`e964df3`) and the housekeeper-minted,
+dual-accept mesh key (`fc2bad7`, `0c07490`) are Part 1's writer-map rows
+above. The home-map mirror and one-hop request-path acceptance
+(`42844ae`..`67c2034`), backhaul dialing with a real TLS hop and a
+per-home-box cached transport (`bdcf6f6`..`0193912`), everywhere-class
+adoption for static/bake routes plus the paired `:80` remote-homed
+redirect (`15c3673`..`af006be`), and the two-edge e2e proving one
+marker-body backhaul, the poisoned-map 502, the stale-map 503 with no
+re-dial, and a tampered-auth hop degrading to an ordinary client
+(`67de75f`) are Part 3's own rows above. Single-box deployments: the home
+map holds only the local box, `Home()` never offers a backhaul target,
+the mesh key mints once and sits unused, and everywhere-class adoption
+outcomes are byte-identical to today's (see each task's single-box
+inertness note). Residual: nothing in this branch has run across two
+REAL boxes — the e2e is in-process with edges files written directly into
+a fake store — tracked as a rollout gate, not a blocker (see the plan's
+rollout notes). Egress attribution for backhauled bytes is an open
+ruling, not a defect: see the plan's rollout notes, T7 egress ruling
+request.
+
| Class | Kinds | Behaviour on a non-home edge |
|---|---|---|
| Everywhere | static, bake-served | adopt and serve locally from converged artifacts — zero backhaul |