Skip to content

docs(fleet): spec one app across every cluster - #60

Merged
dynaum merged 38 commits into
mainfrom
feature/fleet
Aug 22, 2026
Merged

docs(fleet): spec one app across every cluster#60
dynaum merged 38 commits into
mainfrom
feature/fleet

Conversation

@dynaum

@dynaum dynaum commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Design spec for the work the Show HN thread asked for, plus a defect the thread uncovered.

What the thread said

item 49139573, 25 points, 8 comments.

Two people asked the same thing independently. maxweisel runs one app across several clusters and wants to know whether all of them are on the latest version. I confirmed the same pain: clusters per team, plus uat, plus prod, and the recurring need to check one service everywhere. A third commenter read the README tagline and assumed it already shipped.

The defect

docs/04-multi-cluster.md:20 says one environment maps to one or more contexts and the promotion view "aggregates them with per-context detail on expansion." Line 334 repeats it for an environment with 40 contexts.

internal/promotion/promotion.go:144 builds its per-environment map with byEnv[in.Env] = in. Last write wins. A prod environment holding prod-us-east and prod-eu-west renders one cell showing whichever context arrived last and discards the other silently.

So today the promotion view answers "is the fix in prod" with a version it never verified. That is worse than the gap I described in my reply on the thread.

What this specs

  • A fleet screen: one app, one row per cluster, five distinct states so unreachable never reads as absent.
  • The promotion matrix refusing to collapse a disagreement. A multi-context cell collapses only when every context agrees on tag and digest.
  • Argo CD entering the comparison table in docs/01-problem.md, since it was the first objection raised.
  • The TUI request declined in writing against docs/05-architecture.md:20, which already rejects a terminal UI with reasoning.

--serve in-cluster mode stays under Beyond v1. The thread raised demand for it. Demand does not shrink the OIDC and Helm work.

Sequencing note

The Claude Design project has no fleet screen. CLAUDE.md forbids inventing UI, so the first issue authors screens/fleet.html there and human review of it gates the React work.

No code in this PR. Spec only, twelve issues scoped, ten under a new feature:fleet label.

dynaum added 30 commits August 2, 2026 21:19
…hat lies

The Show HN thread asked twice for one app across many clusters. The docs
already promised it at 04-multi-cluster.md:20 and :334, and promotion.go:144
builds its per-environment map with byEnv[in.Env] = in, so a prod environment
holding two contexts renders one cell and drops the other without saying so.

Specs a fleet screen, one row per cluster, and a split cell that refuses to
collapse a disagreement. Records the TUI decline and the missing Argo CD
comparison from the same thread. Leaves --serve under Beyond v1.
Backend first, since the matrix cannot render data nobody gathered: export
the shared comparators, teach the engine to split, stop the service dropping
contexts, then the fleet package, gatherer, and transport. The design screen
gates the React work, per the rule against inventing UI.

Three spec corrections found while reading the code. The drop starts at
service.go:1006, not promotion.go:144. No cluster UID exists, so dedupe keys
off the API server URL. Screen numbering is taken through 6, so fleet is
Screen 7.
internal/fleet must order tags the same way the promotion matrix does. Two
comparators disagreeing about which version is newer would be the worst
defect the fleet view can carry, so it reuses these rather than growing a
second implementation. No behavior change.

Closes #61
…reement

byEnv[in.Env] = in kept the last context and dropped the rest, so a prod
environment holding three clusters rendered one confident version nobody
verified. The cell now collapses only when every cluster agrees on tag and
digest, and renders a split naming the disagreement otherwise. A split never
becomes upstream for the next column, for the same reason an unreadable
column never did.

Closes #62
…and coverage math corrected

Four review findings on 9a5394b:

- row() counted absent and denied cells as drift, since resolve() routes
  them through the same "State != StateSame" check the old code used to
  continue past. Only cells actually evaluated should move drift; a split
  cell is a real disagreement and still counts.
