| new file mode 100644 |
| index 000000000..42dd6ee11 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-05-customer-fleets.md |
| @@ -0,0 +1,299 @@ |
| +# Customer Fleets Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Spec: `docs/superpowers/specs/2026-08-05-customer-fleets-design.md` — read it first; its Settled Decisions table binds every task. |
| + |
| +**Goal:** Account-owned fleets: a customer's boxes join their account (not the platform), the console/CLI create and control them, per-app placement puts apps on them, and the Pi demo runs on fleet `pi` by construction. |
| + |
| +**Architecture:** Fleet + fleet-box records live in the registry (console and edge share `/data/registry.db` on every current deployment; the edge hot-reloads via its existing 2s `Fingerprint` poll). Placement rides the existing instance-record `Region` filter: `place=fleet/<name>` stamps records, and fleet boxes run `CARLOS_REGION=fleet/<name>` so only they adopt. Fleet boxes are outbound-only, so the per-hostname channel binding travels as a deployment-bucket object the platform agent materializes into the registry. Tunnel auth for fleet channels moves from static env to registry rows (token hashes at rest). |
| + |
| +**Tech Stack:** Go (stdlib + modernc sqlite via existing registry), existing tunnel/lease/status machinery, console templates. |
| + |
| +## Global Constraints |
| + |
| +- **Additive wire shapes**: every bucket object and JSON surface written before this change must still parse; new registry columns append to the `ALTER TABLE` list and update ALL FOUR sites (`migrate`, `routeCols`, `scanRoute`, `Upsert`) — `TestUpsertRoundTripsAllColumns` guards the count. |
| +- **Names**: fleet names and box labels validate with `registry.ValidTunnelName` (`^[a-z0-9-]{1,32}$`, registry.go:170). Placement target grammar: `fleet/<name>` exactly; anything else invalid in v1. |
| +- **Tokens**: 32 random bytes hex (`crypto/rand`), shown once at mint; registry stores only `sha256` hex. Auth compares with `crypto/subtle.ConstantTimeCompare`. |
| +- **Console gate order** (server.go:592-639, stepup.go:245): session → CSRF → owner (byte-identical 404) → `requireFreshAuth` LAST. API endpoints: bearer → uniform 401 (`jsonError`, authcli.go:292). |
| +- **Public fleet.json never shows fleet boxes or metrics**: by construction + pinned by the `contract_test.go` allowlists. |
| +- **Icons**: vendored Lucide constants in `internal/console/icons.go`; `template.HTML` only for compile-time constants. |
| +- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1`, `gofmt -l` clean on touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. |
| +- **Branch setup (controller, before Task 1):** `git merge origin/main` — PR #52's `apiSession`/`claimApp`/`RunApps` machinery is required by Tasks 7-8 and is on main. |
| + |
| +--- |
| + |
| +### Task 1: Registry — fleets + fleet_boxes tables |
| + |
| +**Files:** |
| +- Create: `internal/registry/fleets.go`, `internal/registry/fleets_test.go` |
| +- Modify: `internal/registry/registry.go` (only `Fingerprint`, registry.go:999) |
| + |
| +**Interfaces:** |
| +- Consumes: `Registry.conn()` pattern, `ValidTunnelName` (registry.go:179). |
| +- Produces (later tasks rely on these exact signatures): |
| + |
| +```go |
| +type Fleet struct { |
| + Name string // ValidTunnelName |
| + Account string // owning account identity, same domain as Route.Account |
| + Bucket string // customer data bucket name |
| + CreatedAt int64 |
| +} |
| +type FleetBox struct { |
| + Fleet string |
| + Label string // ValidTunnelName; (Fleet, Label) is the channel identity |
| + TokenHash string // sha256 hex of the bearer token; never plaintext |
| + Status string // "active" | "detached" |
| + AddedAt int64 |
| +} |
| +func (r *Registry) UpsertFleet(f Fleet) error |
| +func (r *Registry) GetFleet(name string) (*Fleet, error) |
| +func (r *Registry) ListFleets() ([]Fleet, error) |
| +func (r *Registry) AddFleetBox(b FleetBox) error // errors if (fleet,label) exists and is active |
| +func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error // errors if no row |
| +func (r *Registry) SetFleetBoxStatus(fleet, label, status string) error // errors if no row |
| +func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) |
| +func (r *Registry) FleetChannelAuth() (map[string]string, error) |
| +// FleetChannelAuth: channel name "<fleet>/<label>" -> TokenHash, ACTIVE boxes only. |
| +``` |
| + |
| +- [ ] **Step 1: Write failing tests** in `fleets_test.go`: round-trip Fleet and FleetBox through Upsert/Get/List; `AddFleetBox` rejects duplicate active `(fleet,label)`; `SetFleetBoxStatus("x","y","detached")` then `FleetChannelAuth()` omits it; `SetFleetBoxToken` on a missing row errors; invalid names rejected by all writers (`UpsertFleet{Name:"UPPER"}` errors). |
| +- [ ] **Step 2: Run** `go test ./internal/registry/ -run TestFleet -count=1` → FAIL (undefined types). |
| +- [ ] **Step 3: Implement** `fleets.go`: `CREATE TABLE IF NOT EXISTS fleets (name TEXT PRIMARY KEY, account TEXT NOT NULL, bucket TEXT NOT NULL, created_at INTEGER NOT NULL)` and `CREATE TABLE IF NOT EXISTS fleet_boxes (fleet TEXT NOT NULL, label TEXT NOT NULL, token_hash TEXT NOT NULL, status TEXT NOT NULL, added_at INTEGER NOT NULL, PRIMARY KEY (fleet, label))`, executed from `migrate()` beside the routes DDL. Writers validate names with `ValidTunnelName` first. Follow the fresh-connection read pattern (registry.go:455). |
| +- [ ] **Step 4: Extend `Fingerprint`** (registry.go:999) to fold both new tables into the aggregate (count + summed column lengths, same weighting trick) so the edge's `Watch` poll (edge.go:830) observes fleet changes. Add a test: mutate a fleet row → fingerprint changes. |
| +- [ ] **Step 5: Run** the package suite → PASS; **commit** `registry: fleets + fleet_boxes tables — account-owned fleet records, hashed tokens`. |
| + |
| +--- |
| + |
| +### Task 2: Registry — placement + binding columns on routes |
| + |
| +**Files:** |
| +- Modify: `internal/registry/registry.go` (ALTER list registry.go:237-258, `routeCols` :427, `scanRoute` :433, `Upsert` :527) |
| +- Test: `internal/registry/registry_test.go` (existing round-trip test picks up new fields) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + |
| +```go |
| +// Route gains one column (Region already exists and carries the placement target): |
| +FleetBox string // label of the fleet box currently bound to serve this route ("" = none) |
| +func (r *Registry) SetFleetBox(host, label string) error // narrow setter, errors if no row (SetTunnel pattern, registry.go:805) |
| +func (r *Registry) SetPlacement(host, target string) error // narrow setter for Region; target "" clears; validates fleet/<name> grammar |
| +// Placement helpers (fleets.go): |
| +func FleetPlacement(region string) (fleet string, ok bool) // "fleet/pi" -> ("pi", true); "" or "eu" -> ("", false) |
| +func PlacementTarget(fleet string) string // "pi" -> "fleet/pi" |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests**: `SetFleetBox` round-trips and errors on missing host; `SetPlacement` sets/clears `Region` and rejects `"fleet/UPPER"` and `"bogus"`; `FleetPlacement("fleet/pi")==("pi",true)`, `FleetPlacement("eu")==("",false)`, `FleetPlacement("fleet/UPPER")==("",false)` (name must validate); pre-existing rows (no `fleet_box` column value) scan as `FleetBox==""` — additive. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement: append `ALTER TABLE routes ADD COLUMN fleet_box TEXT NOT NULL DEFAULT ''`; update all four sites; add setter + helpers. |
| +- [ ] **Step 4:** Package suite (`TestUpsertRoundTripsAllColumns` must pass) → PASS; **commit** `registry: fleet placement helpers + fleet_box route binding`. |
| + |
| +--- |
| + |
| +### Task 3: Edge — registry-backed tunnel auth (dynamic clients) |
| + |
| +**Files:** |
| +- Modify: `internal/edge/tunnel.go` (TunnelHub :55, ServeHTTP :88), `internal/edge/edge.go` (`enableTunnelsFromEnv` :1192, `Reload` :219) |
| +- Test: `internal/edge/tunnel_test.go` (existing patterns), new cases in place |
| + |
| +**Interfaces:** |
| +- Consumes: `Registry.FleetChannelAuth()` (Task 1). |
| +- Produces: |
| + |
| +```go |
| +// TunnelHub gains a second credential source. Static env clients keep working (tito hq). |
| +func (h *TunnelHub) SetDynamicAuth(auth map[string]string) // channel -> sha256 token hash; replaces prior dynamic set |
| +// ServeHTTP auth order: static clients (plaintext compare, unchanged) else dynamic (hash the |
| +// presented token, ConstantTimeCompare vs stored hash). Channel names in the dynamic set are |
| +// "<fleet>/<label>" — ServeHTTP accepts them (the "/" requires relaxing ONLY the dynamic-set |
| +// lookup, not ValidTunnelName; static names still validate as before). |
| +func (h *TunnelHub) CloseChannel(name string) // closes + removes a live conn (detach/rotate path) |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests**: dial with a token whose hash is in the dynamic set → 101 + channel registered; wrong token → 401; token valid until `SetDynamicAuth` without it, then `CloseChannel` → live conn closed and next dial with old token 401; static-env client still authenticates unchanged (regression). |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement: `dynamicAuth map[string]string` + mutex on TunnelHub; in `ServeHTTP` after the static lookup misses, compute `sha256(presented)` hex and constant-time-compare against the dynamic entry. `CloseChannel` mirrors the pingLoop delete-if-still-live guard (tunnel.go:182). |
| +- [ ] **Step 4: Wire reload**: in `Edge.Reload()` (edge.go:219), when `e.tunnels != nil`, call `auth, _ := e.reg.FleetChannelAuth(); e.tunnels.SetDynamicAuth(auth)` and close channels for entries that vanished (detached/rotated). Because Task 1 extended `Fingerprint`, `Watch` already triggers this within ~2s of a registry change — that IS the rotate/detach convergence bound. Test via `Reload` with a fake registry. |
| +- [ ] **Step 5:** Package suite → PASS; **commit** `edge: fleet tunnel channels authenticate against registry token hashes`. |
| + |
| +--- |
| + |
| +### Task 4: Edge — fleet route dispatch (binding, fallback, 503) |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` (backing dispatch switch :240-297), `internal/edge/tunnel.go` |
| +- Test: `internal/edge/tunnel_e2e_test.go` patterns for new cases |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.FleetPlacement`, `Route.FleetBox` (Task 2), `TunnelHub.conn/State` (tunnel.go:191,203). |
| +- Produces: routing rule used by e2e (Task 9): |
| + |
| +``` |
| +Route with FleetPlacement(rt.Region) == (f, true): |
| + 1. rt.FleetBox != "" and channel "f/<rt.FleetBox>" connected → proxy over it. |
| + 2. else any connected channel of fleet f → proxy over it (the receiving box's |
| + activator wakes locally; the lease race is the double-wake backstop). |
| + 3. else → 503 with Retry-After: 2 (the fleet is dark). |
| +Explicit rt.Tunnel keeps precedence (legacy/tito path unchanged). |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** (httptest + real TunnelHub, fake dialed conns per existing e2e helpers): bound-and-connected routes to the bound channel; bound-but-dark falls over to the other connected fleet channel; no channels → 503 + `Retry-After` header; non-fleet routes untouched. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement: in `Reload`'s dispatch, insert a case above the `t.Socket` check (below explicit `t.Tunnel`): fleet placement → `fleetProxy(f, t.Host, hub, e.reg)` — a `ReverseProxy` whose transport resolves the channel per request (transportFor pattern, tunnel.go:216) with the 1-2-3 rule above; `TunnelHub` gains `ConnectedOfFleet(fleet string) []string` (sorted, for deterministic fallback). |
| +- [ ] **Step 4:** Package suite → PASS; **commit** `edge: fleet routes ride the bound box's channel, fall over while dark`. |
| + |
| +--- |
| + |
| +### Task 5: Placement plumbing + fleet heartbeats (host side) |
| + |
| +**Files:** |
| +- Modify: `internal/host/agent.go` (statusLoop :970, statusTick :1106), `internal/provision/reconcile.go` (only if the Region filter needs the `fleet/` grammar — verify first: reconcile.go:125,265 filter is string equality, which already works) |
| +- Test: `internal/provision/reconcile_test.go` case; `internal/host/agent_test.go` or `internal/status` test for the publish guard |
| + |
| +**Interfaces:** |
| +- Consumes: instance records' `Region` field (provision), `CARLOS_REGION` env (agent.go:764), `CARLOS_STATUS_PREFIX` (agent.go:977), `status.WriteBox` (snapshot.go:373). |
| +- Produces: the fleet box env contract (documented in Task 10's runbook update): |
| + |
| +``` |
| +CARLOS_REGION=fleet/<name> # adoption filter: only fleet-placed records |
| +CARLOS_STATUS_PREFIX=fleets/<name>/ # heartbeats land at fleets/<name>/status/boxes/<label>.json |
| +CARLOS_FLEET=<name> # NEW: marks this agent as a fleet box |
| +``` |
| + |
| +- [ ] **Step 1: Verify, with a test, not narration**: a provision record with `Region: "fleet/pi"` is adopted by a reconcile configured `Region: "fleet/pi"` and skipped by `Region: "eu"` — string equality means zero code change; the test pins the property. |
| +- [ ] **Step 2: Publisher guard, failing test first**: when `CARLOS_FLEET` is set, `statusLoop` must never attempt the publisher election / `BuildFleet` / `PublishFleet` (fleet boxes write ONLY their own Box under the prefix). Test `statusTick` with `publish=false` forced; then implement: in `statusLoop`, `if os.Getenv("CARLOS_FLEET") != "" { publish = false }` before the election attempt. |
| +- [ ] **Step 3: Contract test** (in `internal/status/contract_test.go` style): `BuildFleet`'s `loadBoxes` lists exactly `"status/boxes/"` (fleet.go:197) — write a store containing `fleets/pi/status/boxes/x.json` and assert the public fleet output ignores it. This pins owner-only-by-construction. |
| +- [ ] **Step 4:** Suite → PASS; **commit** `host: fleet boxes heartbeat under their fleet prefix and never publish fleet.json`. |
| + |
| +--- |
| + |
| +### Task 6: Binding objects — bucket write (fleet box) + materialization (platform box) |
| + |
| +**Files:** |
| +- Create: `internal/host/fleetbind.go`, `internal/host/fleetbind_test.go` |
| +- Modify: `internal/activator/activator.go` (post-wake hook near doWake success, activator.go:571-610), `internal/host/agent.go` (adopt-loop reconcile pass, near domains.Reconcile call :879) |
| + |
| +**Interfaces:** |
| +- Consumes: `release.Store` Put/List/Get (existing S3 store), `Registry.SetFleetBox` (Task 2), lease acquisition success inside `activator.wake`. |
| +- Produces: |
| + |
| +```go |
| +// Bucket object: fleets/<fleet>/bindings/<host>.json |
| +type FleetBinding struct { |
| + Host string `json:"host"` |
| + Fleet string `json:"fleet"` |
| + Box string `json:"box"` // status.Label of the holding box |
| + BoundAt int64 `json:"bound_at"` |
| +} |
| +func WriteFleetBinding(ctx context.Context, s release.Store, b FleetBinding) error |
| +// Platform-side pass, called from the agent's adopt loop: |
| +func ReconcileFleetBindings(ctx context.Context, s release.Store, reg *registry.Registry) error |
| +// For each fleets/*/bindings/*.json: if the route exists and rt.FleetBox != b.Box, SetFleetBox. |
| +// Newest BoundAt wins when duplicates exist. Corrupt objects: logged, skipped, never fatal |
| +// (loadBoxes precedent, fleet.go:193-196). |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests**: `WriteFleetBinding` round-trips through a memory store; `ReconcileFleetBindings` stamps `FleetBox` on a matching route, prefers newest `BoundAt`, skips corrupt JSON, leaves non-fleet routes alone. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement both functions. |
| +- [ ] **Step 4: Hook the fleet box side**: in `activator.wake` success path (after `doWake` returns, activator.go:~578), when the agent runs as a fleet box (`Config` gains `FleetName string` + `BoxLabel string`, populated from `CARLOS_FLEET` + `status.Label(readMachineID())` in `ConfigFromEnv`/agent wiring), spawn `WriteFleetBinding` (fire-and-forget goroutine with logged error — binding is eventually-consistent by design; the fallback routing rule covers the gap). Also write bindings for already-resident routes once at agent startup (covers restart-without-wake). |
| +- [ ] **Step 5: Hook the platform side**: call `ReconcileFleetBindings` from the agent adopt loop beside `domains.Reconcile` (agent.go:879), platform boxes only (skip when `CARLOS_FLEET` set). |
| +- [ ] **Step 6:** Suite → PASS; **commit** `fleet bindings: holding box publishes to bucket, platform agent stamps routes`. |
| + |
| +--- |
| + |
| +### Task 7: Console — fleets API + store plumbing |
| + |
| +**Files:** |
| +- Create: `internal/console/fleets.go`, `internal/console/fleets_test.go` |
| +- Modify: `internal/console/authcli.go` (endpoint registration, beside :214), `cmd/console/main.go` (registry handle already present via CARLOS_REGISTRY) |
| + |
| +**Interfaces:** |
| +- Consumes: `cliUser`/bearer auth (authcli.go:347), account resolution (`apiAccount` from merged PR #52), `jsonError` (:292), `Registry` fleet methods (Task 1), `crypto/rand`. |
| +- Produces (endpoints, all bearer-authenticated, account-scoped; JSON in/out): |
| + |
| +``` |
| +POST /api/cli/fleets/{account}/new {"name","bucket"} -> 200 {"fleet":...} | 409 "that name is taken" |
| +POST /api/cli/fleets/{account}/{fleet}/boxes {"label"} -> 200 {"label","token":"<plaintext, shown once>"} |
| +POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate {} -> 200 {"token":"<plaintext>"} |
| +POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach {} -> 200 {} |
| +GET /api/cli/fleets/{account} -> 200 {"fleets":[{name,bucket,boxes:[{label,status}]}]} |
| +POST /api/cli/apps/{account}/{app}/place {"target":"fleet/pi"} -> 200 {} ; target "" clears |
| +``` |
| + |
| +Rules: fleet ownership = the resolved account; every handler 404s (json) for fleets not owned by the caller's account — byte-identical whether the fleet exists or not. `place` validates the target against the caller's own fleets (you cannot place onto another account's fleet). Token mint: `hex(32 rand bytes)`; store `sha256` hex via `AddFleetBox`/`SetFleetBoxToken`; plaintext appears ONLY in the response body. |
| + |
| +- [ ] **Step 1: Failing tests**: create/list round-trip; duplicate name → 409; add-box returns 64-hex token whose sha256 matches the registry row; rotate changes the stored hash and returns a fresh token; detach flips status; cross-account access → 404 byte-identical to nonexistent; `place` on an app stamps the app's routes' `Region` (walk the app's routes in the registry, `SetPlacement` each) and refuses another account's fleet; missing bearer → 401. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement handlers in `fleets.go`, register in the authcli mux block. The `place` handler stamps routes via `SetPlacement`. It must ALSO stamp the app's bucket instance records (they carry `Region`, and provisioning filters on it — that is what makes placement real for parked instances): use the same release-store handle the console's instance-record writes use, and add a test that a placed app's instance record round-trips with the target. If the console genuinely holds no store handle for instance records, STOP and report BLOCKED — routes-only stamping silently breaks the demo guarantee, do not ship it as a quiet fallback. |
| +- [ ] **Step 4:** Suite → PASS; **commit** `console: fleets API — create, boxes, rotate, detach, place`. |
| + |
| +--- |
| + |
| +### Task 8: CLI — `carlos fleets` verbs + `apps -place` |
| + |
| +**Files:** |
| +- Create: `fleets.go` (repo root, beside `apps.go`), `fleets_cli_test.go` |
| +- Modify: `main.go` (dispatch switch :23-79), `apps.go` (merged from PR #52: `runAppsCreate` gains `-place`) |
| + |
| +**Interfaces:** |
| +- Consumes: `apiSession` machinery from merged PR #52 (`resolveMode`, session verbs in `apps.go`), Task 7 endpoints. |
| +- Produces: |
| + |
| +``` |
| +carlos fleets create <name> -bucket <bucket> [-account <slug>] |
| +carlos fleets add-box <fleet> <label> # prints the token ONCE with a keep-this warning |
| +carlos fleets rotate-token <fleet> <label> |
| +carlos fleets detach-box <fleet> <label> |
| +carlos fleets ls |
| +carlos apps create -app <name> [-place fleet/<f>] ... # place after create via the same API |
| +carlos apps place -app <name> -target fleet/<f>|"" # existing apps |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** (carlosTestEnv REQUIRED — masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`, sets `cmd.Dir` to scratch; the repo has minted real accounts from tests before): usage errors for each verb; `fleets create` against a fake console server hits the right path with the right body; token printed to stdout exactly once. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement `RunFleets(args)` dispatch + verbs on the apiSession pattern; add `case "fleets": RunFleets(args)` to main.go's switch; extend `runAppsCreate` with `-place` (validated client-side with the `fleet/<name>` grammar before any API call). |
| +- [ ] **Step 4:** Suite → PASS; **commit** `cli: carlos fleets verbs + apps -place`. |
| + |
| +--- |
| + |
| +### Task 9: Console — fleet page (owner view) |
| + |
| +**Files:** |
| +- Create: `internal/console/templates/fleet.html` |
| +- Modify: `internal/console/fleets.go` (page handler), `internal/console/server.go` (route registration beside existing account pages), `internal/console/icons.go` (only if a needed icon is missing — vendored Lucide constant, never CDN) |
| +- Test: `internal/console/fleets_test.go` additions |
| + |
| +**Interfaces:** |
| +- Consumes: `account`/`owner` gates (server.go:592,632), `render` (server.go:380), heartbeats at `fleets/<fleet>/status/boxes/<label>.json` via the console's release store handle, `status.Box` shape (metrics block included — raw numbers are correct here, it's the owner's hardware). |
| +- Produces: `GET /a/{sqid}/fleets/{fleet}` — boxes (label, tunnel state from heartbeat, raw metrics, staleness made obvious: older than 3×SnapshotPeriod renders "unreachable + age", never stale numbers), placed apps (routes whose Region is this fleet), rotate/detach as POST forms (CSRF-gated, owner-gated, calling the Task 7 handlers' shared internals). |
| + |
| +- [ ] **Step 1: Failing tests**: member non-owner GET → 200 read-only (no rotate/detach forms rendered); non-member → byte-identical 404; stale heartbeat renders "unreachable"; rotate POST without CSRF → 403; metrics numbers present for the owner. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement handler + template (follow an existing account page template's structure; band words are NOT used here — raw numbers + tunnel state). |
| +- [ ] **Step 4:** Suite → PASS; **commit** `console: fleet page — owner's boxes, raw metrics, rotate/detach`. |
| + |
| +--- |
| + |
| +### Task 10: E2E — multi-box fleet over real channels |
| + |
| +**Files:** |
| +- Create: `internal/edge/fleet_e2e_test.go` (patterns from `tunnel_e2e_test.go`) |
| + |
| +**Interfaces:** consumes everything above; produces the spec's acceptance evidence. |
| + |
| +- [ ] **Step 1: Write the test first** (it IS the deliverable): edge + two fake fleet boxes dialing real reverse channels as `pi/box-a`, `pi/box-b` (registry-backed auth). Assert: (a) request for a bound route rides the bound channel; (b) kill box-a's conn → request falls over to box-b, box-b "wakes" (fake activator claims a short-TTL lease via `acquireTTL` seam, lease.go:198) and its binding write + `ReconcileFleetBindings` restamps `FleetBox` to box-b; (c) box-a re-dials and loses the reclaim (HeldError path); (d) rotate box-b's token via registry → old token's next dial 401 within one Reload; (e) both dark → 503 with Retry-After. |
| +- [ ] **Step 2:** Run → watch it fail for the right reason first, then wire the harness until PASS. Assert a convergence bound on (d) consistent with the 2s fingerprint poll (allow ≤5s in the test). |
| +- [ ] **Step 3:** Full-repo gate → PASS; **commit** `e2e: two-box fleet — failover, restamp, rotation, dark-fleet 503`. |
| + |
| +--- |
| + |
| +### Task 11: Demo path + docs + PR |
| + |
| +**Files:** |
| +- Modify: `scripts/seed-pi-demo.sh` (add `-place fleet/pi` to the create call; DELETE the echoed `carlos add`/`carlos route -tunnel` ceremony — placement subsumes it), `infra/dev/region-pi.md` (tofu section → points at the customer root `paulca/pi-fleet`; env section → the Task 5 fleet-box env contract incl. `CARLOS_FLEET=pi`, `CARLOS_REGION=fleet/pi`, `CARLOS_STATUS_PREFIX=fleets/pi/`, bucket `pi-fleet-640012953448`; seeding section drops the route step), `infra/modules/README.md` + `infra/modules/region-remote-host/README.md` (consumer wording: customers + tito hq as the platform case), `infra/remote-host.md` (fleet-box variant paragraph) |
| +- No code. |
| + |
| +- [ ] **Step 1:** Make the doc/script edits above; `bash -n scripts/seed-pi-demo.sh` clean. |
| +- [ ] **Step 2:** Full gate one last time (`go build ./... && go vet ./... && go test ./... -count=1`, `gofmt -l` clean on touched files). |
| +- [ ] **Step 3: Commit** `docs: fleet demo path — seed placement, runbook on the customer root`; push branch; open PR "Customer fleets: account-attached compute (v1)" linking the spec, noting the `paulca/pi-fleet` customer root, the v1 trust boundary, and that the website-feature-sync batch fires at merge. |
| + |
| +--- |
| + |
| +## Self-review notes (spec coverage) |
| + |
| +- Fleet records/ownership → T1,7. Placement axis + per-app flag → T2,5,7,8. Multi-box: dynamic auth → T3, routing/fallback → T4, bindings → T6, wake-race backstop + e2e → T10. Heartbeat prefix + owner-only-by-construction → T5 (+T9 render). Console page + controls → T9. CLI → T8. Demo guarantee + runbook + ceremony removal → T11. Trust boundary: documented (spec + T11 docs), no code claims beyond it. |
| +- Capacity nudge (spec §wake): v1 lands as the edge's deterministic fallback order (T4) + lease race; heartbeat-informed channel choice is intentionally NOT built until a real multi-box fleet exists to measure — the spec's "measured behavior informs anything smarter" line governs. The e2e proves correctness (no double-wake), not preference. |