carlos / platform Public

spec: fleets console v2 — Fleet tab, shared fleet, categorization, failover posture

Designed from Paul's 2026-08-10 brief; decisions D1-D8 recorded with
alternatives, [Paul] flags on the two calls that most need his review
(tab altitude, shared-fleet-as-presentation). Stacks on #63.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9561657f03869f0491e745a2f02d1b05d2c86bda parent 6629c52
1 file changed, +152 −0
  • docs/superpowers/specs/2026-08-10-fleets-console-design.md +152 −0
diff --git a/docs/superpowers/specs/2026-08-10-fleets-console-design.md b/docs/superpowers/specs/2026-08-10-fleets-console-design.md
new file mode 100644
index 000000000..e990b4df8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-fleets-console-design.md
@@ -0,0 +1,152 @@
+# Fleets in the Console (v2): the Fleet tab, the shared fleet, categorization
+
+**Date:** 2026-08-10
+**Status:** Draft — designed autonomously from Paul's 2026-08-10 brief; decisions
+flagged **[Paul]** need his eyes before this is treated as approved.
+**Depends on:** `2026-08-05-customer-fleets-design.md` (v1, PR #63 — this work
+stacks on branch `worktree-customer-fleets` and merges after it).
+**Related issues:** #73 (fleet API surface), #123/#130 (route records, PR #137).
+
+## The brief (Paul, 2026-08-10, condensed)
+
+Fleet should be a console tab. It offers the **shared fleet** ("Carloku fleet
+[Active] — shared fleet in regions [EU] [UK]…") and **bring your own fleet**
+(instructions for adding boxes and hardening them). A registered box appears in
+the console and becomes placeable; you can move instances onto your fleet and
+opt out of the shared fleet. Boxes must be categorisable (e.g. free accounts on
+the office boxes). If a fleet host dies, another member picks up the capacity.
+End state: Eleven moves to Carloku by attaching its two boxes as a fleet,
+opted out of shared; the platform grows US/IE/UK/AU shared boxes plus a
+control instance in the IE AZ.
+
+## What already exists (v1, PR #63) — not redesigned here
+
+Fleets and fleet boxes in the registry (account-owned, token-hashed, channel
+`<fleet>/<label>`), owner-only heartbeats under `status/fleets/…`, dynamic edge
+auth with rotate/detach, placement axis `Route.Region = "" | fleet/<name>`,
+`carlos fleets …` / `carlos apps place`, a per-fleet console page
+(`/a/{sqid}/fleets/{fleet}`), and **reactive failover**: bindings are
+advisory, the edge falls back per-request to any connected channel of the
+fleet, the S3 lease race prevents split-brain, and a dark box cannot reclaim.
+v1's known gaps this spec addresses: no nav entry anywhere, no fleets index,
+no shared-fleet presentation, no categorization, wedged-channel ~25s black
+hole (decision owed), and the v1 trust boundary (#73, out of scope here).
+
+## Decisions
+
+| # | Question | Decision | Why / alternative rejected |
+|---|---|---|---|
+| D1 | Where does the Fleet tab live? **[Paul]** | **Account-level nav**: Apps · Fleet · Team · Settings, at `/a/{sqid}/fleet`. | Fleets are account-scoped and span apps. Paul said "beside Overview", but Overview is an *app* tab (appnav.html) — per-app is the wrong altitude for "your compute". The app's Instances tab gains the placement control instead (D5), so per-app placement IS visible beside Overview. |
+| D2 | What is "the shared fleet" as an object? | **A presentation, not a new table.** The card renders from `status/fleet.json` (already console-read): region chips = distinct `Box.Region` values of fresh boxes; box count; [Active] when ≥1 of the account's apps is platform-placed (`Region == ""`), else [Not used]. Title = "Shared fleet" (takes the deployment site name, e.g. "Carloku fleet", when PR #72's `CONSOLE_SITE_NAME` is present). | Platform boxes are deliberately NOT `fleet_boxes` rows (v1 decision). Inventing a registry row for the shared fleet would duplicate `status/fleet.json` and create a second source of truth. |
+| D3 | Categorization | Two mechanisms, matching the two kinds of box. **Shared fleet: pools** — already end-to-end (`CARLOS_POOLS` on the box, `Route.Pool` stamped from the app manifest, `poolServed` at reconcile). "Free accounts on the office boxes" = office box runs `CARLOS_POOLS=free`, free accounts' app manifests carry `pool: free`; recipe documented, no new code. **Customer fleet boxes: `fleet_boxes.tags`** (comma-separated, additive column) — display + CLI now, placement/affinity filters later. | One general tag system across both would force the shared fleet into the fleets tables, against v1's separation. Pools already paid for their design. |
+| D4 | Opt-out of the shared fleet | **Per-app placement is the opt-out** (exists: `apps place fleet/<f>`), now surfaced in the console. An app placed on a fleet has zero presence on shared boxes by construction (region-scoped reconcile). Whole-account default placement (new apps land on the fleet automatically) stays v2.1 — it needs a console-store account field and touches app-create; specced, not built now. | An account-level "opt out" toggle that force-moves every app is a mass placement change with data-residency consequences (D6); too much authority for one click today. |
+| D5 | "Move instances to that box" | **Placement targets a fleet, not a box** (v1 invariant: the binding/wake race picks the box; pinning would fight the failover story). The console gains move controls at fleet granularity: on the app Instances tab and on the fleet page ("Place an app here"). Box-affinity via tags is v2.1, after a real need (Eleven) exists. | Box-pinning today would make D7's "another member picks up capacity" a lie for pinned apps. |
+| D6 | Does app state follow a placement move? | **No — and the console must say so.** Moving placement re-homes *serving* (records re-target, routes converge within a pass); hibernated instance state lives in the source bucket and does not copy (v1 "nothing migrates"). The move form carries the warning; state-follow-move is a named v2.1 item. | Silent cross-bucket data copying is a residency violation, not a feature. |
+| D7 | Failover when a fleet host dies | Keep v1's reactive model (lease expiry + per-request fallback + takeover-on-wake) and **close the wedged-channel window**: fleet channels get a faster liveness cadence — ping every 5s, dead after one missed ping (10s worst-case black hole, down from ~25s). Platform-box failover (multi-box-per-region, mesh) stays out of scope — today's shared fleet is one box per region and a dead one is the documented restore runbook. **No background reaper, no auto-lease-break**: a stale heartbeat is display + channel-reap input only; the lease race remains the only authority over serving. | Proactive rebinding off heartbeat staleness risks acting on a partitioned-but-alive box; the lease race is the invariant that already survived review. |
+| D8 | BYO instructions | A console page (`/a/{sqid}/fleet/byo`): prerequisites (a bucket you own, outbound-only box), the `carlos fleets create` / `add-box` ceremony with the env contract filled in per-account, and a hardening checklist. Content lives in the template (static per-deployment, personalized only with account/fleet names) — no CMS. | |
+
+## Console surface
+
+Nav: `layout.html` account nav gains `Fleet` between Apps and Team
+(`ActiveNav: "fleet"`). Routes, all behind the standard gate order
+(session → CSRF → owner-404 → freshness last):
+
+- `GET /a/{sqid}/fleet` — the tab. Sections: shared-fleet card (D2); "Your
+ fleets" — one card per registry fleet owned by this account (name, box
+ count with healthy/dark split from heartbeat staleness, placed-app count,
+ links to the existing `/a/{sqid}/fleets/{fleet}` detail page); "Bring your
+ own fleet" teaser linking the BYO page. Member-readable; forms owner-only.
+- `GET /a/{sqid}/fleet/byo` — instructions (D8). Member-readable.
+- `POST /a/{sqid}/apps/{app}/place` — console form of the existing
+ `apps place` API: target `""` (shared) or `fleet/<name>` (owner + CSRF;
+ reuses the API handler's validation + `SetRecordRegion` logic, one
+ implementation). Rendered on the app Instances tab (current placement +
+ move form with the D6 warning) and as "place an app here" on the fleet
+ detail page.
+- Fleet detail page additions: per-box `tags` chips + owner-only edit form
+ (`POST …/fleets/{fleet}/boxes/{label}/tags`).
+
+View-model: a new `assembleAccountFleet` (overview.go pattern) combining
+(a) `FleetView` (`status/fleet.json`) for the shared card, (b) registry
+fleets/boxes/heartbeats for the account (the fleet-page loader generalized
+to a list), (c) the account's apps' placement split (registry routes by
+account: count with `Region == ""` vs per-fleet).
+
+## Data model (additive only)
+
+```sql
+ALTER TABLE fleet_boxes ADD COLUMN tags TEXT NOT NULL DEFAULT '';
+```
+
+Comma-separated, each tag `^[a-z0-9-]{1,24}$`, max 8 tags. Rows written
+before the change parse unchanged (empty = untagged). `rev` bumps on tag
+writes so the fingerprint/edge poll sees them (display-only today, but the
+column must not be invisible to convergence when placement filters arrive).
+
+## CLI
+
+- `carlos fleets tag-box <fleet> <label> -tags office,free` (owner; `-tags ""`
+ clears; full-replace, not merge).
+- `carlos fleets ls` gains a TAGS column; the fleet API list/detail responses
+ gain `tags` (additive JSON field).
+- `carlos apps place` unchanged (it already does D4/D5's work).
+
+## Edge: fleet channel liveness (D7)
+
+Fleet channels (dynamic-auth channels only — static `CARLOS_TUNNEL_CLIENTS`
+platform channels keep today's cadence) ping every 5s with a 5s deadline;
+one missed ping reaps the channel, which immediately flips
+`ConnectedOfFleet` so the per-request fallback stops selecting the dead box.
+Worst-case request black hole after a silent box death: ≤10s, after which
+requests fall back to a surviving box, whose wake path takes over the lease
+per v1. The reconnecting box re-dials as today; reconnect churn is bounded
+by dial backoff, and a wedged-but-reconnecting box just loses its stamp.
+
+## Security
+
+Unchanged posture: tokens hashed at rest and shown once; owner-gated
+mutations; byte-identical 404 for other-account fleets; fleet heartbeat
+prefix unreachable from the public fleet page (contract-pinned). New
+surfaces to pin: the Fleet tab must not leak other accounts' fleets or the
+platform's per-box private probes (the shared card uses only the public
+`fleet.json` aggregate, never `status/boxes/*` raw snapshots); the tags
+grammar is validated server-side (reject, don't sanitize).
+
+## Testing
+
+- Console: tab renders for owner/member/non-member (404 shape); shared card
+ Active/Not-used flips on placement; region chips from fixture fleet.json;
+ place form round-trip incl. old-console mismatch echo; tags CRUD + grammar
+ rejects; BYO page renders with account names substituted.
+- Registry: tags additive-parse (pre-change rows), rev bump on tag write,
+ fingerprint moves.
+- Edge: fleet channel reaped after one missed 5s ping; fallback selection
+ excludes reaped channel; static platform channels unaffected (cadence
+ assertion); reconnect after reap succeeds.
+- Laddercheck/e2e untouched — no channel-name or wire-shape changes.
+
+## Rollout (recorded, phased — later phases are ops, not this branch)
+
+1. **This branch** (stacked on #63): everything above. Merges after #63;
+ deploy = binary roll + console restart (new templates/endpoints).
+2. **Free-tier office box** (ops): office box joins with
+ `CARLOS_POOLS=free`, free accounts' manifests get `pool: free`. Recipe in
+ the BYO/ops docs; no code.
+3. **Shared fleet build-out** (pinfra): `region-host` boxes for `us`, `ie`,
+ `uk`, `au`; console (control) pinned in the IE AZ. Region *placement
+ values* (`place=us|…`) remain future per v1 — day one, regions appear as
+ shared-card chips via `STATUS_BOX_REGION`, and instances land per each
+ box's `CARLOS_REGION`/`CARLOS_POOLS` scope.
+4. **Eleven → Carloku**: Eleven account on the Carloku console; Eleven-owned
+ tofu root (region-remote-host pattern) provisions its principal against
+ its own bucket; `carlos fleets create eleven` + `add-box` ×2; boxes flip
+ to the fleet env contract; apps placed `fleet/eleven` — opted out of
+ shared by construction. Gated on operator trust until #73 removes the
+ platform-bucket grant. Data stays in Eleven's bucket throughout.
+
+## Out of scope (named, so they don't creep back in)
+
+Account-default placement and state-follow-move (v2.1, D4/D6); box-affinity
+tags in placement (v2.1, D5); platform multi-box-per-region failover / mesh
+(D7); the region registry bucket object and `place=<region>` values;
+#73's account-scoped fleet API surface; any deployment of this branch.