- resolve()'s collapse guard required len(present) == readable, which a
  namespace collapse (e.g. "shop" and "shop-prod" stripping to one identity
  in one environment) can never satisfy once Contexts is empty and clusters
  defaults to 1. That rendered a severe split citing a bogus cluster count.
  Guard on len(present) > clusters instead, and decide those cells on
  consensus alone without citing the count.
- split()'s priority checked partial coverage before a same-tag/different-
  digest mismatch, hiding the worse fact (a mutable tag) behind the lesser
  one (a cluster not caught up yet). Digest mismatch now outranks coverage.
- Cell.Namespace was dropped from absent/denied cells; restored where the
  instance is known. Removed the now-dead orElse helper.
Three defects the plan taught. Two test cases supplied 2 of prod's 3
contexts, so the partial-coverage branch preempted the behavior each test
named. row() counted absent and denied cells as drift, which broke the
shipped single-context matrix the task was told to protect. And split()
reported partial coverage ahead of a mutable tag, hiding the worse fact.

Also ignores .superpowers/, the per-plan execution scratch.
… count

An unreadable context could still be collapsed into agreement when the
environment also had more present instances than declared clusters,
because that branch returned before the Unreadable guard ran. The
unreadable rule wins in that case too: clusters nobody read might
disagree.

Also fixes two smaller defects in the same code: the digest-mismatch
note cited the declared cluster count instead of the count that
actually reported the tag, and the group namespace still dropped when
the first instance in the group had an empty one.
service.go skipped a second context bound to the same environment before it
was ever connected, on the assumption two contexts in one environment are
identical. A team running prod in three regions disproves that, and the
matrix rendered one region's version as prod's. Every context is now read,
and the ones that do not answer are named on the environment.

Closes #63
Task 3 turned up that clusters.Fetch never errors on an RBAC refusal. Every
list reads err == nil and records what it could not read in Snapshot.Partial.
The plan mapped 'app not in snapshot' straight to absent, so a developer
without list permission on Deployments would have seen 'not deployed here'
for a cluster nobody was allowed to look at. That is the exact conflation
the five states exist to prevent, and service.go:196 already draws the line
correctly for the apps list.
The spec said an all-denied environment renders StateDenied 'identical to
today'. It does not. Service.Promotion builds denied placeholders with an
empty app name, the filter strips them before the engine sees them, and
resolve() returns StateAbsent with 'not deployed here' for an environment
nobody could read.

Tracked as #75 and treated as a ship blocker for the feature, since
populating Env.Unreadable and then ignoring it is worse than never having
gathered it.
PromotionView.Unreachable was appending an environment's name whenever
any one of its bound contexts failed, even when its other contexts
read fine. That renders a split cell full of real version data next
to a banner claiming the same column is unknown. Unreachable now
names an environment only when every context bound to it failed,
matching its doc comment and what the split cell/Env.Unreadable
already communicate for a partial failure.

Also covers dedupeStrings directly (previously never exercised),
records that an environment's risk comes from its first-seen context,
and corrects the fakeCluster.denied doc comment: it takes the same
Connect-failure branch as unreachable today, so the distinction is
cosmetic until a fixture returns apierrors.NewForbidden from a list
call instead.
resolve()'s empty-present path checked only Denied:true instances, which
service.go strips before Build ever sees them (they carry no App name). An
environment nobody could read fell through to StateAbsent, "not deployed
here", the exact conflation the package doc warns against.

resolve() now consults Env.Unreadable when no instances are present: a fully
unread environment renders StateDenied naming how many clusters did not
answer; a partially unread one renders StateDenied naming what was read and
what was not, since the readable clusters agreeing on absence still cannot
rule out a cluster nobody read. StateSplit was considered and rejected for
the partial case: nothing was observed to disagree, so claiming a split would
invent a conflict, and it would wrongly count as drift. Only an environment
where every context answered may still say "not deployed here".

Spec corrected alongside the fix.

