|
| 1 | +# Category Drift-Guards + Load-Path Consolidation — Design |
| 2 | + |
| 3 | +**Date:** 2026-06-22 |
| 4 | +**Status:** Approved (pending implementation) |
| 5 | +**Findings:** Code-quality review #3 (category behavior duplicated across dispatch planes) and #5 (overlapping old/new load-message paths). |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +Per-category behavior is encoded across ~19 dispatch planes in ~14 files (domain |
| 10 | +methods, the columns registry, a dozen TUI maps/switches, CLI `get`, MCP tools). |
| 11 | +Several planes default-fall-through **silently** when a category is missing — |
| 12 | +which is how the original GPUWorkload lazy-load bug shipped. Separately, the TUI |
| 13 | +has a `dataMsg` type-switch whose per-category cases duplicate the typed |
| 14 | +`*LoadedMsg` handlers, and that switch silently omits GPUWorkload. |
| 15 | + |
| 16 | +The chosen scope (explicitly **not** a structural rewrite) is to: |
| 17 | +1. Collapse the duplicated load-path so there is one per-category path (#5). |
| 18 | +2. Add drift-guards across the partial planes so a future category that misses a |
| 19 | + plane **fails a test** instead of drifting silently (#3, root-cause mitigation). |
| 20 | + |
| 21 | +## Non-goals |
| 22 | + |
| 23 | +- No single "category descriptor" struct driving all planes (the larger #3 |
| 24 | + rewrite). Deferred. |
| 25 | +- No shared CLI/MCP/TUI loader resolver (the medium #3 option). Deferred. |
| 26 | +- No behavior change to any user-facing command. The only production code change |
| 27 | + is removing dead branches in `handleDataMsg` (#5). |
| 28 | + |
| 29 | +## Section 1 — Collapse the dual load-path (#5) |
| 30 | + |
| 31 | +### Current state |
| 32 | + |
| 33 | +- `dataMsg{Data any, Gen int}` has two **live** production roles: |
| 34 | + - `dataMsg{Data: *models.Dataset}` — the foundational Init load, produced via |
| 35 | + `datasetLoadedMsg` → `handleDataMsg` (`model_update.go`). |
| 36 | + - `dataMsg{}` (nil Data) — a refresh-display signal from `refreshDataCmd` |
| 37 | + (`reducer_category.go:100`). |
| 38 | +- `handleDataMsg` (`model_reducer.go`) type-switches on `msg.Data` with |
| 39 | + per-category cases (`[]models.BaseModel`, `map[string][]models.ImportedModel`, |
| 40 | + `[]models.GPUPool` + `updateGPUPoolState()`, `map[string][]models.GPUNode`, |
| 41 | + `map[string][]models.DedicatedAICluster`, `models.TenancyOverrideGroup`). |
| 42 | + These cases are **dead in production**: every per-category load now flows |
| 43 | + through typed `*LoadedMsg` → `routeListLoadedMsg` → the typed handlers |
| 44 | + (`handleBaseModelsLoaded`, …). Only tests still drive the `dataMsg` |
| 45 | + per-category cases. The switch omits GPUWorkload — a latent asymmetry, not a |
| 46 | + live bug. |
| 47 | + |
| 48 | +### Change |
| 49 | + |
| 50 | +Reduce `handleDataMsg` to its live roles: |
| 51 | + |
| 52 | +```go |
| 53 | +func (m *Model) handleDataMsg(msg dataMsg) tea.Cmd { |
| 54 | + // gen-guard unchanged |
| 55 | + if msg.Gen != 0 && msg.Gen != m.gens.msg { |
| 56 | + m.endTask(true) |
| 57 | + return nil |
| 58 | + } |
| 59 | + if ds, ok := msg.Data.(*models.Dataset); ok { |
| 60 | + m.dataset = ds |
| 61 | + m.endTask(true) |
| 62 | + m.logger.Infow("data loaded", "category", m.category, "pendingTasks", m.pendingTasks) |
| 63 | + } |
| 64 | + m.refreshDisplay() |
| 65 | + return nil |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +- Removes the dead per-category cases. `updateGPUPoolState()` is unaffected — it |
| 70 | + is already invoked by the typed `handleGPUPoolsLoaded` path. |
| 71 | +- `dataMsg` keeps both live roles; the per-category responsibility now lives |
| 72 | + solely in the typed family. The overlap (#5) and GPUWorkload omission are gone. |
| 73 | + |
| 74 | +### Tests touched |
| 75 | + |
| 76 | +- `TestProcessDataAndErrorMsg` (`model_test.go:261-269`) currently drives |
| 77 | + `handleDataMsg` with per-category `dataMsg` payloads. Repoint these at the |
| 78 | + typed handlers (`m.handleBaseModelsLoaded(...)`, `m.handleGPUPoolsLoaded(...)`, |
| 79 | + etc.) so real-path coverage is preserved rather than testing removed branches. |
| 80 | +- `messages_test.go` (`dataMsg{Data: data}`) — adjust if it asserts a removed |
| 81 | + case; keep the `*models.Dataset` and empty-`dataMsg` assertions. |
| 82 | +- Empty `dataMsg{}` usages (refresh signal) across other tests are unchanged. |
| 83 | + |
| 84 | +### Guard for #5 |
| 85 | + |
| 86 | +A test asserting `handleDataMsg` only acts on `*models.Dataset`/refresh — i.e. |
| 87 | +feeding it a per-category payload (e.g. `[]models.GPUPool`) does **not** mutate |
| 88 | +the dataset (that path belongs to the typed handler). This locks in the |
| 89 | +single-path invariant. |
| 90 | + |
| 91 | +## Section 2 — Drift-guard catalog |
| 92 | + |
| 93 | +New `*_drift_test.go` files, one per package, each iterating `domain.Categories`. |
| 94 | +Mirrors the established pattern (`TestRowSources_CoversAllCategories`, |
| 95 | +`internal/columns/registry_test.go`). **No production changes** beyond Section 1: |
| 96 | +the command-constructor switches (`startK8sWatchCmd`, `reloadCategoryCmd`) build |
| 97 | +their `tea.Cmd` lazily, so a test can call them per-category and assert |
| 98 | +handled-vs-nil without triggering any watch or load. |
| 99 | + |
| 100 | +Three guard styles: |
| 101 | +- **Predicate invariant** — plane membership is derived from a domain truth; the |
| 102 | + guard self-maintains (no list to update). |
| 103 | +- **Behavior-probe** — call the plane's function for each category and assert the |
| 104 | + expected handled/excluded outcome (for switches that can't be introspected). |
| 105 | +- **Account-for-all** — for genuine per-category UI choices with no domain |
| 106 | + predicate: a table requires every category to be present or in an explicit |
| 107 | + exclusion set, so a new category fails until someone makes the decision. |
| 108 | + |
| 109 | +### `internal/domain` — `category_drift_test.go` |
| 110 | + |
| 111 | +- `NeedsKubeConfig`: account-for-all — `want := map[Category]bool{…}` with an |
| 112 | + entry for every `domain.Categories`; assert `len(want) == len(Categories)` and |
| 113 | + `want[c] == c.NeedsKubeConfig()` for each. New category → missing entry → fail. |
| 114 | +- `ScopedCategories`/`Parents`: structural invariant — for every category `c` and |
| 115 | + every `child` in `c.ScopedCategories()`, assert `c` appears in |
| 116 | + `child.Parents()` (round-trip consistency, no list). Plus an account-for-all |
| 117 | + scope table asserting each category's `ScopedCategories()` matches an expected |
| 118 | + map covering all categories. |
| 119 | +- `Aliases`: invariant — every category has ≥1 alias; aliases are unique across |
| 120 | + categories (no collisions); `ParseCategory(alias) == c` round-trips for every |
| 121 | + alias of every category. |
| 122 | + |
| 123 | +### `internal/ui/tui` — `category_drift_test.go` |
| 124 | + |
| 125 | +- `lazyLoadedCategories`: predicate — every `c.NeedsKubeConfig()` category is in |
| 126 | + the set. (Supersedes/absorbs the existing |
| 127 | + `TestLazyLoadedCategories_CoversKubeBacked`; remove the old one to avoid |
| 128 | + duplication.) |
| 129 | +- `handlers` map (`reducer_category.go`): predicate — every kube-backed category |
| 130 | + has an entry. |
| 131 | +- `startK8sWatchCmd`: behavior-probe — returns non-nil `tea.Cmd` for every |
| 132 | + kube-backed category and nil for non-kube categories. |
| 133 | +- `reloadCategoryCmd`: behavior-probe — non-nil for the same kube-backed set. |
| 134 | +- `statsColumns`: account-for-all — every category present or in an explicit |
| 135 | + `noStats` exclusion set; assert the union covers all categories. |
| 136 | +- `itemKeyFrom`: behavior-probe — returns a non-nil `ItemKey` for every category, |
| 137 | + probing with a representative `table.Row`. If a synthetic row proves |
| 138 | + impractical for some categories, the implementer may instead assert that |
| 139 | + `itemKeyFrom`'s flat-vs-grouped classification accounts for every category via |
| 140 | + an account-for-all table; either mechanism must fail when a category is added |
| 141 | + without handling. |
| 142 | +- `parentScope`: assert consistency with the domain scope graph — every category |
| 143 | + with `Parents()` is handled by `parentScope` (probe, or account-for-all over |
| 144 | + the scope-category set if a synthetic row is impractical). |
| 145 | +- `deleteItem` ↔ `confirmDelete` consistency: the set of categories for which |
| 146 | + `deleteItem` returns non-nil equals the set `confirmDelete` assigns a tier to; |
| 147 | + assert they agree (guards the finding-#2 coupling from the confirmation work). |
| 148 | + |
| 149 | +### `internal/ui/tui/keys` — `category_drift_test.go` |
| 150 | + |
| 151 | +- `catContext`: account-for-all — every category present or in an explicit |
| 152 | + `noContextKeys` exclusion set; assert the union covers all categories. |
| 153 | + |
| 154 | +### Already guarded (keep, do not duplicate) |
| 155 | + |
| 156 | +- `columns/registry` (`registry_test.go`), `rowSources` |
| 157 | + (`TestRowSources_CoversAllCategories`). |
| 158 | + |
| 159 | +## Architecture / isolation |
| 160 | + |
| 161 | +- Each guard test is self-contained in its package's `*_drift_test.go`, reading |
| 162 | + unexported registries directly (same-package tests) — no new production |
| 163 | + exports. |
| 164 | +- Explicit exclusion sets (`noStats`, `noContextKeys`, expected-value tables) live |
| 165 | + beside their guard test, documented with one line on why each category is |
| 166 | + excluded. |
| 167 | + |
| 168 | +## Error handling |
| 169 | + |
| 170 | +Guards are tests; failures surface at `make ci` / `go test`. No runtime paths |
| 171 | +change. The #5 change removes branches only; the foundational-load and |
| 172 | +refresh-signal paths are preserved exactly. |
| 173 | + |
| 174 | +## Testing |
| 175 | + |
| 176 | +- Section 1: the repointed `TestProcessDataAndErrorMsg`, the new single-path |
| 177 | + guard, and the existing TUI suite must stay green. |
| 178 | +- Section 2: each new guard must **fail if a category is removed from its plane** |
| 179 | + — verify by temporarily dropping a known category from one plane and confirming |
| 180 | + the guard catches it (then revert). This proves the guard actually guards. |
| 181 | +- Full `make ci` green (tests, lint, coverage threshold) on the branch. |
| 182 | + |
| 183 | +## Out of scope / future |
| 184 | + |
| 185 | +- The full category-descriptor refactor (#3 "Full") and the shared loader |
| 186 | + resolver (#3 "Moderate") remain available as later, larger efforts. These |
| 187 | + guards make that refactor safer by pinning current cross-plane invariants |
| 188 | + first. |
0 commit comments