docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans

Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.

Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
This commit is contained in:
Joseph Doherty
2026-07-12 23:52:23 -04:00
parent f6eaa267a0
commit 1891f5d6a7
34 changed files with 7962 additions and 901 deletions
+89 -35
View File
@@ -1,11 +1,44 @@
# Architecture Review 04 — AdminUI (Blazor Server)
- **Date:** 2026-07-08
- **Commit:** `9cad9ed0`
- **Date:** 2026-07-12
- **Commit:** `f6eaa267` (master, clean tree)
- **Updates:** the 2026-07-08 review at `9cad9ed0`. **Zero AdminUI source files changed between the two commits** (`git diff 9cad9ed0..f6eaa267 -- src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` is empty; the ~40 intervening commits are the arch-review remediation merges in Cluster/Runtime/Core/Drivers, a fixdocs XML-doc sweep, and a HistorianGateway package bump). Every prior finding was re-verified against the code rather than carried over on trust; all line references below re-checked at `f6eaa267`.
- **Scope:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` (77 razor components, ScriptAnalysis backend, minimal APIs, SignalR bridges, UNS service layer), plus `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` for coverage assessment. Auth *policy definitions* live in `ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` and are referenced where the AdminUI consumes them.
---
## Prior-finding status (9cad9ed0 → f6eaa267)
| ID | One-liner | Status @ f6eaa267 |
|---|---|---|
| S-1 (Med) | Three components don't drain in-flight timer callbacks on dispose | **STILL OPEN**`Fleet.razor:171` and `AlarmsHistorian.razor:90` are still `public void Dispose() => _timer?.Dispose();`; `DriverTestConnectButton.razor:6881` still uses `System.Timers.Timer` with an async `Elapsed` lambda + sync dispose |
| S-2 (Med) | `Fleet.LoadAsync` has no catch; `AlarmsHistorian` bare `catch { }` leaves "Loading…" forever | **STILL OPEN**`Fleet.razor:120159` is `try/finally` with no catch; `AlarmsHistorian.razor:7378` still admits the role-gating message is a TODO |
| S-3 (Med) | No `<ErrorBoundary>` anywhere | **STILL OPEN**`grep -rn ErrorBoundary` over the project returns nothing |
| S-4 (Med) | Delete flows re-fetch RowVersion at click time, defeating the concurrency guard | **STILL OPEN**`GlobalUns.razor:279326` (Area/Line/Equipment branches) and `EquipmentPage.razor:370377/415422/460467` still load fresh + pass the fresh RowVersion ("Load the tag fresh…" comments intact) |
| S-5 (Med) | No confirmation on `EquipmentPage`/`ScriptEdit` deletes | **STILL OPEN**`EquipmentPage.razor:172/217/263` single-click delete buttons; `ScriptEdit.razor:68→140` `DeleteAsync` with no confirm |
| S-6 (Low) | `DriverStatusPanel` result-chip timer lacks the stale-fire token guard `Alerts` has | **STILL OPEN**`Alerts.razor:244257` has the token compare; `DriverStatusPanel.razor:293299` clears unconditionally |
| S-7 (Low) | `AdminOperationsClient.AskAsync` bypasses the 10 s ask timeout | **STILL OPEN**`Clients/AdminOperationsClient.cs:5859` forwards only the caller's token; the three typed methods still double-guard |
| P-1 (Med) | Monaco pushes the full document to .NET on every keystroke, undebounced | **STILL OPEN**`wwwroot/js/monaco-init.js:205208` |
| P-2 (Med) | ScriptAnalysis recompiles from scratch per request, six endpoints deep | **STILL OPEN**`ScriptAnalysis/ScriptAnalysisService.cs:6067`, no `(text → compilation)` memoization |
| P-3 (Low) | Per-circuit DB polling on dashboard pages | **STILL OPEN**`Hosts.razor:229245` (5 s), `Fleet.razor:112` (10 s) |
| P-4 (Low) | `Deployments` runs a full config snapshot + hash per page load for the drift badge | **STILL OPEN**`Deployments.razor:98` `SnapshotAndFlattenAsync` |
| P-5 (Low) | `Alerts` rows lack `@key` while prepending; no `<Virtualize>` (bounded by design) | **STILL OPEN**`Alerts.razor:6370` `<tr>` without `@key` |
| P-6 (Low) | `inlay-hints` round-trips for a documented no-op | **STILL OPEN**`monaco-init.js:99117` provider active; `ScriptAnalysisService.cs:415` returns empty |
| C-1 (High) | Three authorization idioms; the largest mutating surface is bare `[Authorize]` | **STILL OPEN** — re-enumerated every page attribute: only `Deployments`/`Scripts`/`ScriptEdit` carry `Roles="Administrator,Designer"`, only `RoleGrants` carries `Policy="FleetAdmin"`; `GlobalUns`, `EquipmentPage`, all `Clusters/*` editors, all 8 driver pages, and `Reservations` remain bare `[Authorize]`. No remediation branch targeted this (STATUS.md action-list item 5, unscheduled) |
| C-2 (Med) | CLAUDE.md said ScriptAnalysis was `FleetAdmin`-gated; code uses `Roles="Administrator,Designer"` | **FIXED** — docs branch `9fadead6` (merged to master via `b67bd9e8`) corrected CLAUDE.md, which now reads "gated to the `Administrator,Designer` roles via `RequireAuthorization`" (CLAUDE.md:223); code unchanged and still correct |
| C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | **STILL HOLDS** — map/validator/editors unchanged |
| C-4 (Good) | Numeric-enum serialization bug class closed and test-pinned | **STILL HOLDS** — pages + `*FormSerializationTests` unchanged |
| C-5 (Med) | Two data-access idioms: service seam vs EF-in-page | **STILL OPEN**`ScriptEdit.razor:87/109/132/146/151`, `Scripts.razor:60`, `Deployments.razor:83`, `Fleet.razor:125`, `Hosts.razor:273` still run EF directly in `@code` |
| C-6 (Low) | Naming/marker nits (`"GalaxyMxGateway"` literal, `DataTypes` duplication, casing drift) | **STILL OPEN**`TagModal.razor:98/262/275` etc. unchanged |
| U-1 | Strong C# seam tests, zero component rendering coverage | **STILL OPEN** — still exactly 443 `[Fact]`/`[Theory]` cases, still no bUnit (documented repo posture) |
| U-2 | Stub/known-gap behaviors (InlayHints no-op, AlarmsHistorian TODO, `MarkersChanged` untyped, GlobalUns not-implemented message) | **STILL OPEN** — all four verified in place |
| U-3 | Raw-JSON fallback textarea unvalidated for well-formed JSON | **STILL OPEN**`TagModal.razor:130136` unchanged |
| U-4 | Accessibility gaps (tree expander aria, modal focus trap, live regions) | **STILL OPEN** — unchanged |
New findings this pass: **C-7** (ResilienceFormModel lossy round-trip) and **U-5** (resilience runtime status has no AdminUI reader) — both on the driver resilience-override surface, which existed at `9cad9ed0` but was not covered by the prior review and became *load-bearing* since: the remediation merges (#10 `bacea1a4`, #13 `75403caa`) wired `CapabilityInvoker` + per-instance `ResilienceConfig` into production dispatch, so what this form emits now changes runtime retry/breaker behavior.
---
## Architecture Overview
### Packaging and mounting
@@ -18,15 +51,15 @@ The AdminUI is a **Razor Class Library**, not an app: the fused Host calls `AddA
|---|---|
| Dashboards | `Home`, `Fleet` (per-node deploy state, 10 s poll), `Hosts` (Akka topology + driver health, 5 s poll + event push), `AlarmsHistorian` (sink status, 5 s poll) |
| Live tails | `Alerts` (alarm transitions + DriverOperator Ack/Shelve), `ScriptLog` |
| Config: clusters | `ClustersList`, `ClusterEdit/Overview/Namespaces/Acls/Audit/Redundancy/Drivers`, `NodeEdit`, `NamespaceEdit`, `AclEdit`, `NewCluster`, 8 driver pages behind `DriverEditRouter` + `DriverTypePicker` |
| Config: UNS | `GlobalUns` (tree + Area/Line modals + CSV import), `EquipmentPage` (tabbed Details/Tags/VirtualTags/Alarms with `TagModal`/`VirtualTagModal`/`ScriptedAlarmModal`) |
| Config: clusters | `ClustersList`, `ClusterEdit/Overview/Namespaces/Acls/Audit/Redundancy/Drivers`, `NodeEdit`, `NamespaceEdit`, `AclEdit`, `NewCluster`, 8 driver pages behind `DriverEditRouter` + `DriverTypePicker` (each embedding `DriverResilienceSection` for per-instance resilience overrides) |
| Config: UNS | `GlobalUns` (tree + Area/Line modals + paste-CSV import), `EquipmentPage` (tabbed Details/Tags/VirtualTags/Alarms with `TagModal`/`VirtualTagModal`/`ScriptedAlarmModal`) |
| Scripting | `Scripts`, `ScriptEdit` (Monaco), `ScriptLog` |
| Ops/admin | `Deployments`, `Certificates`, `Reservations`, `RoleGrants`, `Account`, `Login` |
### API surface
- `POST /api/deployments` (`Api/DeployApiEndpoints.cs`) — headless deploy trigger; `AllowAnonymous` but self-gated on `Security:DeployApiKey` via `X-Api-Key` with fixed-time compare, 503 when unconfigured. Clean, well tested.
- `POST /api/script-analysis/{diagnostics,completions,hover,signature-help,format,inlay-hints}` (`ScriptAnalysis/ScriptAnalysisEndpoints.cs`) — Roslyn-backed Monaco language services, gated `Roles = "Administrator,Designer"`.
- `POST /api/script-analysis/{diagnostics,completions,hover,signature-help,format,inlay-hints}` (`ScriptAnalysis/ScriptAnalysisEndpoints.cs`) — Roslyn-backed Monaco language services, gated `Roles = "Administrator,Designer"` (CLAUDE.md now matches — prior C-2 fixed).
- SignalR hubs `/hubs/{alerts,driverstatus,fleetstatus,scriptlog}` — all `[Authorize]`; retained for out-of-process clients only.
### State / broadcast patterns
@@ -49,13 +82,13 @@ The codebase's own best practice (stated in comments) is `System.Threading.Timer
- `Components/Pages/Fleet.razor:171``public void Dispose() => _timer?.Dispose();` (sync, no drain).
- `Components/Pages/AlarmsHistorian.razor:90` — same.
- `Components/Shared/Drivers/DriverTestConnectButton.razor:6881` — uses `System.Timers.Timer` with an `async (_, _)` `Elapsed` handler (line 69; effectively async-void) and a sync `Dispose`, directly contradicting the comment convention in `Alerts.razor:243244`.
- `Components/Shared/Drivers/DriverTestConnectButton.razor:6881` — uses `System.Timers.Timer` with an `async (_, _)` `Elapsed` handler (line 69; effectively async-void) and a sync `Dispose`, directly contradicting the comment convention in `Alerts.razor:242243`.
**Recommendation:** converge on the `IAsyncDisposable` + `Threading.Timer` idiom in all three; it is already the documented house pattern.
### S-2 (Medium) — `Fleet.razor` auto-refresh has no error handling; DB faults become unobserved task exceptions
`Fleet.razor:112` schedules `_ = InvokeAsync(LoadAsync)`; `LoadAsync` (`Fleet.razor:119159`) has `try/finally` but **no catch** — a transient SQL failure faults the discarded task silently and the page shows stale data with no error surface. Contrast `Hosts.razor:269287`, which catches, logs, and degrades gracefully. `AlarmsHistorian.razor:7378` has the opposite problem: a bare `catch { }` that leaves the page on "Loading…" forever (the comment admits the role-gating message is a TODO).
`Fleet.razor:112` schedules `_ = InvokeAsync(LoadAsync)`; `LoadAsync` (`Fleet.razor:120159`) has `try/finally` but **no catch** — a transient SQL failure faults the discarded task silently and the page shows stale data with no error surface. Contrast `Hosts.razor:269287`, which catches, logs, and degrades gracefully. `AlarmsHistorian.razor:7378` has the opposite problem: a bare `catch { }` that leaves the page on "Loading…" forever (the comment admits the role-gating message is a TODO).
**Recommendation:** copy the `Hosts.LoadConfigAsync` catch-log-degrade shape into `Fleet.LoadAsync`; give `AlarmsHistorian` an explicit "no historian on this node role" state.
@@ -69,8 +102,8 @@ The codebase's own best practice (stated in comments) is `System.Threading.Timer
`UnsTreeService` implements RowVersion concurrency rigorously (22 `OriginalValue` sites, 10 `DbUpdateConcurrencyException` handlers), and the *edit* paths carry the RowVersion loaded when the modal opened — correct. But the *delete* paths load a **fresh** DTO immediately before deleting and pass its current RowVersion:
- `GlobalUns.razor:279345` (`ConfirmDeleteAsync` — Area/Line/Equipment branches, e.g. line 293).
- `EquipmentPage.razor:370380, 415425, 460470` (`DeleteTag`/`DeleteVirtualTag`/`DeleteAlarm`).
- `Components/Pages/Uns/GlobalUns.razor:279326` (`ConfirmDeleteAsync` — Area/Line/Equipment branches, e.g. line 296).
- `Components/Pages/Uns/EquipmentPage.razor:370377, 415422, 460467` (`DeleteTag`/`DeleteVirtualTag`/`DeleteAlarm`).
The guard therefore always passes: a concurrent edit between rendering the row and clicking Delete is silently destroyed. The comments ("Load the tag fresh to capture its current RowVersion") show this is deliberate but it makes delete a *last-writer-wins* operation while updates are *first-writer-wins* — an inconsistent contract.
@@ -78,7 +111,7 @@ The guard therefore always passes: a concurrent edit between rendering the row a
### S-5 (Medium) — Destructive actions on `EquipmentPage` and `ScriptEdit` have no confirmation
`GlobalUns` has a proper delete-confirm modal (`GlobalUns.razor:7198`) and `DriverStatusPanel` has an inline Restart confirm (`DriverStatusPanel.razor:119131`). But `EquipmentPage.razor:172/217/263` delete a tag/virtual-tag/alarm on a single click, and `ScriptEdit.razor:140` deletes a script the same way.
`GlobalUns` has a proper delete-confirm modal (`GlobalUns.razor:7198`) and `DriverStatusPanel` has an inline Restart confirm (`DriverStatusPanel.razor:119131`). But `EquipmentPage.razor:172/217/263` delete a tag/virtual-tag/alarm on a single click, and `ScriptEdit.razor:68` (`DeleteAsync`, line 140) deletes a script the same way.
**Recommendation:** reuse the `GlobalUns` confirm-modal pattern (or a shared `ConfirmButton` component) for all deletes.
@@ -97,6 +130,7 @@ The guard therefore always passes: a concurrent edit between rendering the row a
- Event-handler hygiene is excellent: subscribe-before-read TOCTOU ordering (`Alerts.razor:154159`), unsubscribe-before-timer-drain dispose ordering, capture-then-invoke in `InProcessBroadcaster.SetConnected` (`IInProcessBroadcaster.cs:86103`).
- `AdminOperationsActor` access is exclusively via `Ask` through the singleton proxy — no shared mutable state in the AdminUI layer itself; the snapshot store and broadcaster are the only singletons and both are lock/`ConcurrentDictionary`-guarded.
- `MonacoEditor.razor` disposes cleanly and distinguishes `JSDisconnectedException` (silent) from real `JSException` (logged).
- No `MarkupString`/raw-HTML rendering anywhere — no XSS-via-render surface. The CSV import is paste-textarea (`ImportEquipmentModal.razor:3032`), so upload-size abuse is bounded by the SignalR message limit rather than an unbounded file stream.
---
@@ -120,7 +154,7 @@ The guard therefore always passes: a concurrent edit between rendering the row a
### P-4 (Low) — `Deployments.razor:98` runs `ConfigComposer.SnapshotAndFlattenAsync` (full config snapshot + hash) on every page load and after every deploy, purely to compute the drift badge. On a large fleet this is the page's dominant cost.
### P-5 (Low) — No `<Virtualize>` anywhere; lists are instead bounded by design (`Alerts` cap 200, `Deployments` `Take(50)`, cluster-scoped tables). Adequate. Two nits: `Alerts.razor:63` renders rows without `@key` while prepending (`Insert(0, …)`), forcing whole-table diffs per event ( `EquipmentPage` tables do use `@key`); the `UnsTree` filter re-runs the recursive `VisibleUnder` walk per render (self-documented as bounded/cheap — agreed).
### P-5 (Low) — No `<Virtualize>` anywhere; lists are instead bounded by design (`Alerts` cap 200, `Deployments` `Take(50)`, cluster-scoped tables). Adequate. Two nits: `Alerts.razor:63` renders rows without `@key` while prepending (`Insert(0, …)`), forcing whole-table diffs per event (`EquipmentPage` tables do use `@key`); the `UnsTree` filter re-runs the recursive `VisibleUnder` walk per render (self-documented as bounded/cheap — agreed).
### P-6 (Low) — `inlay-hints` round-trips for a documented no-op
@@ -132,23 +166,21 @@ The guard therefore always passes: a concurrent edit between rendering the row a
### C-1 (High) — Three authorization idioms coexist, and the largest mutating surface is effectively ungated
*(Re-verified at f6eaa267 by enumerating every `@attribute [Authorize…]` in `Components/Pages/` — unchanged. STATUS.md confirms no remediation branch targeted this; it was action-list item 5, unscheduled.)*
Observed gates:
| Idiom | Where |
|---|---|
| `Roles = "Administrator,Designer"` | `Deployments.razor:12`, `Scripts.razor`, `ScriptEdit.razor:5`, `ScriptAnalysisEndpoints.cs:18` |
| `Roles = "Administrator,Designer"` | `Deployments.razor:12`, `Scripts.razor:2`, `ScriptEdit.razor:5`, `ScriptAnalysisEndpoints.cs:18` |
| `Policy = "FleetAdmin"` | `RoleGrants.razor:2` (page), `Certificates.razor:90,186` (per-action) |
| `Policy "DriverOperator"` (imperative) | `Alerts.razor:165`, `DriverStatusPanel.razor:166` |
| **bare `[Authorize]`** | **everything else**`GlobalUns`, `EquipmentPage`, `ClusterEdit`, `NodeEdit`, `NamespaceEdit`, `AclEdit`, all 8 driver pages, `Reservations` |
| `Policy "DriverOperator"` (imperative) | `Alerts.razor:165`, `DriverStatusPanel.razor:166`, `OpcUaClientAddressPickerBody.razor:75`, `GalaxyAddressPickerBody.razor:124` |
| **bare `[Authorize]`** | **everything else**`GlobalUns`, `EquipmentPage`, `ClusterEdit` (+ all cluster sub-pages), `NodeEdit`, `NamespaceEdit`, `AclEdit`, `NewCluster`, all 8 driver pages + `DriverEditRouter`/`DriverTypePicker`, `Reservations`, `Certificates` (page level) |
Consequence: any authenticated user — including a read-only viewer — can create/edit/delete areas, lines, equipment, tags, virtual tags, scripted alarms, driver instances, ACLs, and namespaces; only the *deploy* button is role-gated. Project memory says global-roles/simplest-authz is a deliberate posture, but the current state isn't "simplest" — it's three idioms plus an unguarded majority, which is exactly the drift that produces surprises later.
Consequence: any authenticated user — including a read-only viewer — can create/edit/delete areas, lines, equipment, tags, virtual tags, scripted alarms, driver instances, ACLs, and namespaces; only the *deploy* button is role-gated. Project memory says global-roles/simplest-authz is a deliberate posture, but the current state isn't "simplest" — it's three idioms plus an unguarded majority, which is exactly the drift that produces surprises later. Note the stakes rose since the prior review: driver pages now author `ResilienceConfig` that (post-#13, `75403caa`) directly shapes production retry/breaker behavior — still behind bare `[Authorize]`.
**Recommendation:** centralize policy names as constants (they exist only as string literals today), pick policies over role strings (`FleetAdmin` already means `RequireRole("Administrator")`), and apply one write-gate (`Administrator,Designer` equivalent) to every config-mutating page. This is a one-attribute-per-page change.
### C-2 (Medium) — CLAUDE.md / code drift on the ScriptAnalysis gate
CLAUDE.md states the `/api/script-analysis/*` endpoints are "gated by the `FleetAdmin` policy"; the code uses `Roles = "Administrator,Designer"` (`ScriptAnalysisEndpoints.cs:1718`, comment says it deliberately matches the Scripts page). Fix the doc (or converge per C-1).
### C-3 (Good) — Driver-typed tag-editor pattern is complete and consistent
`Uns/TagEditors/TagConfigEditorMap.cs:1020` and `TagConfigValidator.cs:1222` cover **all seven** non-Galaxy drivers (Modbus, S7, AbCip, AbLegacy, TwinCat, Focas, OpcUaClient) with parallel keys; Galaxy intentionally uses the live-browse picker + raw `{"FullName": …}` JSON (`TagModal.razor:96125`). Every editor follows the same shell-over-pure-model shape (`FromJson`/`ToJson`/`Validate`, preserve-unknown-keys), with the parse-only-on-real-change guard against parent re-renders (`ModbusTagConfigEditor.razor:5257`). The composable root-key seams (`TagHistorizeConfig`, `TagArrayConfig`, `NativeAlarmModel`) merge onto the same JSON without clobbering — a genuinely good design.
@@ -159,13 +191,24 @@ All 8 driver pages carry `JsonStringEnumConverter` in their serializer options;
### C-5 (Medium) — Two data-access idioms: service seam vs. EF-in-page
UNS pages go through `IUnsTreeService` (testable, RowVersion-aware, 12 test files). But `ScriptEdit.razor:87156`, `Scripts`, `Deployments.razor:83100`, `Fleet`, and `Hosts` inject `IDbContextFactory` and run EF (including entity mutation and `SaveChangesAsync`) directly in `@code`. The service-seam pages are the ones with meaningful test coverage; the EF-in-page logic is untestable without a host. New pages copy whichever file is nearest.
UNS pages go through `IUnsTreeService` (testable, RowVersion-aware, 12 test files). But `ScriptEdit.razor:87156`, `Scripts.razor:60`, `Deployments.razor:83100`, `Fleet.razor:125`, and `Hosts.razor:273` inject `IDbContextFactory` and run EF (including entity mutation and `SaveChangesAsync`) directly in `@code`. The service-seam pages are the ones with meaningful test coverage; the EF-in-page logic is untestable without a host. New pages copy whichever file is nearest.
**Recommendation:** declare the service-seam the convention; migrate `ScriptEdit`'s save/delete into the existing `UnsTreeService` script methods (`UpdateScriptSourceAsync` already exists at `IUnsTreeService.cs:502`).
### C-6 (Low) — Naming/marker nits
`"GalaxyMxGateway"` is a bare string literal in `TagModal.razor:311`; the `DataTypes` array (`TagModal.razor:244`) duplicates type lists that exist elsewhere; casing drifts across `TwinCat` (map key) / `TwinCATTagConfigEditor` / `FOCASAddressPickerBody` / `FocasTagConfigEditor`. CSS is disciplined (one `site.css` + shared theme kit; chip/panel classes reused consistently, `chip-warn` vs `chip-caution` both exist — verify both are defined in the theme).
`"GalaxyMxGateway"` is a bare string literal in `TagModal.razor` (comments/branching at lines 98, 262, 275 and the dispatch site); the `DataTypes` array (`TagModal.razor:244`) duplicates type lists that exist elsewhere; casing drifts across `TwinCat` (map key) / `TwinCATTagConfigEditor` / `FOCASAddressPickerBody` / `FocasTagConfigEditor`. CSS is disciplined (one `site.css` + shared theme kit; chip/panel classes reused consistently, `chip-warn` vs `chip-caution` both exist — verify both are defined in the theme).
### C-7 (Medium, NEW) — `ResilienceFormModel` round-trip is lossy: malformed JSON silently discarded on next edit, unknown keys stripped, and the promised raw-text view never landed
The per-driver resilience override editor (`Components/Shared/Drivers/DriverResilienceSection.razor`, embedded in all 8 driver pages via `@bind-ResilienceConfig`) violates the preserve-unknown-keys convention its sibling tag editors (C-3) established:
- `ResilienceFormModel.FromJson` (`ResilienceFormModel.cs:5274`) maps **only** the known top-level fields and only capability keys present in the fixed `Capabilities` list (line 67 `TryGetValue` drops the rest); `ToJson` (`:78101`) emits only the model's own fields. Any unknown top-level key or unrecognized capability name in the stored JSON is silently stripped the moment a user edits *any* resilience field (`EmitAsync`, `DriverResilienceSection.razor:6066`, replaces the bound value with `ToJson()`).
- Worse for the malformed case: `FromJson` catches `JsonException` and returns an **empty form** (`ResilienceFormModel.cs:59`), with the comment "malformed -> empty form; **raw view (next task) shows the text**". That task never landed — the "Raw JSON (advanced)" pane (`DriverResilienceSection.razor:3740`) is a read-only `<pre>` of `_m.ToJson()`, i.e. the *re-serialized model*, not the stored text. So a malformed/hand-edited `DriverInstance.ResilienceConfig` renders as an apparently-blank form, and the first keystroke in any resilience field overwrites the stored override with the empty model's output — silent data loss with no indication the original existed.
This mattered less when the column was write-only; since #13 (`75403caa`) the artifact read path applies these overrides to production dispatch, so a silently-emptied override silently reverts a driver to tier defaults.
**Recommendation:** adopt the tag-editor round-trip discipline (deserialize to `JsonObject`, overlay known fields, preserve the rest), surface a "stored JSON could not be parsed — editing will replace it" warning when `FromJson` falls back, and make the raw pane show (or edit) the actual stored text. `ResilienceFormModelTests.cs` exists — extend it to pin unknown-key preservation.
---
@@ -173,7 +216,7 @@ UNS pages go through `IUnsTreeService` (testable, RowVersion-aware, 12 test file
### U-1 — Test coverage: strong C# seams, zero component rendering coverage
443 `[Fact]`/`[Theory]` cases covering: all tag-config models + validator, `UnsTreeService` (structure/tags/vtags/import/equip-token/script CRUD against a test DB), browse session service/registry/reaper, `CertificateStoreManager`, audit writers, `DeployApiEndpoints` auth matrix, driver-page form serialization (the enum bug class), 8 ScriptAnalysis suites (diagnose/completions/hover/format/tag-path/equip-token), `InProcessBroadcaster`, `HostsDriverView`, address builders. That is a deliberately shaped portfolio around the pure seams.
443 `[Fact]`/`[Theory]` cases (unchanged since the prior review) covering: all tag-config models + validator, `ResilienceFormModel`, `UnsTreeService` (structure/tags/vtags/import/equip-token/script CRUD against a test DB), browse session service/registry/reaper, `CertificateStoreManager`, audit writers, `DeployApiEndpoints` auth matrix, driver-page form serialization (the enum bug class), 8 ScriptAnalysis suites (diagnose/completions/hover/format/tag-path/equip-token), `InProcessBroadcaster`, `HostsDriverView`, address builders. That is a deliberately shaped portfolio around the pure seams.
There is **no bUnit** — 77 razor components have no render/binding coverage, which is the documented repo posture ("no bUnit — live-verify"; the `@`-binding bug class passes all unit tests). The consequence is that everything in `@code` blocks that isn't extracted (e.g. all of `EquipmentPage`'s tab/delete handlers, `GlobalUns`'s modal orchestration, `Alerts`' row-action state machine) is verified only by manual `/run` sessions.
@@ -184,7 +227,8 @@ There is **no bUnit** — 77 razor components have no render/binding coverage, w
- `InlayHints` is a wired no-op endpoint + active client provider (P-6); documented in `ScriptAnalysisService.cs:2225`.
- `AlarmsHistorian.razor:7378` — admits the missing role-gated "no historian on this node" message; page silently shows "Loading…" forever on admin-only nodes.
- `MonacoEditor.razor:3337``MarkersChanged` is typed `object[]` with a comment that the DTO "is not modelled yet".
- `GlobalUns.ConfirmDeleteAsync` default branch returns "Delete for this node kind is not yet available." for Tag/VirtualTag nodes (`GlobalUns.razor:~320`) — consistent with equipment-page ownership, but the message leaks a not-implemented posture into the UI.
- `GlobalUns.ConfirmDeleteAsync` default branch returns "Delete for this node kind is not yet available." for Tag/VirtualTag nodes (`GlobalUns.razor:~324`) — consistent with equipment-page ownership, but the message leaks a not-implemented posture into the UI.
- `DriverResilienceSection.razor:59` — the "raw view (next task)" comment in `ResilienceFormModel.cs` documents a follow-up that never shipped (see C-7).
### U-3 — Raw-JSON fallback surfaces
@@ -196,24 +240,34 @@ There is **no bUnit** — 77 razor components have no render/binding coverage, w
~31 `aria-*`/`role=` attributes across 77 components, concentrated in the modals (`role="dialog"`, `btn-close` aria-labels). Gaps: text-glyph expander buttons (`▼`/`▶` in `UnsTree.razor:49`) with no `aria-expanded`/label; modals have no focus trap or Escape handling; live tails (`Alerts`) have no `aria-live` region; tables lack captions/scope. Form labeling is decent (most inputs have `for`/`id` pairs). Acceptable for an internal ops tool; worth a targeted pass on the tree and the modals if operator diversity matters.
### U-5 (NEW) — Resilience runtime status has no AdminUI surface; overrides are authored blind
The remediation merges made per-driver resilience live in production: #10 (`bacea1a4`) wired `CapabilityInvoker` into every `DriverInstanceActor` dispatch site, and #13 (`75403caa`) applies the AdminUI-authored `ResilienceConfig` overrides from the deploy artifact. But `DriverResilienceStatusTracker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs`) still has **no reader** — no AdminUI component, hub, or endpoint consumes it (`grep -rn Resilience src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` matches only the authoring form + driver pages). The planned `/hosts` Stream E.2/E.3 status panels never shipped; the interim observability surface is retry/breaker **log lines** added during #10. `archreview/plans/STATUS.md` explicitly records this as "still open as an Admin-UI follow-on."
Consequence: an operator can now author retry counts, breaker thresholds, and bulkhead limits per driver instance (all 8 driver pages) with zero UI feedback on whether a breaker is open, a bulkhead is queuing, or retries are firing — the only diagnosis path is server logs.
**Recommendation:** ship the deferred `/hosts` resilience panel: expose `DriverResilienceStatusTracker` snapshots through the existing `DriverStatusSignalRBridge`/`IDriverStatusSnapshotStore` pattern (the plumbing idiom already exists) and render breaker/bulkhead state chips beside the driver health chips.
---
## Maturity Ratings
| Dimension | Rating (15) | Justification |
|---|---|---|
| Stability | **4** | Disciplined disposal/marshaling with documented race reasoning and the self-HubConnection ban honored everywhere; docked for three non-draining timers, `Fleet`'s uncaught refresh path, no `ErrorBoundary`, and the RowVersion-refetch delete pattern. |
| Performance | **3** | Event-push where it matters and bounded lists, but keystroke-grain Monaco interop, per-request Roslyn recompiles ×6 endpoints, per-circuit DB polling, and a full config snapshot per Deployments render. |
| Conventions | **3** | The tag-editor/validator pattern is exemplary and test-pinned, but authorization is a three-idiom mix with the main mutating surface ungated, plus service-seam vs EF-in-page duality and a CLAUDE.md gate-description drift. |
| Underdeveloped | **3** | 443 well-aimed seam tests, but zero rendering coverage for 77 components (by policy), several admitted stubs, an unvalidated raw-JSON escape hatch, and thin accessibility. |
| Dimension | 2026-07-08 | 2026-07-12 | Justification |
|---|---|---|---|
| Stability | **4** | **4** | Unchanged code, unchanged assessment: disciplined disposal/marshaling with documented race reasoning and the self-HubConnection ban honored everywhere; docked for three non-draining timers, `Fleet`'s uncaught refresh path, no `ErrorBoundary`, and the RowVersion-refetch delete pattern — all still open. |
| Performance | **3** | **3** | Unchanged: event-push where it matters and bounded lists, but keystroke-grain Monaco interop, per-request Roslyn recompiles ×6 endpoints, per-circuit DB polling, and a full config snapshot per Deployments render. |
| Conventions | **3** | **3** | C-2 (doc drift) fixed, but the dominant C-1 three-idiom/ungated-majority finding is untouched, C-5/C-6 remain, and the new C-7 shows the preserve-unknown-keys convention wasn't carried into the resilience editor. |
| Underdeveloped | **3** | **3** | 443 well-aimed seam tests but still zero rendering coverage (by policy), the same admitted stubs, the unvalidated raw-JSON escape hatch and the new U-5: production-live resilience behavior authored with no status surface. |
---
## Top Recommendations (ordered)
1. **C-1:** Centralize policy constants and put a write-role gate on every config-mutating page (`/uns`, equipment, cluster/driver/ACL/namespace editors, Reservations).
2. **S-4 + S-5:** Make deletes carry the rendered RowVersion (or document last-writer-wins) and add confirm dialogs on `EquipmentPage`/`ScriptEdit` deletes.
3. **S-3:** Add an `ErrorBoundary` around `@Body`.
4. **P-1:** Debounce Monaco's value push to .NET.
5. **S-1/S-2:** Converge the three straggler timers on the async-dispose idiom and give `Fleet.LoadAsync` the `Hosts`-style catch.
6. **C-5/U-1:** Declare the service-seam pattern canonical and keep extracting `@code` logic into testable classes.
1. **C-1:** Centralize policy constants and put a write-role gate on every config-mutating page (`/uns`, equipment, cluster/driver/ACL/namespace editors, Reservations) — now also guarding production-live resilience overrides.
2. **C-7:** Make `ResilienceFormModel` round-trips non-lossy (preserve unknown keys, warn on unparseable stored JSON) before an edit silently reverts a live driver to tier defaults.
3. **S-4 + S-5:** Make deletes carry the rendered RowVersion (or document last-writer-wins) and add confirm dialogs on `EquipmentPage`/`ScriptEdit` deletes.
4. **S-3:** Add an `ErrorBoundary` around `@Body`.
5. **U-5:** Ship the deferred `/hosts` resilience status panel via the existing snapshot-store idiom.
6. **P-1:** Debounce Monaco's value push to .NET.
7. **S-1/S-2:** Converge the three straggler timers on the async-dispose idiom and give `Fleet.LoadAsync` the `Hosts`-style catch.
8. **C-5/U-1:** Declare the service-seam pattern canonical and keep extracting `@code` logic into testable classes.