Closes #75
Round-1 review on task-3b (#75): add a test asserting Row.Drift and
Summary.Split for the unread case the commit's reasoning rests on;
strengthen the "not absent" test to pin StateDenied exactly so a future
StateSplit refactor can't slip through green. Widen resolve()'s clusters
so a comparison and its printed message never disagree ("2 of 1 clusters
did not answer" when Unreadable outgrows Contexts). Shorten the partial-
read note to match every other terse split() note.
One app, one row per cluster. Newest tag across the clusters that answered,
and every cluster behind it. Five states stay distinct so a cluster behind a
VPN is never read as a cluster without the app, and two contexts aimed at one
cluster merge so the headline count stays honest.

Closes #64
CompareTags returns zero for a pair it refuses to order, and zero is not
greater than zero, so an incumbent newest that nothing can be ordered
against was never displaced. Placements [sha-x, v1.0.0, v2.0.0] answered
"newest sha-x, nothing behind" while one cluster sat two minors back, and
the same three in another order answered correctly. The headline moved
with the caller's slice order.

newest is now taken over orderable tags only, via a new
promotion.Orderable that reuses the exact predicate CompareTags applies,
so the two cannot drift. A build id is labelled in its own row and never
counted behind.

A present cluster with no tag is its own fact. A digest-pinned image is
ordinary GitOps and TagOf returns empty for it; the empty tag then matched
the empty newest and every row rendered as up to date, or shipped the
literal note "runs , which cannot be ordered against v2.14.0". It now says
the version is unknown and names the digest pin as the cause, and a tag
that does not exist is never called mutable.

Digests are grouped per tag rather than only under the newest one, so an
older tag resolving to two digests is the defect it is. An incoming
DigestPending is believed instead of overwritten, and View.DigestUnverified
counts the present rows whose digest never arrived, so a headline
reporting agreement can say how much of it rests on unread digests.

A cluster nobody reached now sorts above a cluster whose older version you
can see, matching the promotion matrix: neither agreement nor a defect can
be claimed from clusters we never saw. An unrecognized state reads as
pending and says so rather than rendering blank among the healthy rows.
Merging builds a fresh alias slice instead of appending through the
caller's backing array, and mergeByCluster now records the upstream
invariant that ClusterID must come from the kubeconfig, not from a
successful connection.

Tests: newest independent of placement order, no newest when nothing is
orderable, both empty-tag paths, a mutable tag on a non-newest tag,
DigestPending respected and counted, alias contents across three merge
shapes, every note string including reason passthrough and fallback,
unreadable sorting above behind, and the unrecognized state.
Walks every kubeconfig context, connects lazily, and matches by the same
identity the promotion matrix uses. A cluster that will not answer becomes an
unreachable row naming the reason, never an omitted one, because a missing
row reads as "not deployed there". Deduping keys off the API server URL,
which config already normalizes, read before any connection attempt so an
unreachable duplicate context still merges with its reachable twin.

Replaces the cosmetic denied flag on the shared test fixture, which took the
identical branch as unreachable, with refusedKinds: a fake clientset that
connects but refuses specific List calls, the only way to exercise a genuine
RBAC-denied row rather than an absent one.

Closes #65
Every cluster that simply did not run the app reported "denied". The
snapshot was taken at the background tier, which appends "Pod" to
Snapshot.Partial unconditionally as a deliberate skip, so the
len(Partial) > 0 test was constant-true and StateAbsent was dead code.
Two changes, either of which would have hidden the other's absence:

- Fetch at clusters.TierActive, the way cmd/kubeside's gather already
  does for this kind of sweep. That drops the phantom Pod entry and
  brings the digest, which lives only in pod status, so a tag resolving
  to two digests is findable at all.
- Test denial only on kinds that could carry the app. Partial unions a
  refusal with a skip and only the first implies denial; a refused Pod
  or ReplicaSet list hides health, never existence.

The Scope half of that test could never fire and was inverted besides.
A namespace-scoped fallback means the cluster refused to enumerate its
namespaces and only one was read, so an app living elsewhere was never
looked for. scopeCovers asks whether the namespace we did read would
have carried the identity we asked for, using the rule findApp matches
with.

A denied row now names a verb, a resource and a scope, which is the
shape of a Role rule, and never names a kind nobody refused.

The sweep gives every context its own goroutine and its own copy of the
timeout, because the flag means per-cluster connect and fetch. One
budget spent serially let a few slow clusters report every cluster
behind them as unreachable, inventing the fact this screen exists to
keep honest. Rows are written by index, so the order is the
kubeconfig's and not the scheduler's.

findApp no longer drops a second match in silence: it prefers the
namespace asked for, falls back to name order, and says what it skipped.
fleet.Build surfaces a present row's Reason so that choice is visible.
Step 4 tested len(snap.Partial) > 0, which is constant true: fetch.go appends
Pod to Partial at any non-active tier as a deliberate skip, not a refusal, and
Fleet reads every context at background tier. So absent was dead code and
every app not deployed somewhere reported as denied.

Records that, the unreachable Scope clause, and the shared serial timeout,
with a pointer to what shipped instead.
GET /api/fleet?app=&namespace=. A request without an app is a 400
rather than an empty screen, because the view is about one app by
construction and an empty fleet screen would read as "this app runs
nowhere" — the conflation this feature exists to prevent. Namespace
is optional: Service.Fleet already accepts "" and matches whichever
namespace each cluster holds the app in, so the transport does not
add a requirement the service does not have.

Widens the API interface with Fleet(app, namespace string) fleet.View
and updates both test fakes (stubAPI, streamStub) that implement it.

Closes #66
Ported from screens/fleet.html in the design project. Five row states render
distinctly, an app found nowhere names the clusters it asked rather than
showing an empty window, and a tag resolving to two digests raises a banner
above everything else on the page.

Closes #68
environments.Classify matches by keyword token and returns the name it was
handed, so prod-us-east, production, live, staging-eks, uat and sandbox-3 are
all classified while spelling no tier a screen could compare against. The fleet
screen tested the name against the literal set {qa, stg, prod}, painted
everything else violet, and printed the word "unclassified" over a cluster the
promotion matrix and the shell both render red. Environment colour is a safety
layer in docs/04-multi-cluster.md, so that was a false statement about a safety
property, not a missing colour.

Placement now carries EnvColor and EnvRisk beside Env, populated in the one
literal every state path returns, so an unreachable and a denied row keep their
colour too. The screen reads envToken off the row and prints the resolved name
verbatim, the way every other screen does. The rule stays in one language: a
tokenizer reimplemented in TypeScript would disagree with the binary the first
time someone edits tierRules.

Also on the screen: a row whose namespace differs from the one asked for now
says so, since identity matching strips the environment token and the
substitution was invisible; the mutable-tag banner derives its edge from the
rows actually in conflict rather than inheriting the shell's; the digest note
stops reporting zero as a problem; the severity card stops enumerating a ladder
it cannot keep in step with; and an empty kubeconfig gets a card naming the
cause instead of a header-only table.
An environment whose clusters disagree shows the disagreement and offers the
screen that explains it, rather than a version the matrix never verified.

Closes #69
The command appears only with an app in hand, because the view is about one
app and a palette entry that opens an empty one is a dead end.

Closes #70
…line

One cluster unreachable, one denied, and the screen still fills, with the
two rendering differently: conflating them is the failure the five states
exist to prevent.

FleetScreen.tsx carried logic vitest could never reach, since the include
pattern skips .tsx and there is no jsdom in this package. The not-present
label map, the ready-ratio split, and the verdict badge precedence now live
in fleet.ts beside it, unit tested, with rendered output unchanged.

A Playwright spec exercises every reachable row state in one view and
carries the fix for the layout bug that state made visible: the Env column
was sized for prod/stg/qa, and a resolved name like prod-us-east overflowed
it and pushed Verdict out of place.

Closes #71
04-multi-cluster promised per-context expansion inside the promotion view;
what shipped is a split cell plus a dedicated screen. It also claimed dedupe
by API server URL and cluster UID; only the URL ships, because no cluster UID
exists here and reading one needs a permission a namespace-scoped developer
would be refused.

Closes #72
It was the first objection raised on Show HN and the comparison never
mentioned it. Sync state against git is a delivery question; the app across
its environments is a different one.

Closes #73
Asked on Show HN. 05-architecture already rejected a terminal UI and the
reasoning was not reachable from the docs a user reads.

Closes #74
05-architecture said contextID derives from a cluster UID and that a rename
preserves stored history. Neither is true. Nothing in internal/ reads a
cluster UID, and kubeside stores no history to preserve, which is the whole
bet recorded in 04-multi-cluster.

#72 corrected the same claim in 04-multi-cluster and this was the one
remaining spot asserting a mechanism that exists nowhere in the code.
The product spec said seven views ship and then listed an eighth, while exec
was counted nowhere. Nine ship.

It also said fleet reuses promotion's five states. Promotion has eight, and
none of them are present, unreachable, or pending. Only absent and denied are
spelled the same, and they differ: promotion has no unreachable, so a cluster
that never answered reads denied there and unreachable here. What fleet reuses
is CompareTags, Identity, and Orderable.

The guide claimed no other screen wakes every context. Promotion does too.
Fleet's real difference is that it connects concurrently and reads pod status
everywhere, which is where the digest lives.
dynaum added 8 commits August 16, 2026 22:55
byEnv split() treated every unreadable-adjacent cell the same way: severe,
counted as drift, counted in Summary.Split. But when the clusters that
actually answered fully agree with each other, there is nothing to flag
about them, only an unrelated cluster that never spoke. That is the same
epistemic state issue #75 already routes to StateDenied when the app is
absent everywhere readable. Deciding by whether the app happened to be
found, rather than by what was actually observed, was the bug.

resolve() now intercepts full agreement plus an unread cluster before
split() is ever called, and renders it StateDenied: not severe, excluded
from drift, excluded from Summary.Split, and still never upstream, since
the silent cluster might disagree with the ones that spoke.

split() itself no longer lets an unread cluster override a witnessed
disagreement. It used to check Unreadable first and report only "N of M
readable", silently dropping a real mutable-tag or tag-mismatch finding
whenever any cluster also happened to be unread. Now the disagreement drives
the note, with the unread count appended rather than substituted.

Closes #76
TestPromotionMarksAnUnreachableContextOnItsEnvironment fed Service.Promotion
a single present cluster plus one unreachable context in the same
environment. Under #76, a lone cluster with nothing to disagree with is no
longer treated as a disagreement: it now renders StateDenied, matching every
other "readable clusters agree, one didn't answer" case in the matrix.

The old assertion (StateSplit) encoded the behavior #76 exists to fix.
Connect released mu before dialing, so two concurrent callers for the same
context could both see a non-live state and both dial. The second result
silently overwrote session set by the first. Harmless today (Session.Close
is a no-op), but the outcome depended on which dial finished last, and it
wasted a redundant connection attempt every time it happened.

dialMu now wraps the whole check-dial-update sequence per conn. A second
caller blocks until the first finishes, then re-checks state under mu and
returns that outcome instead of starting its own dial. Different contexts
have independent conns and independent dialMu locks, so this changes
nothing about one cluster never blocking another.

Confirmed the race directly: reverting the fix and running the new test
under -count=10 failed 4 times with 2-12 duplicate dials for one context.
With the fix, 10/10 clean under -race.

Closes #78
Two related conflations in Service.Promotion, both landing on the same
principle #75 already established: an environment we could not verify must
never render "not deployed here".

#77: clusters.Fetch never errors on an RBAC refusal. A forbidden Deployment
list lands in Snapshot.Partial with a nil error, and Promotion discarded
Partial entirely, so a refused kind looked exactly like an app that does not
exist. Now a refused app-carrying kind marks the context unreadable,
alongside whatever the read did find, so resolve() can tell "not deployed
here" apart from "might be here, we were refused" the same way it already
does for a context that never answered. Deliberately coarser than
Service.Fleet's per-app precision: Promotion compares every app in bulk, and
flagging every namespace-scoped RBAC context (the common case) would bury
the signal.

#82: Connect's error was captured but never acted on directly — the code
proceeded to ClientFor regardless, which returns any existing session
whenever one exists, live or not. Connect's error now short-circuits before
ClientFor is ever consulted, matching the pattern Service.Fleet already
used. Tracing the state machine: the precondition this guards against (state
not Live, session still set) was primarily reachable through the double-dial
race #78 just closed, where a losing dial's failure could overwrite a
winning dial's Live state without touching its session. The check stays as
defense in depth regardless.

orElseString is now dead code with this rewrite and is removed.

Closes #77
Closes #82
…hecked

#80: fleetView.rows was fixture order (qa1, prod-us-east, prod-eu-west, stg1,
dr-frankfurt), not fleet.severity()'s order. The frontend does not sort —
Go does — so the committed baseline rendered whatever order the fixture
happened to list, directly under a note card titled "Sorted by what needs
attention". Reordered to match severity (unreachable, behind, denied, then
the two ties broken alphabetically) and pinned it with a row-order
assertion, not only a picture.

