carlos / platform Public

plan: fleet status page — ten tasks, writer-enforced privacy, TDD throughout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ac36bcd4933f1c58b1937db71a551c1f17a59ee5 parent 7cfe97c
1 file changed, +485 −0
  • docs/superpowers/plans/2026-08-01-plan-fleet-status-page.md +485 −0
diff --git a/docs/superpowers/plans/2026-08-01-plan-fleet-status-page.md b/docs/superpowers/plans/2026-08-01-plan-fleet-status-page.md
new file mode 100644
index 000000000..ab6fcb766
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-01-plan-fleet-status-page.md
@@ -0,0 +1,485 @@
+# Fleet Status Page 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:** Every CARLOS deployment computes a fleet status view (per-box snapshots → merged `fleet.json` + static page in the bucket), private to console operators by default, publishable at `STATUS_HOST` when the operator opts in.
+
+**Architecture:** Each box's agent writes an aggregates-only snapshot to `status/boxes/<label>.json` every 5 minutes; one box (lease-gated when a bucket is configured) merges snapshots into `status/fleet.json` and republishes the binary-embedded viewer page beside it. The edge serves a `kind=status` route bucket-backed (60s cache). The console gains an `operator` role that gates the private fleet view. Privacy is enforced in the writer: ratios, hostnames, and machine ids die in the process that banded or counted them.
+
+**Tech Stack:** Go stdlib + existing repo deps only (modernc sqlite via `internal/registry`, `internal/release.Store`, `internal/lease`). Viewer is one embedded HTML file + one CSS file, no JS frameworks, no external requests.
+
+**Spec:** `docs/superpowers/specs/2026-08-01-fleet-status-page-design.md` — read it first.
+
+## Global Constraints
+
+- Additive-only migrations everywhere (registry: append to the `ALTER TABLE routes ADD COLUMN …` list in `internal/registry/registry.go:115-125` and extend the column list at `:136`; console: same pattern in `internal/console/store.go` migrate).
+- Band words only, never numbers: capacity `plenty` / `getting-full` / `at-capacity`, load `comfortable` / `busy` / `critical`, banded at 70% and 90%. The ratios are never serialized.
+- Unknowns publish as JSON `null`, never guessed (an unreadable probe must never render as `0`).
+- No app names or hostnames in output except routes with `status_named=1`; everyone else folds into `apps_unlisted`.
+- Viewer page: same-origin only — no external hosts, no JS frameworks; page fetches `fleet.json` relative.
+- Run tests with `GOCACHE=/tmp/claude-1001/gocache go test ./...`. Some tests need unix sockets and skip in sandboxes via the repo's `requireUnixSockets` pattern — full-suite verification runs unsandboxed.
+- Match repo style: heavy doc comments explaining *why*, table-driven tests, `t.Helper()` helpers. Work on the `depth-console-status` branch (or its successor after PR #3 merges).
+- Commit messages: `<area>: <what> — <why>` like the existing log; end with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
+
+## File Structure
+
+- `internal/status/snapshot.go` — `Box` type, banding, whimsical label, `CollectBox`, `WriteBox`
+- `internal/status/fleet.go` — `Fleet`/`FleetApp` types, `BuildFleet` (merge + staleness + opt-in), `PublishFleet`
+- `internal/status/assets/index.html`, `internal/status/assets/status.css` — embedded viewer
+- `internal/status/dryrun.go` — fixture build + leak self-check (shared by the CLI verb and tests)
+- `internal/status/status_test.go`, `internal/status/contract_test.go` — behavior + allowlist/leak tests
+- `internal/registry/registry.go` — `StatusNamed` column
+- `cli.go` — `-status-named` flag on `add`; new `RunStatus` verb; `main.go` — `case "status"`
+- `internal/edge/edge.go` — `kind == "status"` bucket-backed serving
+- `internal/host/agent.go` — `statusLoop`
+- `internal/console/store.go`, `server.go`, `view.go`, `templates/fleet.html`, `cmd/console/main.go` — operator role + fleet view
+
+---
+
+### Task 1: Registry `status_named` column + `carlos add -status-named`
+
+**Files:**
+- Modify: `internal/registry/registry.go` (Route struct ~line 50, migration list ~line 115, column list ~line 136, the Add/scan helpers that enumerate columns)
+- Modify: `cli.go` (the `add` FlagSet)
+- Test: `internal/registry/registry_test.go`
+
+**Interfaces:**
+- Consumes: existing `registry.Registry` Add/List.
+- Produces: `Route.StatusNamed bool` (column `status_named INTEGER NOT NULL DEFAULT 0`), round-tripped by Add/List/Get; `carlos add … -status-named` sets it. Task 3 reads it; Task 6's fixtures set it.
+
+- [ ] **Step 1: Write the failing round-trip test** (follow the existing column round-trip tests in `registry_test.go` — copy the `backing` test added in commit `9addab9` as the model):
+
+```go
+func TestStatusNamedRoundTrip(t *testing.T) {
+ reg := testRegistry(t) // existing helper
+ r := registry.Route{Host: "kass.training", Kind: "instance", App: "kass", StatusNamed: true}
+ if err := reg.Add(r); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("kass.training")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.StatusNamed {
+ t.Error("status_named did not round-trip")
+ }
+}
+```
+
+- [ ] **Step 2: Run it** — `GOCACHE=/tmp/claude-1001/gocache go test ./internal/registry/ -run StatusNamed -v` — expect FAIL (`unknown field StatusNamed`).
+- [ ] **Step 3: Implement.** Add `StatusNamed bool // publish this route's app by name on the status page — the app's own opt-in` to `Route`; append `` `ALTER TABLE routes ADD COLUMN status_named INTEGER NOT NULL DEFAULT 0` `` to the migration slice; add `status_named` to the shared column list and to every INSERT/SELECT scan site (grep `next_due` to find them all — the compiler + this test catch a missed site).
+- [ ] **Step 4: Test passes**; run the whole registry package.
+- [ ] **Step 5: Wire the flag.** In `cli.go`'s `add` FlagSet (find `fs.Bool("hibernate"` as the model): `statusNamed := fs.Bool("status-named", false, "opt this app into being named on the public status page")`, assign into the Route.
+- [ ] **Step 6: Commit** — `registry: status_named — the app's own opt-in to the status page`.
+
+### Task 2: Snapshot writer — bands, label, nulls
+
+**Files:**
+- Create: `internal/status/snapshot.go`
+- Test: `internal/status/status_test.go`
+
+**Interfaces:**
+- Consumes: `registry.Registry.List()` (`[]Route`; fields `Kind`, `Hibernate`, `Socket`, `Status`), `release.Store.Put(ctx, key, body, ifMatch)` (empty ifMatch = unconditional).
+- Produces (Tasks 3, 5, 6, 8 rely on these exact names):
+
+```go
+// Probes carries everything CollectBox may not read from the registry —
+// each nil means "unknown", and unknown publishes as null, never a guess.
+type Probes struct {
+ MachineID string // hashed into the label; never serialized
+ Region string // operator-set, coarse; "" omits the field
+ MemFrac *float64 // 0..1 memory utilization
+ DiskFrac *float64 // 0..1 proximity to the disk floor
+ Listening func(sock string) *bool // kernel socket table read; nil fn = unknown
+ LagSecs *int64 // seconds since litestream last progressed
+}
+
+type RouteCounts struct {
+ Static, Resident, Hibernating int
+ Awake, Asleep *int // null when the socket table is unreadable
+}
+
+type Replication struct {
+ Healthy *bool `json:"healthy"`
+ LagSecs *int64 `json:"lag_seconds"`
+}
+
+type Box struct {
+ GeneratedAt time.Time `json:"generated_at"`
+ Label string `json:"label"`
+ Region string `json:"region,omitempty"`
+ Routes RouteCounts `json:"routes"`
+ Load *string `json:"load"` // comfortable|busy|critical
+ Capacity *string `json:"capacity"` // plenty|getting-full|at-capacity
+ Replication Replication `json:"replication"`
+}
+
+func Label(machineID string) string // "ada-stargazing" style, stable
+func CollectBox(routes []registry.Route, p Probes, now time.Time) Box
+func WriteBox(ctx context.Context, s release.Store, b Box) error // Put status/boxes/<label>.json
+```
+
+- [ ] **Step 1: Write the failing tests** — banding boundaries, null-unknowns, label stability, no-needle serialization:
+
+```go
+func TestBandBoundaries(t *testing.T) {
+ f := func(v float64) *float64 { return &v }
+ cases := []struct{ frac *float64; want string }{
+ {f(0.69), "plenty"}, {f(0.70), "getting-full"},
+ {f(0.90), "getting-full"}, {f(0.91), "at-capacity"},
+ }
+ for _, c := range cases {
+ got := status.CollectBox(nil, status.Probes{MachineID: "m", DiskFrac: c.frac}, time.Now()).Capacity
+ if got == nil || *got != c.want {
+ t.Errorf("frac %v: got %v want %s", *c.frac, got, c.want)
+ }
+ }
+ if b := status.CollectBox(nil, status.Probes{MachineID: "m"}, time.Now()); b.Capacity != nil || b.Load != nil {
+ t.Error("unknown fractions must band to null, not a word")
+ }
+}
+
+func TestAwakeAsleepNullWhenUnreadable(t *testing.T) {
+ routes := []registry.Route{{Host: "a", Kind: "instance", Hibernate: true, Socket: "/x.sock"}}
+ b := status.CollectBox(routes, status.Probes{MachineID: "m"}, time.Now()) // nil Listening
+ if b.Routes.Awake != nil || b.Routes.Asleep != nil {
+ t.Error("nil socket probe must publish null, never 0")
+ }
+}
+
+func TestLabelStableAndOpaque(t *testing.T) {
+ a, b := status.Label("machine-1"), status.Label("machine-1")
+ if a != b || a == "" || strings.Contains(a, "machine") {
+ t.Errorf("label %q must be stable and never contain the machine id", a)
+ }
+}
+
+func TestSnapshotJSONCarriesNoHostnames(t *testing.T) {
+ routes := []registry.Route{{Host: "secret.example.com", Kind: "instance", Status: "ready"}}
+ b := status.CollectBox(routes, status.Probes{MachineID: "ip-10-0-0-7"}, time.Now())
+ j, _ := json.Marshal(b)
+ for _, leak := range []string{"secret", "example.com", "ip-10-0-0-7"} {
+ if strings.Contains(string(j), leak) {
+ t.Errorf("snapshot leaked %q: %s", leak, j)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run** — expect FAIL (package doesn't exist).
+- [ ] **Step 3: Implement `snapshot.go`.** Banding: `bandWord(f *float64, words [3]string) *string` — nil→nil, `<0.70`→words[0], `≤0.90`→words[1], else words[2]. Capacity = worst (max frac) of `DiskFrac`/`MemFrac` when either known; Load bands `MemFrac`. Label: `sha256(machineID)`, first 8 bytes index two embedded word lists (~40 names × ~40 actions — small const slices, Eleven's `ada-stargazing` shape). Counts: `static` = Kind static; `hibernating` = Hibernate true; `resident` = the rest; awake/asleep only when `p.Listening != nil` — count each hibernating route's `Listening(r.Socket)`; any nil result makes both totals null (partial knowledge is unknown, not a guess). Replication.Healthy: non-nil LagSecs && `*LagSecs < 900`; nil lag → nil healthy. `WriteBox` marshals indented and Puts `status/boxes/<label>.json` with `ifMatch: ""`.
+- [ ] **Step 4: Tests pass.**
+- [ ] **Step 5: Commit** — `status: per-box snapshots — needles die in the writer`.
+
+### Task 3: Fleet merge + publisher
+
+**Files:**
+- Modify: `internal/status/fleet.go` (create)
+- Test: `internal/status/status_test.go`
+
+**Interfaces:**
+- Consumes: Task 2's `Box`; Task 1's `Route.StatusNamed`; `release.Store.Get/Put/List`.
+- Produces:
+
+```go
+type FleetApp struct {
+ Name string `json:"name"`
+ Instances int `json:"instances"`
+ Healthy bool `json:"healthy"`
+ Channel string `json:"channel"`
+ Version string `json:"version"` // short sha as stored on the route
+}
+
+type FleetBox struct { // Box minus its own replication, plus staleness
+ Label string `json:"label"`
+ Region string `json:"region,omitempty"`
+ Load *string `json:"load"`
+ Stale bool `json:"stale"`
+ Routes RouteCounts `json:"routes"`
+}
+
+type Fleet struct {
+ GeneratedAt time.Time `json:"generated_at"`
+ Boxes []FleetBox `json:"boxes"`
+ Instances map[string]*int `json:"instances"` // total/static/resident/hibernating/awake/asleep
+ Capacity struct{ Level *string `json:"level"` } `json:"capacity"`
+ Apps []FleetApp `json:"apps"`
+ AppsUnlisted int `json:"apps_unlisted"`
+ Services []struct{ Name string `json:"name"`; Up bool `json:"up"` } `json:"services"`
+ Replication Replication `json:"replication"`
+}
+
+// BuildFleet merges every status/boxes/*.json (stale = GeneratedAt older
+// than 3×period), folds the local registry's routes into apps/services,
+// and never invents a number a snapshot didn't carry.
+func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, now time.Time, period time.Duration) (*Fleet, error)
+
+// PublishFleet writes fleet.json + the embedded page assets to status/.
+func PublishFleet(ctx context.Context, s release.Store, f *Fleet) error
+```
+
+- [ ] **Step 1: Failing tests** — opt-in + unlisted count, worst-of capacity, stale marking, null propagation:
+
+```go
+func TestFleetAppsOptIn(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "kass.training", Kind: "instance", App: "kass", Status: "ready",
+ Channel: "stable", Version: "aedcae3", StatusNamed: true},
+ {Host: "hidden.app", Kind: "instance", App: "hidden", Status: "ready"},
+ {Host: "svc.pdf.internal", Kind: "service", App: "svc/pdf", Status: "ready"},
+ }
+ s := teststore(t) // DirStore over t.TempDir(); write one Box via WriteBox first
+ f, err := status.BuildFleet(context.Background(), s, routes, time.Now(), 5*time.Minute)
+ if err != nil { t.Fatal(err) }
+ if len(f.Apps) != 1 || f.Apps[0].Name != "kass" || f.Apps[0].Version != "aedcae3" {
+ t.Errorf("apps = %+v, want exactly the opted-in kass", f.Apps)
+ }
+ if f.AppsUnlisted != 1 { // hidden; svc/* routes are services, not apps
+ t.Errorf("apps_unlisted = %d, want 1", f.AppsUnlisted)
+ }
+ if len(f.Services) != 1 || f.Services[0].Name != "svc/pdf" || !f.Services[0].Up {
+ t.Errorf("services = %+v", f.Services)
+ }
+ j, _ := json.Marshal(f)
+ if strings.Contains(string(j), "hidden") {
+ t.Errorf("un-opted app leaked: %s", j)
+ }
+}
+
+func TestFleetWorstOfAndStale(t *testing.T) {
+ s := teststore(t)
+ now := time.Now()
+ old, fresh := boxFixture("old", "at-capacity", now.Add(-16*time.Minute)),
+ boxFixture("fresh", "plenty", now) // helper: Box with that capacity word
+ for _, b := range []status.Box{old, fresh} {
+ if err := status.WriteBox(context.Background(), s, b); err != nil { t.Fatal(err) }
+ }
+ f, _ := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute)
+ if f.Capacity.Level == nil || *f.Capacity.Level != "at-capacity" {
+ t.Errorf("capacity must be the worst box's band, got %v", f.Capacity.Level)
+ }
+ var st, fr *status.FleetBox
+ for i := range f.Boxes {
+ if f.Boxes[i].Label == "old" { st = &f.Boxes[i] } else { fr = &f.Boxes[i] }
+ }
+ if !st.Stale || fr.Stale {
+ t.Error("boxes older than 3 periods must mark stale — loudly, never dropped")
+ }
+}
+```
+
+- [ ] **Step 2: Run — FAIL.**
+- [ ] **Step 3: Implement `fleet.go`.** `BuildFleet`: `s.List(ctx, "status/boxes/")`, Get + unmarshal each (a corrupt snapshot is skipped with a logged warning, never fatal); staleness = `now.Sub(GeneratedAt) > 3*period`; instance sums add only non-null components (any null makes the summed field null); worst-of via band rank map `{plenty:0, getting-full:1, at-capacity:2}` over non-stale boxes (nil bands don't vote; all-nil → nil). Apps: group non-static, non-`svc/` routes by `App`; only groups where **every** route has StatusNamed publish (one un-opted route keeps the app dark); Healthy = all routes `Status=="ready"`; Channel/Version from the first route (log a warning if routes disagree). `AppsUnlisted` = distinct app groups minus published. Services: routes with `Kind=="service"`, Up = `Status=="ready"` (svc names are platform-reserved, not tenant hostnames). Replication = worst across fresh boxes (unhealthy beats healthy beats unknown-only→nil). `PublishFleet`: Put `status/fleet.json`, then each embedded asset (Task 4).
+- [ ] **Step 4: Tests pass.**
+- [ ] **Step 5: Commit** — `status: fleet merge — worst-of bands, loud staleness, opt-in apps`.
+
+### Task 4: Embedded viewer page
+
+**Files:**
+- Create: `internal/status/assets/index.html`, `internal/status/assets/status.css`
+- Modify: `internal/status/fleet.go` (the `//go:embed assets/*` + publish loop)
+- Test: `internal/status/status_test.go`
+
+**Interfaces:**
+- Consumes: `Fleet` JSON shape from Task 3.
+- Produces: `//go:embed assets/*` FS named `assetsFS`; `PublishFleet` writes `status/index.html` + `status/status.css` beside the JSON. Task 7 serves exactly these three keys.
+
+- [ ] **Step 1: Failing test:**
+
+```go
+func TestPublishWritesPageAssets(t *testing.T) {
+ s := teststore(t)
+ f, _ := status.BuildFleet(context.Background(), s, nil, time.Now(), 5*time.Minute)
+ if err := status.PublishFleet(context.Background(), s, f); err != nil { t.Fatal(err) }
+ for _, key := range []string{"status/fleet.json", "status/index.html", "status/status.css"} {
+ if _, _, err := s.Get(context.Background(), key); err != nil {
+ t.Errorf("%s not published: %v", key, err)
+ }
+ }
+ html, _, _ := s.Get(context.Background(), "status/index.html")
+ for _, banned := range []string{"http://", "https://", "//cdn", "<script src"} {
+ if strings.Contains(string(html), banned) && banned != "https://" ||
+ (banned == "https://" && strings.Count(string(html), "https://") > strings.Count(string(html), `rel="me" href="https://`)) {
+ t.Errorf("page must be same-origin only; found %q", banned)
+ }
+ }
+}
+```
+
+(Simplify in implementation: assert the page contains **no** `http://`/`https://` at all and no `<script src` — the footer's fallback-URL text is injected as *text* by inline JS from `location`, not a hardcoded absolute URL.)
+
+- [ ] **Step 2: Run — FAIL.**
+- [ ] **Step 3: Write the page.** One `index.html`: masthead ("Fleet status"), tiles for instances/capacity/replication, a boxes table (label, region, load word, routes, a bordered **stale** chip), an apps table (only what `fleet.json` carries), services chips, and a footer stating the commitments in one sentence plus "if this page is unreachable" — an inline `<script>` (no `src`) that `fetch("fleet.json")`s, renders, shows band words as text, renders `null` as an em-dash titled "unknown", and never formats a number it didn't receive. `status.css`: small, dark/light via `prefers-color-scheme`. Embed with `//go:embed assets/*`; `PublishFleet` ranges the FS and Puts each file under `status/`, **assets before json is not required** (json is the last write so a fresh page never reads a missing stylesheet on first publish — mirror Eleven's upload-styles-first rule by publishing css, html, then fleet.json).
+- [ ] **Step 4: Tests pass.**
+- [ ] **Step 5: Commit** — `status: the viewer rides the binary — same-origin, unknowns loud`.
+
+### Task 5: Contract tests — allowlist + digit scan
+
+**Files:**
+- Create: `internal/status/contract_test.go`
+
+**Interfaces:** Consumes Tasks 2-3 types via their JSON. Produces the guard the spec §4 promises; Task 6's dry-run reuses `scanForLeaks`.
+
+- [ ] **Step 1: Write the tests** (these pass immediately if Tasks 2-4 are honest — they exist to fail *future* edits):
+
+```go
+// allowedBoxKeys / allowedFleetKeys are THE schema. Adding a field to the
+// structs without adding it here fails the build — that friction is the
+// point: every new field is a deliberate publication decision.
+var allowedBoxKeys = map[string][]string{
+ "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication"},
+ "routes": {"static", "resident", "hibernating", "awake", "asleep"},
+ "replication": {"healthy", "lag_seconds"},
+}
+
+func TestBoxSchemaAllowlisted(t *testing.T) {
+ b := status.CollectBox(fixtureRoutes(), fixtureProbes(), time.Now())
+ j, _ := json.Marshal(b)
+ assertOnlyKeys(t, j, allowedBoxKeys) // walks the decoded map recursively
+}
+
+func TestNoDigitsInBandSubtrees(t *testing.T) {
+ b := status.CollectBox(fixtureRoutes(), fixtureProbes(), time.Now())
+ for name, v := range map[string]*string{"load": b.Load, "capacity": b.Capacity} {
+ if v != nil && strings.ContainsAny(*v, "0123456789") {
+ t.Errorf("%s carries a digit: %q", name, *v)
+ }
+ }
+}
+```
+
+Plus `TestFleetSchemaAllowlisted` (same pattern, keys from Task 3's shape incl. `apps`, `apps_unlisted`, `services`, `boxes`, `instances`, `capacity`) and `scanForLeaks(j []byte, banned []string) []string` as a shared helper.
+
+- [ ] **Step 2: Run — expect PASS** (and temporarily add a junk field to `Box` to watch it fail, then remove it — verify the guard bites).
+- [ ] **Step 3: Commit** — `status: the schema is allowlisted — an extra field fails the build`.
+
+### Task 6: `carlos status -dry-run`
+
+**Files:**
+- Create: `internal/status/dryrun.go`
+- Modify: `cli.go` (add `RunStatus(args []string)`), `main.go` (`case "status": RunStatus(rest)` — copy the `case "ops"` wiring at `main.go:44`)
+- Test: `internal/status/status_test.go`
+
+**Interfaces:**
+- Consumes: everything above; `release.DirStore`.
+- Produces: `func DryRun(dir string, out io.Writer) error` — builds a fixture registry **full of hostnames and needles** (`prod-secret.example.com`, machine id `ip-10-0-0-7`, fracs 0.73/0.95), runs CollectBox → WriteBox → BuildFleet → PublishFleet into a `DirStore` at `dir`, then re-reads every published object and fails on any fixture hostname, the machine id, or a digit inside band fields (reusing `scanForLeaks`). Prints what it published and `dry-run: no leaks`.
+
+- [ ] **Step 1: Failing test:**
+
+```go
+func TestDryRunSelfChecks(t *testing.T) {
+ var buf bytes.Buffer
+ if err := status.DryRun(t.TempDir(), &buf); err != nil {
+ t.Fatalf("dry run: %v\n%s", err, buf.String())
+ }
+ if !strings.Contains(buf.String(), "no leaks") {
+ t.Errorf("dry run must attest the self-check ran: %s", buf.String())
+ }
+}
+```
+
+- [ ] **Step 2: Run — FAIL.** **Step 3: Implement** `dryrun.go` + the `cli.go` verb (`carlos status -dry-run <dir>`; with no flag, print usage — live status has no CLI mode, the agent owns it). **Step 4: Pass.**
+- [ ] **Step 5: Commit** — `status: dry-run — the self-hoster's "what would I publish?" in one verb`.
+
+### Task 7: Edge serves `kind=status` bucket-backed
+
+**Files:**
+- Modify: `internal/edge/edge.go` (the kind dispatch at `internal/edge/edge.go:158` — model on the `t.Kind == "static"` arm) and wherever the static handler holds its `release.Store`
+- Test: `internal/edge/edge_test.go`
+
+**Interfaces:**
+- Consumes: `release.Store`, the three keys Task 4 publishes.
+- Produces: a route with `Kind: "status"` serves `/`→`status/index.html`, `/fleet.json`, `/status.css` from the store with a 60-second in-memory cache per key (etag-keyed; a store error inside the TTL serves the cached copy, outside it 503s honestly). Any other path 404s. Same ACME/route-table behavior as every host — no special-casing beyond the dispatch arm.
+
+- [ ] **Step 1: Failing test** (model: the existing static-handler tests in `edge_test.go` — httptest against the edge handler with a `DirStore`):
+
+```go
+func TestStatusRouteServesBucketBacked(t *testing.T) {
+ s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"})
+ s.Put(context.Background(), "status/fleet.json", []byte(`{"generated_at":"x"}`), "")
+ s.Put(context.Background(), "status/index.html", []byte("<title>Fleet status</title>"), "")
+ for path, want := range map[string]string{"/": "Fleet status", "/fleet.json": "generated_at"} {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil))
+ if rec.Code != 200 || !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String())
+ }
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/boxes/anything", nil))
+ if rec.Code != 404 {
+ t.Errorf("non-allowlisted path must 404, got %d", rec.Code)
+ }
+}
+```
+
+- [ ] **Step 2: FAIL.** **Step 3: Implement** the dispatch arm + a `statusHandler{store, ttl, map[string]cached}` (mutex, `cached{body, ctype, fetched}`); content types text/html, application/json, text/css by key. **Step 4: PASS** (package may need `requireUnixSockets` care — model existing tests). **Step 5: Commit** — `edge: kind=status — the page outlives the box that rendered it`.
+
+### Task 8: Agent wiring — the 5-minute loop
+
+**Files:**
+- Modify: `internal/host/agent.go` (add `statusLoop`, launched beside `go adoptLoop(reg)` at `internal/host/agent.go:74`)
+- Test: `internal/host/host_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 2-4; `lease.Acquire(ctx, bucket, prefix, region)` from `internal/lease/lease.go:177`; env config.
+- Produces: env contract, read in `statusLoop` (document in the agent's doc comment): `CARLOS_STATUS_BUCKET`/`_PREFIX`/`_REGION` (the store; unset → the loop never starts — **inert until configured**, the litestream convention), `STATUS_BOX_REGION` (coarse region word), `STATUS_HOST` + `STATUS_PUBLIC` (consumed by ops/tofu, recorded here as the config surface). Each tick: `CollectBox` (probes: machine id from `/etc/machine-id`, mem from `/proc/meminfo`, disk via `syscall.Statfs` on `/data`, sockets via the kernel table — each failure → nil, never a zero), `WriteBox`, then try `lease.Acquire(ctx, bucket, prefix+"/status-lease", region)`; holding it → `BuildFleet` + `PublishFleet`; not holding → someone else publishes, done. Extract the pure part as `func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p status.Probes, publish bool) error` so the test needs no systemd, S3, or timers.
+
+- [ ] **Step 1: Failing test** — `statusTick` against a DirStore + fixture registry: snapshot lands under `status/boxes/`, `publish=true` also lands `status/fleet.json`, `publish=false` doesn't.
+- [ ] **Step 2: FAIL. Step 3: Implement** (`time.NewTicker(5*time.Minute)` with the adoptLoop's jitter pattern; lease-held path publishes, `lease.HeldError` path skips silently — it's contention, not failure; DirStore/no-bucket deployments publish every tick, single box needs no election). **Step 4: PASS. Step 5: Commit** — `host: status loop — inert until configured, one publisher via the lease`.
+
+### Task 9: Console operator role + fleet view
+
+**Files:**
+- Modify: `internal/console/store.go` (users migration + accessors), `internal/console/server.go` (route + gate), `internal/console/view.go` (FleetView), `cmd/console/main.go` (`grant -operator`)
+- Create: `internal/console/templates/fleet.html`
+- Test: `internal/console/console_test.go` (the existing test file — follow its server-test harness)
+
+**Interfaces:**
+- Consumes: Task 3's `Fleet` JSON via the console's existing `release.Store` handle (the one promote/rollback use).
+- Produces: users column `operator INTEGER NOT NULL DEFAULT 0` (additive, console migrate pattern at `internal/console/store.go:77`); `Store.SetOperator(address string, on bool) error`, `Store.IsOperator(address string) (bool, error)`; CLI `console grant -operator <address>` / `console revoke -operator <address>` (extend the existing grant/revoke verbs — box-side only, exactly like app grants); `GET /fleet` renders `fleet.html` for operators and **404s for everyone else** (the same not-found the console gives non-owners — role existence is not probeable).
+
+- [ ] **Step 1: Failing tests:**
+
+```go
+func TestFleetViewOperatorOnly(t *testing.T) {
+ ts := newTestServer(t) // existing harness: store + fake keymail + release DirStore
+ seedFleetJSON(t, ts.releases) // writes a small status/fleet.json fixture
+ owner := ts.signIn(t, "owner@keymail.dev") // has an app grant, not operator
+ if code := ts.get(t, owner, "/fleet"); code != 404 {
+ t.Errorf("non-operator must see the same 404 as a missing page, got %d", code)
+ }
+ op := ts.signIn(t, "paul@keymail.dev")
+ if err := ts.store.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) }
+ code, body := ts.getBody(t, op, "/fleet")
+ if code != 200 || !strings.Contains(body, "Fleet status") {
+ t.Errorf("operator fleet view: %d %s", code, body)
+ }
+}
+```
+
+- [ ] **Step 2: FAIL. Step 3: Implement.** Migration + accessors; server route (CSRF-exempt GET, session-gated then `IsOperator` else `http.NotFound`); `FleetView` decodes `status/fleet.json` (missing object → the page's "no snapshots yet" empty state, never a 500); `fleet.html` mirrors Task 4's page server-side with the console's existing template idioms (it renders the same JSON — keep the two tables' column order identical so the pages read as one product). CLI: `-operator` bool flag on grant/revoke; keyless mode: `/fleet` stays readable (it's a read).
+- [ ] **Step 4: PASS — run the whole console package. Step 5: Commit** — `console: the operator role — box-granted, UI-projected`.
+
+### Task 10: e2e + docs
+
+**Files:**
+- Modify: `e2e_test.go` (extend the existing end-to-end), `README.md` (status section), `docs/superpowers/specs/2026-08-01-carlos-platform-design.md` (§7/§9 cross-reference: one line pointing at the status spec)
+- Test: is the deliverable.
+
+- [ ] **Step 1: Extend the e2e** — in the existing e2e flow (DirStore + real edge): add a `kind=status` route, run one `statusTick(publish=true)`, then GET the status host through the edge and assert 200 + `Fleet status` + a `fleet.json` whose schema passes `assertOnlyKeys`. Then `carlos status -dry-run $TMP` via the CLI path and assert exit 0.
+- [ ] **Step 2: Run unsandboxed** (e2e needs unix sockets): full suite green.
+- [ ] **Step 3: README**: a "Fleet status" section — what publishes, the commitments in three sentences, the config surface (`CARLOS_STATUS_*`, `STATUS_BOX_REGION`, `STATUS_HOST`, `STATUS_PUBLIC`), the dry-run verb, and the honest private-deployment fallback (`aws s3 cp` of `fleet.json`).
+- [ ] **Step 4: Commit** — `status: e2e + docs — the feature is the page, the page is the contract`.
+
+---
+
+## Deployment follow-ups (NOT in this plan — operator/infra work, tracked in platform-infrastructure)
+
+Ship/adopt the new binary on both deployments; flagship tofu: public-read bucket policy on the `status/` prefix + `STATUS_*` env in host config; `status.carlosframework.com` A record (Paul Campbell DNSimple account); `console grant -operator paul@keymail.dev`; Tito stays private by default.
+
+## Self-review notes
+
+- Spec coverage: §1 access → Tasks 9 (+ deployment follow-ups); §2 data model → Tasks 1-3, 5; §3 publish/serve → Tasks 4, 6, 7, 8; §4 testing → Tasks 5, 6, 10; §5 rollout → follow-ups section. No gaps.
+- Names used across tasks: `CollectBox`/`WriteBox`/`BuildFleet`/`PublishFleet`/`Probes`/`Box`/`Fleet`/`scanForLeaks`/`statusTick` — consistent.
+- The `svc/*` naming: services publish by name deliberately (platform-reserved namespace, spec §2 services line) — not a privacy hole.