Files
lmxopcua/archreview/04-adminui.md
T
Joseph Doherty 44f6408e1c docs(archreview): round-3 re-review at 12b0035a — 7 domain reports + 00-OVERALL rewritten in place, STATUS.md round-3 section
Every round-2 finding re-verified against code (73 closed / 31 partial / 97 carried);
matrix 19/28 cells up, one down. New Critical 03/S14 (AdminUI FallbackPolicy 401s every
mesh gRPC map on a login-enabled central; masked on every rig gate by DisableLogin=true).
New Highs 03/S15, 05/STAB-19, 05/STAB-18, 03/S16 — the stale-Good family at four seams.
2026-08-20 09:09:46 -04:00

184 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture Review 04 — AdminUI (Blazor Server)
- **Date:** 2026-08-19
- **Commit:** `12b0035a` (master, clean tree)
- **Updates:** the 2026-07-12 re-review at `f6eaa267` (which updated the 2026-07-08 original at `9cad9ed0`). **This is the second update, and unlike the first it lands on a heavily-changed surface:** 177 commits touched `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` since `f6eaa267` — the R2 remediation wave (R2-02/-05/-10), the ACL authoring surface (#520 stage 7), the `/apikeys` + Secrets pages, the whole `/raw` project tree (RawTree + 9 modals + CSV import/export + browse-commit), four new driver editor families (Calculation, Sql, Mqtt, MTConnect), the 2026-08-10 UI sweep, the density pass, and a run of targeted perf commits (drift watermark, projections, snapshot caches). Every prior finding was re-verified against code at `12b0035a`; no remediation claim was carried on trust.
- **Scope:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` (111 razor components — up from 77; ScriptAnalysis backend, minimal APIs, 3 SignalR bridges — the fleet-status chain was deleted end-to-end in #542, UNS/Raw/Acl service layer), plus `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` (978 `[Fact]`/`[Theory]` cases, up from 443).
---
## Prior-finding status (f6eaa267 → 12b0035a)
| ID | One-liner | Status @ 12b0035a |
|---|---|---|
| S-1 (Med) | Three components don't drain in-flight timer callbacks on dispose | **PARTIALLY CLOSED** — `Fleet.razor` fully fixed (`63081d52`/`4b6a93bb`/`445bb855`: `IAsyncDisposable` + page-lifetime CTS + `Interlocked` tick-overlap guard + drain, `Fleet.razor:131–210`; pinned by `PollTimerTeardownOrderingTests`). The other two stragglers unchanged: `AlarmsHistorian.razor:85` is still `public void Dispose() => _timer?.Dispose();` and `DriverTestConnectButton.razor:68–81` still runs `System.Timers.Timer` with an async `Elapsed` lambda + sync dispose. |
| S-2 (Med) | `Fleet.LoadAsync` no catch; `AlarmsHistorian` bare `catch { }` leaves "Loading…" forever | **PARTIALLY CLOSED** — Fleet now catches, but only `OperationCanceledException`/`ObjectDisposedException` (`Fleet.razor:167–171`); a transient SQL fault still faults the discarded `InvokeAsync` task silently (state no longer wedges — the `finally` at 172–181 resets the guard — but no error surfaces). `AlarmsHistorian.razor:68–74` still bare-catches with the admitted "lands when we add role gating" TODO. |
| S-3 (Med) | No `<ErrorBoundary>` anywhere | **STILL OPEN** — `grep -rn ErrorBoundary` over the project returns nothing, now across 111 components. |
| S-4 (Med) | Delete flows re-fetch RowVersion at click time, defeating the concurrency guard | **STILL OPEN on the UNS pages, NOT propagated to new code** — `GlobalUns.razor:280–311` ("Loads the entity's RowVersion first") and `EquipmentPage.razor:449–452, 494–497` ("Load … fresh to capture its current RowVersion") unchanged. But every NEW delete carries the *rendered* RowVersion: `RawTree.razor:462/469/498/546/640/659`, `AclEdit.razor:264/288`, and the reference remove `EquipmentPage.razor:407`. The codebase now demonstrates the correct pattern beside the wrong one. |
| S-5 (Med) | No confirmation on destructive actions | **STILL OPEN — surface grew.** Unchanged: `EquipmentPage.razor:242/297` (vtag/alarm delete), `ScriptEdit.razor:70→142`. New unconfirmed instances: `ApiKeys.razor:102–107` (Revoke + **Delete** of a live data-plane credential, single click), `AclEdit.razor:150` (grant delete), `EquipmentPage.razor:189` (reference Remove). Counter-example now in-tree: `RawTree` routes every delete through `RawConfirmDialog` (`RawTree.razor:466–470` etc.). |
| S-6 (Low) | `DriverStatusPanel` result-chip timer lacks the stale-fire token guard `Alerts` has | **STILL OPEN** — `Alerts.razor:260–268` has the `ReferenceEquals(_opResultClearToken, token)` guard; `DriverStatusPanel.razor:382–390` clears unconditionally (its comment even says "mirrors DriverStatusPanel" in Alerts — the mirror is one-way). |
| S-7 (Low) | `AdminOperationsClient.AskAsync` bypasses the 10 s ask timeout | **STILL OPEN** — `Clients/AdminOperationsClient.cs:57–58` forwards only the caller's token; the three typed methods still double-guard (`:33–54`). |
| P-1 (Med) | Monaco pushes the full document to .NET on every keystroke, undebounced | **STILL OPEN** — `wwwroot/js/monaco-init.js:213–214`. |
| P-2 (Med) | ScriptAnalysis recompiles from scratch per request | **PARTIALLY CLOSED** — the *catalog* half is fixed: `ScriptTagCatalogSnapshotCache` (`3795899d`, singleton, 5 s TTL, gate-collapsed rebuild — `ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs`) removed the six-table scan per keystroke that tag-path completion used to pay. The Roslyn `(text → tree/compilation)` memoization is still absent (`ScriptAnalysisService.cs:60–65` builds a fresh `CSharpCompilation` per call). |
| P-3 (Low) | Per-circuit DB polling on dashboard pages | **STILL OPEN (accepted)** — `Hosts` 5 s, `Fleet` 10 s per circuit; the overlap/teardown hazards around those ticks are now fixed (see S-1). |
| P-4 (Low) | `Deployments` runs a full config snapshot + hash per page load for the drift badge | **CLOSED** — `dbc9a132` lists a projection (`Deployments.razor:94–105`, no artifact blobs) and `684e59d8` added `DeploymentDriftProbe` (`Deployments/DeploymentDriftProbe.cs`, singleton at `EndpointRouteBuilderExtensions.cs:112`): recompose only when the `ConfigEdit` `(MAX(EditedAtUtc), COUNT(*))` watermark or the sealed hash moves, gate-collapsed, staleness trade documented. SQL-Server-backed pins in `c149410b`. |
| P-5 (Low) | `Alerts` rows lack `@key` while prepending | **STILL OPEN** — `Alerts.razor:281` `_rows.Insert(0, evt)`, row markup carries no `@key` (Hosts/EquipmentPage tables do). |
| P-6 (Low) | `inlay-hints` round-trips for a documented no-op | **STILL OPEN** — `monaco-init.js:107` posts; `ScriptAnalysisService.cs:472` returns empty. |
| C-1 (High) | Authorization idioms / ungated mutating surface | **CLOSED AND HELD.** R2-05's one-idiom state survived 177 commits: all 30 routable pages carry an explicit named-policy `@attribute` (only `Login` exempt, `[AllowAnonymous]`); the six pages added since were consciously classified — `ApiKeys` = FleetAdmin ("minting a key hands out a data-plane credential"), `SecretsAdmin` = the Secrets RCL's own `ManagePolicy`, `GlobalRaw`/`AclEdit` = ConfigEditor, `ClusterAcls` = AuthenticatedRead (verified read-only: `ClusterAcls.razor:107–108` is the only DB touch, `AsNoTracking`), `Dev/ContextMenuDemo` = AuthenticatedRead. `PageAuthorizationGuardTests` still reflects over `typeof(Routes).Assembly` and its census includes every new page (`Authorization/PageAuthorizationGuardTests.cs:29–79`). `/api/script-analysis/*` = ConfigEditor (`ScriptAnalysisEndpoints.cs:18`). `Certificates` keeps markup + imperative FleetAdmin defense-in-depth (`Certificates.razor:85, 180–186`). The anti-drift guard did its job. |
| C-2 (Med) | CLAUDE.md ScriptAnalysis policy drift | **STILL FIXED** — CLAUDE.md now says "gated by the `ConfigEditor` policy (Administrator or Designer)", matching `ScriptAnalysisEndpoints.cs:18` (the policy constant replaced the role string; semantics identical). |
| C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | **HOLDS, EXTENDED** — `TagConfigEditorMap.cs:17–27` and `TagConfigValidator.cs:19–29` now cover **11** drivers (added Calculation, Sql, Mqtt, MTConnect) with parallel keys, all keyed off `DriverTypeNames` constants (the casing-drift class is structurally closed; #558 consolidated the browse-commit parity guard onto one list, `17130d9c`). Authoring↔runtime parity got a new backstop: `TagConfigParseGate` makes authoring models consult the driver's own runtime parse (#554/#549/#551). |
| C-4 (Good) | Numeric-enum serialization bug class closed | **HOLDS** — every new enum-carrying driver form carries `JsonStringEnumConverter` (`SqlDriverForm.razor:121`, `MTConnectDriverForm.razor:170`, `CalculationDriverForm.razor:52`, …); `MqttDriverForm` routes through the driver's own shared `MqttJson.Options` (`MqttDriverForm.razor:7–8`) — same discipline, different mechanism. Per-driver `*FormSerializationTests` extended. |
| C-5 (Med) | Two data-access idioms: service seam vs EF-in-page | **STILL OPEN, GREW** — 15 components now inject `IDbContextFactory` directly (was 5 named): all the prior offenders plus `Reservations`, `ClusterOverview/Redundancy/Edit/Acls/Audit/List`, `NewCluster`, `NodeEdit`, `AclEdit`. Meanwhile the *new* Raw/Acl surfaces went through real seams (`IRawTreeService`, `IAclScopeService`, `IApiKeyAdminService`) — the split is now seam-for-new, EF-for-old. |
| C-6 (Low) | Naming/marker nits | **STILL OPEN (reduced)** — `"GalaxyMxGateway"` literal survives in `DriverIdentitySection.razor:38` and `GalaxyAddressPickerBody.razor:136` (though `RawDriverTypeDialog.razor:60` now documents + guards the mapping); dispatch maps moved onto `DriverTypeNames`. |
| C-7 (Med) | `ResilienceFormModel` lossy round-trip; malformed JSON silently overwritten | **CLOSED (R2-02)** — `ResilienceFormModel.cs:13–17/43–48/106–124`: `JsonObject` bag preserves unknown top-level keys, unknown capabilities, unknown per-policy fields; a malformed blob sets `ParseFailed`, `ToJson` returns the stored text **verbatim**, and `DriverResilienceSection.razor:9–14` shows the warning + explicit discard button while every input is `disabled="@_m.ParseFailed"` (`:40–42`) and `EmitAsync` early-returns (`:93`). `ResilienceFormModelTests` pins it. |
| U-1 | Strong seam tests, zero component rendering coverage | **IMPROVED IN VOLUME, SAME SHAPE** — 978 cases (443 → 978): Acls, Raw (CSV mapper, tree service, modals), Api, Browsing, Grpc, Telemetry, Hosts suites added. Still no bUnit (documented posture) — but a **reflection-driven component-test harness now exists**: `RawBrowseModalStaleOpenTests` drives a real modal class end-to-end (inert `Renderer` subclass for the render handle, reflection for `[Parameter]`s), proving modal `@code` logic *is* testable here without bUnit. |
| U-2 | Stub/known-gap behaviors | **STILL OPEN** — InlayHints no-op (`ScriptAnalysisService.cs:472` + active provider `monaco-init.js:107`); `AlarmsHistorian.razor:68–74` role-gating TODO; `MonacoEditor.razor:44` `MarkersChanged` still `object[]`; `GlobalUns.razor:327` "Delete for this node kind is not yet available." |
| U-3 | Raw-JSON fallback unvalidated | **SUBSTANTIALLY CLOSED** — typed client validation covers 11 drivers and is now **service-enforced**, not just modal-side: `RawTreeService.cs:977` (create/update), `:1273` (import), `RawTagCsvMapper.cs:228` (CSV rows), `RawTagModal.razor:242` / `RawManualTagEntryModal.razor:243` (modals) — the #535→#559 authoring-vs-deploy-gate arc. Residual: the schemaless textarea for *unmapped* drivers (Galaxy only) still takes any text ("Validated server-side at deploy", `RawTagModal.razor:90`) — the deploy gate is the only check there. |
| U-4 | Accessibility gaps | **STILL OPEN** — tree expanders are still text glyphs without `aria-expanded` (`RawTree.razor:176–179`, `UnsTree`), modals have no focus trap/Escape, live tails no `aria-live`. New code at least marks decorative emoji `aria-hidden` (`RawTree.razor:229`). |
| U-5 | Resilience runtime status has no AdminUI reader | **CLOSED (R2-10 + #546)** — `DriverStatusPanel.razor:15/77–92/203/263–264/292–303` renders breaker-OPEN / consecutive-failures / in-flight / staleness chips off `IDriverResilienceStatusStore` (in-process, self-HubConnection ban honored), and #546 (`d33e4e20` + `e669fb34`) wired the panel into every `/hosts` driver row via the Controls dialog — with the `Enabled`-in-`@key` recycle trick documented in the markup (`Hosts.razor:283–299`) so a live Enabled flip re-mounts rather than orphaning subscriptions. R2-10's live gate ran (per the R2-10 memory/plans register). |
---
## Maturity Ratings
| Dimension | 2026-07-08 | 2026-07-12 | **2026-08-19** | Justification |
|---|---|---|---|---|
| Stability | 4 | 4 | **4** | The dominant open items moved: Fleet's timer/teardown fully fixed, C-7's silent data loss closed, staleness guards + confirm dialogs are the *norm* in the new Raw surface, and the tick-overlap class got a dedicated test file. Docked at 4 (not higher) because the fixes were applied N−1-wide — five sibling modals still carry the exact superseded-open race that three others were just fixed for (S-8), three EditForms silently no-op on invalid input (S-9), and ErrorBoundary/AlarmsHistorian/DriverTestConnectButton stragglers persist. |
| Performance | 3 | 3 | **4** | A deliberate, well-engineered perf sweep landed: drift-watermark probe (P-4 closed), `/deployments` and raw-tree projections that stop shipping blobs, the 5 s tag-catalog snapshot cache, server-side rename script-scan with chunked OR-chains, one guard round-trip per bulk reference add. What remains (Monaco keystroke push, per-request Roslyn compile, per-circuit polling) is bounded at single-admin scale and known. |
| Conventions | 3 | 3 | **4** | C-1 closed *and held under drift pressure* — the reflection guard forced conscious classification of all six new pages. C-7 closed to the tag-editor discipline. The editor/validator/parity pattern extended cleanly to four new drivers, keyed off constants. Docked for the EF-in-page split growing (C-5) and the naming residue (C-6). |
| Underdeveloped | 3 | 3 | **3.5** | U-5 closed (resilience observability shipped and wired to `/hosts`), U-3 substantially closed (validation now service-enforced), tests 443→978 with a component-harness pattern emerging. Still: no bUnit (by policy) with most new modal orchestration verified only by `/run`, the same four U-2 stubs, and accessibility untouched. |
---
## Architecture Overview (delta since f6eaa267)
- **Page map changes:** `+/apikeys` (FleetAdmin), `+/secrets` (Secrets RCL wrapper), `+/raw` (`GlobalRaw` → `RawTree`, the v3 Batch-2 authoring surface), `+/clusters/{id}/acls` list + `AclEdit` + `AclScopePickerModal`/`AclScopeTree` (#520 stage 7), `+Dev/ContextMenuDemo`. **Retired:** the routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, 8 `*DriverPage` shells) — form bodies live on inside the `/raw` modals (`DriverConfigModal`/`DeviceModal` + `Forms/`/`DeviceForms/`); the UNS `TagModal` (Batch 3 made equipment Tags reference-only via `AddReferenceModal`).
- **Hubs:** 3 (`alerts`, `scriptlog`, `driverstatus`) — `FleetStatusHub` + bridge + broadcaster deleted as a zero-client chain (#542, `f1c18811`). The in-process singleton pattern (DPS bridge → store/broadcaster → component event) unchanged and still honored everywhere.
- **New service seams:** `IRawTreeService` (1701 lines, RowVersion-aware, warn-on-rename substring scan — now server-side filtered + chunked, `fe8d132c`/`ca87329a`), `IAclScopeService` (mirrors `DraftValidator.ValidateNodeAclScopes` rule-for-rule so page and deploy gate cannot disagree; blocks save on unresolvable scope, re-checked on submit — `AclEdit.razor:238–242`), `IApiKeyAdminService` (server-side validation, hash-only storage), `RawTagCsvExportReader`, `DeploymentDriftProbe` (singleton), `ScriptTagCatalogSnapshotCache` (singleton).
- **UI sweep held:** the scoped-CSS bundle is linked (`App.razor:23`), chip variants documented against the Theme kit (`site.css:20–22`), empty-string columns guarded (`Hosts.razor` Last-error uses `Abbreviate … is { Length: > 0 } … : "—"`), `#504` slicing discipline held — every `[..N]` in the tree is length-guarded or Guid-sourced.
---
## 1. STABILITY
### S-8 (Medium, NEW) — The superseded-open staleness guard was applied to three modals; five siblings share the unguarded shape
The 2026-08-05/08 fixes (`87637e4e` RawBrowseModal, `2c19a50b`/`4e3a6e84` RawManualTagEntryModal + RawTagModal) established the class: Blazor does not serialize `OnParametersSetAsync` against its own outstanding task, and a bool open-flag cannot tell two opens apart — so a close-and-reopen onto another target while a load is in flight lets the first open's late continuation install its state under the second open's displayed modal. The fixed modals carry generation counters and post-await re-checks (`RawBrowseModal.razor:362–446`, `RawManualTagEntryModal.razor:183–197`, `RawTagModal.razor:202`), and `RawBrowseModalStaleOpenTests` pins one of them.
Five sibling modals have the identical shape with **no** guard:
- **`RawCsvImportModal.razor:209–240`** — the sharpest instance. `ResolveTargetAsync` awaits `ResolveGroupContextAsync` and then writes `_effectiveDeviceId` + `_groupPrefix` unconditionally; `_resolved` is a bool, not a generation. A stale resolve retargets *both* the parse (`_groupPrefix` is baked into every row's `EffectiveGroupPath`, `:250`) and the **commit itself** — `Commit()` calls `Svc.ImportTagsAsync(_effectiveDeviceId, rows)` (`:293`), and `ImportTagsAsync` takes a device id with no RowVersion, so unlike the edit modals nothing downstream catches the mistake: the all-or-nothing import lands **in the wrong device**.
- **`RawCsvExportModal.razor:88–129`** — same shape, read-only consequence (another device's tags exported under this device's filename).
- **`DeviceModal.razor:126–171`** — `_openHandled` bool; `LoadAsync` writes `_name`/`_deviceConfigJson`/`_rowVersion`/`_parentDriverConfig` after two awaits with no re-check. A stale install shows device A's config under device B's title; Save is *accidentally* blocked by the RowVersion mismatch, but Test-connect probes the wrong endpoint with no error.
- **`DriverConfigModal.razor:132–165`** — identical to DeviceModal (`_driverConfigJson`/`_resilienceConfig`/`_rowVersion` installed post-await).
- **`AddReferenceModal.razor:83–101`** — `_wasVisible` bool; a stale `LoadReferencePickerRootAsync` installs equipment A's picker root under equipment B's open; picks then commit as B's references (`CommitAsync:116` uses the current `EquipmentId`), which the cluster-scope server check will *accept* whenever A and B share a cluster.
**Recommendation:** port the `_openGeneration` + post-await `IsStale` idiom (and its reflection-harness test — the pattern is proven) to all five. This is the "ForTest-seam / N−1 dispatch sites" failure mode in modal form: the fix commits named three modals; the class has eight members.
### S-9 (Medium, NEW) — Three EditForms validate with no validation display: invalid input = silent no-op Save
`NodeEdit.razor:42` and `AclEdit.razor:36` show the house convention (`DataAnnotationsValidator` + `<ValidationSummary>`); the UNS modals render `ValidationMessage`s. Three pages drifted:
- **`ClusterEdit.razor:35–36`** — `DataAnnotationsValidator`, `[Required] Name` (`:195`), no `ValidationSummary`/`ValidationMessage` anywhere in the form.
- **`NewCluster.razor:18–19`** — worse: `[Required]` ×3 plus a `[RegularExpression(..., ErrorMessage = "Use uppercase letters, digits, dash, underscore.")]` (`:132–136`) whose message **can never render**.
- **`ScriptEdit.razor:32–33`** — `[Required] Name` + `[Required] SourceCode` (`:167–168`), no display.
Consequence: clear the Name (or type a lowercase cluster id) and click Save/Create — `OnValidSubmit` never fires, no message appears, the button just does nothing. This is precisely the #505-adjacent "EditForm with no ValidationSummary = silent no-op Save" class the project memory documents, and it passes every unit test because there is no rendering coverage.
**Recommendation:** one-line fix per page; add the three `<ValidationSummary class="alert alert-danger" />` lines, and consider a guard-style scan (the pages are compiled classes — a reflection test could assert every component that renders `DataAnnotationsValidator` also renders a validation display, mirroring `PageAuthorizationGuardTests`).
### S-1 / S-2 residue (Medium, carried) — Two straggler timers and two swallowed-error paths
Fleet is now the model citizen (`Fleet.razor:113–210`: CTS-before-drain, `Interlocked` overlap guard, tick-holding-CTS dispose reasoning — all commented). Still owed:
- `AlarmsHistorian.razor:85` sync `Dispose`; `:68–74` bare catch that leaves "Loading…" forever on admin-only nodes (the TODO is now over a year of review cycles old).
- `DriverTestConnectButton.razor:68–81` `System.Timers.Timer` + async `Elapsed` (effectively async-void) + sync dispose.
- `Fleet.razor:167–171` catches only OCE/ODE — a transient SQL fault is still an unobserved task exception with no error surface (the `Hosts` catch-log-degrade shape was never copied over).
### S-3 (Medium, carried) — No `<ErrorBoundary>`; S-4 (Medium, carried) — UNS delete RowVersion refetch; S-5 (Medium, carried) — unconfirmed destructive actions
All three persist as described in the table above. Two aggravations worth noting:
- **S-4 is now an internal inconsistency, not just a defect**: `RawTree` and `AclEdit` prove the rendered-RowVersion delete works fine in this codebase; `GlobalUns`/`EquipmentPage` sit beside them doing the opposite.
- **S-5's sharpest new instance is `ApiKeys.razor:106–107`**: single-click **Delete** of an API key destroys a live machine credential (revocation is immediate on ConfigDb nodes) with no confirm and no undo — and unlike Revoke it also erases the row. At minimum, Delete here deserves the `RawConfirmDialog` treatment; arguably Delete should be gated behind Revoke-first.
### S-6 / S-7 (Low, carried)
`DriverStatusPanel.razor:382–390` still lacks the stale-fire token guard its own Alerts twin has (`Alerts.razor:260–268` — whose comment claims to "mirror DriverStatusPanel"); `AdminOperationsClient.cs:57–58` still forwards only the caller's token.
### Positive stability notes
- The three fixed modals + `RawBrowseModalStaleOpenTests` show the *harness* problem (no bUnit) is solvable for modal state machines — an inert `Renderer` subclass + reflection drives the real component class.
- `RawTree` is a well-built coordinator: one modal set for the whole recursive tree, `@key` per row, lazy loads with captured errors, confirm dialogs on every delete, rendered-RowVersion mutations, warnings surfaced through a shared message modal (`RawTree.razor:705–723`).
- `Hosts`' Controls dialog documents its own `@key`-recycle rationale in markup (`Hosts.razor:283–299`) — the "Enabled flips must recycle the panel or its subscriptions dangle" trap is pinned in words where the next editor will read them.
- `AlarmsHistorian` aside, every new page reports load errors (`ApiKeys.ReloadAsync` catches and surfaces; `AclEdit` catches `DbUpdateConcurrencyException` with a human message on both save and delete).
---
## 2. PERFORMANCE
### P-1 / P-2 / P-3 / P-5 / P-6 (carried — see table)
The two Mediums still worth scheduling: Monaco's undebounced full-document push (`monaco-init.js:213–214`) and the per-request Roslyn compile (`ScriptAnalysisService.cs:60–65` — a size-1 LRU keyed on normalized text would collapse the completions-after-diagnostics case; the catalog half is done).
### Positive performance notes (the sweep that closed P-4)
- `DeploymentDriftProbe` is the standout: watermark = `(MAX(EditedAtUtc), COUNT(*))` over the append-only `ConfigEdit` audit (the COUNT half defends same-timestamp edits), volatile-published verdict, gate-collapsed recompose, and an honest "accepted staleness" paragraph naming the exact condition under which the chip lies and why that's tolerable (`Deployments/DeploymentDriftProbe.cs`).
- `/deployments` lists a projection (`Deployments.razor:94–105`) instead of 50 artifact blobs; the raw tree renders tag rows without `TagConfig` (`ba977dbe`); the rename script-scan filters server-side and chunks its OR-chain with SQL-Server-backed pins (`fe8d132c`/`ca87329a`/`c149410b`); bulk reference adds pay one guard round-trip (`fa36d64e`); `ScriptTagCatalogSnapshotCache` bounds editor-assist DB load at one six-table scan per 5 s process-wide.
- CSV import bounds the upload at 16 MB via `OpenReadStream(maxAllowedSize:)` (`RawCsvImportModal.razor:251`) and stages all-or-nothing review before any write.
---
## 3. CONVENTIONS
### C-1 — CLOSED AND HELD (see table). The reflection guard proved its worth
The census in `PageAuthorizationGuardTests` was *maintained*, not bypassed: each of the six post-R2-05 pages appears with a policy and, for the interesting ones, a rationale comment (ApiKeys→FleetAdmin "same authority level as editing role grants"; SecretsAdmin carrying the RCL's own policy). This is the anti-drift outcome R2-05 was designed for.
### C-5 (Medium, carried) — The seam/EF-in-page split is now generational
New surfaces (Raw, Acls, ApiKeys, drift, catalog) all went through injectable services with real test suites; the older cluster/script/dashboard pages still run EF in `@code` (15 components inject `IDbContextFactory`). The convention argument has effectively been won by the new code — declare it and migrate the old pages opportunistically (`ScriptEdit`'s save/delete still belongs in the existing script service methods).
### C-6 (Low, carried) — reduced to two `"GalaxyMxGateway"` literals (`DriverIdentitySection.razor:38`, `GalaxyAddressPickerBody.razor:136`); the dispatch-map keying moved to `DriverTypeNames` fleet-wide.
### C-8 (Low, NEW) — A dev demo page ships in the production route table
`Components/Pages/Dev/ContextMenuDemo.razor` is routable in every deployment (gated `AuthenticatedRead`, consciously classified in the guard census — so this is a deliberate choice, recorded here as a nit). If it's meant as a pattern gallery, name it so; if not, `#if DEBUG` or delete.
### Positive conventions notes
- The tag-editor discipline scaled to 11 drivers without a single map/validator drift, because the keys are constants and the parity tests are consolidated onto one list (#558).
- The authoring↔runtime parity arc (#549/#550/#551/#554/#557) gave the AdminUI a genuinely novel convention: authoring models consult the **driver's own runtime parse** (`TagConfigParseGate`) so the editor can never accept a config the runtime rejects — enforced at modal, CSV, and service layers (one authority, service-enforced — the `reference_authoring_vs_deploy_gate_drift` lesson applied).
- `AclScopeService` mirrors `DraftValidator.ValidateNodeAclScopes` rule-for-rule with better operator wording, and the picker makes invalid grants *unauthorable* (Cluster ⇒ null ScopeId at projection, `AclScopeService.cs:265–267`; UNS tags deliberately unofferable with the rationale in code, `:317–320`).
---
## 4. UNDERDEVELOPED AREAS
### U-1 (carried, improved) — 978 seam tests, no rendering coverage — but the harness excuse is gone
The reflection-driven component harness (`RawBrowseModalStaleOpenTests` — inert `Renderer` subclass for the render handle, reflection past BL0005 for parameters) demonstrates modal `@code` machines are testable without bUnit. S-8's five unguarded modals and S-9's three silent forms are exactly the tests that harness should now grow.
### U-2 (carried) — the same four stubs
InlayHints wired-no-op (client provider still POSTs, `monaco-init.js:107`; `ScriptAnalysisService.cs:472`); `AlarmsHistorian` role-gating TODO; `MonacoEditor.MarkersChanged` untyped `object[]`; `GlobalUns` "not yet available" delete branch. None moved in ~1300 commits — either schedule or annotate as accepted.
### U-3 (carried, substantially closed) — residual: the Galaxy schemaless textarea
Only unmapped drivers (today: Galaxy) reach the raw textarea, and only the deploy gate validates it. A `JsonDocument.Parse` in the service create/update path for unmapped types remains a cheap backstop.
### U-4 (carried) — accessibility unchanged; the new tree components repeated the text-glyph expander pattern without `aria-expanded` (`RawTree.razor:176–179`, `AclScopeTree`).
---
## Top Recommendations (ordered)
1. **S-8:** Port the superseded-open generation guard to the five unguarded modals — `RawCsvImportModal` first (its stale `_effectiveDeviceId` retargets a commit that nothing downstream can catch).
2. **S-9:** Add the missing `ValidationSummary` to `ClusterEdit`/`NewCluster`/`ScriptEdit`; consider a reflection guard for the class.
3. **S-5 (ApiKeys):** Confirm-dialog the API-key Delete (and AclEdit/EquipmentPage deletes) — a live credential should not die to one click.
4. **S-4:** Carry rendered RowVersions on the UNS deletes (the codebase's own new code shows how) or document delete as last-writer-wins.
5. **S-3:** `ErrorBoundary` around `@Body` — still absent at 111 components.
6. **P-1/P-2:** Debounce Monaco's value push; memoize `(text → compilation)` size-1 in `ScriptAnalysisService`.
7. **S-1/S-2 stragglers:** `AlarmsHistorian` + `DriverTestConnectButton` timers; give `Fleet` (and `AlarmsHistorian`) a real error surface.
8. **C-5:** Declare the service-seam convention won; migrate `ScriptEdit` first.