#81: promotionView had no cell with state "split", so PromotionScreen.tsx's
split branch, the only new promotion UI on this branch, was exercised by
nothing. It renders a full backend sentence into a version-sized column.
Added a split cell (api-gateway, a 3-context prod with a mutable tag) and
assertions that the note renders verbatim, in the error hue, and that
clicking it opens the fleet screen for that app.

Both screenshot baselines regenerated and verified with a clean second run.

Closes #80
Closes #81
…eachable

vite.config.ts's vitest include matched .test.ts only, never .test.tsx, and
there was no jsdom or testing-library anywhere in the package. So nothing a
.tsx file decided for itself was ever exercised by a unit test. health.test.ts
tested envToken directly, but never rendered FleetScreen, so the component's
own wiring of envToken shipped the prod-renders-violet bug undetected: the
first version of the fleet screen matched environment names against the
literal set {qa, stg, prod} instead of using the colour/risk the backend
already resolved, and no test in the tree could see it.

Adds jsdom, @testing-library/react, and @testing-library/jest-dom. A .test.tsx
file opts into jsdom per-file with a @vitest-environment pragma rather than a
global environmentMatchGlobs, which vitest 3.2 deprecated in favour of
test.projects — per-file is the right grain anyway, since most tests never
need a DOM.

FleetScreen.test.tsx renders the screen with an environment named
"production" and asserts it resolves to the prod token and prints its name
verbatim. This is the exact class of bug the harness exists to catch, proven
by rendering it, not by asserting the fix exists.

