carlos / platform Public

plan: console clarity batch (P0s+P1s from the 2026-08-09 critique)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 56dfee06dd036c5d75b5d7875dd42a698aa6985b parent 11670ef
1 file changed, +993 −0
  • docs/superpowers/plans/2026-08-09-console-clarity-batch.md +993 −0
diff --git a/docs/superpowers/plans/2026-08-09-console-clarity-batch.md b/docs/superpowers/plans/2026-08-09-console-clarity-batch.md
new file mode 100644
index 000000000..e0d21fe8c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-09-console-clarity-batch.md
@@ -0,0 +1,993 @@
+# Console Clarity Batch (P0s + P1s) 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:** Make every console screen answer "what are my apps, what are they doing, is everything healthy" in plain language, with the technical layer (ladder, channels, bake mechanics) behind progressive reveals.
+
+**Architecture:** All copy and layout changes live in `internal/console/` (templates + the Go that assembles their view data — templates render facts, never compute them). Two small new computations: a health verdict derived from data the pages already load, and a release summary derived from the channels the Releases page already reads. Wire shapes, channel names, URL paths, and template `define` names are untouched.
+
+**Tech Stack:** Go server-rendered `html/template`, no JS dependencies, vendored Lucide icons via the existing `icon` funcmap helper.
+
+**Source of truth for the copy:** `.impeccable/critique/2026-08-09T15-36-14Z__internal-console-templates.md` (the critique this plan implements). Scope is its P0s + P1s only. Paul's decisions 2026-08-09: bake → "safety delay"; health verdict ships in this same batch.
+
+## Global Constraints
+
+- Channel names on the wire (`stable`, `beta`, `edge`, `canary/*`) are NEVER renamed — display copy only. `internal/release/laddercheck_test.go` pins production channel names; do not touch `internal/release/`.
+- URL paths are unchanged (`/apps/<app>/instances` stays even though the tab label becomes "Running") — bookmarks and tests depend on them.
+- Template `define` names are unchanged (`app_instances`, `ladder`, etc.) — `render()` calls them by name.
+- The em-dash discipline stands: never render a guessed zero; unknown is `—` or an explicit "can't tell" word.
+- Chip grammar stands (layout.html comments): bordered = a fact worth noticing; filled = must-not-skim. `chip-prod` stays the only filled red chip. New health chips: `Needs attention` may be filled amber; `All good` is bordered.
+- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, plus `GOOS=darwin go build ./...`, plus `gofmt -l` clean for touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need the sandbox disabled.
+- Copy strings below are exact — implement them verbatim. Where a test asserts an old string, update the test in the same task (grep `internal/console/*_test.go` for the old string before assuming there isn't one).
+- Work happens in this worktree on branch `console-clarity-batch`.
+
+---
+
+### Task 1: Health verdict computation (overview.go)
+
+**Files:**
+- Modify: `internal/console/overview.go`
+- Test: `internal/console/overview_test.go`
+
+**Interfaces:**
+- Consumes: `OverviewStats` fields already assembled by `assembleOverview` (`internal/console/overview.go:15-54`).
+- Produces: `OverviewStats.InstanceExpected int` (count of account routes with `Kind == "instance"`), `OverviewStats.Verdict string` (one of `"ok" | "attention" | "notdeployed" | "unknown"`), `OverviewStats.VerdictLabel string` (one of `"All good" | "Needs attention" | "Not deployed yet" | "Can't tell right now"`). Tasks 3 and 2 render these.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/console/overview_test.go`:
+
+```go
+func TestOverviewVerdict(t *testing.T) {
+ cases := []struct {
+ name string
+ st OverviewStats
+ verdict string
+ label string
+ }{
+ {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 2}, "ok", "All good"},
+ {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
+ {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1}, "attention", "Needs attention"},
+ {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"},
+ {"not deployed", OverviewStats{}, "notdeployed", "Not deployed yet"},
+ {"unreadable stable pointer", OverviewStats{LiveErr: "boom"}, "unknown", "Can't tell right now"},
+ {"sweep failed with instances expected", OverviewStats{HasStable: true, InstanceExpected: 1, MetricsUnavailable: true}, "unknown", "Can't tell right now"},
+ {"static-only app has nothing expected", OverviewStats{HasStable: true, InstanceExpected: 0, MetricsUnavailable: true}, "ok", "All good"},
+ }
+ for _, c := range cases {
+ v, l := healthVerdict(c.st)
+ if v != c.verdict || l != c.label {
+ t.Errorf("%s: got %q/%q, want %q/%q", c.name, v, l, c.verdict, c.label)
+ }
+ }
+}
+
+func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) {
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a", Account: "bab", Kind: "instance"},
+ {Host: "b", Account: "bab", Kind: "static"},
+ {Host: "c", Account: "other", Kind: "instance"},
+ }}
+ st := assembleOverview(view, "bab", []InstanceMetrics{}, nil)
+ if st.InstanceExpected != 1 {
+ t.Errorf("InstanceExpected = %d, want 1 (instance-kind, this account only)", st.InstanceExpected)
+ }
+ if st.Verdict == "" || st.VerdictLabel == "" {
+ t.Error("assembleOverview must fill Verdict and VerdictLabel")
+ }
+}
+```
+
+Note: check `RouteView` in `view.go` for the exact field names (`Account`, `Kind`, `Host`) before writing — mirror whatever `TestInstanceRowsSkipOtherAccountsRoutes` (overview_test.go:258) constructs.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/console/ -run 'TestOverviewVerdict|TestOverviewExpected' -count=1`
+Expected: FAIL — `healthVerdict` undefined, `InstanceExpected` undefined.
+
+- [ ] **Step 3: Implement**
+
+In `internal/console/overview.go`, add to `OverviewStats` (after `MetricsUnavailable`):
+
+```go
+ // InstanceExpected is how many of this account's routes are lifecycle-
+ // managed processes (kind "instance") — the only kind the metrics sweep
+ // can ever hear from. Static and service routes are served without a
+ // tenant process, so they are never "silent": expecting a reading from
+ // them would flag every static site as unhealthy forever.
+ InstanceExpected int
+
+ // Verdict is the dashboard's one-word answer to "is this app healthy",
+ // computed here so templates render it as a fact. Verdict is the CSS
+ // suffix ("ok", "attention", "notdeployed", "unknown"); VerdictLabel the
+ // words. The rule errs toward "can't tell" over a guessed "all good" —
+ // same honesty contract as the em-dash.
+ Verdict string
+ VerdictLabel string
+```
+
+In `assembleOverview`, inside the existing routes loop (`overview.go:100-110`), add after `st.InstanceTotal++`:
+
+```go
+ if rt.Kind == "instance" {
+ st.InstanceExpected++
+ }
+```
+
+At the end of `assembleOverview`, before `return st`:
+
+```go
+ st.Verdict, st.VerdictLabel = healthVerdict(st)
+```
+
+Add the function:
+
+```go
+// healthVerdict folds the dashboard's numbers into one word. Order matters:
+// an unreadable stable pointer means we don't know what's live, which
+// outranks everything; an app never promoted isn't unhealthy, it's not
+// deployed; and a failed sweep with real instances is "can't tell", never
+// "all good" — a verdict this page can't back is the lie the old Status
+// column told.
+func healthVerdict(st OverviewStats) (string, string) {
+ switch {
+ case st.LiveErr != "":
+ return "unknown", "Can't tell right now"
+ case !st.HasStable:
+ return "notdeployed", "Not deployed yet"
+ case st.DomainAttention > 0:
+ return "attention", "Needs attention"
+ case st.MetricsUnavailable && st.InstanceExpected > 0:
+ return "unknown", "Can't tell right now"
+ case st.InstanceReporting+st.InstanceAsleep < st.InstanceExpected:
+ return "attention", "Needs attention"
+ default:
+ return "ok", "All good"
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS (all existing tests too — `assembleOverview`'s signature is unchanged).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/overview.go internal/console/overview_test.go
+git commit -m "console: compute a health verdict on the app overview"
+```
+
+---
+
+### Task 2: Apps list — verdict chip, plain columns (server.go, apps.html, layout.html)
+
+**Files:**
+- Modify: `internal/console/server.go:520-528` (appRow), `internal/console/server.go:2404-2438` (summarize)
+- Modify: `internal/console/templates/apps.html`
+- Modify: `internal/console/templates/layout.html` (chip CSS, after the `.chip-env` rule ~line 179)
+- Test: `internal/console/server_test.go` (grep `summarize` in tests first; if untested, add `TestSummarizeVerdictAndURL` to server_test.go)
+
+**Interfaces:**
+- Consumes: `AppView` (view.go), `humanAge` (view.go), the verdict vocabulary from Task 1 (labels only — the list computes its own registry-scope verdict; it must NOT claim "All good", see Step 3 comment).
+- Produces: new `appRow` shape rendered by apps.html: `App, URL, Verdict, VerdictLabel, Stable, StableAge string; CanaryN int`. Shared helper `apexHost(routes []RouteView) string` in ladder.go. CSS classes `chip-health-ok`, `chip-health-attention`, `chip-health-notdeployed`, `chip-health-unknown` used again in Task 3.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestSummarizeVerdictAndURL(t *testing.T) {
+ now := time.Now()
+ v := AppView{App: "kass",
+ Routes: []RouteView{
+ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready"},
+ {Host: "kass.example.com", Account: "bab", Kind: "instance", Status: "ready"},
+ },
+ Channels: []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
+ {Name: "canary/rc-1", Version: "aaa1111"},
+ }}
+ row := summarize(v)
+ if row.URL != "kass.example.com" {
+ t.Errorf("URL = %q, want the apex host kass.example.com", row.URL)
+ }
+ if row.Verdict != "ok" || row.VerdictLabel != "Deployed" {
+ t.Errorf("verdict = %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel)
+ }
+ if row.CanaryN != 1 {
+ t.Errorf("CanaryN = %d, want 1", row.CanaryN)
+ }
+
+ v.Routes[0].Status = "failed"
+ row = summarize(v)
+ if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" {
+ t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel)
+ }
+
+ empty := summarize(AppView{App: "new"})
+ if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" {
+ t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel)
+ }
+
+ bad := summarize(AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}})
+ if bad.Verdict != "unknown" || bad.VerdictLabel != "Can't tell right now" {
+ t.Errorf("stable err: verdict = %q/%q, want unknown/Can't tell right now", bad.Verdict, bad.VerdictLabel)
+ }
+}
+```
+
+(Adjust `RouteView`/`ChannelView` field literals to match view.go — same rule as Task 1. Check whether `RouteView` carries `LastError`; if it does, a route with non-empty `LastError` is also `attention` — add a case. If it exists only on `registry.Route` and not on the view, do NOT add it to the view in this task.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/console/ -run TestSummarizeVerdictAndURL -count=1`
+Expected: FAIL — `appRow` has no `URL`/`Verdict` fields.
+
+- [ ] **Step 3: Implement summarize + appRow + apexHost**
+
+Replace `appRow` (server.go:521-528):
+
+```go
+// appRow is one line of the "your apps" table: the app, where it's running,
+// whether anything the REGISTRY tracks needs attention, and what's live.
+// The verdict here is registry-scope by design — process liveness only
+// exists in the per-app metrics sweep, which is too heavy to run across a
+// whole account's list. So the healthy word is "Deployed", never "All
+// good": the list claims exactly what it knows, and the app's Overview
+// (which has the sweep) gives the full verdict. Publishing per-app liveness
+// into the aggregate feed so this list can say more is a filed follow-up.
+type appRow struct {
+ App string
+ URL string // apex host, "" when no routes
+ Verdict string // ok | attention | notdeployed | unknown
+ VerdictLabel string // Deployed | Needs attention | Not deployed yet | Can't tell right now
+ Stable string // live version, "" when none
+ StableAge string // humanAge of its promotion, "" when unknown
+ CanaryN int // count of canary/* channels
+}
+```
+
+Replace `summarize` (server.go:2404-2438):
+
+```go
+// summarize folds one AppView into its "your apps" table row. See appRow for
+// why its verdict is registry-scope.
+func summarize(v AppView) appRow {
+ row := appRow{App: v.App, URL: apexHost(v.Routes)}
+ attention := false
+ for _, rt := range v.Routes {
+ if rt.Status == "failed" {
+ attention = true
+ }
+ }
+ stableErr := false
+ for _, ch := range v.Channels {
+ if ch.Name == "stable" {
+ if ch.Err != "" {
+ stableErr = true
+ continue
+ }
+ row.Stable = ch.Version
+ if !ch.PromotedAt.IsZero() {
+ row.StableAge = humanAge(time.Since(ch.PromotedAt))
+ }
+ }
+ if ch.Err == "" && strings.HasPrefix(ch.Name, "canary/") {
+ row.CanaryN++
+ }
+ }
+ switch {
+ case stableErr:
+ row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now"
+ case attention:
+ row.Verdict, row.VerdictLabel = "attention", "Needs attention"
+ case row.Stable == "":
+ row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet"
+ default:
+ row.Verdict, row.VerdictLabel = "ok", "Deployed"
+ }
+ return row
+}
+```
+
+Extract the apex ordering that `prodWiredCanaryHosts` (ladder.go:251-269) already implements, so the two never drift. In `ladder.go`, pull the inline logic into a package function and call it from both places:
+
+```go
+// apexHost picks the app's primary host — fewest labels, then shortest,
+// then alphabetical — the same "which host is the apex" answer
+// prodWiredCanaryHosts uses. "" when there are no named routes.
+func apexHost(routes []RouteView) string {
+ apex := ""
+ better := func(a, b string) bool {
+ la, lb := strings.Count(a, "."), strings.Count(b, ".")
+ if la != lb {
+ return la < lb
+ }
+ if len(a) != len(b) {
+ return len(a) < len(b)
+ }
+ return a < b
+ }
+ for _, rt := range routes {
+ if rt.Host == "" {
+ continue
+ }
+ if apex == "" || better(rt.Host, apex) {
+ apex = rt.Host
+ }
+ }
+ return apex
+}
+```
+
+and in `prodWiredCanaryHosts` replace the inline apex block with `apex := apexHost(routes)` (delete the now-duplicated `better` closure there).
+
+- [ ] **Step 4: Rewrite apps.html**
+
+Replace the table block (apps.html:6-21):
+
+```html
+<h1>{{.Account.Name}}</h1>
+<div class="tablewrap">
+<table>
+ <tr><th scope="col">App</th><th scope="col">Running at</th><th scope="col">Health</th><th scope="col">Live version</th></tr>
+ {{range .Apps}}
+ <tr>
+ <td><a href="{{$.Nav}}/apps/{{.App}}">{{.App}}</a></td>
+ <td>{{if .URL}}<a href="https://{{.URL}}">{{.URL}}</a>{{else}}—{{end}}</td>
+ <td><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></td>
+ <td>{{if .Stable}}<code>{{.Stable}}</code>{{if .StableAge}} <span class="small">· {{.StableAge}}</span>{{end}}{{else}}—{{end}}{{if .CanaryN}} <span class="chip chip-canary">{{.CanaryN}} test version{{if ne .CanaryN 1}}s{{end}}</span>{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+```
+
+Change the trash line (apps.html:26-27) to:
+
+```html
+{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps,
+ restorable for 30 days.</p>{{end}}
+```
+
+- [ ] **Step 5: Add the health chip CSS**
+
+In layout.html, directly after the `.chip-env` rule (~line 179), add:
+
+```css
+ /* Health verdict chips. "attention" is the one filled variant here — the
+ row a reader must not skim past — in the warn tone, not .chip-prod's
+ red: a silent instance is a problem, not the unreviewed-build outage.
+ ok/notdeployed/unknown are bordered facts. */
+ .chip-health-ok { border: 1px solid #2f7a4f; color: #2f7a4f; }
+ .chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); }
+ .chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+```
+
+(Check whether layout.html defines a green token — grep `--good` / `2f7a4f`; `chip-up` at line 181 uses `#2f7a4f`, reuse whatever it uses.)
+
+- [ ] **Step 6: Run the full gate**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/`
+Expected: all clean, no files listed by gofmt. Grep first for tests asserting the old columns: `grep -rn '"Hosts"\|Instances</th>\|Statuses' internal/console/*_test.go` — update any hits.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/console/server.go internal/console/ladder.go internal/console/templates/apps.html internal/console/templates/layout.html internal/console/server_test.go
+git commit -m "console: apps list answers app/where/health/version, drops provisioning jargon"
+```
+
+---
+
+### Task 3: Overview — headline verdict, four tiles, plain tile copy (app_overview.html, overview.go)
+
+**Files:**
+- Modify: `internal/console/overview.go` (add `PrimaryHost`)
+- Modify: `internal/console/templates/app_overview.html`
+- Test: `internal/console/overview_test.go` (extend `TestOverviewPageRendersTiles` at line 274)
+
+**Interfaces:**
+- Consumes: Task 1's `Verdict`/`VerdictLabel`/`InstanceExpected`, Task 2's `apexHost` and `chip-health-*` CSS.
+- Produces: `OverviewStats.PrimaryHost string`. Final Overview layout other tasks link to.
+
+- [ ] **Step 1: Failing test**
+
+In `overview_test.go`, extend the assemble tests: construct a view with routes and assert `st.PrimaryHost` equals the apex host (same fixture shape as Task 2's test). Extend `TestOverviewPageRendersTiles` to assert the rendered page contains the fixture's verdict label and `Machines`, and does NOT contain `stable pointer unreadable` or `Supply-chain bake`.
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/console/ -run 'TestOverview' -count=1` — FAIL.
+
+- [ ] **Step 3: Implement `PrimaryHost`**
+
+In `OverviewStats` add `PrimaryHost string` (comment: "the apex host, for the headline's 'live at' link; empty when no routes"). In `assembleOverview`, before `return st`:
+
+```go
+ var mine []RouteView
+ for _, rt := range view.Routes {
+ if rt.Account == acct {
+ mine = append(mine, rt)
+ }
+ }
+ st.PrimaryHost = apexHost(mine)
+```
+
+- [ ] **Step 4: Rewrite the Overview template**
+
+Replace app_overview.html:16-68 (the whole `{{with .Overview}}` section and the bake block) with:
+
+```html
+{{with .Overview}}
+<section class="pagehead" aria-label="App status">
+ <p><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></p>
+ {{if .HasStable}}
+ <p>{{$.AppName}} is live{{if .PrimaryHost}} at <a href="https://{{.PrimaryHost}}">{{.PrimaryHost}}</a>{{end}} —
+ running <code>{{.LiveVersion}}</code>, published {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}.</p>
+ {{else if .LiveErr}}
+ <p>We can't tell what's live right now — the record of what's live didn't read. It usually recovers on its own; check again shortly.</p>
+ {{else}}
+ <p>Nothing is live yet. Publish a first version from your terminal — the command is on the
+ <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p>
+ {{end}}
+</section>
+<section class="tiles stat-tiles" aria-label="Vital statistics">
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
+ <h2>Machines</h2>
+ <p class="tile-value">{{.InstanceTotal}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
+ <h2>Resources</h2>
+ <p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p>
+ <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the machines responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
+ <h2>Web addresses</h2>
+ <p class="tile-value">{{.DomainTotal}}</p>
+ <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}all working{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
+ <h2>Versions</h2>
+ <p class="tile-value">{{.ReleaseCount}}{{if .ReleasesCapped}}+{{end}}</p>
+ <p class="tile-detail">{{if .LatestVersion}}latest <code>{{.LatestVersion}}</code>{{else}}nothing shipped yet{{end}}</p>
+ </a>
+</section>
+{{end}}
+{{with .Detail.Production}}
+{{if .Err}}
+<p class="small error">Safety delay: this app's setting can't be read right now, so the delay stays on. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p>
+{{else if .On}}
+<p class="small">Safety delay: <strong>on</strong> — new versions wait before going live. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.</p>
+{{else if .TurningOff}}
+<p class="small">Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.</p>
+{{else}}
+<p class="small">Safety delay: off — new versions go live as soon as they're promoted.{{if .Nudge}} {{icon "triangle-alert"}} This app looks like it's serving real users — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.{{end}}</p>
+{{end}}
+{{end}}
+```
+
+And change the footer line (app_overview.html:78):
+
+```html
+{{if not .CanPromote}}<p class="small">Deploys are temporarily unavailable on this console.</p>{{end}}
+```
+
+Note the section reuses the `pagehead` class (layout.html:262-270). Verify the chip renders sanely inside it; if `pagehead p` styling fights the chip, wrap the chip line in `<p class="eyebrow">` instead and note it in the commit.
+
+- [ ] **Step 5: Gate and commit**
+
+Run the full gate (Global Constraints). Grep for tests asserting the old tile copy: `grep -rn 'Live now\|on stable ·\|readings unavailable\|reporting' internal/console/*_test.go` — update hits (especially `TestOverviewPageRendersTiles`).
+
+```bash
+git add internal/console/overview.go internal/console/overview_test.go internal/console/templates/app_overview.html
+git commit -m "console: overview leads with URL + health verdict, four plain tiles"
+```
+
+---
+
+### Task 4: Safety-delay rename — every bake string (templates, server.go, confirm.go)
+
+**Files:**
+- Modify: `internal/console/templates/ladder.html:5-53` (bake block), `internal/console/templates/app_releases.html:21,30,44-47,77,88-94`, `internal/console/templates/app_settings.html:91-92`, `internal/console/templates/trash.html:35-39`
+- Modify: `internal/console/server.go` flash/audit strings (lines 1900, 1906, 1908-1909, 1920, 2062, 2070-2074, 2132, 2158, 2162)
+- Modify: `internal/console/confirm.go:137,142,181-183,188,192-196,201`
+- Modify: `internal/console/server.go` funcmap — add `until` helper
+- Test: `internal/console/bakecontrols_test.go`, `internal/console/confirm_test.go` (update assertions), plus a new funcmap test
+
+**Interfaces:**
+- Consumes: existing `humanAge` (view.go:681-693).
+- Produces: funcmap helper `until` (Go func `untilText(t time.Time) string` → `"~4h"`-style horizon via `humanAge(time.Until(t))`, `"now"` for past/zero) used by Task 6's summary strip too.
+
+- [ ] **Step 1: Inventory + failing test**
+
+Run `grep -rn -i 'bake\|fast.track\|production-wired\|Re-declare' internal/console/ --include='*.go' --include='*.html' | grep -v _test.go` and keep the list — every hit must be visited. Write the funcmap test first:
+
+```go
+func TestUntilHelper(t *testing.T) {
+ if got := untilText(time.Now().Add(4*time.Hour + time.Minute)); got != "~4h1m" && got != "~4h" {
+ t.Errorf("until ~4h = %q", got)
+ }
+ if got := untilText(time.Time{}); got != "now" {
+ t.Errorf("until zero = %q", got)
+ }
+ if got := untilText(time.Now().Add(-time.Hour)); got != "now" {
+ t.Errorf("until past = %q", got)
+ }
+}
+```
+
+(Check `humanAge`'s exact formatting at view.go:681-693 first and pin the assertion to what it actually emits for ~4h — the test must not encode a guess.)
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/console/ -run TestUntilHelper -count=1` FAILS.
+
+- [ ] **Step 3: Implement `untilText` + register `until`**
+
+Following the funcmap pattern at server.go:61-123:
+
+```go
+// untilText renders a future horizon the way the cards speak: "~4h", never
+// a timestamp. Past or zero is "now" — the wait is over, not unknown.
+func untilText(t time.Time) string {
+ if t.IsZero() || !t.After(time.Now()) {
+ return "now"
+ }
+ return "~" + humanAge(time.Until(t))
+}
+```
+
+Register as `"until": untilText` in the template FuncMap.
+
+- [ ] **Step 4: The template strings**
+
+ladder.html bake block (lines 5-53) — replace the four states' copy (forms/CSRF fields unchanged, only words; the one `title=` tooltip becomes visible text):
+
+- Err: `Safety delay: this app's setting can't be read right now, so the delay stays on.` — button text `Re-declare production` → `Set it again`; trailing clause `— writes a fresh signed flag over the unreadable one.` stays.
+- On: `Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before machines pick them up.` Replace line 26's tooltip-span entirely with visible text after the Turn off button: `<span class="small">Turning it off asks for your passkey and takes effect after ~72h.</span>` (no `title=` attribute).
+- TurningOff: `Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.`
+- Off: `Safety delay: off — new versions go live as soon as they're promoted.`
+- Nudge (line 51): `{{icon "triangle-alert"}} This app looks like it's serving real users but new versions go live immediately — consider turning the safety delay on.`
+
+ladder.html rung chips and control (lines 67-75):
+- Hotfix chip: `{{icon "zap"}} published directly — skipped the pipeline`
+- FastTrack chip: `{{icon "fast-forward"}} delay skipped`
+- Fast-track button: `<button type="submit" class="btn-sm btn-danger">Skip the delay for {{.Version}}</button>`
+- The `baking until` span: `<span class="small">goes live {{until .BakedUntil}}</span>`
+
+app_releases.html:
+- Line 21 header `<th>Bake</th>` → `<th>Goes live</th>`
+- Line 30 cell → `{{if .BakedUntil.IsZero}}live now{{else}}in {{until .BakedUntil}}{{end}}{{if .FastTrack}} · delay skipped{{end}}`
+- Lines 44-46 footnote → `<p class="small">Go-live times are estimates — each machine keeps its own clock. Rollbacks skip the delay.</p>`
+- Line 77 canary empty state → `No test versions — a test version is a private copy serving one chosen build immediately, with no safety delay.`
+- Line 88 → `Creates <code>canary/&lt;name&gt;</code> — a test version serving the chosen build immediately, no delay. It gets its own URL from a machine that follows it:`
+- Lines 91/93 → `This app has no machines yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.` / `The address comes live within a couple of minutes.`
+
+app_settings.html:91-92 → `It is marked as production right now, which blocks deletion — turn the safety delay off on the Versions tab first.`
+
+trash.html:35-39 → adjust the "flagged production" sentence to: `An app still marked as production can't be permanently deleted, whatever you type — turn its safety delay off first.` (Keep the surrounding warning structure.)
+
+- [ ] **Step 5: The Go strings**
+
+server.go (exact replacements at the listed lines):
+- 1900: `" The production flag could not be read, so the safety delay was not enabled — use the toggle on the app's Versions tab."`
+- 1906: `" Turning the safety delay on failed — use the toggle on the app's Versions tab."`
+- 1909: `" Safety delay enabled."`
+- 1920: `" It goes live on each machine after the safety delay — rollbacks skip it."`
+- 2062: `"Could not change the safety delay: "+err.Error()`
+- 2071: `"Safety delay enabled — new versions now wait on beta and stable before going live."`
+- 2074: `"Safety delay turning off — takes effect on each machine after ~72h."`
+- 2132: `"Channel "+channel+" has no safety delay running — there is nothing to skip."`
+- 2162: `"Skipped the delay for "+ver+" on "+channel+" — machines pick it up within a couple of minutes."`
+- Audit details 1908/2070/2073/2158: keep the action keys (`"production"`, `"fast-track"`) unchanged; details become `"enabled the safety delay (production: on, at stable promote)"`, `"enabled the safety delay (production: on)"`, `"disabled the safety delay (production: off)"`, `"skipped the safety delay for "+ver+" on "+channel`.
+
+confirm.go:
+- 137: `cv.Lines = append(cv.Lines, "This app has the safety delay on: each machine waits "+holdText(hold)+" on "+channel+" before running "+version+".")`
+- 142: `cv.CheckboxLabel = "This app is live for real users — keep new versions on the safety delay (recommended)"`
+- 181 Title: `"Skip the safety delay on " + channel + "?"`
+- 182 Warn: `"The safety delay is the window where a new build proves itself — skipping it puts an unproven build in front of users."`
+- 183 Note: `"You'll confirm it's really you with your passkey. The version keeps a permanent 'delay skipped' badge."`
+- 188 Button: `"Skip the delay"`
+- 192 Title: `"Skip the safety delay for " + cur.Version + " on " + channel + "?"`
+- 193-195 Lines: `channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.", "Skipping waives the rest of it: machines run "+cur.Version+" within a couple of minutes."`
+- 196 Button: `"Skip the delay for " + cur.Version`
+- 201: `"Waives the rest of "+channel+"'s safety delay: machines run its current version within a couple of minutes."`
+
+- [ ] **Step 6: Update asserting tests, run gate**
+
+`grep -rn -i 'bake\|fast.track' internal/console/*_test.go` — update every assertion to the new strings (bakecontrols_test.go and confirm_test.go will have several). Then the full gate. Re-run the Step 1 inventory grep: the only remaining `bake`/`fasttrack` hits must be identifiers (form field `enable_bake`, path `/fasttrack`, `BakedUntil`, `FastTrack` field refs) — wire/code names, correctly untouched.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A internal/console/
+git commit -m "console: rename the supply-chain bake to 'safety delay' in every user-facing string"
+```
+
+---
+
+### Task 5: Instances disambiguation — Running tab, honest columns (appnav.html, app_instances.html, overview.go)
+
+**Files:**
+- Modify: `internal/console/templates/appnav.html:9`
+- Modify: `internal/console/templates/app_instances.html`
+- Modify: `internal/console/overview.go` (InstanceRow gains `HealthWord`)
+- Modify: `internal/console/server.go` (pass attach-hint fields to the instances page if not already on its page struct — check `attachhint.go` and how app_releases gets `.AttachEnable`/`.AttachCmd`; reuse, do not duplicate)
+- Test: `internal/console/overview_test.go`, plus the page-render test `TestOverviewInstancesPageRendersTable` (overview_test.go:299)
+
+**Interfaces:**
+- Consumes: `InstanceRow` (overview.go:153-161), attach-hint builder in `attachhint.go`.
+- Produces: `InstanceRow.HealthWord string` — one of `Running | Asleep | Starting up | Setting up | Not responding | Failed | Serving` — via `healthWord(kind, status string, hibernate, reporting bool) string`.
+
+- [ ] **Step 1: Failing test**
+
+```go
+func TestInstanceRowHealthWord(t *testing.T) {
+ cases := []struct {
+ kind, status string
+ hibernate bool
+ reporting bool
+ want string
+ }{
+ {"instance", "ready", false, true, "Running"},
+ {"instance", "ready", true, false, "Asleep"},
+ {"instance", "ready", false, false, "Not responding"},
+ {"instance", "starting", false, false, "Setting up"},
+ {"instance", "pending", false, false, "Setting up"},
+ {"instance", "creating", false, false, "Setting up"},
+ {"instance", "issuing", false, false, "Setting up"},
+ {"instance", "failed", false, false, "Failed"},
+ {"static", "ready", false, false, "Serving"},
+ {"service", "ready", false, false, "Serving"},
+ }
+ for _, c := range cases {
+ got := healthWord(c.kind, c.status, c.hibernate, c.reporting)
+ if got != c.want {
+ t.Errorf("healthWord(%q,%q,hib=%v,rep=%v) = %q, want %q", c.kind, c.status, c.hibernate, c.reporting, got, c.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — FAIL, `healthWord` undefined.
+
+- [ ] **Step 3: Implement**
+
+In overview.go add to `InstanceRow`: `HealthWord string` (comment: "the Status enum + hibernate + reporting folded into one plain word — the column's whole job"). In `assembleInstanceRows`, set `HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting())` in the row literal. Add:
+
+```go
+// healthWord folds provisioning state, hibernation and liveness into the one
+// word the table shows. Provisioning words win while provisioning is real
+// (a cert mid-issue IS the situation); once the platform says ready, the
+// question becomes "is the process alive", which only instance-kind routes
+// can answer — static and service routes are served without a tenant
+// process, so ready means Serving, full stop.
+func healthWord(kind, status string, hibernate, reporting bool) string {
+ switch status {
+ case "pending", "creating", "starting", "issuing":
+ return "Setting up"
+ case "failed":
+ return "Failed"
+ }
+ if kind != "instance" {
+ return "Serving"
+ }
+ switch {
+ case reporting:
+ return "Running"
+ case hibernate:
+ return "Asleep"
+ default:
+ return "Not responding"
+ }
+}
+```
+
+- [ ] **Step 4: Templates**
+
+appnav.html:9 — label only: `>Running</a>` (href unchanged).
+
+app_instances.html — replace lines 6-33 with:
+
+```html
+<h2>Running</h2>
+{{if .InstanceRows}}
+<div class="tablewrap">
+<table class="instances">
+ <tr><th scope="col">Address</th><th scope="col">Health</th><th scope="col">Version</th><th scope="col">Channel</th><th scope="col">CPU</th><th scope="col">Memory</th></tr>
+ {{range .InstanceRows}}
+ <tr>
+ <td><a href="https://{{.Host}}">{{.Host}}</a></td>
+ <td>{{.HealthWord}}{{if eq .HealthWord "Asleep"}}<span class="small"> · wakes on the next request</span>{{end}}</td>
+ <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td>
+ <td><code>{{.Channel}}</code></td>
+ {{if .Metrics.Reporting}}
+ <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkCPU}}"/></svg>{{end}}</td>
+ <td><span class="cell-value">{{bytesOrDash .Metrics.MemBytes}}</span>{{if .Metrics.SparkMem}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkMem}}"/></svg>{{end}}</td>
+ {{else}}
+ <td colspan="2"><span class="small">no data</span></td>
+ {{end}}
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Version is what each machine reports it is running — a dash means
+it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn
+against that machine's own peak.</p>
+{{else}}
+<p>Nothing running yet. Publish a version, then give it a web address:</p>
+{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
+{{.AttachCmd}}</code></pre>{{else if .AttachCmd}}<pre><code>{{.AttachCmd}}</code></pre>{{end}}
+<p class="small">The address comes live within a couple of minutes.</p>
+{{end}}
+```
+
+(The `Kind` suffix on the host is dropped: `HealthWord` already folds what mattered. The old box-only `carlos add` command is gone; the attach hint is the same one Releases shows — check whether the instances handler fills `AttachEnable`/`AttachCmd`; if only the releases handler does, lift that fill into the shared app-page assembly so both tabs get it. If the hint genuinely can't be built there without new I/O, fall back to a literal: `<p class="small">Set one up from your terminal: <code>carlos instances create -app {{.Detail.App}} -host &lt;name&gt;</code></p>` — never `carlos add`.)
+
+And the second section (lines 35-52) becomes a disclosure:
+
+```html
+{{if and .Instances .Instances.Reporting .Instances.Instances}}
+<details>
+<summary>Reported by the app itself ({{len .Instances.Instances}})</summary>
+<p class="small">This app tracks its own internal workers and reports them to the platform.</p>
+<div class="tablewrap">
+<table>
+ <tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Region</th><th scope="col">Detail</th></tr>
+ {{range .Instances.Instances}}
+ <tr>
+ <td><code>{{.Slug}}</code></td>
+ <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td>
+ <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td>
+ <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+</details>
+{{end}}
+```
+
+- [ ] **Step 5: Gate + commit**
+
+Update `TestOverviewInstancesPageRendersTable` assertions (old headers → new). Full gate.
+
+```bash
+git add internal/console/overview.go internal/console/overview_test.go internal/console/templates/appnav.html internal/console/templates/app_instances.html internal/console/server.go
+git commit -m "console: Instances tab becomes Running with honest one-word health"
+```
+
+---
+
+### Task 6: Releases progressive reveal — summary strip, two disclosures (releasesummary.go, app_releases.html, ladder.html, ladder.go)
+
+**Files:**
+- Create: `internal/console/releasesummary.go`
+- Create: `internal/console/releasesummary_test.go`
+- Modify: `internal/console/templates/app_releases.html`, `internal/console/templates/ladder.html:2-4,91,112`, `internal/console/ladder.go:119-124,173`
+- Modify: `internal/console/server.go` (add `ReleaseSummary ReleaseSummary` to the `page` struct near `Detail`, fill it in the releases handler where `p.Detail` is set: `p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels)`)
+- Modify: `internal/console/templates/layout.html` (one `.reveal summary` rule)
+- Test: update `internal/console/ladder_test.go` gate-string assertions if any
+
+**Interfaces:**
+- Consumes: `ChannelView` (view.go — fields `Name, Version, PromotedAt, Actor, Err, FastTrack, BakedUntil` per ladder.go:156-159), Task 4's `until` funcmap helper.
+- Produces: `type ReleaseSummary struct { HasLive bool; LiveVersion string; LivePromotedAt time.Time; LiveActor string; LiveErr string; NextVersion string; NextBakedUntil time.Time }` and `func assembleReleaseSummary(channels []ChannelView) ReleaseSummary`.
+
+- [ ] **Step 1: Failing tests**
+
+`internal/console/releasesummary_test.go`:
+
+```go
+package console
+
+import (
+ "testing"
+ "time"
+)
+
+func TestReleaseSummary(t *testing.T) {
+ now := time.Now()
+ chans := []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-48 * time.Hour), Actor: "paul@keymail.dev"},
+ {Name: "beta", Version: "e91ac22", PromotedAt: now.Add(-20 * time.Hour), BakedUntil: now.Add(4 * time.Hour)},
+ {Name: "canary/rc-1", Version: "aaa1111"},
+ }
+ s := assembleReleaseSummary(chans)
+ if !s.HasLive || s.LiveVersion != "d388035" || s.LiveActor != "paul@keymail.dev" {
+ t.Errorf("live = %+v", s)
+ }
+ if s.NextVersion != "e91ac22" || !s.NextBakedUntil.Equal(now.Add(4*time.Hour)) {
+ t.Errorf("next = %+v", s)
+ }
+
+ same := assembleReleaseSummary([]ChannelView{
+ {Name: "stable", Version: "v1"}, {Name: "beta", Version: "v1"},
+ })
+ if same.NextVersion != "" {
+ t.Errorf("same version on beta must not be Next, got %q", same.NextVersion)
+ }
+
+ bad := assembleReleaseSummary([]ChannelView{{Name: "stable", Err: "boom"}, {Name: "beta", Version: "v2"}})
+ if bad.HasLive || bad.LiveErr == "" || bad.NextVersion != "" {
+ t.Errorf("unreadable stable = %+v", bad)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — FAIL.
+
+- [ ] **Step 3: Implement releasesummary.go**
+
+```go
+// The Versions tab's first-glance strip: what is live, and what is next in
+// line — the two facts a visitor came for, computed here so the template
+// renders them without touching the ladder. "Next" is beta's version when it
+// differs from stable's: the ladder's one real candidate step. Everything
+// deeper (rungs, gates, canaries) stays in the pipeline disclosure.
+package console
+
+import "time"
+
+type ReleaseSummary struct {
+ HasLive bool
+ LiveVersion string
+ LivePromotedAt time.Time
+ LiveActor string
+ LiveErr string
+ // NextVersion is beta's candidate when it differs from live; "" when
+ // there is nothing next. NextBakedUntil zero means it can go live now.
+ NextVersion string
+ NextBakedUntil time.Time
+}
+
+func assembleReleaseSummary(channels []ChannelView) ReleaseSummary {
+ var s ReleaseSummary
+ var beta ChannelView
+ for _, ch := range channels {
+ switch ch.Name {
+ case "stable":
+ if ch.Err != "" {
+ s.LiveErr = ch.Err
+ continue
+ }
+ s.HasLive = true
+ s.LiveVersion = ch.Version
+ s.LivePromotedAt = ch.PromotedAt
+ s.LiveActor = ch.Actor
+ case "beta":
+ beta = ch
+ }
+ }
+ if s.LiveErr == "" && beta.Err == "" && beta.Version != "" && beta.Version != s.LiveVersion {
+ s.NextVersion = beta.Version
+ s.NextBakedUntil = beta.BakedUntil
+ }
+ return s
+}
+```
+
+- [ ] **Step 4: Restructure app_releases.html**
+
+Replace line 15 (`{{template "ladder" .}}`) with the summary strip:
+
+```html
+{{with .ReleaseSummary}}
+<section class="pagehead" aria-label="Release status">
+ {{if .HasLive}}
+ <p>Live: <code>{{.LiveVersion}}</code> <span class="small">— promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</span></p>
+ {{else if .LiveErr}}
+ <p>We can't tell what's live right now — it usually recovers on its own; check again shortly.</p>
+ {{else}}
+ <p>Nothing is live yet.</p>
+ {{end}}
+ {{if .NextVersion}}
+ <p>Next up: <code>{{.NextVersion}}</code> on beta — {{if .NextBakedUntil.IsZero}}ready to go live now{{else}}goes live {{until .NextBakedUntil}}{{end}}.</p>
+ {{if and $.CanPromote $.IsOwner}}
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="channel" value="stable">
+ <input type="hidden" name="version" value="{{.NextVersion}}">
+ <input type="hidden" name="back" value="releases">
+ <button type="submit" class="btn">Make {{.NextVersion}} live</button>
+ </form>
+ {{else}}
+ <p class="small">Only an account owner can make it live.</p>
+ {{end}}
+ {{end}}
+</section>
+{{end}}
+```
+
+(Check the funcmap's `ago` output — if it emits `2d ago`, the strip's first line reads "promoted 2d ago by …", which is correct as written. The Make-live button posts the existing promote endpoint, which renders the existing confirm card + passkey step — no handler change.)
+
+Reorder the rest of the page: the Versions table (current "Releases" section, lines 97-135) moves UP to sit directly under the strip, wrapped:
+
+```html
+{{if .Versions}}
+<details class="reveal" open>
+<summary>All versions ({{len .Versions}})</summary>
+... existing Releases table, with two edits below ...
+</details>
+{{else}}
+... existing empty state (Nothing shipped yet + carlos ship pre-block), unchanged ...
+{{end}}
+```
+
+Two edits inside the table: delete the free-text input line
+
+```html
+ <input type="text" name="new_channel" placeholder="or a new channel" size="14">
+```
+
+and give the select an accessible name: `<select name="channel" aria-label="Channel to promote {{.Version}} to">`. (The server keeps accepting `new_channel` — no handler change; new-channel creation now happens via the canary form or CLI. Grep tests for `new_channel` posts through this page first.)
+
+Then the expert layer — the ladder plus the whole Channels, Canaries (+create form and attach hint), and Tags sections move inside one closed disclosure at the bottom:
+
+```html
+<details class="reveal">
+<summary>Release pipeline — channels, test versions, and the promotion ladder</summary>
+{{template "ladder" .}}
+... Channels section (existing, with Task 4's strings) ...
+... Canaries section (existing, with Task 4's strings) ...
+... Tags section (existing) ...
+</details>
+```
+
+In layout.html add one rule near the existing `details` styling (grep `summary` first; add if absent):
+
+```css
+ .reveal > summary { font-size: 1.1rem; font-weight: 600; cursor: pointer; }
+```
+
+- [ ] **Step 5: Ladder copy (ladder.html, ladder.go)**
+
+ladder.html header block (lines 2-4) becomes:
+
+```html
+<h2>The ladder</h2>
+<p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live).
+Promoting moves it one step up; the machines following that step pick it up.</p>
+```
+
+ladder.go `gates` map (lines 119-124) becomes:
+
+```go
+var gates = map[string]string{
+ release.RungCanary: "No sign-off needed — a test version can be pointed at anything you choose, including production.",
+ release.RungEdge: "Builds land here when they merge to main — nothing is promoted onto edge.",
+ release.RungBeta: "Any account member can promote here; it's recorded.",
+ release.RungStable: "An account owner promotes here with their passkey, after the safety delay.",
+}
+```
+
+ladder.go:173 → `Err: "nothing has been published here yet — these machines are waiting for a version"`.
+ladder.html:91 → `No machine follows this channel.`
+ladder.html:112 → `Only an account owner can promote to {{.Up.To}}.`
+(Rung names `canary/edge/beta/stable` stay verbatim inside the pipeline layer — it is the expert layer; the lede's parentheticals do the teaching.)
+
+- [ ] **Step 6: Gate + commit**
+
+Update `ladder_test.go` if it asserts gate strings (`grep -n 'canary is proven\|Sign-off\|pointer that does not exist' internal/console/ladder_test.go`). Full gate.
+
+```bash
+git add internal/console/releasesummary.go internal/console/releasesummary_test.go internal/console/server.go internal/console/templates/app_releases.html internal/console/templates/ladder.html internal/console/ladder.go internal/console/ladder_test.go internal/console/templates/layout.html
+git commit -m "console: releases page leads with live/next, ladder and tables move behind reveals"
+```
+
+---
+
+### Task 7: Whole-batch verification + review
+
+**Files:** none new.
+
+- [ ] **Step 1: Full gate, all packages** — `go build ./... && go vet ./... && go test ./... -count=1`, `GOOS=darwin go build ./...`, `gofmt -l` on every touched file. All clean.
+
+- [ ] **Step 2: Jargon regression grep** — `grep -rn -i 'supply-chain\|bake\|fast-track\|adopt\|pointer\|manifest' internal/console/templates/` must return only: identifiers/paths (`/fasttrack`, `enable_bake` form field, `.BakedUntil`, `.FastTrack` field refs) and operator-only pages (fleet.html, metrics.html — out of this batch's scope). Anything else is a miss — fix it.
+
+- [ ] **Step 3: Render smoke** — the page-render tests execute the real templates and are the closest thing to a screenshot in CI: `go test ./internal/console/ -run 'Render|Page' -v -count=1`.
+
+- [ ] **Step 4: Reviewer dispatch (per CLAUDE.md delegation policy)** — this batch touches production console paths: request an **Opus** review of the whole branch diff (superpowers:requesting-code-review), attaching the critique file as the spec. Fix findings, re-run the gate.
+
+- [ ] **Step 5: Push and open a draft PR; do NOT merge** — Paul's human review is the explicit next gate. Draft PR title: `console: clarity batch — health verdict, safety delay, progressive reveal (P0s+P1s)`. Body links `.impeccable/critique/2026-08-09T15-36-14Z__internal-console-templates.md` and names the two deliberate scope-outs: (1) apps-list verdict is registry-scope ("Deployed", not "All good") pending a per-app liveness aggregate — file that gap as an issue; (2) P2s/P3s (box-only command copy on Settings, raw err two-parting, footer disclaimer, full a11y sweep) are a follow-up batch.
+
+**Deploy note (not part of this plan's execution):** rolling this needs the standard console artifact rules — console = `cmd/console` build, NOT the root binary; both boxes arm64; promote → adopt → console restart (until #110's unit-stamped route is rolled everywhere). Deploy only on Paul's go.