carlos / platform Public

docs: HA posture ladder (#153) + multi-upstream addr routes (#152) design

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell aff51604a8b4607f24313d87743774f9b37f96d5 parent a73539f
1 file changed, +247 −0
  • docs/superpowers/specs/2026-08-12-ha-posture-and-multi-upstream-routes-design.md +247 −0
diff --git a/docs/superpowers/specs/2026-08-12-ha-posture-and-multi-upstream-routes-design.md b/docs/superpowers/specs/2026-08-12-ha-posture-and-multi-upstream-routes-design.md
new file mode 100644
index 000000000..9a79e0c4a
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-ha-posture-and-multi-upstream-routes-design.md
@@ -0,0 +1,247 @@
+# HA posture and multi-upstream routes
+
+**Date:** 2026-08-12
+**Status:** Approved design
+**Prompted by:** #152 (multi-upstream, health-checked addr routes) and #153
+(the edge box is a per-deployment SPOF), both filed out of the Rails-behind-
+the-edge PoC's §7 HA fallout (`2026-08-11-rails-behind-carlos-edge-poc-design.md`,
+PR #150). This doc answers both at once because they are two altitudes of
+one problem: #152 raises the availability of the *upstream* layer, #153 is
+the availability ceiling above it — the edge box that terminates TLS for
+every route on a deployment.
+
+## Context
+
+- Every deployment (flagship, tito, dev) routes all traffic through one
+ edge box. On tito that box (`carlos-tito-ie`) fronts go.tito.io, every
+ instance, the console alias, and — since the Rails PoC — potentially
+ Tito Classic/Pro staging. New tenants widen the blast radius of an
+ existing, deliberately recorded risk; they don't create it.
+- An `addr` route today is one SQLite row with one `Addr`
+ (`internal/registry/registry.go`). The edge's `tcpProxy`
+ (`internal/edge/edge.go`) sets no dial timeout and no `ErrorHandler`: a
+ dead upstream surfaces as the stdlib's bare 502, still stamped with the
+ route-level `X-Carlos-Version`. The only "resolve a live target per
+ request" precedent is `fleetProxy`'s tunnel-channel resolution; the only
+ retry machinery is unix-socket-specific (`dialUnixWithRetry`).
+- The control bucket already holds releases, channels, pointers, and —
+ once PR #137 (route records) merges — provisioned-route desired state.
+ Genuinely box-local state: the registry DB (`/data/registry.db`), minted
+ TLS certs, instance data dirs, and the running processes. #137's
+ `shapeMismatch` deliberately refuses record authority over box-scoped
+ rows (`Backing`, `Tunnel`, `Caps`, `Platform`, `Canary`), so records
+ alone cannot reconstruct a full registry today.
+- Addr routes are forbidden from `Hibernate`
+ (`provision.ValidateShape`, on the #137 branch), so probing an addr
+ upstream can never trigger a wake. That invariant is load-bearing for
+ this design; relaxing it later would reintroduce the wake hazard.
+
+## Decisions taken during design (Paul, 2026-08-12)
+
+- **One doc for both issues** — the #153 ceiling decided first, #152
+ designed within it; implementation still lands separately.
+- **#153 is a ladder, not a point choice**: accepted-risk now, cold
+ standby documented and built, warm standby sketched behind explicit
+ triggers.
+- **#152 designed implementation-ready**, riding #137's record shape
+ additively — not roadmap-priced shape-only.
+- **#152 mechanism: active probe + in-request dial retry** (option C).
+ Active probing owns fail-away/fail-back and dashboard truth; a dial
+ failure additionally ejects the upstream and retries in-request so box
+ death never surfaces a 502 even between probe ticks. Probe-only and
+ passive-only were considered and rejected (the first 502s during the
+ probe gap; the second has dial-level health only and no clean
+ rolling-deploy story).
+- **Cold-standby enabler: registry snapshot to the bucket now**;
+ records-complete stays the long-term trajectory and retires the
+ snapshot when it gets there. Records-complete-first was rejected as a
+ large scope increase on an unmerged PR; runbook-only was rejected as
+ rot-prone.
+
+---
+
+## Part I — the availability ladder (#153)
+
+### Rung 0: today, written down as accepted risk
+
+One edge box per deployment terminates TLS for everything on it. Box
+death = every route on that deployment dark until recovery. Mitigations
+that are already real: EC2 auto-recovery (action item: verify it is
+actually enabled in tofu for both production boxes — it is not believed
+to be explicit today), the control bucket, and 2s serial convergence.
+This is not an oversight; the single-box posture is the recorded
+"multi-node failover is where an orchestrator earns its keep" decision.
+This rung exists so the risk is written where the migration evaluation
+can cite it.
+
+### Rung 1: cold standby (built by this design)
+
+**1. Registry snapshot to the bucket.** The box's converge loop gains a
+snapshot pass: every 5 minutes, if `registry.Fingerprint()` changed since
+the last snapshot, `VACUUM INTO` a temp copy and PUT it to the control
+bucket: current at `control/registry-snapshot.db`, with a sidecar JSON
+(`control/registry-snapshot.json`: created-at, fingerprint, box
+identity); prior generations at
+`control/registry-snapshots/<created-at>.db`, pruned beyond the newest
+8. This
+covers every row, including the box-scoped ones records cannot rebuild.
+Worst-case staleness is one interval; after restore, the reconcile pass
+corrects any record-backed drift. The snapshot is additive bucket
+content — nothing existing parses it, so no wire-shape risk.
+
+**2. TLS: re-mint, don't replicate.** Private keys never enter the
+bucket. A replacement box re-mints via HTTP-01 the moment it holds the
+EIP; wildcards go via the existing DNS-01/DNSimple path (token in SSM).
+Cost: a brief per-host TLS brownout on first hits after failover —
+accepted at this rung. Let's Encrypt duplicate-certificate limits are
+generous enough for disaster recovery.
+
+**3. Runbook + rehearsal.** A pre-written standby `.tf` (count = 0) lands
+in platform-infrastructure per deployment. The runbook, kept next to it:
+
+1. `tofu apply` the standby box.
+2. Remap the EIP to it.
+3. Restore `control/registry-snapshot.db` to `/data/registry.db`.
+4. Arm the path units (`update-platform-binary.sh` does this).
+5. Verify: `X-Carlos-Version` over the remapped EIP via
+ `curl -sI --resolve`, then a console login. Never a cached answer.
+
+The runbook explicitly separates platform recovery from tenant data:
+instance DBs come back only via their own `Replication` setting;
+unreplicated instance state on the dead box is lost, and the runbook says
+so plainly. The rung is not "done" until rehearsed once on the dev
+deployment (Kass account) with the measured RTO recorded in this doc's
+follow-up. Target: under 60 minutes; the rehearsal replaces this guess
+with a number.
+
+### Rung 2: warm standby (trigger-gated, sketched only)
+
+A second box converging the same channels, promoted by EIP remap for a
+~1–2 minute RTO. Known open problems, recorded but deliberately not
+solved here:
+
+- A standby cannot answer HTTP-01 without the EIP — pre-minted certs need
+ DNS-01-everything or a cert-sync channel.
+- The console must stay single-writer; a standby console is read-only or
+ off.
+- Exec/unit-backed tenants cannot run on two boxes at once, so an honest
+ warm standby is closer to a continuously-rehearsed cold one for
+ everything except addr/static/tunnel routes.
+
+**Triggers to design rung 2 for real:** the first paying or SLA-bearing
+traffic on a deployment (the Rails production cutover is the named
+candidate), or the first time measured rung-1 RTO exceeds what a real
+tenant conversation can bear.
+
+---
+
+## Part II — multi-upstream, health-checked addr routes (#152)
+
+### Record and registry shape (additive, rides #137)
+
+The route record gains two optional fields:
+
+```json
+{
+ "addrs": ["10.0.1.10:3000", "10.0.2.11:3000"],
+ "health": {
+ "path": "/up",
+ "status": 200,
+ "host_header": "",
+ "interval_ms": 2000,
+ "fail_after": 3,
+ "rise_after": 2
+ }
+}
+```
+
+Defaults: path `/`, status `200`, host header = route host, 2000 ms, 3,
+2. The legacy singular `addr` stays; `AddrsOrDefault()` folds it into a
+one-element list, so every record written before this change parses and
+behaves identically (the wire-shape rule). `health` on a single-addr
+route is legal and useful: it buys dashboard state and a 503 with
+`Retry-After` instead of today's bare 502.
+
+Registry side: two new TEXT columns — `addrs` (JSON array) and `health`
+(JSON blob) — both folded into `Fingerprint()` so the 2s watch notices
+changes, and both compared in `shapeMismatch` with canonicalized
+ordering. `routeFor`, the single shared mint/adopt builder on the #137
+branch, is the one place the widening happens.
+
+### The prober
+
+For each route with `health` set (or `len(addrs) > 1`, which implies the
+default health config), the edge runs one background prober: direct
+`GET http://<addr><path>` per upstream with the route host as `Host`
+header and a 2 s client timeout. `fail_after` consecutive failures →
+upstream leaves the live set; `rise_after` consecutive passes → rejoins.
+Transitions are logged with the reason (dial error vs wrong status).
+Probes dial upstreams directly, never the edge's public host — combined
+with the no-hibernate invariant for addr routes, there is no wake hazard.
+Probers start and stop at `Reload()` when the fingerprint moves.
+
+### The request path
+
+Live-upstream resolution happens at RoundTrip time (the `fleetProxy`
+precedent), round-robin across the live set — these upstreams are
+replicas, so the edge does the balancing an NLB would have. On a **dial**
+failure (no bytes sent, so safe for any method) the upstream is ejected
+immediately — not waiting for the probe to notice — and the request
+retries once against another live upstream. Never a retry after bytes
+have flowed. Empty live set → 503 + `Retry-After: 2`, mirroring
+`errFleetDark`; the bare-502 path disappears for health-configured
+routes. `X-Carlos-Version` stays route-level as today: addr upstreams are
+processes CARLOS didn't build, and per-upstream version attribution is
+out of scope.
+
+### Surfaces
+
+Per-upstream state (up/down, last transition, consecutive-failure count)
+flows into the existing readings/sweep path, so the dashboard's instance
+page lists each upstream with its state. The instances API carries the
+same, and `carlos instances` shows `2/3 healthy`-style summaries. Route
+mutation goes through the console API / route-records door like
+everything else post-#137 — no box-local-only verb (the CLI no-infra
+rule).
+
+### Rolling deploys
+
+V1 needs no drain verb: the deploy stops the app on box 1 → the probe
+fails it away → deploy → probe passes → fail-back → next box.
+`fail_after × interval_ms` (~6 s at defaults) is the window in which
+requests can still land on a stopping box; the in-request dial retry
+covers them. An explicit per-upstream drain/maintenance flag is named as
+a future nicety, not built.
+
+---
+
+## Testing
+
+- Unit tests for prober state transitions (fail-away at `fail_after`,
+ fail-back at `rise_after`, dial-error vs wrong-status reasons) against
+ `httptest` upstreams.
+- An edge e2e with two backends where killing one mid-request-stream
+ produces zero 5xx responses.
+- Golden compatibility tests proving pre-change records and registry rows
+ still parse and produce today's behavior (the rule this repo has paid
+ for three times).
+- Snapshot pass: a test that a restored snapshot plus a reconcile pass
+ reproduces the source registry's routes.
+
+## Sequencing
+
+1. PR #137 (route records) merges first — #152's shape rides it.
+2. Part I's snapshot + runbook are independent of #137 and #152 and can
+ proceed in parallel; the rehearsal on the dev deployment closes rung 1.
+3. #152 edge work follows #137; the Rails staging boxes are the first
+ live consumer, replacing their internal-NLB rung when it lands.
+
+## Out of scope
+
+- An orchestrator, in any form (#153 says so explicitly).
+- Data HA: Postgres/Redis/instance-DB replication have their own stories
+ (RDS/Elasticache multi-AZ; the route `Replication` column).
+- Warm-standby design (rung 2) until a trigger fires.
+- Per-upstream version attribution in `X-Carlos-Version`.
+- A drain/maintenance verb for upstreams (recorded as future nicety).
+- Relaxing the addr-routes-never-hibernate invariant.