Documents three tiers of test in vite.config.ts: pure logic (.test.ts),
component decision logic (.test.tsx, jsdom), rendering fidelity (Playwright).
Prefer extracting logic out of a component into a plain function next to it,
as fleet.ts already does, so it stays in the fast tier.

npm audit found one high-severity transient advisory (nanoid) pulled in by
these new dev dependencies; npm audit fix resolved it to zero vulnerabilities
before this commit.

Closes #83
PR #60's visual CI check has been failing since the fleet screen shipped:
only a macOS baseline was ever committed for fleet.png, and CI runs on
Linux, so it had nothing to diff against and reported a failure with no
underlying regression. promotion.png's Linux baseline also needed
regenerating after the split-cell fixture (#81) and the row reorder (#80)
changed the pixels.

Generated inside mcr.microsoft.com/playwright:v1.61.0-noble, the exact image
ci.yml's visual job runs, matching Node 22 and the same npm ci install.
Verified stable with a second run, no --update-snapshots, 27/27 clean.
GoReleaser generates release notes from commit history at tag time, but
nothing tracked what a release actually contains between tags. This is
the same information in plain language, kept as the branch goes rather
than reconstructed from commit subjects later.

Unreleased section covers this branch: the fleet screen and the
promotion-matrix correctness fixes underneath it.
@dynaum
dynaum merged commit 7f61c68 into main Aug 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant