carlos / platform Public

docs: customer fleets plan — 11 tasks; spec wake/binding sections match discovered mechanics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e9bb8d9273a51268b7dd930dbe30fa6dba09c48b parent eaf5fb7
2 files changed, +314 −10
  • docs/superpowers/plans/2026-08-05-customer-fleets.md +299 −0
  • docs/superpowers/specs/2026-08-05-customer-fleets-design.md +15 −10
diff --git a/docs/superpowers/plans/2026-08-05-customer-fleets.md b/docs/superpowers/plans/2026-08-05-customer-fleets.md
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.
diff --git a/docs/superpowers/specs/2026-08-05-customer-fleets-design.md b/docs/superpowers/specs/2026-08-05-customer-fleets-design.md
index 4f9e9337a..f2dd01749 100644
--- a/docs/superpowers/specs/2026-08-05-customer-fleets-design.md
+++ b/docs/superpowers/specs/2026-08-05-customer-fleets-design.md
@@ -102,9 +102,11 @@ remote boxes (tito hq) — the C6 path is untouched.
### Routing to N boxes: the lease stamp
A fleet route does not name a channel statically. The box that holds an
-instance's lease stamps its box label onto the route via the route-update
-path boxes already use; the edge proxies requests for that hostname over
-the stamped box's channel. Failure sequence: box goes dark → heartbeat
+instance's lease publishes a binding for that hostname **as a deployment-
+bucket object** (fleet boxes are outbound-only; they can never write the
+platform box's registry) — the platform box's reconcile loop materializes
+the binding into its local registry, and the edge proxies requests for
+that hostname over the bound box's channel. Failure sequence: box goes dark → heartbeat
ages, lease expires → next request finds no live channel for the stamp →
the wake path runs (below) on a surviving box → winner restamps. The
returning box cannot reclaim — the conditional-write lease race already
@@ -112,13 +114,16 @@ guarantees this (proven by `TestDarkHostCannotReclaimAfterFailover`).
### Wake selection (multi-box placement)
-Every fleet box runs its activator against the fleet bucket. A parked
-instance wakes on whichever box wins the lease race, with a **capacity
-nudge**: a box whose capacity band is in the upper range delays its claim
-by a beat before racing. A comfortable box therefore usually wins, and no
-scheduler, bin-packer, or placement service exists. That is the entirety
-of v1 within-fleet placement; measured behavior on real multi-box fleets
-(Eleven) informs anything smarter.
+Every fleet box runs its activator against the fleet bucket. Wake is
+request-driven: the edge picks a channel (the bound box if connected, else
+a deterministic fallback among the fleet's connected channels) and the
+receiving box's activator wakes the instance locally; the lease race is
+the double-wake backstop, and a box that lost the race answers 503 with
+Retry-After rather than serving. No scheduler, bin-packer, or placement
+service exists. Capacity-informed channel choice (preferring a
+comfortable box's channel using its last heartbeat) is deliberately
+deferred until a real multi-box fleet (Eleven) exists to measure — v1
+ships the deterministic order.
### Surfaces