review pass: partial-mismatch move refusal, Slice caveat, search-box styling
Three findings from the whole-branch review of dashboard-truth-and-scale. The account-move door is offered under AllMismatched but acted on under the first route's account alone: a direct POST (or a tab held open across a re-stamp) in a PARTIAL mismatch, where the mismatched route sorts first, rendered a card claiming the app's hosts already read from the target when only a minority did — and confirming moved the whole record to the minority account, turning a partial disagreement into a total one the other way. routeAccountFor now also reports whether the non-platform routes agree among themselves, and the handler refuses on disagreement before the card and before the write. AllMismatched itself is untouched. Runbook step 2 gains the Slice= caveat: a unit whose cgroup sits outside system.slice yields no reading, which is the "Not responding" false alarm this branch retires. The Running tab's filter box was a bare browser widget — the form-control rule never covered input[type=search] — and its form had no row styling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+107
−19
docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md+1 −0internal/console/appsettings.go+45 −18internal/console/appsettings_test.go+57 −0internal/console/templates/layout.html+4 −1
diff --git a/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md b/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md| index 027aeaf4c..7489137fd 100644 |
| --- a/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md |
| +++ b/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md |
| @@ -1315,6 +1315,7 @@ Order matters; each step verifies against the thing it changed with the binary i |
| 1. Merge the PR. Deploy **boxes first** (`update-platform-binary.sh` on flagship + `carlos-tito-ie`) — sampling stamped units is harmless while nothing is stamped. Then both consoles (`roll-console.sh`; console = the `cmd/console` build, NOT the root binary — both boxes are arm64). Coordinate on the current deploy lock discipline (another session was rolling PR #140; check open PRs and parallel-session deploys BEFORE deploying). |
| 2. Stamp tito's bespoke-unit routes, as user carlos on `carlos-tito-ie`, one per route (registry `-registry /data/titogo/registry.db` — CHECK which registry the tito console reads; the box has two: `/data/registry.db` platform, `/data/titogo/registry.db` titogo): |
| `carlos route -registry <path> -host vicky.go.tito.io -unit titogo@vicky.service` (canaries use `titogo-canary@<name>.service` — confirm each unit's real name with `systemctl list-units 'titogo*'` first; a wrong stamp yields no reading, not an error). |
| + Before stamping each unit, check where its cgroup lives: `systemctl show -p Slice <unit>` must report `Slice=system.slice` (or a `system-*.slice` child). A unit with a custom `Slice=` sits outside the root the sweep walks, so it yields no reading at all — which renders exactly the red "Not responding" false alarm this branch exists to retire. Leave such a unit unstamped (it reads "No readings") until its slice is moved. |
| 3. Verify: the titogo Overview on the tito console shows the stamped routes **Running with real CPU/memory** within ~2 sweep ticks; the Running tile reads `N of N reporting`; `curl -sI --resolve` untouched — this change is console-content only. |
| 4. Confirm the banner: after the roll it must name the actual `bab`-stamped rows (if any remain) instead of the app-wide alarm. Fix those rows (re-stamp/migrate) as a separate operational step. |
| 5. Investigate `titogo.bae.platform.tito.io` (404, no version header) — genuinely broken, pre-existing, not addressed by this branch. |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 4a6f67728..a77a78324 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -574,11 +574,23 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { |
| s.setFlash(w, kind, msg) |
| http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| } |
| - target, err := s.routeAccountFor(app) |
| + target, agreed, err := s.routeAccountFor(app) |
| if err != nil { |
| back("err", "Could not read the routes — nothing was moved. Try again.") |
| return |
| } |
| + // The action-time equivalent of the card's AllMismatched gate. The card is |
| + // only offered when EVERY non-platform route disagrees with this account, |
| + // but a direct POST — or a tab opened before the routes changed — reaches |
| + // here regardless, and target is only the FIRST route's account. Under a |
| + // partial mismatch that first route can be the minority: confirming would |
| + // then move the whole record to where a minority of hosts deploy from, |
| + // turning a partial disagreement into a total one the other way. Refuse |
| + // before the card is rendered and before anything is written. |
| + if !agreed { |
| + back("err", "This app's routes disagree among themselves — nothing was moved; fix the routes first.") |
| + return |
| + } |
| if target == "" || target == acct.Sqid { |
| back("err", "This app's routes agree with this account — there is nothing to move.") |
| return |
| @@ -644,30 +656,45 @@ func moveAdvice(err error, destName string) string { |
| return "Try again." |
| } |
| -// routeAccountFor reads what the ROUTES say the app's account is: the first |
| -// non-platform route's stamped account, read fresh at action time so the |
| -// move never trusts a stale page. The Settings card only offers the move |
| -// when AllMismatched (assembleSettings), and under AllMismatched every |
| -// non-platform route disagrees with the viewed account — so that first |
| -// route is provably also the first MISMATCHED one, and this returns the |
| -// same account Assemble's mismatchedByApp[app][0].Account does. Outside |
| -// that gate (no mismatch, or only a partial one) this can return an account |
| -// that agrees with the viewer, or one route's account when others differ — |
| -// callers must not use it to decide whether a move is offered, only to |
| -// re-derive the target once the card has already decided it is. |
| -func (s *Server) routeAccountFor(app string) (string, error) { |
| +// routeAccountFor reads what the ROUTES say the app's account is, and whether |
| +// they say it with one voice: the first non-platform route's stamped account, |
| +// plus agreed=false the moment any other non-platform route names a different |
| +// one. Both are read fresh at action time, so the move never trusts a stale |
| +// page. |
| +// |
| +// The Settings card only offers the move when AllMismatched (assembleSettings), |
| +// and under AllMismatched every non-platform route disagrees with the viewed |
| +// account — so the first route is provably also the first MISMATCHED one, and |
| +// the target here is the same account Assemble's mismatchedByApp[app][0].Account |
| +// is. But a target alone cannot police the door: outside that gate the first |
| +// route may be the ONLY one disagreeing, and moving to it would re-home the |
| +// record away from where most of the app's hosts actually deploy from. agreed |
| +// is what the handler refuses on — an app whose routes contradict each other |
| +// has no single account to move to, and the routes are the repair, not the |
| +// record. An app with no non-platform routes at all agrees vacuously (target |
| +// ""), which the handler's own nothing-to-move refusal then catches. |
| +func (s *Server) routeAccountFor(app string) (target string, agreed bool, err error) { |
| reg, err := openRegistry(s.cfg.Registry) |
| if err != nil { |
| - return "", err |
| + return "", false, err |
| } |
| routes, err := reg.List() |
| if err != nil { |
| - return "", err |
| + return "", false, err |
| } |
| + var seen bool |
| for _, rt := range routes { |
| - if rt.App == app && !rt.Platform { |
| - return release.RouteAccount(rt), nil |
| + if rt.App != app || rt.Platform { |
| + continue |
| + } |
| + acct := release.RouteAccount(rt) |
| + if !seen { |
| + target, seen = acct, true |
| + continue |
| + } |
| + if acct != target { |
| + return target, false, nil |
| } |
| } |
| - return "", nil |
| + return target, true, nil |
| } |
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go| index 94fb98ec3..38b10b441 100644 |
| --- a/internal/console/appsettings_test.go |
| +++ b/internal/console/appsettings_test.go |
| @@ -596,6 +596,63 @@ func TestAppMoveRefusesWhenRoutesAgree(t *testing.T) { |
| } |
| } |
| +// A PARTIAL mismatch is not a move. The Settings card is only offered when |
| +// every non-platform route disagrees (AllMismatched), but a direct POST — or a |
| +// tab left open across a re-stamp — reaches the handler regardless, and the |
| +// target it derives is only the FIRST route's account. Here that first route |
| +// (docs.* sorts before kass.*, and List orders by host) is the ONLY one |
| +// stamped elsewhere: acting on it would move the whole record to where a |
| +// minority of the app's hosts deploy from, converting a partial disagreement |
| +// into a total one the other way. The routes are the repair, so the handler |
| +// says so — before the card, and before anything is written. |
| +func TestAppMoveRefusesAPartialMismatch(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + const destID = 4444 |
| + // A destination that would otherwise pass every later check — known to the |
| + // console, and one paul belongs to — so the refusal can only be the |
| + // disagreement itself. |
| + if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(destID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // One of kass's two routes re-stamped, and it is the one that sorts first. |
| + fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready", |
| + Version: "v1", App: "kass", Account: "cdef", Hibernate: true}) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // Unconfirmed: no card, because a card here would claim the app's hosts |
| + // already read from Second when only one of them does. |
| + status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("partial-mismatch move = %d -> %q:\n%s", status, loc, body) |
| + } |
| + notACard(t, body) |
| + if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, |
| + "routes disagree among themselves") { |
| + t.Fatalf("no routes-disagree refusal flash:\n%s", page) |
| + } |
| + |
| + // Confirmed, straight at the door: same refusal, and nothing moved. |
| + status, loc, body = postGated(t, fx, c, fx.u("/apps/kass/move"), |
| + url.Values{"csrf": {csrf}, "confirmed": {"1"}, "to": {"cdef"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("confirmed partial-mismatch move = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, |
| + "routes disagree among themselves") { |
| + t.Fatalf("no routes-disagree refusal flash on the confirmed POST:\n%s", page) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| + t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err) |
| + } |
| + if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has { |
| + t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved to the minority account", has, err) |
| + } |
| +} |
| + |
| // ---- the Settings page ------------------------------------------------------ |
| // The rename form is offered while the name is still only a record here, and |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 9ac4b4589..ebf25f568 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -43,7 +43,7 @@ |
| should wrap inside the box rather than force the horizontal scrollbar |
| every other <pre> on this page gets for a shell command. */ |
| .changelog { white-space: pre-wrap; } |
| - select, input[type=text] { |
| + select, input[type=text], input[type=search] { |
| font: inherit; font-family: var(--sans); font-size: 0.9rem; color: var(--ink); background: var(--bg); |
| border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem; |
| } |
| @@ -52,6 +52,9 @@ |
| it must not break the line it is part of. */ |
| form.inline { display: inline; margin: 0; } |
| form.inline button { vertical-align: baseline; } |
| + /* A list's filter row (the Running tab): its control and its button on one |
| + line, wrapping onto the next when the screen is too narrow for both. */ |
| + form.listctl { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 0.6rem; } |
| /* Interface icons: inline SVG from internal/console/icons.go — Lucide, |
| vendored, because this page fetches nothing from anywhere. They carry no |
| width/height of their own, so they size to the text beside them and |