plan: fleet status page — ten tasks, writer-enforced privacy, TDD throughout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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. |