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>
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. |