carlos / platform Public

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>
Paul Campbell 0e6e0e0fdc9663c9e91615a630b3e7b1614c12a4 parent dcd86ed
4 files changed, +107 −19
  • docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md +1 −0
  • internal/console/appsettings.go +45 −18
  • internal/console/appsettings_test.go +57 −0
  • internal/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