carlos / platform Public

docs: customer fleets spec — account-attached compute, v1 concept

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell eaf5fb7d22ae350066ea96a943e6e3433847dfa7 parent 7302bad
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.