7.6 KiB
High-Findings Remediation Plan — 2026-08-19 Arch Review
Scope: the four High findings from the 2026-08-19 updated architectural review
(report artifact 43c7fa20-9626-4d59-a9c9-061524794660; all four adversarially verified
CONFIRMED). Baseline main @ e9e44a9e. Medium/Low findings and register-row reopens
(95, 99) are explicitly out of scope for this plan.
Execution model: four file-disjoint tasks, one dedicated git worktree + subagent each,
run in parallel; integration = cherry-pick onto main, combined build, affected test
suites, git diff review, single merge session.
T1 — P2-1 (High): Subscription completions carry no adapter generation
File: src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
(SubscribeCompleted ~:2806, AlarmSubscribeCompleted ~:2814 — actor-internal records,
no wire-evolution constraint).
Defect: completions are applied unconditionally. An in-flight subscribe on
generation N (window up to SeedOverallTimeout 120 s) racing a SwapAdapter
(row-97 in-place reconfigure, failover) to generation N+1 causes:
(a) stale completion stores a dead gen-N handle (:1368), then the live chunk's
duplicate guard (:2548–2557)._subscriptionIds.ContainsKey, :2109) releases the live monitored
item (:2145) — tag reads resolved-forever, delivers nothing;
(b) :1431 re-stamps old-endpoint seed values with current :434);
(d) alarm twin: a real completion releases the NEW live alarm feed as a duplicate (_adapterGeneration,
bypassing the stale-data guard;
(c) stale connection-level-failure rows knock a healthy connection back to Reconnecting (
Fix:
- Add
AdapterGeneration(stamped at subscribe launch) to both completion records; handlers drop mismatched completions entirely (no handle store, no seed apply, no failure-row processing, no duplicate-release). - On drop, release any successfully-created handles against the captured adapter reference, not the current one (also audit release sites ~:1295/:1404 — MxGateway int handles collide across sessions, so a handle must never be released against a different adapter instance than created it).
- Do NOT widen scope to
ConnectResult(P2-3, Med — separate follow-up).
Tests (DataConnectionLayer.Tests): stale SubscribeCompleted after a generation
bump (1) does not overwrite/store handles, (2) does not release the live item,
(3) does not seed stale values; alarm variant for AlarmSubscribeCompleted; released
handles go to the captured adapter.
T2 — P2-2 (High): MxGateway native alarm feed permanently dead after plain reconnect
Files: src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs
(ConnectAsync :93–157; alarm state _alarmCts/_alarmSubCount/_alarmSubSources/
_alarmStreamPrefix), RealMxGatewayClient.cs (:415–467 RunAlarmStreamAsync retry loop).
Defect: ConnectAsync resets the event loop/client/_disconnectFired but not the
alarm-stream state (reset only in Disconnect/Dispose, which a reconnect never calls).
After a gateway restart, SubscribeAlarmsAsync sees _alarmCts != null && Covers and
returns success without opening a stream on the new client, while the orphaned
RunAlarmStreamAsync retries the disposed old client every 5 s forever. The native-alarm
mirror silently stays Uncertain until failover/reconfigure/redeploy.
Fix: in ConnectAsync, cancel + dispose _alarmCts and reset the alarm-subscription
bookkeeping so the actor's post-reconnect re-subscribe opens a fresh stream on the new
client; ensure the old stream task observes cancellation and exits (no zombie retry
loop). Verify end-to-end that the actor's transparent re-subscribe path actually calls
SubscribeAlarmsAsync again after reconnect and the feed resumes.
Tests: reconnect-then-alarm-resubscribe with a fake client: new stream opened on the new client, old loop terminated, alarm events flow again.
T3 — P6-1 (High): Secured-write reject is not CAS-guarded
Files: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
(reject ~:1629–1655), SecuredWriteRepository (approve CAS TryMarkApprovedAsync
:90–114 is the pattern; reject uses a blind full-row UpdateAsync :44–52), plus the
repository interface.
Defect: handlers run concurrently (fire-and-forget PipeTo per envelope, :145–148),
PendingSecuredWrite has no concurrency token. Reject reads Pending → approve's CAS
wins and the multi-second WriteTagAsync relay runs → reject's blind update lands
mid-relay, stamping Rejected while the plant write executes; approve's final blind
update then overwrites back to Executed. The two-person-control record can be falsified
in either direction.
Fix:
- Add
TryMarkRejectedAsync(UPDATE … WHERE Status = 'Pending') mirroring the approve CAS; reject handler uses it and, on CAS failure, re-reads and returns a precise conflict error (same UX shape as approve's existing lost-race path). - Confirm approve's post-relay status write can no longer collide (once reject can only
win from Pending, the relay leg owns the row) — if any other blind
UpdateAsyncon this row remains reachable concurrently, CAS it too. - Emit the
SecuredWriteRejectaudit row only on CAS success.
Tests (ManagementService.Tests): reject-after-approve returns conflict and writes nothing; approve-after-reject returns conflict; repository-level CAS test (two transitions from one Pending row — exactly one wins).
T4 — P6-2 (High): AlarmSummary page bypasses site scoping
File: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor
(~:278). Pattern donors: DebugView.razor (:363/:427), EventLogs.razor, ParkedMessages.razor.
Defect: the page is RequireDeployment-gated (Deployer — exactly the role
SiteScopeService scopes) but never injects SiteScopeService: _sites = GetAllSitesAsync() unfiltered, and no scope check guards site selection or
AlarmSummaryService.GetSiteAlarmsAsync. A scoped Deployer can watch any site's live
alarms including CurrentValue/LimitValue process data. Server-side gap (Blazor Server).
Fix: mirror the sibling pattern exactly — inject SiteScopeService, filter the site
list, and enforce scope on the selected site before every alarm fetch (a crafted
interaction naming an out-of-scope site must be denied server-side, not just hidden).
Tests (CentralUI.Tests): mirror whatever pins the siblings' scoping (site list filtered; out-of-scope fetch denied). If no sibling UI-scoping test exists, add a service/page-logic-level test rather than a new UI harness.
Shared constraints (all tasks)
- Fix + tests only; no drive-by refactors. Match surrounding style/comment density.
- Targeted verification per task: build the touched project(s) + test project(s), run the affected suites filtered — not the full solution sweep (that runs once at integration).
- Each agent works ONLY inside its own worktree (absolute paths,
git -C), commits with a pathspec limited to its own files, and reports the commit SHA + test results. - Update the relevant
docs/requirements/Component-*.mdonly where it documents the now- contradicted behavior; one sentence, no restructuring.
Integration
- Cherry-pick the four commits onto
main(file-disjoint; T1/T2 share a project but not files — verify no test-fixture collisions). dotnet build ZB.MOM.WW.ScadaBridge.slnx, then run DataConnectionLayer, Management- Service, and CentralUI test suites (plus any suites the agents touched).git diffreview, commit plan doc + register/CLAUDE.md touch-ups if any, remove worktrees. No push without the user.