Overview
Cut #2a registered the two advisory loops (determinism-review, a11y-review) behind an AdvisoryTail spec. Nothing read it yet — the dashboard still hard-coded a determinism tab and an a11y tab as near-identical twins. This cut makes the registration load-bearing: the dashboard now derives its advisory tabs from the registry's emit-signal specs.
The two families were duplicated end to end — 6 model types, 6 read-model builders, 8 render functions, 4 execution types, all near-copies that differed only in a few nouns and the workflow-id namespace. They collapse into one generic family. Net change: about 225 fewer lines, across the dashboard's model, source, read-model, and renderer.
The intent is behaviour-preserving for the data — same numbers, same attribution. The deliberate UX changes: finding copy generalised to "findings"/"details", advisory tabs now order by registration (A11y before Determinism), and the global liveness count now includes the a11y loops it used to omit. Each is called out below.
One generic model family
AdvisoryLoop / AdvisoryRow / AdvisoryRecord replace the Review*/A11y* pairs. The row's per-loop detail (banned calls vs gap kinds) becomes one detail tuple; the record's total (hazards vs issues) becomes one findings count. AdvisoryView is the emit-signal mirror of the acting family's LoopView: one advisory loop's whole tab, its icon and title carried from the registry.
src/froot/dashboard/model.py · 508 lines
⋯ 325 lines hidden (lines 1–325)
⋯ 89 lines hidden (lines 420–508)
DashboardModel drops its eight flat review_*/a11y_* fields for one advisory tuple — a new advisory loop is one more view here, nothing else.
src/froot/dashboard/model.py · 508 lines
⋯ 497 lines hidden (lines 1–497)
⋯ 1 line hidden (lines 508–508)
One execution stream, tagged by loop
The four execution types become two — AdvisoryExecution (the per-repo heartbeat) and PrAdvisoryExecution (the per-PR review) — each tagged with the loop it belongs to, so the read-model can partition the one stream back per loop.
src/froot/dashboard/temporal_source.py · 366 lines
⋯ 71 lines hidden (lines 1–71)
⋯ 267 lines hidden (lines 100–366)
What genuinely differs per advisory loop — the bespoke workflow type names and the rule/kind result key — lives in one small source-wiring table. This is the seam the runtime hasn't unified; a new advisory loop adds one row, and the rest of the dashboard is generic.
src/froot/dashboard/temporal_source.py · 366 lines
⋯ 34 lines hidden (lines 1–34)
⋯ 316 lines hidden (lines 51–366)
fetch loops that table: list each loop's two workflow types, tag every execution, extract its findings' kinds with the loop's detail_key.
src/froot/dashboard/temporal_source.py · 366 lines
⋯ 311 lines hidden (lines 1–311)
⋯ 15 lines hidden (lines 352–366)
The read-model derives the views from the registry
The per-PR id join was the one place the two families' namespaces differed. A small dispatch maps each loop to its per-repo id namer; the per-PR prefix is then a uniform transform of that id — widen froot- to froot-pr-, append the dash before the PR/SHA tail.
src/froot/dashboard/read_model.py · 861 lines
⋯ 581 lines hidden (lines 1–581)
⋯ 239 lines hidden (lines 623–861)
The builder loops the registry's specs, keeps the ones whose tail is an AdvisoryTail (the emit-signal loops; the acting CommitTail loops are skipped), and builds each view with its icon and title read straight from the spec. Register an advisory spec and its tab appears — this is the consumer that makes cut #2a's registration real.
src/froot/dashboard/read_model.py · 861 lines
⋯ 708 lines hidden (lines 1–708)
⋯ 120 lines hidden (lines 742–861)
The renderer loops the views
One _advisory_panel replaces the two near-identical panels; its icon and title come off the view (so off the spec). The caption, finding noun, and detail column are generic now.
src/froot/dashboard/render.py · 902 lines
⋯ 689 lines hidden (lines 1–689)
⋯ 176 lines hidden (lines 727–902)
page() loops model.advisory to append one tab per view — no hard-coded determinism+a11y pair. A new advisory loop's tab appears here with no renderer edit.
src/froot/dashboard/render.py · 902 lines
⋯ 833 lines hidden (lines 1–833)
⋯ 57 lines hidden (lines 846–902)
One behaviour change worth flagging: the global header's live/total loops live aggregate now spans every advisory view's loops.
src/froot/dashboard/render.py · 902 lines
⋯ 356 lines hidden (lines 1–356)
⋯ 536 lines hidden (lines 367–902)
The per-loop config stays at the edge
Each advisory loop keeps its own poll-interval setting. server.py reads both and passes them as a loop -> seconds map; assemble takes the map and the view builder reads each loop's own cadence. The bespoke config read stays at the edge; the derivation stays generic.
src/froot/dashboard/server.py · 242 lines
⋯ 122 lines hidden (lines 1–122)
⋯ 109 lines hidden (lines 134–242)
src/froot/dashboard/read_model.py · 861 lines
⋯ 781 lines hidden (lines 1–781)
⋯ 75 lines hidden (lines 787–861)
The discriminating tests
The render test that only the new layer can pass: a tab's icon flows from its spec, so changing the spec's icon changes the rendered tab with no renderer edit (the advisory mirror of the acting family's spec-driven test).
tests/test_dashboard_render.py · 546 lines
⋯ 309 lines hidden (lines 1–309)
⋯ 210 lines hidden (lines 337–546)
And the read-model test that guards the one real hazard of sharing a code path: the two loops, partitioned by their loop tag, never cross-attribute — each view carries only its own rows and findings.