Files
ScadaBridge/docs/plans/2026-08-19-medium-findings-remediation-plan.md

375 lines
25 KiB
Markdown
Raw Permalink 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.
# Medium-Findings Remediation Plan — 2026-08-19 Arch Review
Scope: all **Medium** and **Medium-High** findings from the 2026-08-19 updated architectural
review (report artifact `43c7fa20-9626-4d59-a9c9-061524794660`). The four Highs (P2-1, P2-2,
P6-1, P6-2) were fixed in the preceding wave (`c4d3c8b8..d18f344e`, deployed to the rig).
Baseline: `main @ d18f344e`.
Two borderline findings are rounded **in** because they are cheap and adjacent to in-scope
work: P3-3 (Low-Med, Debug View dual-address requirement) and P4-4 (Med-Low, standby
event-log retention). All plain-Low findings stay out of scope (P1-4, P2-5, P2-6, P3-4,
P3-5, P4-5..P4-7, P5-3..P5-6, P6-10..P6-12, P7-5, P7-6, P8-7..P8-10).
Register consequences (handled at integration, NOT by task agents, to avoid file
contention): row 99 reopened+closed by T8 (P6-3 shows the closure was defective);
row 95 reopened+closed by T17 (P8-6 shows its premise correction was wrong).
Execution model (same as the High wave): file-disjoint tasks, one dedicated manual git
worktree + model-matched subagent each, pathspec commits, cherry-pick onto main,
combined build + affected suites, `git diff` review, single merge session. Two waves.
Decision made in this plan (owner may override later): **P7-2 takes the conservative
option** — bulk `DeploySiteAsync` skips `Disabled` instances and surfaces them in the
result as skipped, matching the reconcile path's deliberate preservation of Disabled.
Rationale: the alternative (silently force-enabling deliberately-stopped equipment on a
bulk push) is the hazard the finding names.
---
## Wave 1
### T1 (Opus) — P1-1 + P1-2: DeploymentManager races (heal resurrection; redeploy override reset)
**Files:** `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs`,
`InstanceActor.cs`, the `EnsureInstanceStartedResult` message, `InstanceActorTests.cs`.
- **P1-1 (Med):** `HandleEnsureInstanceStarted` (:1571) re-reads the store off-thread but
captures no `_storeWriteGeneration`; the result handler never calls
`IsStoreSnapshotStale` (unlike boot :521 and sweep :1726). A Disable/Delete processed
between read and result resurrects the instance permanently (sweep has no runtime→store
stop leg; disabled rows `continue`, deleted rows are absent). Verifier note: trigger is
the ONE-SHOT startup reconcile (`SiteReconciliationActor.cs:267`), so the window is
boot-adjacent; the stale comment at :1567-1570 must go. **Fix:** thread the generation
captured at read through `EnsureInstanceStartedResult`; discard on mismatch (boot/sweep
pattern). Test: heal result arriving after a Disable/Delete is dropped.
- **P1-2 (Med, arguably Med-High):** ApplyDeployment creates the new InstanceActor (:1202)
BEFORE the Task.Run that ends in `ClearStaticOverridesAsync` (:1222-1234); the new
actor's PreStart override load typically beats the clear (verifier: likely interleaving)
→ pre-redeploy overrides applied for the actor's lifetime, violating "reset on
redeployment". Leg 2: old actor's PostStop fire-and-forget `FlushDirtyStaticOverrides`
(InstanceActor.cs:363-364) can durably commit AFTER the clear. **Fix:** on redeploy,
clear overrides in the persistence task BEFORE `CreateInstanceActor` (or gate the
override load behind the persistence result), and order the old actor's final flush
before the clear (e.g. await/flush before actor creation — Terminated is already awaited
:1004-1066, so flushing synchronously in PostStop's stop path or draining before clear
are both acceptable; pick the minimal ordering that closes both legs).
`InstanceActorTests.cs:355-356` pins the WRONG contract — fix the test to pin the right
one. Tests: redeploy never applies pre-redeploy overrides; late flush cannot resurrect.
### T2 (Sonnet) — P1-3: Alarm on-trigger runs bypass the stuck-script watchdog
**File:** `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs`
(`RunAlarmScriptAsync` :454-537 / `LaunchAlarmScript` :149-186).
Script path arms watchdog + slot capture (:244-253); alarm path does none on the same
bounded pool — a wedged alarm body silently costs a pool thread forever (bounded 4/alarm
by the in-flight cap), with no site event, no detach, no healthCollector error increment,
no `ScriptRunSummaryRecorder`. **Fix:** mirror the script path exactly — slot/stamp/
watchdog + health/summary threading (timeout catch today is LogWarning-only :518-524).
Tests: first tests to drive `LaunchAlarmScript` — wedged alarm body triggers watchdog
detach + site event + `DetachedScriptThreads`; timeout increments health error.
### T3 (Opus) — P2-3 + P2-4: DCL ConnectResult generation; OPC UA batch rollback
**Files:** `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs`,
`Adapters/RealOpcUaClient.cs`.
- **P2-3 (Med):** `ConnectResult` piped untagged (:777-787). Reconfigure mid-connect: old
adapter's `ConnectResult(true)` drives ReSubscribeAll+BecomeConnected against the NEW
never-connected adapter; new adapter's result dropped in Connected; idempotent-
reconfigure guard (:644-652) then blocks recovery until restart/redeploy. **Fix:** tag
`ConnectResult` with the adapter generation (stamped at connect launch, exactly the
P2-1 pattern now on `SubscribeCompleted` — reuse it); drop mismatches. Tests mirror
`DataConnectionStaleCompletionTests` for the connect leg.
- **P2-4 (Med):** `CreateSubscriptionsCoreAsync` (RealOpcUaClient.cs:663): mid-batch
`ApplyChangesAsync` throw leaves all `created` items registered (_callbacks/_itemShard/
ItemCount/SDK items :628-661) with no rollback, and `IsConnectionLevelFailure`
(DataConnectionActor.cs:1464-1471) misses `ServiceResultException` → chunk treated
per-tag unresolved, each backoff probe RE-ADDS items → unbounded shard growth. **Fix:**
unwind `created` via `ReleaseFailedItem` on throw; classify SDK bad-session codes
connection-level. Tests: fake-SDK mid-batch throw leaves no residue; bad-session code
drives the connection-level path.
### T4 (Opus) — P3-1 + P3-2 + P3-3: gRPC client lifecycle/disposal races
**Files:** `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs`,
`Grpc/SitePairChannelProvider.cs`, `DebugStreamService.cs`.
- **P3-1 (Med):** `RegisterSubscription` (:147-153) disposes the prior handler's CTS while
its `ConsumeStreamAsync` may still read the token (ODE at :219/:294/:359/:364);
`Unsubscribe` (:455-462) unguarded Cancel; `ReleaseResources` (:609-619) unguarded
iteration whose ODE aborts the factory's DisposeAsync loop leaving later clients
undisposed; an ODE from `prior.Cancel()` escapes the NEW SubscribeAsync with the CURRENT
generation → phantom fault. **Fix:** cancel-only on replacement (dispose only from the
owning path), ODE-guard every Cancel, keep factory disposal loop fault-isolated.
`SiteStreamGrpcClientTests.cs:680` pins current behavior — re-pin.
- **P3-2 (Med):** `UpdateSite`/`RemoveSite` (:123-131/:155-159) Dispose channels under
`pair.Gate` while `ExecuteAsync` invokes outside the lock (:203-215) → 60s DB refresh
with an edited address kills in-flight RPCs (multi-minute DeployArtifactsCommand) with
ODE/Cancelled (not Unavailable → no cross-node retry). **Fix:** deferred disposal — park
replaced channels until quiescent or time-delayed; mirror the stream factory's redesign
rationale (SiteStreamGrpcClientFactory.cs:12-22).
- **P3-3 (Low-Med, rounded in):** `DebugStreamService` (:111-114) throws unless BOTH gRPC
node addresses are set, though single-endpoint sites are supported elsewhere. **Fix:**
apply the N9 degeneration (`SiteAlarmLiveCacheService.cs:324-330`): reuse the sole
endpoint.
### T5 (Opus) — P4-1 + P4-3: LocalDb sync divergence + liveness (cross-repo)
**Files (scadaproj):** `~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/src/.../Internal/SyncSession.cs`,
`OplogStore.cs`, `SyncBackgroundService.cs`, tests. **Files (here):**
`src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (site Kestrel h2 keepalive),
`Directory.Packages.props` (0.3.1 → 0.3.2 when published).
- **P4-1 (Med):** `PumpLoopAsync` never checks `needs_snapshot` after a cap-prune though
`OplogStore.ReadBatchAboveAsync` documents the obligation (OplogStore.cs:27-29) —
maintenance prunes above `_sentThruSeq` on a slow-but-alive peer → pump silently skips
the gap, pair diverges for the session's lifetime (heals only on reconnect). **Fix:**
fault the live session when `EnforceCapsAsync` prunes into the in-flight range (the
0.3.1 fault-teardown control-lane flush already exists — reuse it). Test: prune during
an active session faults it; reconnect serves the snapshot.
- **P4-3 (Med):** no keepalive anywhere on the sync stream: bare `GrpcChannel.ForAddress`
client-side, no Kestrel `Http2.KeepAlivePingDelay` on site 8083 → hard crash/partition
of the dialing node leaves the passive node's handler hung on MoveNext holding
`_active=1`, rejecting the rebooted initiator's reconnects `AlreadyExists`, possibly
forever; zombie side reports `LocalDbReplicationConnected=true`. **Fix:** client
`SocketsHttpHandler` KeepAlivePing (delay/timeout, always-with-active-requests) in
`SyncBackgroundService`; Kestrel h2 keepalive on the site gRPC listener in Host
`Program.cs`; AND newest-wins eviction (or a bounded read deadline) on the server's
single-stream guard so a fresh authenticated Sync can displace a dead one.
- **Version/publish:** bump LocalDb to 0.3.2, commit in scadaproj (do NOT push);
`build/push.sh` needs the user-held `GITEA_NUGET_KEY` — if absent, leave the
ScadaBridge `Directory.Packages.props` bump as a flagged follow-up and land only the
Host keepalive here. The ScadaBridge-side Kestrel change is independent and lands
regardless.
### T6 (Sonnet) — P4-2 + P4-4: audit-writer durability; standby event-log retention
- **P4-2 (Med, arguably Med-High):** `AuditLog/Site/SqliteAuditWriter.cs` — per-row
`TrySetResult` (:487/:499) BEFORE `transaction.Commit()` (:503); commit failure
(SQLITE_FULL/IOERR) no-ops `TrySetException` → `FallbackAuditWriter` sees success, no
ring fallback, whole batch silently lost in exactly the outage the ring exists for;
`SiteAuditWriteFailures` stays 0. Bonus (verifier): a mid-batch insert failure on row k
loses rows 0..k-1 the same way. **Fix:** complete TCSes only after commit (the pattern
`SiteEventLogger.FlushBatch` already uses :319/:357-361); on commit/insert failure fail
EVERY row's TCS so the ring fallback engages. Tests: failed commit → all TCSes faulted +
ring receives batch + failure counter increments.
- **P4-4 (Med-Low, rounded in):** `SiteEventLogging/EventLogPurgeService.cs:215-219`
early-exits on the standby on the stale premise "standby receives no writes" — DCL
actors run on both nodes since rows 89/90/97 and write site events. On an unreplicated
site (site-b/c posture) the standby's `site_events` grows forever. **Fix:** let the
standby run retention/cap against its own file (no-op-safe on a replicated pair —
deletes are CDC-captured and LWW-converge; say so in a comment). Test: purge runs when
not primary.
### T7 (Sonnet) — P5-1 + P5-2: central ingest fallback budget; SiteCalls query shape
- **P5-1 (Med):** `AuditLog/Central/AuditLogIngestActor.cs:372-405` — per-entry fallback
loop passes NO token/timeout (contrast `OnIngestAsync`'s 5s `IngestFallbackBudget`
:268); each entry sits inside `SqlServerRetryingExecutionStrategy` 5×30s → minutes
inside `ReceiveAsync`, singleton mailbox frozen, site re-sends amplify. **Fix:** mirror
`OnIngestAsync` — short linked CTS + `IngestSqlCommandTimeout` per entry, pass the token
to `InsertIfNotExistsAsync`/`UpsertAsync`. Test: fallback loop honors budget.
- **P5-2 (Med):** `ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs:301-331`
orders `(CreatedAtUtc DESC, TrackedOperationId DESC)` with no index leading on
`CreatedAtUtc` (full scan+sort per unfiltered page, row-35 class) and binds
nvarchar(4000) params against `IsUnicode(false)` columns (may block seeks, incl. the
keyset predicate :316; write path types VarChar deliberately :230). **Fix:** add the
`(CreatedAtUtc DESC, TrackedOperationId DESC)` index via EF migration (BUILD FIRST —
`--no-build` scaffolds empty) + typed varchar params on the read path. Update the
handwritten production SQL script if one exists for this table (migration-parity rule).
### T8 (Sonnet) — P6-3: ParseEnumListStrict comma smuggling (register row 99 defective)
**File:** `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditQueryParamParsers.cs:117-119`.
`Enum.TryParse` ORs comma-joined names for any enum; `LooksNumeric` checks first char
only; dense 0-based `AuditStatus` makes composites land on defined members —
`"Forwarded,Attempted"` = 1|2 = 3 = `Delivered` (silent wrong data); comma also smuggles
ordinals past the row-88/99 guard (`"Failed,7"` → `Skipped`). Server path confirmed:
`AuditEndpoints.cs:656-658` passes the raw query value whole. **Fix:** reject any value
containing ',' before TryParse (per-token error, same UX as existing invalid-name error).
Tests: comma-joined names rejected; `"Failed,7"` rejected; single names/known-good paths
unchanged. Do NOT edit the register (integration does).
## Wave 2
### T9 (Opus) — P6-4 + P6-5: ManagementActor scoping gaps (single-writer lane)
**File:** `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (sole owner
this wave).
- **P6-4 (Med):** `HandleListDataConnections` (:1875-1881) takes no user, null role gate →
lists every site's connections (scrubbed but topology-revealing existence oracle) while
`HandleGetDataConnection` fail-closes (:1888-1900) — Get denies what List hands over.
**Fix:** filter by `PermittedSiteIds` exactly as `HandleListSites` (:1719-1729) /
`HandleListSecuredWrites` (:1665-1695) do. Check the Design UI page's separate
`SiteRepository.GetAllDataConnectionsAsync` bypass (DataConnections.razor:309) — if it
serves scoped users unfiltered, route it through scope too (same one-line filter).
- **P6-5 (Med):** resolve-before-scope oracles outside row 98's five shapes: approve
(:1465-1474) and reject (:1627-1635) check not-found + status (leaking "is Executed,
not Pending") BEFORE `EnforceSiteScopeForIdentifier`; same shape `HandleDebugSnapshot`
(:3604-3608) + `HandleSubmitSecuredWrite` (:1417-1423). **Fix:** hoist scope checks
above resolution/status checks at all four sites (row-98 `DenyUnresolvedTargetIfScoped`
semantics). Tests: out-of-scope caller gets the scope denial, not the status oracle,
at each site.
### T10 (Opus) — P6-6: Debug-stream session abandoned mid-start leaks permanently
**Files:** `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/DebugView.razor(.cs)`
(:493), `DebugStreamHub.cs` (:195).
`Connect()` awaits `StartStreamAsync` (≤30s) then assigns `_session` with no `_disposed`
re-check; if Dispose already ran, StopStream never fires and the singleton
`DebugStreamService` keepalive renews the registered session forever (:157/:213-219; the
5-min orphan net only reaps UNregistered sessions) — holds the site gRPC debug sub +
bridge actor + dead circuit graph until restart. Identical shape in `DebugStreamHub.cs:195`.
**Fix:** post-await `_disposed` re-check that stops the just-created session (both sites);
make Dispose vs late-assign race-safe (interlocked/lock — Blazor Sync context helps but
Dispose can run on a different flow). Tests: dispose-during-start stops the session and
leaves nothing registered. The lesser TTL-bounded instances (TransportImport slot,
Topology timer) are OPTIONAL — take them only if trivial with the same pattern.
### T11 (Sonnet) — P6-7 + P6-8: audit export injection + CLI deadline
- **P6-7 (Med):** `ManagementService/AuditEndpoints.cs:810-840` CSV quotes only on
`, " \r \n`; leading `= + - @ \t` pass through → formula/DDE execution on operator open
(CWE-1236); exported Target/ErrorMessage/Request/ResponseSummary/Extra carry inbound-
API-caller text verbatim. **Fix:** prefix-escape cells starting with those chars
(`'` prefix, the standard neutralization). Secondary: CSV omits ExecutionId/
ParentExecutionId/SourceNode vs JSONL and the `AuditExportRow.cs:11-13` "24-field"
claim — add the columns and align the doc comment.
- **P6-8 (Med):** `CLI/Commands/AuditExportHelpers.cs:129` —
`SendGetStreamAsync(..., CancellationToken.None)` after `HttpClient.Timeout = Infinite`
(residue of the e0e4b246 timeout-honesty fix): a hung server hangs the CLI forever, and
body reads (:141/:152)/`CopyToAsync` (:171) are tokenless (mid-stream fault → raw stack
+ truncated file). **Fix:** headers-received deadline + overall/inactivity deadline +
Ctrl-C token wiring through all reads; friendly error on mid-stream fault. Tests:
deadline fires; cancellation propagates.
### T12 (Sonnet) — P6-9: LoginThrottle never throttles single-IP password spray
**File:** `src/ZB.MOM.WW.ScadaBridge.CentralUI/Security/LoginThrottle.cs:189-190`.
Key is `{username}|{ip}` → independent 5-failure budget per username per IP; a spray
(many usernames, one IP) is never throttled though the class doc claims spray protection.
**Fix:** add a secondary per-IP counter with a higher ceiling (e.g. 20-30 failures/window
→ per-IP lockout), layered on the existing per-pair key; keep the doc honest. Tests:
spray locks the IP; normal per-user behavior unchanged; distinct IPs unaffected.
### T13 (Sonnet) — P7-1: ExternalSystem TimeoutSeconds silently dropped by bundles
**Files:** `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:248`,
`EntitySerializer.cs:127-141`, `BundleImporter.cs:3087-3128`, `ArtifactDiff.cs:166-179`,
`RoundTripEquivalenceTests.cs:445-452`.
Row-27 SHAPE recurrence: DTO lacks `TimeoutSeconds` (operative at runtime and deployed as
proto field 6); export never writes it, Add resets to 0, Overwrite keeps old, diff never
compares → a tuned-timeout-only bundle previews Identical and the target env silently runs
the gateway default. The round-trip guard is vacuous because the fixture never sets the
field. **Fix:** add the DTO field + both serializer directions + both import branches +
`AddIfDifferent` in the diff + a NON-DEFAULT fixture value so the reflection round-trip
guard actually covers it. Schema stays additive (schemaVersion unchanged — additive field).
### T14 (Opus) — P7-2 + P7-3 + P7-4: bulk-deploy semantics, reconcile deadline, probe TOCTOU
**Files:** `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs`,
`StaleInstanceProbe.cs`, `FlattenSession.cs`.
- **P7-2 (Med, decided above):** `DeploySiteAsync` deploys ALL instances (:804) and every
success runs `forceEnabledState:true` (:610-612) — bulk push re-enables deliberately-
Disabled equipment while the reconcile path deliberately preserves Disabled (:1638-1649,
pinned). **Fix:** skip Disabled instances in bulk deploy; surface them in the result as
a skipped list with reason; pin with tests (zero Disabled cases exist in
DeploySiteAsyncTests today). Update `Component-DeploymentManager.md` (one sentence).
- **P7-3 (Med):** phase-1 `TryReconcileWithSiteAsync` per instance is bounded only by the
30s QueryTimeout → unreachable site × N instances ≈ hours serial before a single send;
CLI's 5-min BulkDeployTimeout cancels and re-arms the identical cost. **Fix:** in bulk
mode, short linked per-reconcile deadline AND a once-per-batch site reachability probe —
first reconcile timeout marks the site unreachable for the rest of Prepare (instances
proceed on DB state, which is what happens on timeout today anyway).
- **P7-4 (Med):** `StaleInstanceProbe.cs:106-109` re-reads per-template versions AFTER
flatten (comment claims before) → a non-structural template edit during flatten pairs
pre-edit hash with post-edit version; memo permanently serves not-stale. FlattenSession
captures correctly (FlattenSession.cs:114) but doesn't expose it. **Fix:** expose the
session's captured `MemberVersions`; probe stores those instead of re-reading. Test:
template edit mid-flatten still reports stale.
### T15 (Sonnet) — P8-1: three SiteHealthReport fields never cross the wire
**Files:** `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs:452`,
the vendored centralcontrol proto (regen via `bash docker/regen-proto.sh centralcontrol`),
`SiteRuntime SiteHealthCollector.cs:243/:317-327`, `HealthReportSender.cs:180`.
`DetachedScriptThreads`, `ScriptRunShedCount`, `SiteStreamAlarmDropCount` are populated
site-side but `ToDto` omits all three (proto ends at field 29) — a watchdog-detaching/
shedding/alarm-dropping site looks healthy centrally. Aggravator: `AddIntervalCounters`
takes only the 5 original counters and `HealthReportSender` never passes the shed count →
a failed send permanently loses the interval's shed count (drained by
`Interlocked.Exchange`). **Fix:** proto fields **30-32** (additive, never reuse numbers),
mapper both directions, extend `AddIntervalCounters` so failed sends restore all drained
counters. Follow the vendored-proto workflow exactly (regen script restores the csproj; an
active `<Protobuf>` item must never be committed). Tests: mapper round-trips the three
fields; failed send restores shed count.
### T16 (Sonnet) — P8-2 + P8-3 + P8-4: env2 topology drift + regen-proto trap
- **P8-2 (Med):** `docker-env2/docker-compose.yml` — add `init: true` to all 4 services
(docker/ has it ×8 with the issue-#34 rationale; env2's PID-1 boot failure wedges at
100% CPU instead of restart-looping).
- **P8-3 (Med):** env2 centrals still carry the SEC-36-rotated LDAP bind cred in plaintext
(`central-node-a/appsettings.Central.json:28,34` + node-b), plus inline SQL passwords +
JWT key. **Fix:** port the docker/ posture — compose env-var overrides for secrets, and
the same LDAP fallback/DisableLogin arrangement docker/ uses. Do NOT invent new secret
values; reference the same env vars docker/ consumes.
- **P8-4 (Med):** `docker/regen-proto.sh:58` — `trap ... ERR` doesn't fire on Ctrl-C
during the long build → active `<Protobuf>` left in the csproj (breaks Docker build).
**Fix:** idempotent restore trap on `EXIT` (covers ERR/INT/TERM/success; guard so a
successful run doesn't double-restore).
### T17 (Sonnet) — P8-5 + P8-6: test-health (register rows 95/96/40/102 classes)
- **P8-5 (Med):** Communication.Tests: (a) add `xunit.runner.json` `maxParallelThreads: 4`
(row-96 policy; ~14 TestKit classes + real Kestrel, last uncapped resource-heavy suite);
(b) `SiteAlarmLiveCacheTelemetryTests.cs:55-93` asserts fixed baselines on the
process-global `aggregators.active` gauge with no `[Collection]` while two sibling
suites move the instruments (row-102 shape) — apply the row-102 treatment (serialize via
collection or delta-based assertions); (c) rework the 8 sleep-then-assert-absence sites
in `SiteAlarmAggregatorActorTests` (:385-386, :624-625 et al) per row-40 (await the
observable effect / use TestKit expectations, never wall-clock sleeps), and fix the
discarded 100ms-tick actor in `PendingDeploymentPurgeActorTests.cs:90` (row-95 class:
Watch + Stop + ExpectTerminated).
- **P8-6 (Med):** SiteCallAudit.Tests DOES leak 100ms-tick actors (row 95's premise
correction was wrong): `SiteCallAuditPurgeTests` actors at :127/:152/:174 + discarded
`Sys.ActorOf` :209-212, `SiteCallAuditReconciliationTests` :58/:248/:372/:592 — zero
Watch/Stop/ExpectTerminated in either file. **Fix:** row-82 treatment on every leaked
actor. Do NOT edit the register (integration reopens/closes row 95 with a citation).
- Test-only task: verify by running both suites (Communication.Tests 3× for flake
confidence).
---
## Shared constraints (all tasks)
- Fix + tests only; no drive-by refactors. Match surrounding style/comment density.
- Targeted verification per task: build touched project(s) + test project(s); run affected
suites filtered. Full solution sweep once, at integration.
- Each agent works ONLY inside its own worktree (absolute paths, `git -C`), commits with a
pathspec limited to its own files, reports commit SHA + test results. Agents do NOT
touch `docs/plans/2026-07-08-deferred-work-register.md` or `CLAUDE.md` (integration
does).
- Component-doc updates only where the doc states the now-contradicted behavior; one
sentence each.
- Wire/proto changes additive only; never reuse field numbers.
## Integration (per wave, then final)
1. Cherry-pick wave commits onto `main` (verify file-disjointness first; T4/T15/T17 share
the Communication project — files must stay disjoint).
2. `dotnet build ZB.MOM.WW.ScadaBridge.slnx`; run the touched suites; full
`dotnet test` sweep after wave 2.
3. Register edits: reopen+close rows 95 and 99 citing P8-6/P6-3; add rows for anything an
agent flags as deliberately deferred.
4. `git diff` review; commit plan doc + register/docs touch-ups; remove worktrees.
5. No push, no rig redeploy without the user.