carlos / platform Public

console: fix Settings move-target gate, per-host strong banner, partial coverage

Review fixes on 1b2f9c5:

- CRITICAL: assembleSettings offered the account-move repair whenever
  RouteAccount was non-empty, but RouteAccount is now populated on ANY
  mismatch (partial or total). For a partial mismatch where the first
  non-platform route agrees but a later one doesn't, the card showed the
  full "not deploying" alarm and a Move button that handleAppMoveAccount
  refuses ("there is nothing to move") because routeAccountFor re-derives
  from the first route, which agrees. Gated on p.Detail.AllMismatched too:
  under AllMismatched every non-platform route disagrees, so the first
  route is provably the first mismatched one and routeAccountFor's return
  now always agrees with mismatchedByApp[app][0].Account. Corrected
  routeAccountFor's doc comment, which described Assemble's retired
  first-writer-wins rule.
- IMPORTANT: the AllMismatched banner claimed one account for every host
  ("reads releases from account X"), which is false when two foreign
  accounts each hold routes for the same app name. Both templates now list
  each affected host with its own account, matching the partial banner's
  form, and the lead sentence drops the single-account claim.
- Added TestAppPageShowsPartialMismatchNotFullAlarm (overview_test.go,
  same fixture pattern as TestAppPageWarnsWhenAccountsDisagree): moves
  only one of kass's two routes, asserts the partial copy renders (host +
  its account) and the full-alarm copy does not.
- Minor: Affected/won't-follow host lists are now comma-joined with a
  terminating period in both templates' both branches; the partial
  branch's lead sentence has correct subject-verb agreement for the
  singular case. Both templates stay byte-identical in this block.

Verified: go test ./internal/console/ -run 'TestMismatch|TestAppPage|TestSettings' -count=1
(15 tests, all PASS, including the new one); go test ./internal/console/
-count=1; go test ./... -count=1 (21 packages, all ok); go vet ./...
clean; gofmt -l clean on touched files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8ebc685f63c721b4749f7f6e44951761c5c9f0ce parent cd100e9
4 files changed, +61 −18
  • internal/console/appsettings.go +12 −4
  • internal/console/overview_test.go +35 −0
  • internal/console/templates/app_overview.html +7 −7
  • internal/console/templates/app_releases.html +7 −7
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 8b0fe44b4..4a6f67728 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -108,7 +108,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
sv.CanRename = true
}
sv.Store = s.storeCard(ctx, acct, p.AppName)
- if ra := p.Detail.RouteAccount; ra != "" {
+ if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched {
sv.MismatchTarget = ra
if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
sv.TargetKnown = true
@@ -644,9 +644,17 @@ func moveAdvice(err error, destName string) string {
return "Try again."
}
-// routeAccountFor reads what the ROUTES say the app's account is — the same
-// rule Assemble applies (first non-platform route's stamped account), read
-// fresh at action time so the move never trusts a stale page.
+// 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) {
reg, err := openRegistry(s.cfg.Registry)
if err != nil {
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index d58d08d09..d60cadbe7 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -551,3 +551,38 @@ func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) {
t.Error("assembleOverview must fill Verdict and VerdictLabel")
}
}
+
+// TestAppPageShowsPartialMismatchNotFullAlarm guards the boundary
+// AllMismatched exists to draw: when only SOME of an app's routes disagree
+// with the viewed account, the page must name the disagreeing route(s), not
+// claim the whole app is not deploying — the false alarm task 4 was written
+// to retire (2026-08-10 tito incident, see the MismatchedRoute doc comment).
+func TestAppPageShowsPartialMismatchNotFullAlarm(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ moved := testAccountID()
+ sqid := accountSqid(moved)
+ if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // Only ONE of kass's two fixture routes moves (kass.carlosapp.dev);
+ // docs.kass.carlosapp.dev stays on ops — a partial mismatch, not a full
+ // one.
+ if err := reg.SetAccount("kass.carlosapp.dev", sqid); err != nil {
+ t.Fatal(err)
+ }
+ reg.Close()
+
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ if strings.Contains(body, "This app is not deploying") {
+ t.Errorf("a partial mismatch must not render the full-alarm copy:\n%s", body)
+ }
+ if !strings.Contains(body, "kass.carlosapp.dev") || !strings.Contains(body, "(reads <code>"+sqid+"</code>)") {
+ t.Errorf("the partial-mismatch banner does not name the disagreeing route and its account:\n%s", body)
+ }
+}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 85764cdc1..3d53ef2c4 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -6,9 +6,9 @@
{{if .Detail.AllMismatched}}
<p class="banner error">
<strong>This app is not deploying.</strong> Every one of its addresses reads
- releases from account <code>{{.Detail.RouteAccount}}</code>, but this page
- writes them to <code>{{.Account.Sqid}}</code> — so a promote here succeeds
- and reaches nothing. Affected: {{range .Detail.Mismatched}}<code>{{.Host}}</code> {{end}}
+ releases from another account, but this page writes them to
+ <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
+ nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
Finish the move on this app's
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
<span class="small">If it is the routes that are in the wrong account, move
@@ -16,10 +16,10 @@
</p>
{{else}}
<p class="banner">
- {{len .Detail.Mismatched}} of this app's addresses read releases from another
- account and won't follow promotes made here:
- {{range .Detail.Mismatched}}<code>{{.Host}}</code> (reads <code>{{.Account}}</code>) {{end}}
- — the rest deploy normally. Fix on
+ {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
+ read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
+ follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
+ The rest deploy normally. Fix on
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
</p>
{{end}}
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index ae2be7564..ae7a6a299 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -6,9 +6,9 @@
{{if .Detail.AllMismatched}}
<p class="banner error">
<strong>This app is not deploying.</strong> Every one of its addresses reads
- releases from account <code>{{.Detail.RouteAccount}}</code>, but this page
- writes them to <code>{{.Account.Sqid}}</code> — so a promote here succeeds
- and reaches nothing. Affected: {{range .Detail.Mismatched}}<code>{{.Host}}</code> {{end}}
+ releases from another account, but this page writes them to
+ <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
+ nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
Finish the move on this app's
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
<span class="small">If it is the routes that are in the wrong account, move
@@ -16,10 +16,10 @@
</p>
{{else}}
<p class="banner">
- {{len .Detail.Mismatched}} of this app's addresses read releases from another
- account and won't follow promotes made here:
- {{range .Detail.Mismatched}}<code>{{.Host}}</code> (reads <code>{{.Account}}</code>) {{end}}
- — the rest deploy normally. Fix on
+ {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
+ read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
+ follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
+ The rest deploy normally. Fix on
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
</p>
{{end}}