docs: customer fleets spec — account-attached compute, v1 concept
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+203
−0
docs/superpowers/specs/2026-08-05-customer-fleets-design.md+203 −0
diff --git a/docs/superpowers/specs/2026-08-05-customer-fleets-design.md b/docs/superpowers/specs/2026-08-05-customer-fleets-design.md| new file mode 100644 |
| index 000000000..4f9e9337a |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-05-customer-fleets-design.md |
| @@ -0,0 +1,203 @@ |
| +# Customer Fleets: Account-Attached Compute (v1) |
| + |
| +**Date:** 2026-08-05 |
| +**Status:** Approved design (Paul, via brainstorming session) |
| +**Depends on:** remote host-agent machinery (`2026-08-04-remote-host-pi-design.md`, |
| +merged as PR #57/#59) — tunnel transport, heartbeat metrics, lease race, |
| +off-cloud bootstrap. All of it is consumed unchanged. |
| + |
| +## The reframe this spec exists for |
| + |
| +The remote-host work conflated two products. **Platform region on remote |
| +hardware** — the box joins the deployment's own fabric, provisioned in |
| +`platform-infrastructure` — is correct for tito's hq (C6), where deployment |
| +operator and box owner are the same entity. **Customer fleet** — the box is |
| +a customer's hardware running that customer's apps, with the CARLOS console |
| +as control surface only — is what the Raspberry Pi actually is, and what |
| +Eleven will be. Paul, verbatim: "I don't want the Pi to be part of the |
| +PLATFORM boxes available, I want it to be part of a fleet for my apps ON |
| +the platform… The tofu plan for that fleet should live outside of CARLOS |
| +platform, to be managed by whoever the customer is." |
| + |
| +The first instantiation of the customer shape already exists: |
| +`paulca/pi-fleet` (private) provisions bucket `pi-fleet-640012953448` and |
| +principal `carlos-remote-host-pi` by consuming the platform's |
| +`region-remote-host` module by git URL. This spec gives that arrangement |
| +its platform-side machinery. |
| + |
| +## Settled decisions (approved 2026-08-05) |
| + |
| +| Question | Decision | |
| +|---|---| |
| +| Fleet ownership | **Exactly one account** owns a fleet; only that account's apps may target it. Eleven = one account with a fleet. | |
| +| v1 scope | **Concept first.** The deployment-bucket coupling stays (see Trust); account-scoped release/heartbeat surface and dynamic `hosts join` are the v2 spec. | |
| +| Placement knob | **Per-app flag** on a generalized placement axis. Values: platform region codes (`eu`, `us`, `uk`, … — future) or `fleet/<name>` (now). | |
| +| Fleet size | **Full multi-box in v1**: N boxes, per-box wake selection, N channels and heartbeats. | |
| +| Console surface | **Fleet page + controls**: per-box raw metrics, tunnel state, placed apps; rotate-token and detach-box actions. Fleets never appear on the public fleet page. | |
| +| Creation path | **CLI verbs via console API** (`carlos fleets …`), console renders. Edge learns fleet channels **dynamically from the registry** — no static env for fleet channels. | |
| +| Demo constraint | Demo instances are **Pi-placed by construction**: `pi-demo-*` apps carry `place=fleet/pi`, so their instances cannot exist anywhere but the Pi fleet's bucket. | |
| +| Dark fleet | The Pi decision generalized: fleet-resident instances render unreachable (age shown, never stale numbers), placed apps 503 at the edge, **nothing migrates**. | |
| + |
| +## Vocabulary |
| + |
| +- **Fleet** — an account-owned, named set of boxes sharing one data bucket |
| + (customer-owned). Name validates like a tunnel channel name |
| + (`^[a-z0-9-]{1,32}$`). |
| +- **Fleet box** — one host in a fleet: a box label, a tunnel channel, a |
| + token (hashed at rest), a heartbeat. Labels validate like fleet names; |
| + the pair (fleet, label) is the channel identity. |
| +- **Placement target** — the single per-app axis: absent (platform-placed, |
| + today's behavior, unchanged) or `fleet/<name>`. Platform region codes |
| + join this axis later without schema change; the stamp lives in the |
| + dormant `Route.Region` column, which this spec activates. |
| +- **Residency is placement, per fleet** — an app placed on a fleet has all |
| + its instances resident in the fleet's bucket; the fleet's boxes are the |
| + only activators pointed at it. |
| + |
| +## Architecture |
| + |
| +### Data shapes (registry) |
| + |
| +Two new tables, synced to the edge exactly like routes: |
| + |
| +- `fleets`: name, owning account id, data bucket name, created-at. |
| +- `fleet_boxes`: fleet name, box label, token hash, status |
| + (active/detached), added-at. |
| + |
| +Routes gain the placement stamp in `Route.Region` (existing dormant |
| +column — no schema migration). **Additive wire-shape rules apply**: routes |
| +written before this change parse unchanged and mean platform-placed; |
| +nothing rewrites old rows. |
| + |
| +Token material: the registry stores **hashes only**. The plaintext token |
| +is displayed exactly once, at mint (`fleets add-box` / `rotate-token` |
| +output), and otherwise exists only in the box's own env file — the same |
| +posture as the current `CARLOS_TUNNEL_TOKEN` contract, minus the |
| +platform-side plaintext. |
| + |
| +### Heartbeats |
| + |
| +Fleet boxes write the existing Box snapshot shape (metrics block included) |
| +to `status/fleets/<fleet>/<label>.json` in the deployment bucket — a new |
| +prefix. Owner-only visibility is **by construction**: the public |
| +`fleet.json` builder never reads the prefix, exactly as `FleetBox` never |
| +copies the `metrics` key today, and the contract tests pin both facts. |
| +The console serves the fleet's snapshots to the owning account only, |
| +after the standard gate order (session → CSRF → owner). |
| + |
| +### Edge: dynamic fleet channels |
| + |
| +The edge's tunnel listener authenticates a dialing fleet box against |
| +`fleet_boxes` (constant-time hash compare on the presented token) instead |
| +of the static `CARLOS_TUNNEL_CLIENTS` env. Registry changes (add-box, |
| +rotate, detach) reach the edge over the existing route-sync / |
| +fast-convergence path — seconds, no restart. Rotation semantics: mint new |
| +token → registry row updates → box re-dials with the new token; the old |
| +hash is refused from the moment the row lands. Detach: row marked |
| +detached → channel refused and existing channel closed. |
| + |
| +`CARLOS_TUNNEL_CLIENTS` static env remains only for the platform's own |
| +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 |
| +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 |
| +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. |
| + |
| +### Surfaces |
| + |
| +CLI (console-API verbs, `apps create` pattern, account-scoped): |
| + |
| +- `carlos fleets create <name>` — mints the fleet record. The data bucket |
| + must already exist (customer tofu); the verb records its name. |
| +- `carlos fleets add-box <fleet> <label>` — mints a box row + token |
| + (shown once). |
| +- `carlos fleets rotate-token <fleet> <label>` — new token (shown once), |
| + old refused on registry convergence. |
| +- `carlos fleets detach-box <fleet> <label>` / `carlos fleets ls`. |
| +- `carlos apps create -place fleet/<name>` and |
| + `carlos apps place <app> fleet/<name>` for existing apps. Setting |
| + placement stamps the app's routes; new instances provision into the |
| + fleet bucket. |
| + |
| +Console: a fleet page on the owning account — each box with raw heartbeat |
| +metrics (it's the owner's hardware; band words are for the public page), |
| +tunnel state, staleness made obvious; the placed-apps list; rotate and |
| +detach controls behind the standard gate order. |
| + |
| +The current manual ceremony (`carlos add` on the edge + |
| +`carlos route -host … -tunnel …` per hostname) **dies for fleet apps**: |
| +placement subsumes it. The runbook step exists only until this ships. |
| + |
| +## Trust boundaries (v1, explicit) |
| + |
| +The fleet box principal holds RW on the **platform's deployment bucket**, |
| +because release adoption and heartbeats are direct S3 calls today. This is |
| +acceptable **only for customers the platform operator already trusts** |
| +(Paul's Pi; Eleven on tito, where operator ≈ customer) and is the named |
| +coupling the v2 spec removes (account-scoped release pull + heartbeat |
| +ingest — console-minted account authority, not AWS IAM). Until v2, fleet |
| +onboarding is gated on operator trust, and the customer root's README says |
| +so (see `paulca/pi-fleet`). |
| + |
| +What v1 does fix: no platform-side plaintext tokens, no static env for |
| +customer channels, no customer resources in platform tofu, no customer |
| +boxes on the public fleet page. |
| + |
| +## Out of scope (v2 horizon) |
| + |
| +- Account-scoped release/heartbeat surface (removes the bucket grant). |
| +- `carlos hosts join` dynamic enrollment (console mints account authority). |
| +- Platform region choice (`place=eu|us|uk`) — the axis ships now, the |
| + values ship when multi-region placement does. |
| +- Whole-account default placement (v1 is per-app only). |
| +- Cross-account fleet grants (one account per fleet, hard). |
| + |
| +## Demo path (the Pi, concretely) |
| + |
| +1. Fleet `pi`, bucket `pi-fleet-640012953448` (exists, `paulca/pi-fleet`), |
| + one box `pi-box`. |
| +2. `carlos fleets create pi` + `add-box pi pi-box` against the dev console; |
| + the minted token replaces the hand-minted SSM one on the Pi's env. |
| +3. Seed script gains `-place fleet/pi` on creation (or `apps place` after); |
| + the echoed route-ceremony instructions are deleted. |
| +4. Acceptance stays `infra/dev/region-pi.md`'s checklist, minus the manual |
| + route step; instances are Pi-resident by construction. |
| + |
| +## Testing |
| + |
| +- E2E, two fake fleet boxes over real reverse channels: wake race lands on |
| + the comfortable box; kill the stamped box → next request wakes on the |
| + survivor and restamps; dark box returns and loses the reclaim. |
| +- Rotation e2e: rotate mid-flight; old token refused within the |
| + convergence bound; new dial succeeds. |
| +- Contract tests: `status/fleets/*` prefix never reachable from public |
| + `fleet.json` (allowlist pins it); console fleet JSON schema allowlisted; |
| + owner gate returns byte-identical 404 for non-members. |
| +- Additive-shape: pre-stamp routes parse and mean platform-placed; |
| + laddercheck and existing tunnel e2e untouched. |
| + |
| +## Existing-artifact impacts (owed when this ships) |
| + |
| +- `infra/dev/region-pi.md`: tofu step already superseded by |
| + `paulca/pi-fleet`; the route ceremony section dies with fleet placement. |
| +- `scripts/seed-pi-demo.sh`: `-place fleet/pi`, drop the route echoes. |
| +- `infra/modules/README.md` + module README: consumer list wording — the |
| + module's consumers are *customers* (and tito hq as the platform case). |
| +- Website batch: the fleet story supersedes the "Pi region" framing. |