carlos / platform Public

plan: fleets console v2 — 8 tasks, subagent-sized, model tiers per CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b31d9384ec8ba411c52f892d772f0fecf419cd80 parent 9561657
1 file changed, +489 −0
  • docs/superpowers/plans/2026-08-10-fleets-console.md +489 −0
diff --git a/docs/superpowers/plans/2026-08-10-fleets-console.md b/docs/superpowers/plans/2026-08-10-fleets-console.md
new file mode 100644
index 000000000..0b75318de
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-fleets-console.md
@@ -0,0 +1,489 @@
+# Fleets Console v2 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.
+
+**Goal:** The account-level Fleet tab (shared-fleet card, fleets index, BYO instructions), fleet-box tags, console placement moves, and the fleet-channel fast-ping — implementing `docs/superpowers/specs/2026-08-10-fleets-console-design.md` (D1–D8) on branch `worktree-fleets-v2`, stacked on PR #63.
+
+**Architecture:** Everything stacks on #63's fleets machinery. New console surface follows the redesign conventions (accountPage/appPage, `{{.Nav}}` links, `{{.Base}}` forms, gate order session → CSRF → owner-404). One additive registry column (`fleet_boxes.tags`). The edge change is confined to dynamic (fleet) channels' ping cadence.
+
+**Tech Stack:** Go 1.x, modernc sqlite, html/template, x/net/http2. No new dependencies.
+
+## Global Constraints
+
+- Branch `worktree-fleets-v2`; base is #63's tip (`6629c52`). Never touch `main`.
+- Gate before every commit batch: `go build ./... && go vet ./... && go test ./... -count=1` (all 20 packages), `gofmt -l` clean on touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need sandbox disabled.
+- Wire/storage shapes are **additive only**: pre-change registry rows and JSON responses must parse unchanged.
+- Console security gate order: session → CSRF → owner (byte-identical 404) — `requireFreshAuth` last. Unknown fleet and other-account fleet answer identical `http.NotFound`.
+- Tag grammar (spec): comma-separated, each tag `^[a-z0-9-]{1,24}$`, max 8 tags, empty string = untagged. Reject, don't sanitize.
+- Fleet ping cadence (spec D7): dynamic channels ping every 5s, timeout 5s; static channels keep 15s/10s.
+- Match surrounding comment density and idiom; icons are vendored Lucide, emoji never chrome.
+
+---
+
+### Task 1: Registry — `fleet_boxes.tags` (additive column + accessors)
+
+**Files:**
+- Modify: `internal/registry/fleets.go` (migrateFleets ~L74, FleetBox struct ~L40, ListFleetBoxes ~L344; add ValidBoxTags + SetFleetBoxTags beside SetFleetBoxToken ~L289)
+- Test: `internal/registry/fleets_test.go`
+
+**Interfaces:**
+- Produces: `FleetBox.Tags string` (comma form, storage-identical); `func ValidBoxTags(tags string) bool`; `func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error` (bumps `rev`; refuses invalid grammar and unknown (fleet,label)).
+- Consumes: existing `migrateFleets`, `freshFleetsConn`, `ValidTunnelName` patterns in the same file.
+
+- [ ] **Step 1: Write the failing tests** in `internal/registry/fleets_test.go` (same package/test style as the existing fleet tests there):
+
+```go
+func TestFleetBoxTags(t *testing.T) {
+ r := openTestRegistry(t) // reuse this file's existing constructor helper (whatever name it uses)
+ mustCreateFleet(t, r, "pi", "acct1", "bkt")
+ mustAddBox(t, r, "pi", "box-a") // reuse/add tiny helpers mirroring existing tests
+ // grammar
+ for _, bad := range []string{"Office", "a b", "-lead", "toolongtoolongtoolongtoolo", "a,,b",
+ "a,b,c,d,e,f,g,h,i"} {
+ if ValidBoxTags(bad) {
+ t.Errorf("ValidBoxTags(%q) = true, want false", bad)
+ }
+ }
+ for _, good := range []string{"", "office", "office,free", "a-1"} {
+ if !ValidBoxTags(good) {
+ t.Errorf("ValidBoxTags(%q) = false, want true", good)
+ }
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "Office"); err == nil {
+ t.Fatal("invalid grammar accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "nope", "office"); err == nil {
+ t.Fatal("unknown box accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "office,free"); err != nil {
+ t.Fatal(err)
+ }
+ boxes, err := r.ListFleetBoxes("pi")
+ if err != nil || len(boxes) != 1 || boxes[0].Tags != "office,free" {
+ t.Fatalf("tags round-trip: %+v err=%v", boxes, err)
+ }
+ // clearing
+ if err := r.SetFleetBoxTags("pi", "box-a", ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestFleetBoxTagsFingerprintMoves(t *testing.T) {
+ // mirror the existing fingerprint test for token writes in this file:
+ // snapshot Fingerprint(), SetFleetBoxTags, assert Fingerprint() changed.
+}
+
+func TestFleetBoxesPreTagsRowsParse(t *testing.T) {
+ // additive-shape: build a registry, then simulate a pre-change row by
+ // creating the table WITHOUT tags in a scratch db file, running
+ // migrate via NewRegistry over it, and asserting ListFleetBoxes returns
+ // Tags == "" with no error. Mirror however the existing routes
+ // pool/region additive tests do this (see registry_test.go).
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/registry/ -run TestFleetBoxTags -count=1` → compile error (`ValidBoxTags` undefined).
+
+- [ ] **Step 3: Implement.** In `migrateFleets`, after the two CREATEs, add the additive column exactly the way `registry.go` adds `routes.pool`/`routes.region` (grep `ALTER TABLE routes ADD COLUMN` and copy that ignore-duplicate-error idiom):
+
+```go
+// tags: additive (2026-08-10-fleets-console-design.md D3). Pre-change rows
+// read as '' — untagged.
+r.sql.Exec(`ALTER TABLE fleet_boxes ADD COLUMN tags TEXT NOT NULL DEFAULT ''`)
+```
+
+Add to `FleetBox`: `Tags string // comma-separated, ValidBoxTags grammar; "" = untagged`. Extend `ListFleetBoxes`'s SELECT + scan with `tags`. Add:
+
+```go
+var boxTagRe = regexp.MustCompile(`^[a-z0-9-]{1,24}$`)
+
+// ValidBoxTags reports whether tags is a well-formed comma-separated tag
+// list: each tag boxTagRe, at most 8, no empty segments. "" is valid and
+// means untagged.
+func ValidBoxTags(tags string) bool {
+ if tags == "" {
+ return true
+ }
+ parts := strings.Split(tags, ",")
+ if len(parts) > 8 {
+ return false
+ }
+ for _, p := range parts {
+ if !boxTagRe.MatchString(p) {
+ return false
+ }
+ }
+ return true
+}
+
+// SetFleetBoxTags mirrors SetFleetBoxToken: same rev bump, same
+// RowsAffected guard for an unknown (fleet, label).
+func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error { ... }
+```
+
+`SetFleetBoxTags` body: copy `SetFleetBoxToken`'s shape verbatim (UPDATE … SET tags=?, rev=rev+1 WHERE fleet=? AND label=?; RowsAffected==0 → error), swapping the validation to `ValidBoxTags`.
+
+- [ ] **Step 4: Run** `go test ./internal/registry/ -count=1` → PASS.
+- [ ] **Step 5: Commit** — `git add internal/registry && git commit -m "registry: fleet_boxes.tags — additive column, grammar, rev-bumped writer"`
+
+---
+
+### Task 2: Fleet API + CLI — tags surface (`carlos fleets tag-box`, ls column)
+
+**Files:**
+- Modify: `internal/console/fleets.go` (fleetBoxView + handleAPIFleetList ~L211; new handleAPIFleetTagBox beside handleAPIFleetRotateBox ~L312), `internal/console/authcli.go` (register the door beside the rotate/detach registrations), `fleets.go` (repo root — RunFleets dispatch ~L85, new runFleetsTagBox, runFleetsList ~L278)
+- Test: `internal/console/fleets_test.go` (or wherever the existing fleet API door tests live — find `handleAPIFleetRotateBox` tests and sit beside them), `fleets_cli_test.go` if CLI-level tests exist for fleets verbs (check `cli_test.go` for the fleets pattern; if the existing verbs have no CLI-level tests, API-level tests suffice — do not invent a new harness).
+
+**Interfaces:**
+- Consumes: Task 1's `ValidBoxTags`, `SetFleetBoxTags`, `FleetBox.Tags`.
+- Produces: `POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags` accepting `{"tags":"office,free"}` (owner bearer auth like rotate; 400 on grammar, 404 on unknown box — same shapes as rotate); `fleetBoxView.Tags string` (`json:"tags,omitempty"` — additive); CLI verb `carlos fleets tag-box <fleet> <label> -tags office,free` (full replace, `-tags ""` clears) and a TAGS column in `fleets ls`.
+
+- [ ] **Step 1: Failing API test**, modeled exactly on the existing rotate-box door test:
+
+```go
+func TestAPIFleetTagBox(t *testing.T) {
+ // arrange: server + account + fleet "pi" + box "box-a" (copy the rotate test's arrange)
+ // act: POST /api/cli/fleets/{acct}/pi/boxes/box-a/tags body {"tags":"office,free"}
+ // assert: 200; ListFleetBoxes shows Tags "office,free";
+ // GET /api/cli/fleets/{acct} body contains "tags":"office,free"
+ // bad grammar {"tags":"Office"} → 400
+ // unknown label → 404, byte-identical to rotate's unknown-label 404
+ // other account's fleet → 404 (apiFleet already gives this — assert anyway)
+}
+```
+
+- [ ] **Step 2: Verify failure** — `go test ./internal/console/ -run TestAPIFleetTagBox -count=1` → 404 (route unregistered).
+- [ ] **Step 3: Implement.** Handler is a trimmed copy of `handleAPIFleetRotateBox` (same `apiFleet` gate + `findFleetBox`), ending in `SetFleetBoxTags` + `s.audit(acct, "", addr, "fleet", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (cli)")` + `writeJSON(w, http.StatusOK, map[string]any{})`. Validate `registry.ValidBoxTags` before touching the registry → `jsonError(w, http.StatusBadRequest, "invalid tags — comma-separated [a-z0-9-], max 8")`. Extend `fleetBoxView` with `Tags string \`json:"tags,omitempty"\`` and populate in `handleAPIFleetList`.
+- [ ] **Step 4: CLI.** In root `fleets.go`: add `case "tag-box":` to `RunFleets`; `runFleetsTagBox` copies `runFleetsRotateToken`'s flag/arg shape plus `-tags` string flag (NOTE: distinguish "flag absent" from `-tags ""` is unnecessary — the verb requires `-tags`, and `""` clears). `runFleetsList`: add TAGS to the header and row printf (empty prints as `-`, matching the file's existing column style if it has one; otherwise print raw).
+- [ ] **Step 5: Gate + commit** — full `go build ./... && go test ./... -count=1`; `git commit -m "fleets: tag-box — API door, CLI verb, ls column"`
+
+---
+
+### Task 3: Fleet detail page — tags chips + owner edit
+
+**Files:**
+- Modify: `internal/console/templates/fleetdetail.html` (Boxes table), `internal/console/fleets.go` (`fleetPageBox` ~L626, `loadFleetPage` ~L721; new `handleFleetTagBoxPage` beside `handleFleetRotateBoxPage` ~L837), `internal/console/server.go` (route registration beside L341-342)
+- Test: beside the existing fleet-page tests (find `handleFleetPage`/rotate-page tests in `internal/console`)
+
+**Interfaces:**
+- Consumes: Task 1's accessors; existing `ownerFleet` gate + flash pattern from `handleFleetRotateBoxPage`.
+- Produces: `POST /a/{sqid}/fleets/{fleet}/tags` (form fields `csrf`, `label`, `tags`), redirect back with flash; `fleetPageBox.Tags []string` (split for chips).
+
+- [ ] **Step 1: Failing tests** — extend the fleet-page render test: box with tags shows each tag; POST tags as owner updates + redirects with flash; POST as plain member 403/404s exactly like rotate does; bad grammar → flash error, nothing written.
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** `fleetPageBox` gains `Tags []string`; `loadFleetPage` fills it (`strings.Split` on non-empty). Handler copies `handleFleetRotateBoxPage`'s gate/flash/redirect skeleton, calling `SetFleetBoxTags`. Template: in the Boxes table add a `Tags` column —
+
+```html
+<td>{{range .Tags}}<span class="chip">{{.}}</span> {{end}}{{if not .Tags}}—{{end}}</td>
+```
+
+and, in the owner cell, a third inline form:
+
+```html
+<form method="post" action="{{$.Base}}/fleets/{{$.FleetDetail.Name}}/tags">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="label" value="{{.Label}}">
+ <input type="text" name="tags" value="{{join .Tags ","}}" size="14" placeholder="office,free">
+ <button type="submit" class="linkish">Tag</button>
+</form>
+```
+
+(If the template FuncMap has no `join`, add it in server.go's template setup beside the existing helpers — `"join": strings.Join`.) Reuse the existing `.chip` CSS class; add nothing new to layout.html unless `.chip` doesn't exist (it does — `chip chip-down` is used in this very template).
+- [ ] **Step 4: Gate.** — full test run.
+- [ ] **Step 5: Commit** — `git commit -m "console: fleet page tags — chips + owner edit form"`
+
+---
+
+### Task 4: The Fleet tab — nav, page, shared-fleet card, fleets index
+
+**Files:**
+- Create: `internal/console/accountfleet.go`, `internal/console/accountfleet_test.go`, `internal/console/templates/account_fleet.html`
+- Modify: `internal/console/server.go` (route beside L313; `page` struct fields near the fleet fields ~L528; ActiveNav comment L443), `internal/console/templates/layout.html` (account nav L316-318)
+
+**Interfaces:**
+- Consumes: `FleetView(ctx, s.cfg.Releases)` (view.go:597, reads `status/fleet.json` → `*status.Fleet` with `.Boxes[].Region`), `reg.ListFleets()`/`ListFleetBoxes`, `loadFleetPage` (fleets.go:721) for per-fleet heartbeat freshness, `registry.FleetPlacement`, `s.accountPage` chrome (server.go), `fleetPageURL` (fleets.go:826).
+- Produces: `GET /a/{sqid}/fleet` (`ActiveNav: "fleet"`), template `account_fleet`, view types `sharedFleetCard{Title string; Active bool; Regions []string; Boxes int; PlatformApps int}` and `accountFleetCard{Name string; Boxes, Reachable, Apps int}`; page fields `SharedFleet *sharedFleetCard`, `AccountFleets []accountFleetCard`. Task 5 hangs its BYO link off this page.
+
+- [ ] **Step 1: Failing tests** in `accountfleet_test.go` (copy the arrange style of the fleet-page tests; the console test harness has fixtures for registry + fake release store):
+
+```go
+// renders for owner AND plain member; non-member gets the account gate's usual answer
+// shared card: with a fixture status/fleet.json of two boxes (regions "EU · Ireland",
+// "UK · London") → Regions == both, Boxes == 2
+// Active flips: account route with Region "" → Active; all routes fleet-placed → not Active
+// fleets index: two fleets owned + one other-account fleet → only the two, each with
+// box counts; page links to /a/<seg>/fleets/<name>
+// degraded: no fleet.json in store → page still 200, shared card renders "no boxes yet"
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement `assembleAccountFleet`** in `accountfleet.go` — the `overview.go` posture: every read soft-fails to an empty section, never a 500.
+
+```go
+// assembleAccountFleet builds the Fleet tab's two halves: the shared
+// platform fleet as a PRESENTATION of status/fleet.json (D2 — deliberately
+// not a registry object), and the account's own fleets from the registry.
+func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (*sharedFleetCard, []accountFleetCard, string) {
+ card := &sharedFleetCard{Title: "Shared fleet"}
+ if s.cfg.Releases != nil {
+ if f, err := FleetView(ctx, s.cfg.Releases); err == nil && f != nil {
+ card.Boxes = len(f.Boxes)
+ seen := map[string]bool{}
+ for _, b := range f.Boxes {
+ if b.Region != "" && !seen[b.Region] {
+ seen[b.Region] = true
+ card.Regions = append(card.Regions, b.Region)
+ }
+ }
+ sort.Strings(card.Regions)
+ }
+ }
+ // placement split from this account's routes
+ reg, err := openRegistry(s.cfg.Registry)
+ ...
+ routes, _ := reg.List()
+ platformApps := map[string]bool{}
+ fleetApps := map[string]map[string]bool{} // fleet -> app set
+ for _, rt := range routes {
+ if rt.Account != acct.Sqid { continue }
+ if f, ok := registry.FleetPlacement(rt.Region); ok {
+ if fleetApps[f] == nil { fleetApps[f] = map[string]bool{} }
+ fleetApps[f][rt.App] = true
+ } else {
+ platformApps[rt.App] = true
+ }
+ }
+ card.PlatformApps = len(platformApps)
+ card.Active = card.PlatformApps > 0
+ // account fleets
+ fleets, _ := reg.ListFleets()
+ var cards []accountFleetCard
+ for _, f := range fleets {
+ if f.Account != acct.Sqid { continue }
+ boxes, apps, _ := s.loadFleetPage(ctx, reg, acct, &f)
+ c := accountFleetCard{Name: f.Name, Boxes: len(boxes), Apps: len(apps)}
+ for _, b := range boxes { if b.Reachable { c.Reachable++ } }
+ cards = append(cards, c)
+ }
+ return card, cards, warn
+}
+```
+
+(Exact soft-fail/warn wiring: mirror `loadFleetPage`'s `warn` convention. `_ = fleetApps` is NOT dead — render per-fleet placed counts if `accountFleetCard.Apps` from `loadFleetPage` disagrees; prefer `loadFleetPage`'s apps since it's the page users click through to.)
+
+Handler in `accountfleet.go`:
+
+```go
+// handleAccountFleetTab is the account-level Fleet tab (D1) — a different
+// thing from handleFleet (the operator's global /fleet status page) and
+// from handleFleetPage (one fleet's detail).
+func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) {
+ _, _, acct, role, ok := s.account(w, r)
+ if !ok { return }
+ shared, own, warn := s.assembleAccountFleet(r.Context(), acct)
+ p := s.accountPage(r, acct, role)
+ p.ActiveNav = "fleet"
+ p.SharedFleet, p.AccountFleets, p.FlashErr = shared, own, warn
+ s.render(w, r, "account_fleet", p)
+}
+```
+
+**NOTE to implementer:** `s.account`, `s.accountPage`, `s.render` signatures — copy from `handleTeam` (server.go:1329-ish) exactly; the sketch above compresses. Register `s.mux.HandleFunc("GET /a/{sqid}/fleet", s.handleAccountFleetTab)` beside `/a/{sqid}/team`. Extend the ActiveNav comment to `"apps" | "fleet" | "team" | "settings"`.
+
+Nav (layout.html, after the Apps line):
+
+```html
+<a href="{{.Nav}}/fleet"{{if eq .ActiveNav "fleet"}} aria-current="page"{{end}}>Fleet</a>
+```
+
+Template `account_fleet.html` (pagehead + three sections; `.chip` for regions, `card` layout — copy structural CSS class usage from `apps.html`):
+
+```html
+{{define "account_fleet"}}{{template "top" .}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">{{.Account.Name}}</p>
+ <h1>Fleet</h1>
+ <p class="lede">Where this account's apps run — the shared fleet, and boxes you bring.</p>
+</div>
+
+<h2>{{.SharedFleet.Title}} {{if .SharedFleet.Active}}<span class="chip">Active</span>{{else}}<span class="chip">Not used</span>{{end}}</h2>
+{{if .SharedFleet.Boxes}}
+<p>{{.SharedFleet.Boxes}} box{{if ne .SharedFleet.Boxes 1}}es{{end}} in
+{{range .SharedFleet.Regions}}<span class="chip">{{.}}</span> {{end}}</p>
+<p class="small">{{if .SharedFleet.Active}}{{.SharedFleet.PlatformApps}} of your apps run here. Apps run on the shared fleet unless you place them on a fleet of your own.{{else}}None of your apps run on the shared fleet — they are all placed on your own fleets.{{end}}</p>
+{{else}}<p class="small">No shared boxes are reporting right now.</p>{{end}}
+
+<h2>Your fleets</h2>
+{{if .AccountFleets}}
+<div class="tablewrap"><table>
+ <tr><th>Fleet</th><th>Boxes</th><th>Reachable</th><th>Placed apps</th></tr>
+ {{range .AccountFleets}}
+ <tr>
+ <td><a href="{{$.Nav}}/fleets/{{.Name}}">{{.Name}}</a></td>
+ <td>{{.Boxes}}</td><td>{{.Reachable}}</td><td>{{.Apps}}</td>
+ </tr>
+ {{end}}
+</table></div>
+{{else}}<p class="small">No fleets yet.</p>{{end}}
+
+<h2>Bring your own fleet</h2>
+<p>Run your apps on hardware you own — an office box, a Raspberry Pi, your
+own cloud account. Your boxes dial out to the platform; nothing connects in.</p>
+<p><a class="btn" href="{{.Nav}}/fleet/byo">How to add and harden a box</a></p>
+{{template "bottom" .}}{{end}}
+```
+
+- [ ] **Step 4: Gate** — full run; check the nav renders on every account page (existing server tests exercising layout will catch template errors).
+- [ ] **Step 5: Commit** — `git commit -m "console: the Fleet tab — shared-fleet card, fleets index, nav (D1/D2)"`
+
+---
+
+### Task 5: BYO instructions page
+
+**Files:**
+- Create: `internal/console/templates/fleet_byo.html`
+- Modify: `internal/console/server.go` (route `GET /a/{sqid}/fleet/byo`), `internal/console/accountfleet.go` (handler)
+- Test: extend `accountfleet_test.go`
+
+**Interfaces:**
+- Consumes: Task 4's nav/ActiveNav; the **authoritative env contract** in `infra/dev/region-pi.md` and `internal/host/agent.go`'s package doc (copy variable names from there — do not invent).
+- Produces: `GET /a/{sqid}/fleet/byo`, member-readable, `ActiveNav: "fleet"`.
+
+- [ ] **Step 1: Failing test** — page 200s for a member, contains `carlos fleets create`, `CARLOS_TUNNEL_NAME`, and a "Hardening" heading.
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Handler mirrors Task 4's minus assembly. Template content, in order (pagehead: eyebrow "Fleet · {{.Account.Name}}", h1 "Bring your own fleet"):
+ 1. **What you need** — a Linux box (arm64 or amd64) that can reach the internet outbound; an object-storage bucket you own for the fleet's data; nothing listens inbound, ever.
+ 2. **Create the fleet** — fenced code block: `carlos fleets create -bucket <your-bucket> <name>` then `carlos fleets add-box <name> <label>` with the shown-once token warning (reuse fleetdetail.html's exact rotation-is-not-revocation phrasing for consistency).
+ 3. **Configure the box** — the env contract block copied verbatim from `infra/dev/region-pi.md` (implementer: read that file on this branch and transcribe the fleet env vars — `CARLOS_FLEET`, `CARLOS_FLEET_BOX`, `CARLOS_REGION=fleet/<name>`, `CARLOS_STATUS_PREFIX`, `CARLOS_TUNNEL_NAME=<fleet>/<label>`, `CARLOS_TUNNEL_TOKEN`, `CARLOS_HIBERNATE_BUCKET`, plus whichever tunnel-address/bucket-credential vars that doc lists), with `<name>`/`<label>` placeholders.
+ 4. **Harden it** — checklist (`<ul>`): dedicated non-root user for the agent; the env file root-owned mode 600 (it holds the token); default-deny inbound firewall (the box only dials out); unattended security upgrades on; clock kept sane (chrony/systemd-timesyncd — leases compare timestamps); systemd unit with `ProtectSystem=strict`/`NoNewPrivileges` (point at `internal/host/units/` as the reference shapes); rotate the token if it may have leaked, **Detach** (not rotate) to cut a compromised box off.
+ 5. **Then** — the box appears on your fleet page within minutes (heartbeat cadence); place an app with `carlos apps place` or from the app's Instances tab.
+- [ ] **Step 4: Gate.**
+- [ ] **Step 5: Commit** — `git commit -m "console: BYO fleet instructions page (D8)"`
+
+---
+
+### Task 6: Console placement moves — app Instances tab + fleet page form
+
+**Files:**
+- Modify: `internal/console/fleets.go` (extract `placeAppTarget` core from `handleAPIPlace` ~L479-601; new browser handler `handleAppPlace`), `internal/console/server.go` (route `POST /a/{sqid}/apps/{app}/place`; app-instances assembly additions), `internal/console/templates/app_instances.html` (placement section), `internal/console/templates/fleetdetail.html` ("Place an app here" form + handler route `POST /a/{sqid}/fleets/{fleet}/place`)
+- Test: `internal/console/fleets_test.go` additions + existing `handleAPIPlace` tests must stay green **unchanged** (they pin the refactor)
+
+**Interfaces:**
+- Consumes: `handleAPIPlace`'s existing body — its store-first, routes-before-records ORDER MATTERS discipline moves verbatim into the core; `appPlacement` (fleets.go:412) for current placement; `s.accountApp`/`s.owner` gates; `confirm`? No — plain form + warning copy (D6).
+- Produces: `func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target string) (status int, errMsg string)` — returns (0, "") on success; both handlers translate. Template data: `AppPlacement string`, `OwnedFleetNames []string` on the instances-tab view; `FleetPlaceableApps []string` on the fleet page.
+
+- [ ] **Step 1: Failing tests.**
+
+```go
+// browser place: owner POST /a/{sqid}/apps/{app}/place form target=fleet/pi →
+// 303 back to instances tab, flash contains "placed"; registry route Region
+// updated; instance record region updated (fake release store fixture)
+// member (non-owner) POST → the owner gate's usual refusal
+// bad csrf → the standard csrf refusal
+// target naming another account's fleet → 404 byte-identical to unknown
+// clearing: target "" → routes cleared, flash "back on the shared fleet"
+// fleet-page form: POST /a/{sqid}/fleets/{fleet}/place app=<name> → same effects,
+// redirect back to fleet page
+// API regression: the existing handleAPIPlace tests all still pass UNTOUCHED
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Refactor + implement.** Mechanical extraction: everything in `handleAPIPlace` from the `s.cfg.Releases == nil` check through the record-stamping loop becomes `placeAppTarget`; the API handler keeps auth, rate-limit, JSON decode, audit(`"(cli)"`), and translates `(status, errMsg)` to `jsonError`. **Move the comments with the code** — the ORDER MATTERS block guards the core, not the handler. Browser handlers:
+ - `handleAppPlace`: gate like the other app POSTs (`s.owner` + `s.accountApp` + CSRF — copy `handleAppRename`'s skeleton), read `target` form value, call core, audit detail suffix `"(console)"`, redirect `{{.Nav}}/apps/{app}/instances` with flash.
+ - `handleFleetPlacePage`: `ownerFleet` gate, `app` form value, target = `registry.PlacementTarget(fleet.Name)`, same core, redirect to fleet page.
+- App Instances tab (find `handleAppInstances`'s assembly in server.go): add `AppPlacement` (via `appPlacement`) and `OwnedFleetNames` (ListFleets filtered by account). Template section (owner-only forms, member sees state):
+
+```html
+<h2>Placement</h2>
+<p>{{if .AppPlacement}}This app runs on your fleet <a href="{{.Nav}}/fleets/{{fleetName .AppPlacement}}"><code>{{fleetName .AppPlacement}}</code></a>.{{else}}This app runs on the shared fleet.{{end}}</p>
+{{if and .IsOwner .OwnedFleetNames}}
+<form method="post" action="{{.Base}}/apps/{{.AppName}}/place">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <select name="target">
+ <option value=""{{if not .AppPlacement}} selected{{end}}>Shared fleet</option>
+ {{range .OwnedFleetNames}}<option value="fleet/{{.}}"{{if eq (printf "fleet/%s" .) $.AppPlacement}} selected{{end}}>fleet/{{.}}</option>{{end}}
+ </select>
+ <button type="submit" class="btn-sm">Move</button>
+</form>
+<p class="small">Moving changes where this app <em>serves</em>. Data does not move:
+hibernated instance state stays in the old placement's bucket.</p>
+{{end}}
+```
+
+(`fleetName` FuncMap helper = `func(target string) string { f, _ := registry.FleetPlacement(target); return f }` — add beside `join`.) Fleet page form (below Placed apps): select over `FleetPlaceableApps` (account's apps not already on this fleet — derive in `loadFleetPage`'s caller from the same route walk) + Move button + the same data-does-not-move line.
+- [ ] **Step 4: Gate** — the pre-existing API place tests passing unmodified is the refactor's proof.
+- [ ] **Step 5: Commit** — `git commit -m "console: placement moves from the app Instances tab and fleet page (D4/D5/D6)"`
+
+---
+
+### Task 7: Edge — fleet channels ping fast (D7)
+
+**Files:**
+- Modify: `internal/edge/tunnel.go` (constants ~L46; `tunnelConn` ~L53; `ServeHTTP` auth branch ~L129; `pingLoop` ~L201)
+- Test: `internal/edge/` beside the existing tunnel tests (grep `pingLoop`/`SetDynamicAuth` tests)
+
+**Interfaces:**
+- Consumes: nothing from other tasks (independent; can run in parallel with 2-6).
+- Produces: `fleetPingEvery = 5s`, `fleetPingTimeout = 5s`; dynamic-auth'd conns ping at fleet cadence; static conns provably unchanged.
+
+- [ ] **Step 1: Failing tests.**
+
+```go
+func TestPingCadence(t *testing.T) {
+ if e, to := pingCadence(false); e != tunnelPingEvery || to != tunnelPingTimeout {
+ t.Fatal("static cadence changed")
+ }
+ if e, to := pingCadence(true); e != fleetPingEvery || to != fleetPingTimeout {
+ t.Fatal("fleet cadence wrong")
+ }
+}
+
+func TestDynamicChannelMarkedDynamic(t *testing.T) {
+ // register via the dynamic-auth path (copy the SetDynamicAuth round-trip
+ // test's arrange: hub + SetDynamicAuth{"pi/box-a": sha256hex(tok)} + a
+ // real handshake through httptest — the existing rotate/detach tests
+ // show how); then assert h.conns["pi/box-a"].dynamic == true, and a
+ // static-auth'd channel's conn has dynamic == false.
+}
+```
+
+- [ ] **Step 2: Verify failure** (`pingCadence` undefined).
+- [ ] **Step 3: Implement.**
+
+```go
+// Fleet (dynamic) channels ping tighter (fleets-console spec D7): a customer
+// fleet's failover is "requests fall back to a surviving box once the dead
+// channel is reaped", so the reap bound IS the user-visible outage window —
+// ≤10s (one 5s sleep + one 5s deadline) instead of the static channels'
+// ~25s. Static platform channels (tito hq) keep the wider cadence: their
+// routes have no fallback box, so a tighter ping only adds churn risk.
+fleetPingEvery = 5 * time.Second
+fleetPingTimeout = 5 * time.Second
+
+func pingCadence(dynamic bool) (every, timeout time.Duration) { ... }
+```
+
+`tunnelConn` gains `dynamic bool`. In `ServeHTTP`, hoist a `viaDynamic bool`, set it `true` inside the dynamic-auth fallback branch when its compare sets `authOK`; store it on the conn (`h.conns[name] = &tunnelConn{cc: cc, raw: conn, dynamic: viaDynamic}`) and pass to `go h.pingLoop(name, cc, conn, viaDynamic)`; `pingLoop` uses `pingCadence(dynamic)` for both the sleep and the context deadline.
+- [ ] **Step 4: Gate** — `go test ./internal/edge/ -count=1 -race` (race mode: the edge suite runs it in CI) then the full suite.
+- [ ] **Step 5: Commit** — `git commit -m "edge: fleet channels ping at 5s/5s — reap bound is the failover window (D7)"`
+
+---
+
+### Task 8: Whole-branch gate, docs touch-ups, PR
+
+**Files:**
+- Modify: `infra/dev/region-pi.md` (add a pointer: tags + console Fleet tab exist; BYO page is the canonical box-add instructions now), spec status line (Draft → notes what shipped)
+- No code.
+
+- [ ] **Step 1:** Full gate: `go build ./... && go vet ./... && go test ./... -count=1` all packages; `gofmt -l` on every touched file; `GOOS=darwin go build ./...` (CI runs it; `internal/host` links into the CLI).
+- [ ] **Step 2:** Docs touch-ups above; verify no template references a helper that isn't registered (server tests cover, but `grep -n "join\|fleetName" internal/console/server.go templates` as a belt).
+- [ ] **Step 3:** Commit docs; push branch; open PR **against `worktree-customer-fleets`** (stacked — the diff must show only this plan's work) titled "Fleets console v2: the Fleet tab, tags, placement moves, fleet fast-ping". Body: spec link, D1-D8 summary, "merges after #63; do not deploy" note.
+
+---
+
+## Self-review notes (run before execution)
+
+- Spec coverage: D1/D2 → Task 4; D3 → Tasks 1-3 (+ pools recipe lands in Task 5's step 4? NO — pools recipe is ops docs, folded into Task 5's BYO page? Also no — spec says ops docs; it lives in the Task 8 region-pi.md touch-up and the spec itself. Covered, no code owed); D4/D5/D6 → Task 6; D7 → Task 7; D8 → Task 5. Shared-card site-name variant: deliberately NOT implemented (PR #72 unmerged) — spec says "when present"; today's title is the constant.
+- Type consistency: `sharedFleetCard`/`accountFleetCard` defined Task 4, consumed only there + template; `placeAppTarget` defined and consumed in Task 6; `ValidBoxTags`/`SetFleetBoxTags`/`FleetBox.Tags` defined Task 1, consumed Tasks 2/3.
+- Parallelism: Task 7 is independent; Tasks 2 and 3 depend on 1; Task 5 depends on 4; Task 6 is independent of 1-5 (template merge conflicts with 4 are trivial — different sections).