carlos / platform Public

spec: remote host-agent (#53) + Raspberry Pi instance host design

Two stacked PRs: C6 Step 3 machinery (tunnel backing via reverse HTTP/2,
heartbeat metrics banded-on-publish, failover verification, off-cloud
bootstrap), then Pi enablement as dev-platform region 'pi'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 615787764e29fd4cf12cc2980955a80a3568e1cc parent 761d3d0
1 file changed, +239 −0
  • docs/superpowers/specs/2026-08-04-remote-host-pi-design.md +239 −0
diff --git a/docs/superpowers/specs/2026-08-04-remote-host-pi-design.md b/docs/superpowers/specs/2026-08-04-remote-host-pi-design.md
new file mode 100644
index 000000000..5b06fb4f0
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-04-remote-host-pi-design.md
@@ -0,0 +1,239 @@
+# Remote host-agent + Raspberry Pi instance host — design
+
+**Date:** 2026-08-04
+**Charter:** [platform#53](https://github.com/carlosframework/platform/issues/53)
+(C6 Step 3 of `docs/superpowers/plans/2026-08-01-plan-c-tito-selfhosted.md`,
+PR #49) plus Raspberry Pi enablement on the dev platform.
+**Settled upstream, not relitigated here:** the control plane is the
+deployment bucket; a remote host is outbound-only (443 to S3 and the edge —
+no inbound port, no SSH path, no push channel); this is the existing
+`carlos` binary in host-agent role, not a new binary.
+
+## Goal
+
+Two deliverables, two stacked PRs:
+
+1. **PR 1 — remote host-agent machinery (#53 verbatim):** tunnel route
+ backing on the edge, host metrics in heartbeats, placement/failover
+ verification, off-cloud bootstrap. Deployment-agnostic.
+2. **PR 2 — Pi enablement:** a Raspberry Pi 4 (4 GB) joins the **dev
+ platform** (Kass account, 640012953448) as instance host for region
+ `pi`, hosting a demo fleet — proving "a CARLOS host is anything that
+ can reach the deployment bucket" on a $60 board, and rehearsing
+ exactly the machinery tito's `hq` office box needs.
+
+Demo blast radius stays off production; the pocket-Pi demo only needs
+outbound 443, which survives venue Wi-Fi.
+
+## PR 1 — remote host-agent machinery
+
+### Tunnel route backing
+
+- `registry.Route` gains an additive `Tunnel` column: the name of the
+ reverse channel backing the route. Channel name = the owning host's
+ box label. Wire/DB shape additive; routes written before this change
+ parse unchanged.
+- The dispatch switch in `internal/edge/edge.go` `Reload()` (today:
+ socket → addr → static → status) gains a fourth backing:
+ `t.Tunnel != ""` → `tunnelProxy`, the same `httputil.ReverseProxy`
+ shape as `tcpProxy` but with a RoundTripper bound to the named
+ channel. Header discipline is inherited because it is the same proxy
+ path: `SetXForwarded`, `scrubInboundPurgeHeader` inbound,
+ `applyPurgeHeader` outbound. **A tunnel-backed host is exactly as
+ untrusted as an addr-backed one.**
+
+### Transport: reverse HTTP/2 over a host-dialed TLS connection
+
+Decision (the in-repo judgment call #53 asks for, recorded): we port the
+*idea* of titogo's `internal/tunnel` — persistent authenticated dial-out
+— but not its websocket transport.
+
+- The host dials the edge's **existing 443 listener** — no new port —
+ with an upgrade request to a reserved path carrying
+ `Authorization: Bearer <token>` and its channel name.
+- The edge verifies the token (constant-time; the token is bound to the
+ channel name it may claim — dev configures one `name=token` pair in
+ the edge environment, hq can list several), hijacks the connection,
+ and **inverts** it: the edge runs an `http2.Transport` client over
+ the hijacked conn; the host runs an `http2.Server` whose handler is
+ its own local edge routing.
+- HTTP/2 supplies stream multiplexing, flow control, and pings.
+ A failed ping tears the channel down and flips its state to
+ `reconnecting`. `golang.org/x/net` is already in the module graph;
+ no websocket dependency is added.
+- New `internal/edge/tunnel.go`: the channel hub (name → live channel),
+ registration handler, token check, lifecycle.
+
+Rejected: porting the websocket transport (vendors a dependency and
+hand-rolls multiplexing h2 gives us); a dedicated listener port (venue
+and office networks may only pass 443).
+
+### Host dial-out
+
+- New `tunnelLoop` goroutine in `RunAgent` (`internal/host/agent.go`),
+ beside `statusLoop`/`configSyncLoop`; active only when
+ `CARLOS_TUNNEL_URL` + `CARLOS_TUNNEL_TOKEN` are configured.
+- Jittered exponential backoff **capped low**: the acceptance test
+ asserts a reconnect bound in seconds. hq's dual-WAN failover is a
+ re-dial over the surviving link; the Pi's Wi-Fi blip is the same
+ event. Reconnect latency is user-visible.
+- While a channel is down the edge answers its routes 502; the live
+ edge cache keeps public pages serving through a blip.
+
+### Host metrics in heartbeats
+
+The heartbeat record is the existing fleet status Box snapshot
+(`status/boxes/<label>.json`, writer `internal/status/snapshot.go`
+`WriteBox`, driven by `statusLoop`). Decision: **extend Box with an
+optional additive `metrics` block; band or strip before anything
+public.**
+
+- New fields: memory total/available, load, disk free, resident and
+ parked instance counts, current release + channel, tunnel/channel
+ state (connected/reconnecting; egress link hint if knowable).
+- The public fleet merger (`internal/status/fleet.go` `BuildFleet` →
+ `PublishFleet`) continues to publish **only the existing banded
+ words**. Raw numbers never reach the publicly served `fleet.json` —
+ the privacy banding in `CollectBox` was deliberate and survives.
+- Consumers of the raw block (consumer list = everything that PARSES
+ the shape): the private, session-gated console fleet view renders raw
+ metrics from an in-process unbanded merge; `carlos status` gains a
+ live mode reading box snapshots from the bucket (operator
+ credentials). Nothing else parses Box today (verified: edge status
+ serving is byte-passthrough; activator and ops do not parse it).
+- **Additive:** Boxes written by old hosts must still parse — pinned by
+ a back-compat test fixture (yesterday's shape parses today).
+- Staleness is the one existing rule: a Box older than 3× the publish
+ period renders **unreachable** — never its last happy numbers — in
+ both console fleet view and CLI live status.
+
+### Placement + failover: verify, don't build
+
+- No pool machinery. `Route.Pool`/`Route.Region` remain dormant
+ columns. Placement stays IAM + topology: one region, one data
+ bucket, one host that can reach it.
+- New tests prove the dark-then-returns sequence end to end: host goes
+ dark (stale heartbeat + lease expiry) → instance wakes on a host
+ with fallback eligibility from the regional bucket → the returning
+ host **loses** the conditional-write race and does not reclaim.
+- No redirect verb exists or is added.
+
+### Off-cloud bootstrap
+
+- `carlos bootstrap -offcloud`: identical structural work (units, dirs,
+ service user, timers — Raspberry Pi OS and the office box are both
+ systemd Debian), but expects a **staged credentials file** (0600, in
+ the credentials dir, owned by the service user) instead of an EC2
+ instance profile. The flag is explicit; no IMDS sniffing.
+- Credential scope: the deployment bucket + the host's own region data
+ bucket and **nothing else** — wrong-region-fails-at-IAM holds
+ off-cloud. IAM Roles Anywhere is documented as the hq-grade
+ alternative to a long-lived key.
+- The existing IMDS public-IP probe is already gated off for non-EC2.
+- Deliverable includes the one-page **software-half host runbook**
+ shape (the hq physical runbook lands later in
+ `infra/tito/region-hq.md`).
+
+### Acceptance (tests, not narration)
+
+- E2E in-repo: edge + fake remote host over a real reverse channel;
+ route resolves, request round-trips, response headers survive.
+- Kill the channel mid-flight → host re-dials → next request succeeds;
+ measure and assert a reconnect bound.
+- Stale-heartbeat host renders unreachable; its instance wakes on a
+ fallback host; the returning host loses the lease race.
+- Heartbeat parse test: yesterday's shape still parses today.
+- All 14 packages: `go build`, `go vet`, `go test -count=1`, `gofmt`
+ clean on touched files; pinned production channel names in
+ `internal/release/laddercheck_test.go` keep passing.
+
+Per CLAUDE.md delegation policy this is production-path work: **Opus
+reviews** the edge listener, auth, and lease/failover pieces.
+
+## PR 2 — Raspberry Pi enablement (dev platform)
+
+### Infra
+
+- New reusable tofu module `infra/modules/region-remote-host`: the
+ region's data bucket + an off-cloud IAM principal scoped to exactly
+ the deployment bucket and that region's bucket. Pi uses an IAM user
+ + access key; the module shape leaves room for an IAM Roles Anywhere
+ trust later. This is deliberately the module tito's hq Step 1 reuses.
+- The dev root module (platform-infrastructure checkout, like other
+ roots) instantiates it as region `pi`.
+
+### Region + placement
+
+- Dev gets a `pi` region entry. Demo instances are created with
+ residency `pi`; the Pi's activator — the only one pointed at that
+ bucket — wakes them.
+- **No live fallback** (decision): Pi instances are Pi-resident,
+ period. A dark Pi renders unreachable; it never silently migrates the
+ demo to the cloud host mid-blip. Failover semantics are proven by
+ PR 1's tests, not wired live here.
+- No tier field, no provisioner changes — that is tito's C6 Step 2.
+
+### The Pi itself
+
+Hardware: Raspberry Pi 4, 4 GB. Runbook `infra/dev/region-pi.md`
+(software half, mirroring the hq runbook shape):
+
+- **64-bit Raspberry Pi OS Lite is mandatory** — hosts pick artifacts
+ by `runtime.GOOS + "-" + runtime.GOARCH`; a 32-bit OS resolves no
+ artifact.
+- SSD over USB3 recommended; high-endurance SD acceptable for the demo.
+ SQLite + WAL streaming is the I/O load and SD I/O is the honest
+ constraint on a Pi 4.
+- Stage the `linux-arm64` binary + `host.env` + credentials file, run
+ `carlos bootstrap -offcloud`, verify the dial-out.
+- **Zero build-pipeline work:** ship's default target is already
+ `linux-arm64` and the fleet is arm64 Graviton; the Pi eats the same
+ artifact.
+
+### Demo seeding + success criteria
+
+A small script creates the demo fleet via `carlos apps create` (the
+PR #52 console-API path) against dev: **≥5 resident + ≥50 parked**
+instances (hibernation makes parked ~free; resident count is bounded by
+per-app footprint in 4 GB). The demo proves:
+
+1. The Pi appears in the fleet view with real metrics and its channel
+ state.
+2. Instances serve through the dev edge over the reverse channel with
+ correct headers.
+3. A parked instance wakes on first request through the tunnel.
+4. Pull the Wi-Fi → the next request after re-dial succeeds within the
+ asserted reconnect bound.
+5. Power the Pi off → it renders unreachable; power it on → it resumes
+ serving. Nothing migrated.
+
+That is the viability claim demonstrated rather than asserted: *a
+CARLOS host is anything that can reach the deployment bucket.*
+
+## Error handling (both PRs)
+
+- Token mismatch → 401; the host backs off with a cap (no thundering
+ re-dial) and logs.
+- Half-dead channel → h2 ping timeout closes it; the host re-dials.
+- Bucket unreachable from the host → heartbeats stop → unreachable in
+ fleet after 3× period.
+- Channel down → 502 from the edge; edge cache covers public pages.
+
+## Sequencing
+
+1. PR 1 in its own worktree/branch/draft PR; Opus review on edge
+ listener, auth, lease/failover. On merge: tick C6 Step 3 in Plan C
+ (once PR #49's plan text is on main) and record divergences.
+2. PR 2 stacks on PR 1: tofu module, dev `pi` region, runbook, demo
+ seeding.
+3. Website-feature-sync rule applies at merge: "run CARLOS on a
+ Raspberry Pi" is website material.
+
+## Out of scope
+
+- C6 Steps 1, 2, 4–7 (tito hq bucket/IAM tofu, tier/provisioner,
+ hardware, relocation verb, canary, default flip).
+- Pool/eligibility machinery (dormant columns stay dormant).
+- Live fallback for the Pi region.
+- Multi-arch CI (manual `make build GOARCH=...` remains the build
+ path).