Files
lmxopcua/archreview/04-adminui.md
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

28 KiB
Raw Permalink Blame History

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 ValidationMessages. 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.