135 lines
7.6 KiB
Markdown
135 lines
7.6 KiB
Markdown
# 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 (`_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 `_adapterGeneration`,
|
||
bypassing the stale-data guard;
|
||
(c) stale connection-level-failure rows knock a healthy connection back to Reconnecting (~:434);
|
||
(d) alarm twin: a real completion releases the NEW live alarm feed as a duplicate (~:2548–2557).
|
||
|
||
**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 `UpdateAsync` on
|
||
this row remains reachable concurrently, CAS it too.
|
||
- Emit the `SecuredWriteReject` audit 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-*.md` only where it documents the now-
|
||
contradicted behavior; one sentence, no restructuring.
|
||
|
||
## Integration
|
||
|
||
1. Cherry-pick the four commits onto `main` (file-disjoint; T1/T2 share a project but
|
||
not files — verify no test-fixture collisions).
|
||
2. `dotnet build ZB.MOM.WW.ScadaBridge.slnx`, then run DataConnectionLayer, Management-
|
||
Service, and CentralUI test suites (plus any suites the agents touched).
|
||
3. `git diff` review, commit plan doc + register/CLAUDE.md touch-ups if any, remove
|
||
worktrees. No push without the user.
|