Files
ScadaBridge/docs/plans/2026-09-09-arch-review.md
T
2026-09-09 22:56:36 -04:00

139 KiB
Raw Blame History

ScadaBridge — architectural / code review (whole system)

Date: 2026-09-09 · Status: ALL 13 HIGH AND 33 MEDIUM FINDINGS COMPLETED (see §§9–10) Reviewed revision: main @ c078bc8f. Its only difference from the first review's d8229916 is the original findings document; production source/configuration is identical. Scope: all 26 src/ projects, 32 test/support projects, both Docker rig configurations, composition roots, contracts, and relevant design documentation. Method: three parallel read-only subagents reviewed (1) Site Runtime / DCL / S&F / event logging / CLI / notifier, (2) Management / Deployment / Templates / ScriptAnalysis / UI / Security, and (3) Commons / ConfigurationDatabase / Transport / edge integrations / audit / health. The primary reviewer covered Communication / ClusterInfrastructure / Host / configuration, rechecked all High source paths, ran build/test/format/package checks, and executed isolated probes. Each area revalidated its existing IDs and searched for additional defects. Source-traced mechanisms, runtime probes, latent risks, and unproven operational impact are distinguished below. This is a focused whole-system review, not a claim that every line or production topology was exercised. The review phase ran no rig rebuild, device writes, failover drill, or remote QA/production inspection. Sections 1–8 preserve that reviewed baseline; §§9–10 record subsequent implementation and local rig verification.

Second-pass changes: 11 new findings; UI-3 withdrawn as unsupported; XC-6 merged into CM-5; UI-4/6/7 reclassified as Low hardening gaps. Existing IDs are preserved for traceability. The first report counted the same dead RPCs twice and overstated several triggers and fixes; the corrections here supersede that wording. §8 records the review decisions and validation limits.

Severity: High = data loss / security boundary / outage; Medium = wrong behaviour an operator will hit, or a documented property that does not hold; Low = quality, consistency, narrow edge.


1. Verdict

The component structure remains intact. The twelve structural/runtime commitments catalogued in the first pass retain their code anchors (§6.H — active-node definition, single ActorSystem name, UTC everywhere, no ClusterClient, auto-down emitted, replication exclusions, metadata resolver on all four compile surfaces, no health publisher, no central buffering for unreachable sites, single trust boundary, additive proto evolution). The dependency graph is an acyclic DAG rooted at Commons with zero project references; every mapped gRPC service is behind a fail-closed constant-time interceptor; all contract changes since August are additive; all eight migrations are sound and the model snapshot matches. The test suite is broad; fresh execution evidence and its limits are in §7.

The reviewed baseline contained 92 unique findings: 13 High, 33 Medium, 46 Low. Remediation status: all 13 High and 33 Medium findings completed; 46 / 46 Low findings completed (see §11). Completion evidence and limits are recorded in §§9–10. This includes low-priority test/documentation gaps and latent risks, not just reproduced failures. The reviewed High-priority findings were:

# Sev Finding Area
DC-1 High Nothing in production ever unsubscribes a tag or alarm source — the DCL leaks device subscriptions for the life of the node; dead Instance Actor refs get a TagValueUpdate per change DCL / Site Runtime
DC-2 High Releasing a "redundant" MxGateway handle tears down the LIVE subscription — an ordinary double-subscribe during resolution silently freezes the tag at its last Good value until the next reconnect DCL
DC-3 High Device writes are stashed across a reconnect and replayed minutes later, after the script was told the write failed — the doc says there is no buffering of setpoints DCL
SR-1 High The synchronous Attributes[key] accessor blocks a script-pool worker on a continuation queued to the same pool — saturation can stall the script pool until watchdog recovery (timing is configuration-dependent) Site Runtime
CM-1 High No explicit gRPC message-size configuration; a 256-row site audit batch of 64 KB error rows exceeds the 4 MB default and, because the drain is deterministic oldest-first, the site's audit forwarding can repeatedly stall on the same oversized window and its audit DB grows unbounded Communication / AuditLog
MG-1 High Script trust deny-list omits System.Runtime.CompilerServices (Unsafe, RuntimeHelpers) and System.Runtime.Loader — type-punning and constructor bypass pass the deploy gate and the site backstop ScriptAnalysis
UI-1 High Many Central UI configuration mutations bypass the configuration audit log (16 page files save through repositories; service-mediated template/shared-script edits are audited) Central UI
UI-2 High Passive session pings keep an unattended tab authenticated — sliding renewal and periodic role-refresh renewal advance the session Security
SR-11 High A raw transaction exposes the underlying database connection, allowing commands to bypass the audit wrapper Site Runtime
DC-12 High OPC UA snapshot-end markers clear unrelated sources' active alarms because per-feed boundaries are broadcast connection-wide DCL / Site Runtime
CM-9 High A site-A key can request site-B reconciliation tokens — authenticated metadata is never bound to payload ownership Communication / Security
CM-10 High Cross-node retry treats every Unavailable as unsent, repeating non-idempotent writes after an ambiguous response loss Communication
UI-13 High Open Blazor circuits retain revoked roles and site scopes, even after the HTTP cookie has refreshed Central UI / Security

The other reviewed findings grouped into five themes: audit completeness (EI-1/2/3/5, UI-1), authorization scope (MG-2, UI-6/7/8, XC-1, XC-8), stale/duplicate-apply under failover (MG-3/4, EI-4/9, DL-1/6, ORCH-1), subscription/stream lifecycle (DC-4/5, SR-3/5, CM-2), and inert configuration (XC-2/3/7). §5 groups everything into a remediation program.

Most changes fit the current component structure. Reliable script containment and safe retries need explicit runtime/idempotency boundaries; they cannot be fixed by a timeout or status-code check alone. The immediate sequence is revised in §5.


2. Findings by area

Each finding: where (file:line), what the code does, why it matters, fix, effort.

2.1 Site Runtime / StoreAndForward / SiteEventLogging (SR)

SR-1 — Synchronous Attributes[key] blocks a script-pool worker on a continuation queued to that same pool [High][CONFIRMED, orch-verified] Scripts/ScopeAccessors.cs:58-59 (GetAwaiter().GetResult()); Scripts/ScriptRuntimeContext.cs:704, 914 (the Asks inside GetAttribute/SetAttribute have no ConfigureAwait(false)); Scripts/ScriptRunLauncher.cs:110-123 (the run body is started with Task.Factory.StartNew(..., executionScheduler)). Inside the run, TaskScheduler.Current is the ScriptExecutionScheduler, so the awaiter captures it and the continuation that completes the Ask is QueueTask'd onto the same bounded pool whose worker is blocked in GetResult(). Pool floor is 8; eight concurrent runs each doing Instance.Attributes["Setpoint"] = 42 (the documented, compile-gated API — the rig's own Delmia stand-in is a per-attribute write script) block all 8 workers; script/alarm execution can stall until the watchdog detaches/replaces blocked workers. The nominal 30 s timeout + 30 s grace is not a guaranteed recovery time; below saturation these synchronous paths consume additional worker capacity. The class comment at ScopeAccessors.cs:12-21 asserts the opposite. Fix: .ConfigureAwait(false) on the awaits in GetAttribute, SetAttribute, WaitInternal, WriteBatchAndWait (AsyncLocal TraceStepScope flows with ExecutionContext, not the scheduler, so attribution is unaffected); correct the comment; add a test running poolSize concurrent bodies each doing one sync Attributes["x"] on a scheduler of that size. Small.

SR-2 — Alarm on-trigger runs have no integration surface [Medium][CONFIRMED] ScriptRunLauncher.cs:701-725 (RunAlarmScriptAsync) never creates a DI scope; _externalSystemClient, _databaseGateway, _storeAndForward, _operationTrackingStore, _cachedForwarder, _auditWriter, _siteId, _sourceNode are null, so ExternalSystem/Database/Notify/Tracking throw (ScriptRuntimeContext.cs:1422/1485/2145/2195/2760). The deploy gate compiles alarm on-trigger scripts against the full ScriptCompileSurface, so Notify.To("Ops").Send(...) deploys cleanly and throws the first time the alarm fires. Component-SiteRuntime.md:482 says the full API is available to alarm on-trigger runs. Fix: wire the scope + dependencies exactly as RunScriptAsync does (small), or narrow the doc and gate it at design time (medium).

SR-3 — NativeAlarmActor subscribe retry has no in-flight guard, no backoff, no correlation match [Medium][CONFIRMED] Actors/NativeAlarmActor.cs:147-165, 320-340: fresh correlation id per send, discarded; fixed-interval re-send; HandleSubscribeResponse checks only Success. This is the amplifier Lever 1 (2026-08-28) closed on InstanceActor and left as a follow-up; a late Success from a superseded request also cancels the timer armed for the current one. Fix: port TagSubscribeState (in-flight id + ceiling, NextTagSubscribeInterval, drop non-matching replies). Small.

Second-pass clarification: Lost replies retry at twice the configured interval; explicit failures use the configured interval. DCL does have _alarmSubscribesInFlight deduplication, so repeated requests are not proof that every retry opens another physical feed. Correlation and stale-response handling remain defective.

SR-4 — AuditingDbConnection forwards a settable ConnectionString while audit rows keep the construction-time name [Medium][CONFIRMED] Scripts/AuditingDbConnection.cs:113-120 vs :35; AuditingDbCommand.cs:426. A script can Close(), assign an arbitrary connection string, Open(), and every DbWrite row still says target = "MES-Prod"; with Persist Security Info=true the getter returns the password. The audit log is the compensating control for the raw-ADO escape hatch. Fix: reject connection retargeting and redact the getter, then close the raw-transaction escape in SR-11; the class is already sealed. Small plus SR-11.

SR-5 — InstanceActor.PostStop blocks the default dispatcher for up to 5 s per instance [Medium][CONFIRMED] Actors/InstanceActor.cs:129, 443-444, 693-708 (flush.Wait(FinalStaticOverrideFlushTimeout)); no WithDispatcher anywhere in SiteRuntime. A bulk redeploy landing during an EventLogPurgeService tick (1000-row DELETE + incremental_vacuum under SiteEventLogger._writeLock, EventLogPurgeService.cs:519-552) stops N actors concurrently, each burning a dispatcher thread on SQLITE_BUSY backoff — node-wide, since DCL, Deployment Manager, Script/Alarm actors and the stream relay share it. Fix: gate the redeploy clear on an explicit "flush committed" ack piped from the persist task instead of Terminated + a blocking wait; with bounded stop fanout. A shorter blocking timeout alone does not establish safe flush/reset ordering. Medium.

SR-6 — Trace arm is consumed by any subsequent value change, not the traced write's echo [Low][CONFIRMED] — InstanceActor.cs:185, 775-780, 1085-1092; a periodic device publish inside the 30 s window is stamped with the writer's execution id and the real echo goes untraced. Debug fidelity only. Fix: define explicit echo-matching semantics or mark linkage inferred; device updates carry no write-request id, and clamped/coerced values need deliberate handling. Narrowing the window alone is only a heuristic. Small.

SR-7 — EmitTrace/Collect log their own failures through the decorated logger [Low][CONFIRMED] — ScriptRuntimeContext.cs:415-419, 551, 562, 568 use _logger (the TraceCapturingLogger on a traced run) where EmitLog (:590-645) correctly uses _innerLogger; each failed trace write becomes a ScriptLog row plus a second write attempt (bounded at depth 2). Fix: _innerLogger. Small.

SR-8 — ScriptExecutionScheduler.TryExecuteTaskInline keys on the thread-name prefix, not identity [Low][CONFIRMED] — :385-393 vs naming at :331-335; co-resident schedulers (the #18 seam) cross-inline, defeating the bound and detach bookkeeping. Latent. Fix: [ThreadStatic] CurrentScheduler + ReferenceEquals. Small.

SR-9 — Shared trigger gate does not recreate a disposed instance [Low][CONFIRMED latent lifecycle gap] SiteRuntime/Scripts/TriggerEvalGate.cs:55-64 returns the existing shared instance after disposal, unlike the scheduler's disposed guard. No production call disposing that shared gate was found; this is not an established current node-outage path. Fix: either recreate after disposal or make process-lifetime ownership explicit; test Shared→Dispose→Shared if that lifecycle is supported. Small.

SR-10 — Script helper properties allocate a fresh helper + guard closure per access [Low][CONFIRMED] — ScriptRuntimeContext.cs:1070, 1092, 1130, 1150, 1213. Fix: lazily cached fields. Small.

SR-11 — A raw transaction exposes the underlying connection and bypasses DB auditing [High][CONFIRMED, isolated reproduction, orch-verified] src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbConnection.cs:149-150 returns the provider's transaction. AuditingDbCommand.cs:172-175 also exposes its raw transaction. A script can call connection.BeginTransaction().Connection, create a command on that raw connection, and execute it without AuditingDbCommand; abstract ADO types are permitted by the trust policy. This also bypasses any ConnectionString/ChangeDatabase hardening proposed under SR-4. An in-memory SQLite probe constructed the internal wrapper through a test harness, then used ordinary public ADO APIs: normal command audit count 1, transaction connection identity the original provider connection, escaped write rows 1, escaped audit count 0. Reflection was only harness setup, not part of the script escape. Fix: wrap transactions, return the auditing connection from transaction.Connection, and unwrap only internally when assigning a provider command transaction. Cover sync/async starts and Transaction readback. Test exactly-one audit through every public connection/transaction/command path. Medium.

SR-12 — Late native-alarm rehydration can resurrect already resolved conditions [Medium][CONFIRMED source path] src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs:125-128 starts an asynchronous storage read and immediately subscribes. HandleRehydration:167-175 guards only current _alarms.ContainsKey; :219 inserts old state. A resolved live transition (:297-306) or completed empty snapshot (:251-282) removes that key. If the old storage read arrives afterward, the prior active condition is restored and emitted. Fix: fence rehydration against the first authoritative snapshot and track live-touched keys including deletions. Test delayed read completion after both a resolved live event and an empty snapshot; neither may resurrect the old alarm. Small.

SR-13 — An in-progress S&F sweep starts further deliveries after node demotion [Medium][CONFIRMED source path] src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:828-846 checks the active gate once before loading a batch. Lanes wait at :864 and start each remaining delivery at :873 without rechecking; :917 calls the external handler. A demotion during the first slow delivery leaves the old node starting additional work while the new active can drain replicated rows. This exceeds a single already-in-flight delivery window. Fix: recheck a fail-closed gate before each new attempt, including after lane waits, and abandon the remaining batch on demotion. Already-sent operations still need idempotency/fencing for ambiguous outcomes. Test a blocked first handler, flip the gate, release it, and assert no second attempt starts on the old node. Small.

2.2 Communication / ClusterInfrastructure / Host (CM)

CM-1 — No explicit gRPC message-size configuration; the audit batch arithmetic can exceed the 4 MB defaults and can repeatedly stall the same oversized audit window [High][CONFIRMED, orch-verified] Host/Program.cs:291-294 (central AddGrpc) and :825-829 (site) set no MaxReceiveMessageSize; neither do CentralChannelProvider.cs:111-120, SitePairChannelProvider.cs:424-437, SiteStreamGrpcClient.cs:87-95; a repo-wide grep is empty. Defaults: receive 4 MB both ends, send unlimited. SiteAuditTelemetryOptions.BatchSize = 256 × AuditLogOptions.ErrorCapBytes = 65 536 = 16 MB possible; 64 error-only rows at the cap already exceed 4 MiB once protobuf framing is included. SqliteAuditWriter.cs:659-661 serves a deterministic oldest-first window and SiteCommunicationAuditClient.cs:78-82 deliberately does not catch, so an unchanged oversized window fails again on each tick. PurgeExpiredAsync (:1170-1176) never purges Pending, so the site audit DB on /app/data grows unbounded behind the stuck head. Same arithmetic on the reconciliation pull (BatchSize = 256, site → central, 4 MB client receive) and on DeployArtifactsCommand (unlimited central send, 4 MB site receive). Fix: explicit MaxReceiveMessageSize (e.g. 32 MB) on both AddGrpc calls and every channel, and a byte budget on the drain/pull mirroring LocalDb's MaxBatchBytes; at minimum treat StatusCode.ResourceExhausted in SiteAuditTelemetryActor as "halve the batch". Test: serialize a 256-row batch of ErrorCapBytes rows and assert it fits the configured limit. Medium.

Second-pass clarification: The isolated serializer probe measured 16,779,264 bytes for 256 rows containing only a 65,536-byte ErrorDetail, versus the configured-by-framework 4,194,304-byte receive default. Caps apply per field, not per whole row; account for all fields/framing and single oversize rows. Ordinary pending reads are at AuditLog/Site/SqliteAuditWriter.cs:587-620; the original :659 anchor is the cached lane. The stall persists while the same oversized windows remain and neither a smaller reconciliation window nor operator action drains them; no live disk-exhaustion experiment ran.

CM-2 — docker-env2 runs the same simultaneous --force-recreate deploy that split site pairs twice, with the bootstrap guard OFF [Medium][CONFIRMED] — docker-env2/deploy.sh:24 is byte-identical to docker/deploy.sh:25; env2's four node appsettings have no BootstrapGuard block (ClusterOptions.cs:144 default false). A deploy there can produce two 1-node clusters that never merge, and env2 is the second real environment gating Transport work. Fix: add the block to all four files. Small.

CM-3 — PullAuditEvents/PullSiteCalls accept an unbounded batch_size [Low][CONFIRMED] — SiteStreamGrpcServer.cs:727-734, 816-822 only check > 0; PullTraceEvents (:668-676) caps 1..2000; SiteAuditReconciliationOptionsValidator.cs:23 only > 0. Feeds CM-1. Fix: same ceiling + validator upper bound. Small.

CM-4 — ClusterBootstrapCoordinator can issue JoinSeedNodes during host shutdown [Low][CONFIRMED] — Host/Actors/ClusterBootstrapCoordinator.cs:186-188 swallows the shutdown cancellation as "partner unreachable", so RunAsync (:118-126) self-first-joins with no ct re-check. Fix: check ct before the join / rethrow. Small.

CM-5 — Site-hosted IngestAuditEvents / IngestCachedTelemetry are permanently inert [Low][CONFIRMED] — SetAuditIngestActor (SiteStreamGrpcServer.cs:159-162) is called only from AkkaHostedService.cs:569-570 inside RegisterCentralActors, but SiteStreamGrpcServer is Site-branch-only (Program.cs:830, 868); null on central, never set on site. Both RPCs answer an empty ack + Warning on every real node, reachable on the PSK-gated listener; the comment at AkkaHostedService.cs:565-568 reads as if live. (XC-6 is the same finding from the RPC-inventory side.) Fix: remove inert host wiring and return Unimplemented from retired handlers while preserving additive proto compatibility; production ingest uses CentralControlService. Small.

CM-6 — Central Kestrel sets no HTTP/2 keepalive, and the optional metrics listener exposes the whole routing table [Low][CONFIRMED] — Program.cs:231-263 (no Limits.Http2.KeepAlivePing*, unlike SiteKestrelSetup.cs:51-52); :254-262 binds MetricsPort as Http1AndHttp2 on the shared pipeline. Defensible today (unary-only CentralControlService), undocumented. Fix: apply the site keepalive on central; document the metrics-port surface. Small. (See XC-1 for the larger form of this.)

CM-7 — ChannelDisposalGrace (5 min) can be outlived by RouteToWaitForAttribute [Low][PLAUSIBLE] — SitePairChannelProvider.cs:56-62 sizes the grace to "the longest command deadline"; GrpcSiteTransport.cs:224 resolves that request's deadline as caller-supplied r.Timeout + IntegrationTimeout, unclamped (RouteToInstanceRequest.cs:119, DeploymentManagerActor.cs:3036). A > 5-min wait crossing an address refresh gets ObjectDisposed/Cancelled (not Unavailable, so no cross-node retry). Fix: clamp, or grace = max(5 min, longest resolvable). Small.

CM-8 — SiteStreamGrpcClient.ConnectedHeaderTimeout is a mutable static test seam [Low][CONFIRMED] — :460, mutated by SiteStreamGrpcClientTests.cs:440, 474 while SiteAlarmAggregatorActorTests (:865) depends on the default; a parallel-run flake source of the register-row-118 kind. Fix: instance value from CommunicationOptions. Small.

CM-9 — Per-site authentication is not bound to payload site ownership [High][CONFIRMED source path and isolated gate probe, orch-verified] src/ZB.MOM.WW.ScadaBridge.Host/CentralControlAuthInterceptor.cs:149-188 authenticates the key for the metadata x-scadabridge-site, but neither records an authoritative site principal nor validates the decoded payload. Communication/Grpc/CentralControlGrpcService.cs:258-268 forwards ReconcileSiteRequestDto unchanged through CentralControlDtoMapper.cs:381. Communication/ReconcileService.cs:64-79,110-161 resolves the payload SiteIdentifier and returns fresh/existing deployment fetch tokens for that site's snapshots. Thus a caller holding only site-A's PSK can name site-B in its request and obtain B's deployment fetch capability, subject to B having deployments. Health/audit source-site fields also retain untrusted payload attribution. The isolated real interceptor+mapper probe accepted site-A metadata/key with a site-B reconciliation payload. No production secrets were fetched. Fix: carry verified site identity into dispatch, reject mismatched reconciliation/health/notification ownership, validate every audit/telemetry row against authenticated site policy, and authorize status queries by stored ownership. Preserve legitimate cross-site execution links without letting a caller forge the owning site. Test a cross-site request through the actual interceptor+service+repository path and prove no token or mutation is produced. Medium.

CM-10 — Unavailable triggers blind replay of non-idempotent site commands [High][CONFIRMED retry mechanism, orch-verified] src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SitePairChannelProvider.cs:226-237 retries every RpcException(Unavailable) on the second node, asserting the call never reached a server. GrpcSiteTransport.cs:125-175 uses this for lifecycle, tag writes, and RouteToCall commands. A response-side connection loss can occur after application; gRPC does not guarantee that Unavailable means unsent (gRPC error guide, HTTP/2 protocol). An isolated provider probe applied a synthetic effect then threw Unavailable: the provider applied it again on the peer and returned success (2 applications). This proves retry behavior under an injected ambiguous outcome, not a live-device duplicate. GrpcCentralTransport.cs:176-186 shares the incorrect premise, although notification/audit identity dedup protects some operations. Fix: classify operations and guarantee durable/shared deduplication before retrying mutations, or return an explicit uncertain outcome after a possibly committed send. Restrict unprotected retry to provable pre-dispatch failures; merely checking status is insufficient. Test response-loss after commit and assert one effect across the pair. Medium, with cross-layer operation identity.

2.3 ManagementService / DeploymentManager / TemplateEngine / ScriptAnalysis (MG)

MG-1 — Script trust deny-list omits System.Runtime.CompilerServices and System.Runtime.Loader [High][CONFIRMED, orch-verified] ScriptAnalysis/ScriptTrustPolicy.cs:36-62 (ForbiddenScopes) blocks System.Runtime.InteropServices and nothing else under System.Runtime.*; Pass 2's ReflectionGatewayMembers (ScriptTrustValidator.cs:162-172, 266-281) has none of Unsafe.As/Unsafe.Write/RuntimeHelpers.GetUninitializedObject/RunClassConstructor/LoadFromAssemblyPath; DefaultAssemblies (:149-156) includes CoreLib, which hosts all of them on net10.0. Unsafe.As<object,T>(ref o) / Unsafe.Write need no unsafe context, so AllowUnsafe=false does not stop them; type-punning reaches the host's private state (the ScriptRuntimeContext, the DI graph, secrets). The deploy gate and the site backstop share the policy, so nothing catches it. Component-ScriptAnalysis.md:38 claims "Reflection — forbidden entirely". Fix: add type-level entries System.Runtime.CompilerServices.Unsafe, System.Runtime.CompilerServices.RuntimeHelpers, System.Runtime.Loader (type-level so AsyncMethodBuilder/CallerMemberName and compiler-generated attributes stay allowed — verify against the clean-script corpus); negative test per entry; doc table. Small. Ranked High because it is the trust boundary itself, even though it needs the Designer role.

MG-2 — /api/audit/tree and FlowDebugReplayCommand apply no site scope [Medium][CONFIRMED, orch-verified] AuditEndpoints.cs:280-305 (HandleTree) authenticates and role-checks but never calls ApplySiteScope (which HandleQuery/HandleExport do at :584); ManagementActor.cs:423 passes only sp, cmd to FlowDebugRunner.ReplayAsync (:538), which takes no AuthenticatedUser; AuditLogRepository.cs:946-1077 walks the whole (ExecutionId, ParentExecutionId) chain with no SourceSiteId predicate. A site-A-scoped operator picks any ExecutionId from their (correctly scoped) query and receives site-B's ScriptRun/AttributeWrite/DbOutbound rows (SQL text + bound values) from a multi-site chain — the boundary rows 98/99 and P6-4/P6-5 hardened on every other read path. The three Live run commands DO enforce scope. Fix: optional permitted-SourceSiteId set on GetExecutionTreeAsync/GetTraceEventsAsync (NULL = central rows, always visible); thread the user into ReplayAsync; stop-gap: refuse both for scoped non-admins. Medium.

MG-3 — Process-local stale-probe invalidation misses configuration edits on the other central [Medium][CONFIRMED, corrected trigger] DeploymentManager/StaleInstanceProbe.cs:45,120-140 caches against an in-process watermark; ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:772-774,888-897 advances only that process's injected watermark after writes. ManagementService/ManagementActor.cs:343-350 accepts configuration handlers without an active-node gate. Warm A's memo, then edit the shared configuration DB through an authenticated direct connection to B: A's cached revision remains valid by its own unchanged watermark, so DeploymentService.cs:1178 can skip a changed instance. The earlier A→standby→A scenario was not established: planned failover leaves/restarts the process, which clears static memory. Fix: authoritative shared revisions or reliable cross-node cache invalidation; alternatively remove memoized hashes from deployment skip decisions. Activation-only clearing is insufficient while the active process remains active. Test two independently owned watermarks against a shared store and a peer-originated edit. Medium.

MG-4 — MgmtDeploySiteCommand's worst-case runtime exceeds its own Ask timeout by construction [Medium][CONFIRMED] ManagementEndpoints.cs:44-77 (LongRunningCommandTimeout 5 min); DeploymentManagerOptions.cs:34, 42 (parallelism 4 × 120 s per instance → ceil(N/4) × 120 s; N = 9 already needs three waves (360 s), before serial prepare and finalize). The caller gets 504 while the batch keeps applying; a redeploy is not idempotent at the site (resets overrides, clears native alarm state), so the retry re-applies live instances and its prepare loses the 5 s lock timeout on still-held locks. Fix: derive the bulk Ask timeout from the batch, or make bulk deploy submit-and-poll with stable operation identity. Propagate the overall budget through CLI/HTTP as well; a longer inner Ask alone cannot prevent caller timeout. Medium.

MG-5 — FlowDebugRunner.MaxWaitSeconds equals the Ask timeout it must stay under, unvalidated [Low][CONFIRMED] — FlowDebugRunner.cs:56-63 (300 s) vs ManagementServiceOptionsValidator.cs:26-28 (only > 0); a --wait-seconds 300 run 504s the caller while the runner keeps a scoped provider and polls the site. Fix: establish a total deadline at command entry and pass remaining budget through the initial write, audit, and every pull; reserve response grace and validate configuration. A poll-only clamp ignores time already spent before polling. Small.

MG-6 — WriteAndPollAsync re-fetches a fixed oldest page every second; register row 117 confirmed, plus a silent 64-id truncation [Low][CONFIRMED] — FlowDebugRunner.cs:501 (since never advances), :510 (ids.Take(64) drops descendant ids beyond MaxExecutionIdsPerRead with no diagnostic); the site serves ORDER BY OccurredAtUtc ASC LIMIT (SqliteAuditWriter.cs:899-908), so > 2000 rows in the window makes the awaited run unreachable and up to 300 polls re-transfer the same page. Fix: use timestamp-plus-event-id keyset paging and explicit per-execution batching/truncation. Advancing only a timestamp can lose ties and older rows belonging to newly discovered descendants; test both plus more than 64 ids. Small.

MG-7 — The value-change / write-kind Flow Debugger result bypasses the FlowTraceCollector caps [Low][CONFIRMED] — FlowDebugRunner.cs:525-526 returns all of known with Truncated: false hardcoded, vs the capped paths at :190-197/:416-418; 200 log lines × a cascade is an unbounded reply through SerializeResult and the Blazor circuit. Fix: feed known through a collector (null redactor — rows were redacted at the site). Small. (Same class as DL-3.)

MG-8 — Shared scripts never reach the design-time trust/compile gate on create/update [Low][CONFIRMED] — TemplateEngine/SharedScriptService.cs:60, 109, 178-206 run only CSharpDelimiterScanner; ValidationService.cs:276-293 compiles configuration.Scripts only; ArtifactDeploymentService.cs:172-179 ships them fleet-wide; only BundleImporter (:912, 4658) gates them. A forbidden-API shared script is rejected at the site's ScriptCompilationService.Compile, at run time. Fix: ScriptCompiler.TryCompile in create/update (memoised by ScriptCompileVerdictCache). Small.

MG-9 — ManagementActor.Page turns an out-of-range Take into unlimited [Low][CONFIRMED] — :685-691 (take is > 0 and <= 1000 ? take : int.MaxValue; comment says clamped). Fix: clamp positive takes to 1000 while explicitly preserving/documenting null and nonpositive semantics; test every boundary. Small.

MG-10 — Component-ManagementService.md documents none of the four FlowDebug* commands [Low][CONFIRMED] — zero occurrences, though RequiredRoleMatrixTests.cs:16-18 names the catalog as its cross-check. Fix: add the subsection. Small.

2.4 Central UI / Security (UI)

UI-1 — Many Central UI configuration mutations bypass configuration auditing [High][CONFIRMED, corrected scope, orch-verified] Sixteen page files call repository SaveChangesAsync directly: NotificationLists, NotificationListForm, SmtpConfiguration, SmsConfiguration, ExternalSystemForm, SiteForm, LdapMappingForm, ApiMethodForm, TemplateEdit, LdapMappings, DbConnectionForm, DataConnectionForm, ExternalSystems, Sites, DataConnections, and InstanceConfigure. Examples under CentralUI/Components/Pages/: Admin/LdapMappingForm.razor:170-178,200-223, Design/DataConnectionForm.razor:337-349, Notifications/NotificationLists.razor:168-169, and Design/TemplateEdit.razor:1088-1119 (native alarms). Role/scope grants and connection changes can commit without the configuration audit row that the corresponding management path writes. Repository bypasses also miss application-level scrubber/validation/delete guards. The original “every edit” claim was incorrect: TemplateEdit's ordinary template/script/attribute changes call audited TemplateService (TemplateService.cs:144,410,802), and SharedScriptService.cs:77,118,141 audits its mutations. Fix: route the bypass paths through audited application services/management envelopes. A temporary audit integration must use the same transaction, scrub secrets, and handle generated IDs correctly. Test actual UI create/update/ delete success and rollback, including LDAP mappings; a source-string sweep alone is insufficient. Large.

UI-2 — Passive session checks keep an unattended tab authenticated [High][CONFIRMED, orch-verified] CentralUI/Components/Shared/SessionExpiry.razor:37,49,64,80-100 pings every minute without an interaction signal. Normal cookie sliding renewal applies, and Security/CookieSessionValidator.cs:177-210 periodically rebuilds the principal with a fresh LastActivity anchor when role mapping refresh succeeds. Security/ServiceCollectionExtensions.cs:274-275 explicitly renews that replacement too. With healthy mapping, an unattended tab can remain logged in indefinitely. Fix: distinguish passive validity checks from actual activity: disable passive sliding and Replace-driven activity renewal, preserve role refresh without advancing idle time, and supply real interaction tracking including Blazor input. Merely stopping ping renewal can also expire actively used circuits because SignalR events are not new cookie-authenticated HTTP requests. Test idle pings beyond the timeout and genuine interaction over the same interval; separately fix UI-13's stale circuit authority. Medium.

UI-3 — Alleged off-renderer Flow Debugger refresh mutation [WITHDRAWN — unsupported] CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs:543-609,612-625 starts the refresh from UI event paths and ordinarily awaits both Delay and ReplayAsync. Those caller awaits capture the Blazor renderer synchronization context. A service's internal ConfigureAwait(false) does not remove the caller's context capture. No background entry point establishing the alleged cross-thread mutation was found. Explicit InvokeAsync could be defensive, but this is not a confirmed defect and is excluded from the active tally.

UI-4 — Poll-gate cleanup depends on a posted renderer callback running [Low][CONFIRMED mechanism; live outage not established] CentralUI/Components/Pages/Monitoring/Health.razor:719-734 and AlarmSummary.razor:394-411 acquire the gate outside the posted callback but release inside it. A rejected callback can leave the gate held and its unobserved task faulted. Rejection normally occurs during disposal, when refresh should stop anyway; the previous continuing-component outage claim was not reproduced. Fix: observe dispatch in an outer try/finally, release the gate there, and guard disposed/queued callbacks. Test dispatcher rejection and disposal races. Small hardening change.

UI-5 — Noncooperative Test Run scripts permanently occupy admission slots [Medium][CONFIRMED; remediation corrected] CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:207-214,471-481,524-527 has four static slots, awaits actual Roslyn execution, and releases only when it completes. A cancellation-ignoring loop retains one worker/slot indefinitely; four exhaust Test Run capacity. Do not release the slot merely because a logical timeout fired: the work is still running, and admitting replacements permits unbounded runaway CPU/memory and continued side effects through retained gateways. Fix: a bounded pool of isolated, killable workers with confirmed termination before reuse. Interim timeout responses must observe/quarantine unfinished work and retain its capacity charge until actual exit. Test noncooperative code only in a disposable child process, including no further operations after termination. Medium; robust containment is larger than the original small fix.

UI-6 — Manual failover lacks service-level authorization [Low][CONFIRMED defense-in-depth gap] Host/Health/AkkaManualFailoverService.cs:59,89 has no authoritative caller check; its current component entry is protected by an Admin AuthorizeView. No unauthorized invocation of that handler was demonstrated. Fix: an authenticated application facade/management command must verify the Administrator role before calling the host service; respect scoped circuit versus singleton service lifetimes. Test denial at that facade. UI-13 separately covers real stale-grant access through an existing circuit. Small.

UI-7 — No complete UI route-policy guard [Low][CONFIRMED prevention gap] Security/AuthorizationPolicies.cs:195-244 defines no fallback policy, and no sweep freezes every routable component's required role. Current application routes do carry authorization attributes; no anonymous existing-page bypass was shown. Fix: reflection-based route/policy tests including the external secrets assembly. A global HTTP fallback is useful only after preserving intended auth/bootstrap endpoints and does not itself enforce each interactive component's role policy. Small.

UI-8 — Flow Debugger's parked-node site-scope check fails open on an unresolvable site [Low][CONFIRMED] — FlowDebugger.razor.cs:816-838 (lookup ultimately permits a null site); FlowDebugService.RelayAsync (:284-345) has no scope check of its own. Fix: deny for scoped principals; move the check into RelayAsync (shared with the Site Calls page). Small.

UI-9 — TemplateFolderTree rebuilds the whole forest on every parameter set [Low][CONFIRMED] — :99-103 vs the InstancePicker.razor.cs:157-166 reference-equality guard. Fix: reference guards only under an explicit immutable/reference-change contract covering every tree-affecting parameter; otherwise in-place changes would disappear. Small.

UI-10 — Cookie login accepts cross-site form submission without antiforgery validation [Low][CONFIRMED, narrowed] CentralUI/Auth/AuthEndpoints.cs:25-110 manually reads a form, sets the authentication cookie, and disables antiforgery. An attacker with valid credentials can establish their session in a victim browser, causing later actions to be attributed to that account. /auth/token:112-171 returns JSON and sets no cookie; the same browser-session claim does not apply there. Fix: render an antiforgery token in Login and explicitly validate it or attach required validation metadata to the manual form handler. Removing DisableAntiforgery alone is insufficient. Test missing/invalid token rejection and retain legitimate CLI bearer-token exchange. Small.

UI-11 — Topology loads every instance and template into the circuit, unpaged [Low][CONFIRMED] — Topology.razor:346-349; the report pages page at 50. Fix: lazy per expanded site/area. Medium.

UI-12 — Empty "Notifications" nav heading for a Viewer; Flow Debugger unreachable from the nav for the roles its policy admits [Low][CONFIRMED] — NavMenu.razor:55-58 (false premise: a Viewer holds none of Admin/Design/Deployment) and :48 (link inside the Deployer section though RequireFlowDebugger admits Viewer — the F3 auditor intent is half-served). Fix: any-of gate; own AuthorizeView. Small.

UI-13 — Existing circuits retain revoked roles and site scopes [High][CONFIRMED source path, orch-verified] src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CookieAuthenticationStateProvider.cs:42,46-47 serves one captured principal for the circuit lifetime; Auth/SiteScopeService.cs:95-98,128-129 caches scope too. HTTP cookie refresh (Security/ServiceCollectionExtensions.cs:274-275) updates neither. CentralUI/Auth/AuthEndpoints.cs:214-218 returns 200 while the replacement principal is still authenticated, so the ping never signals a role/site revocation. Services/FlowDebugService.cs:581-591 builds ManagementEnvelope authority from the frozen principal. An open circuit can therefore keep issuing formerly allowed mutations after an administrator narrows its grants. Fixing UI-2 alone does not revoke grants on an actively used circuit. Fix: server-side circuit revalidation and permission-cache invalidation, with authoritative checks at mutation time; publish changed auth state or terminate/re-establish the circuit on authorization-version changes. Repeatedly reading IHttpContextAccessor is not a valid circuit-lifetime solution. Test revoke role and site while the same circuit stays authenticated, then assert no site command is sent. Medium.

UI-14 — Sandbox output is bounded only after unbounded capture allocation [Medium][CONFIRMED source path] src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:460 uses an ordinary StringWriter; :488 and error/timeout branches call captured.ToString() before :579-582 truncates to the 32k display ceiling. SandboxConsoleCapture.cs:117-129 forwards every write without a byte/ character budget. A finite or cancellation-cooperative script can allocate a large buffer, then a second full string, in the central process. Four slots and a time limit are not memory bounds. Fix: a synchronized bounded writer that discards/counts excess without materializing it, including error/timeout paths. Test many writes of a reused large string and concurrent child output with bounded retained memory. Review return-value serialization under the same output budget. Small.

2.5 InboundAPI / ESG / Notifications / SiteCallAudit / AuditLog / KpiHistory / HealthMonitoring (EI)

EI-1 — Relay failure with all peers unreachable leaves no inbound audit row [Medium][CONFIRMED, narrowed] InboundAPI/InboundApiEndpointFilter.cs:95-99 sets RelayedToActiveItemKey before forwarding; Middleware/AuditWriteMiddleware.cs:236,251 then suppresses the local row even when HttpStandbyRequestForwarder.cs:160-163 returns ACTIVE_NODE_UNREACHABLE. If every peer is unreachable, none can audit. A reachable standby refusal may audit, so “no peer executes” does not universally mean zero rows; multiple refusal attempts can also create multiple rows. Fix: explicit relay-attempt/serving-peer audit ownership and a correlated local failure row when no serving peer accepts. A response alone is not proof of persisted audit. Test all-unreachable, all-standby, successful relay, and active method failure through middleware plus filter. Small.

EI-2 — SkipBodyCapture does not suppress errorDetail, which routinely quotes the body [Medium][CONFIRMED] — AuditWriteMiddleware.cs:195, 243-249 vs :335-341; InboundScriptExecutor.cs:601-604 (ErrorDetail: ex.ToString()); EndpointExtensions.cs:288-291. F11 closed this for the RouteCall row (RouteHelper.cs:422); the failure path re-opens it for every failing call, traced or not (an ArgumentException quoting a lot number, a JsonException quoting the payload, a SqlException echoing a key). Fix: if this option is intended to prevent body-derived persistence, suppress both free-text error message and detail or replace them with a fixed error category; either can quote the payload. The current option contract names request/response summaries only, so document the strengthened privacy semantics and test complete stored rows. Small.

EI-3 — Per-target SQL-parameter redaction is not applied to DbOutbound error rows [Medium][CONFIRMED] — ScadaBridgeAuditRedactor.cs:217-231 redacts only the request summary; InboundDatabaseHelper.cs:251 and AuditingDbCommand.cs:516 stamp errorDetail: thrown?.ToString(), and SQL Server error text carries parameter values (The duplicate key value is (…)). Fix: the SQL regex matches parameter NAMES, so applying it to free-text errors cannot reliably remove VALUES. Suppress sensitive free-text SQL exceptions (retain error category/number), or redact the matched parameter values at emission plus configured body patterns. Include all error fields; see EI-11. Small.

EI-4 — Relay timeout can expire before method deadline and cause ambiguous replay [Medium][CONFIRMED mechanism; duplicate impact conditional] InboundAPI/HttpStandbyRequestForwarder.cs:112-113,147-152 times out at the relay budget and tries another peer; method timeouts can be longer (EndpointExtensions.cs:260-262). The executor does link RequestAborted (InboundScriptExecutor.cs:342-349,525), so a cooperative method need not continue. Already-committed side effects, queued operations, and noncooperative code can survive; replaying after a lost response may therefore repeat effects. Fix: align an end-to-end deadline and use stable idempotency keys/result deduplication for retriable operations, or return an explicit uncertain outcome instead of automatically replaying an ambiguous send. Increasing the timeout alone does not establish at-most-once behavior. Test both cooperative cancellation and commit-before-response-loss against two peers. Medium.

EI-5 — Relayed audit rows lose verified caller IP [Medium][CONFIRMED; proposed trust rule corrected] InboundAPI/HttpStandbyRequestForwarder.cs:166-192 supplies no authenticated address provenance; Middleware/AuditWriteMiddleware.cs:344 records the standby's remote address. The X-ScadaBridge-Forwarded marker is caller-controlled loop prevention, not peer authentication. Fix: have the standby overwrite verified client provenance and accept it only through a configured trusted-proxy chain or authenticated peer protocol with bounded hops. Reuse the trusted forwarded-header setup in Host/Program.cs:669-675 where appropriate. Never trust XFF solely because the loop marker exists. Test forged direct headers and a genuine trusted relay. Small.

EI-6 — Per-channel retention purge is an unbounded loop on the maintenance path [Low][CONFIRMED] — AuditLogRepository.cs:699-735 (while (true) { DELETE TOP … }) driven serially per channel from AuditLogPurgeActor.cs:262-325; a ScriptTrace@30 d backlog after TraceAllCalls holds the actor and starves later channels. Fix: per-tick row/time budget like MaxPurgedRowsPerTick. Small.

EI-7 — A hung KPI source can stall KPI sampling until actor restart [Low][CONFIRMED] — KpiHistoryRecorderActor.cs:315-342 awaits CollectAsync with only the shutdown token; the in-flight guard stays raised forever. Fix: bound the actual wait, not just a cooperative cancellation token; keep abandoned work/scopes bounded and do not start unlimited replacement tasks. Test a cancellation-ignoring source and later healthy sources. No live production hang was observed. Small.

EI-8 — The standby relay replays only status + Content-Type, while Accept-Encoding passes through [Low][CONFIRMED] — HttpStandbyRequestForwarder.cs:131-141, 206-236 vs :59-63; the day response compression is enabled, callers get gzip labelled application/json. Fix: strip Accept-Encoding or copy response headers minus hop-by-hop. Small.

EI-9 — Outbox dispatch has no row-level claim; dual-active can repeatedly duplicate notifications [Low][CONFIRMED] — NotificationOutboxRepository.cs:140-158 (plain read), status written only after delivery (NotificationOutboxActor.cs:601-637); under the accepted auto-down dual-active state both singletons can pick overlapping batches and repeatedly deliver the same notifications. Component-NotificationOutbox.md:112 describes "one notification, rare failover". Fix: widen the doc; optionally an UPDATE-claim with a lease. Small (doc) / medium.

EI-10 — Audit body pre-read disables the configured chunked-request size limit [Medium][CONFIRMED source path and isolated Kestrel reproduction] src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:701-708 mounts audit middleware before endpoint execution. InboundAPI/Middleware/AuditWriteMiddleware.cs:203-205,495-496 reads body bytes before InboundApiEndpointFilter.cs:80-84 attempts to set IHttpMaxRequestBodySizeFeature. Kestrel has already made it read-only, so chunked requests without Content-Length skip the intended limit and fall back to the listener/proxy cap. The JSON handler and standby body copy then consume the rest. An isolated real-Kestrel reproduction of this ordering used global limit 1000, desired limit 8, and a 100-byte chunked request: HTTP 200; IsReadOnly=True; actual cap=1000; accepted bytes=100. This demonstrates the ordering mechanism, not the entire application pipeline. Fix: install the route-specific cap before any audit buffering/read. Test chunked and HTTP/2 unknown-length JSON over the configured cap with audit on, asserting 413 and no handler/relay dispatch; include SkipBodyCapture and declared-length variants. Small.

EI-11 — Normal audit redaction leaves ErrorMessage unredacted and uncapped [Medium][CONFIRMED source path, orch-verified] src/ZB.MOM.WW.ScadaBridge.AuditLog/Redaction/ScadaBridgeAuditRedactor.cs:192-214,235-247 redacts and caps request, response, ErrorDetail, and Extra while preserving d.ErrorMessage unchanged. InboundAPI/InboundDatabaseHelper.cs:250 and other emitters put exception.Message there. A global or target secret pattern can scrub the stack detail yet leave the same value in the short error message; long exception messages also bypass the advertised field cap. This is broader than EI-3's SQL-parameter-name policy. Fix: apply normal-path body redaction and caps to ErrorMessage too, and verify the entire serialized event contains no secret marker. Test all error fields with global/per-target patterns and oversized messages, not just ErrorDetail. Small.

2.6 Commons / ConfigurationDatabase / Transport (DL)

DL-1 — Instance.UniqueName is assumed globally unique but the DB enforces only (SiteId, UniqueName) [Medium][CONFIRMED, orch-verified] — InstanceConfiguration.cs:61; InstanceLocator.cs:25-26 (what Route.To uses to pick the site) is FirstOrDefault(i => i.UniqueName == name) with no site qualifier or ordering; also DeploymentManagerRepository.cs:516, TemplateEngineRepository.cs:468. Global uniqueness is an app-side check-then-act (InstanceService.cs:67-70) on a per-node ManagementActor, so two operators on the two centrals can both commit; BundleImporter Rename (:4205-4215) does no check. A duplicate makes Route.To route setpoints to the wrong plant with nothing logged. Fix: inventory and resolve existing duplicates, then add a global unique index and translate concurrent/import collisions into domain conflicts. Wrong-site execution remains subject to the caller’s existing site-scope checks. Small.

DL-2 — DataConnection.Primary/BackupConfiguration hold protocol credentials in plaintext at rest, unlike every sibling secret column [Medium][CONFIRMED, orch-verified] — ScadaBridgeDbContext.cs:334-353 encrypts exactly four columns (SMTP credentials, SMS auth token, ESG auth config, DB connection string); ManagementActor.cs:1936 itself says the DataConnection JSON "can embed OPC UA user and certificate passwords"; Component-Transport.md:98 puts them in the encrypted SecretsBlock; GuardSecretWritesHaveAKeyRing (:258-268) omits the entity. Caveat: the key ring is PersistKeysToDbContext in the same DB without ProtectKeysWith* (ServiceCollectionExtensions.cs:109-110), so the existing encryption is defence-in-depth, not a hard boundary — but this is the one column class an operator would assume covered. Fix: add the converter to both properties + the guard switch + a one-off re-encrypt migration; or record the deferral in the register and the doc. Medium.

DL-3 — Flow Debugger Replay reads an unbounded audit-row set into memory and over the envelope [Medium][CONFIRMED] — AuditLogRepository.cs:1182-1230 (GetTraceEventsAsync, no Take); FlowDebugRunner.cs:544-553 returns every row. PullTraceEventsMax = 2000 governs only the site RPC; the Live path is capped 500 events / 1 MB. v1.4's 200 ScriptLog rows per execution × a tree, ApiInbound rows up to 1 MiB each, and the page polls Replay every 5 s for 2 min. Fix: bounded row paging AND a byte budget, plus truthful FlowGraph.Truncated and separately retained root metadata. A 2001-row limit alone can still produce a very large response. Small.

DL-4 — FlowGraphBuilder emits two edges between a write and the wait it satisfied [Low][CONFIRMED] — :150-152 + :219-224; FlowGraphView draws both, obscuring the dashed echo edge; the test at FlowGraphBuilderTests.cs:90 asserts Single(External), not (From, To) uniqueness. Fix: coalesce by endpoints with explicit priority for the dashed External echo edge, or omit the ordinary chain edge when it is replaced. Test both unique endpoints and preserved echo semantics. Small.

DL-5 — Refresh merge violates the builder's input-order contract for late-arriving executions [Low][CONFIRMED mechanism / PLAUSIBLE impact] — FlowDebugger.razor.cs:707-714 (known-first, then fresh) vs the F5 rule (FlowGraphBuilder.cs:84-117); a trigger-cascaded child whose rows arrive by telemetry lands at the tail regardless of when it ran. Fix: base on the (complete) Replay order, append live-only rows. Small.

DL-6 — A bundle Overwrite silently reassigns a deployed instance to a different site [Medium][CONFIRMED path / PLAUSIBLE operationally] — BundleImporter.cs:4236-4247 rewrites existing.SiteId (+ State = NotDeployed, and ReplaceInstanceChildrenAsync :4419-4438 hard-deletes overrides/bindings). Transport never touches runtime nodes, so the instance keeps running at the old site with no central record binding it there — invisible on the Deployments page. Component-Transport.md:306-315 never mentions the SiteId rewrite. Fix: block the site change on Overwrite when State != NotDeployed, or an explicit preview blocker; document. Small.

DL-7 — Every audit row is JSON-deserialized 2–3× per graph build [Low][CONFIRMED] — FlowStepProjector.cs:20, 54, 63, 82 from FlowGraphBuilder.cs:72-73, 386, 419; ~4–6 k parses per 5 s poll on a 2 000-row tree. Fix: deserialize once into a tuple list. Small.

DL-8 — Replay's root execution id is picked by timestamp order, which F5 declared untrustworthy [Low][CONFIRMED] — FlowDebugRunner.cs:552 (events[0]) though ResolveChainAnchorAsync (AuditLogRepository.cs:945-1004) already computes the true root. Fix: return ChainAnchor.RootExecutionId. Small.

DL-9 — Commons documentation omits debugging types and collector ownership [Low][CONFIRMED documentation gap] docs/requirements/Component-Commons.md:171-208 omits Types/Debugging/. Its REQ-COM-6 :319-325 already permits self-contained helpers with owned state, so FlowTraceCollector being mutable does not by itself violate the architecture. It collects bounded in-memory data and accepts a redactor abstraction. Fix: document the namespace and per-run collector ownership/redaction contract; no relocation is justified merely by stateful fields. Small.

DL-10 — No enum lock-in tests for FlowLane/FlowStepKind/FlowStepOutcome [Low][CONFIRMED] — AuditEnumTests covers only the four audit enums; KindRank's display-order contract is unguarded. Fix: member/value contract assertions where serialization requires them, plus a separate KindRank display-order test. Existing functional tests already exercise these enums. Small.

DL-11 — Folded outbound steps retain the first exception detail instead of the final one [Low][CONFIRMED source path] src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs:424-440 creates a folded step from the first lifecycle row and replaces latest Outcome/ErrorMessage but omits ErrorDetail. FlowStepProjector.cs:46 does project each row's detail; lifecycle rows (:82-93) do not retain it. A submit with null detail followed by a failed terminal event displays no terminal stack, while an earlier failure's detail can remain after later success. Fix: carry latest ErrorDetail with latest ErrorMessage, with explicit lifecycle detail semantics if needed. Test null-submit→failure and failure→success. Small.

2.7 DataConnectionLayer / CLI / DelmiaNotifier / docker scripts (DC)

DC-1 — Nothing in production ever sends UnsubscribeTagsRequest / UnsubscribeAlarmsRequest; the DCL leaks subscriptions for the life of the node [High][CONFIRMED, orch-verified] Handlers Actors/DataConnectionActor.cs:1799, 3138; routing DataConnectionManagerActor.cs:153, 178; a repo-wide grep finds senders only in tests — InstanceActor.cs and NativeAlarmActor.cs contain zero references, and DataConnectionActor has no Context.Watch at all. The connection actor is never stopped on redeploy (HandleReconfigureConnection :118 forwards; HandleCreateConnection :75 short-circuits on an existing name). Disable, delete, or redeploy an instance with a narrower attribute set and: the removed tags stay advised on the device (OPC UA monitored items / MxAccess advises never released), ReSubscribeAll re-issues them on every reconnect forever, TotalSubscribedTagCount/ResolvedTagCount never drop, _subscribers[instanceName] keeps a dead IActorRef that receives a TagValueUpdate per value change (dead-letter flood at tag rate), and _alarmSourceSubscribers never empties. On a churny site this is unbounded device-side subscription growth. Component-DataConnectionLayer.md:447, 455 describe the unsubscribe as a live interaction. Fix: owner-aware cleanup on stop/undeploy plus DCL DeathWatch. Fence tag removal by actor identity/generation: the current handler at DataConnectionActor.cs:1804 keys only by instance name, so a late old actor unsubscribe must not remove its same-name replacement. Preserve actual subscriber identity on alarm cleanup too. Test stop cleanup and old-owner cleanup arriving after replacement subscription. Medium.

DC-2 — Releasing a "redundant" MxGateway handle tears down the LIVE subscription for that tag [High][CONFIRMED, orch-verified code path] DataConnectionActor.cs:1642-1653 + :1703 (_ = UnsubscribeIdsAsync(msg.Adapter, redundantIds)), same in :2500, 2509. On MxGateway the "subscription id" is the MXAccess item handle (RealMxGatewayClient.cs:193, reply.ItemHandle.ToString(...)), per tag, so the redundant id is normally the live handle — and both removals are identity-blind anyway (MxGatewayDataConnection.cs:294-304 _tagToSub.TryRemove(s.TagPath); RealMxGatewayClient.cs:219 _tagToHandle.TryRemove(tag)), deleting the mapping the event loop routes on. The trigger is ordinary: HandleSubscribe's dedup partition (:1151) checks _subscriptionIds and _subscribesInFlight but not _resolutionInFlight, so a second instance subscribing to an unresolved tag while its resolution probe (:2277) is in flight double-subscribes. Result: the tag silently stops updating — the attribute freezes at its last Good value, no quality change, nothing in the health report — until the next reconnect. Same class as the QA Z28061 frozen-value incident. Fix: (a) include _resolutionInFlight in the partition; (b) identity-checked removals (if (cur == id)); (c) skip the release when result.SubscriptionId == _subscriptionIds[tag]. Test the DCL duplicate-completion path: it must never issue an unsubscribe for the current live handle; separately verify atomic identity-checked reverse-map removal for distinct old/new handles. A direct unsubscribe of the live handle is supposed to remove it. Same-handle behavior was source-traced, not reproduced against the live gateway. Small.

DC-3 — Device writes are stashed across a reconnect and applied minutes later, contradicting the "no buffered setpoints" invariant [High][CONFIRMED, orch-verified] DataConnectionActor.cs:417-422 (Connecting) and :793-796 (Reconnecting) stash WriteTagRequest/WriteTagBatchRequest; :494 UnstashAll on BecomeConnected. Component-DataConnectionLayer.md:421: "There is no store-and-forward for device writes … buffering stale setpoints would be dangerous." The script's 30 s Ask (InstanceActor.cs:787) times out and the script is told the write failed; the reply dead-letters; the value is still written to the device when the link returns. A 10-minute outage ends with a burst of stale setpoints hitting equipment whose scripts may already have compensated. Secondarily the stash is unbounded (no stash-capacity configured) and Lever 1's SubscribeTagsRequest re-sends (every ≤ 60 s during the outage) are all stashed and replayed, each a full SeedTagsAsync. Fix: answer writes in Connecting/Reconnecting with an immediate failed response appropriate to single/batch requests (what the no-buffering contract requires); dedupe stashed subscribes per instance / set stash-capacity. Small.

DC-4 — OPC UA silently downgrades to an unsecured endpoint when the requested SecurityMode is unavailable or discovery fails [Medium][CONFIRMED] — RealOpcUaClient.cs:151-158 (?? endpoints.FirstOrDefault()), :386-392 (catch → new EndpointDescription(url), mode None); no post-connect assertion. A SignAndEncrypt connection connects unencrypted and reports green; Verify has the same fallback. Fix: fail the connect when preferred != None and no match / discovery threw, naming requested vs available. Small.

DC-5 — New alarm subscribers can miss standing-state snapshots on both adapters [Medium][CONFIRMED, expanded] DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:319-322 skips a fresh source snapshot when the existing prefix covers it. Independently, Actors/DataConnectionActor.cs:2881-2886 returns Success to a new subscriber of an already active source without replaying its state; that path affects OPC UA too. A newly deployed instance can remain blind to standing conditions until a later transition/reconnect. The original “OPC UA unaffected” claim was incorrect. Fix: deliver an initial snapshot to each subscriber, using a DCL state replay or a correctly scoped refresh; handle the Mx prefix optimization separately. Fix DC-12's marker scoping before increasing refresh traffic. Test distinct covered sources and a second instance sharing one source on each adapter. Medium.

DC-6 — deploy site exits 0 when its domain result reports failed instances [Medium][CONFIRMED] — CLI/Commands/CommandHelpers.cs:169-193 returns 0 for any 2xx; DeployCommands.cs:68-84. A CI scadabridge deploy site … || rollback never fires; other command-specific success payloads need separate contract checks; do not assume every JSON command has the same failure shape. Fix: an onSuccess handler inspecting failureCount → exit 1; document. Small.

DC-7 — DelmiaNotifier treats a standby central's 503 as final and never fails over [Medium][CONFIRMED] — DelmiaNotifier/Notifier.cs:31-52 rolls over only on ConnectFailed. Its HA therefore depends entirely on StandbyForwardPeerEndpoints being configured on both centrals — which QA (vq03/vq04) and prod (vd03) still lack (CLAUDE.md). Fix: distinguish a known pre-execution standby rejection from an ambiguous post-send failure before retrying; generic retry-on-any-503 can duplicate effects. Document and test the notifier HA contract. QA/prod peer settings quoted here were not remotely rechecked in this pass. Small.

DC-8 — secured-write is a shipping CLI group absent from the README and from CommandTreeTests [Low][CONFIRMED] — CLI/Program.cs:42; CommandTreeTests.cs:26-45, 63-71 asserts 18 groups vs 19 in Program.cs and builds data-connection without ConnectionBrowseCommands.AddTo, so browse/search/verify-endpoint/certs are uncovered too. Fix: add to the test (19), AddTo in the test build, README section. Small.

DC-9 — Stale statements in Component-DataConnectionLayer.md [Low][CONFIRMED] — :412 (volatile bool → int + Interlocked.Exchange), :318 (UnsubscribeAlarmsRequest is handled, not stashed, in Reconnecting at :802), :382/384 vs :421 contradiction on write stashing (DC-3), :323 prefix restart "costs bandwidth, not correctness" (DC-5). Small.

DC-10 — Per-item shard planning allocates a list per monitored item on the subscribe hot path [Low][CONFIRMED] — RealOpcUaClient.cs:765-772 from the loop at :686; ~75 k short-lived objects on a 37.5 k-tag reconnect. Fix: hoist / plan per chunk. Small.

DC-11 — DelmiaNotifier aborts the whole failover loop on a malformed BaseUrls entry [Low][CONFIRMED] — HttpRecipeSender.cs:20-45 catches only HttpRequestException/OperationCanceledException; a UriFormatException on the first URL escapes to Program's outer catch. Fix: validate every BaseUrls entry as an absolute HTTP(S) URI during config load, with a clear diagnostic or explicit invalid-endpoint skip policy. Do not map arbitrary exceptions/cancellation to connect failure. Small.

DC-12 — OPC UA per-feed snapshot completion clears unrelated active alarms [High][CONFIRMED source path, orch-verified; live multi-feed gate pending] src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:867-909 registers per-source monitored callbacks. HandleAlarmEvent:1165-1168 emits an unscoped SnapshotComplete for each feed's RefreshEnd. Actors/DataConnectionActor.cs:3021-3027 broadcasts each marker to all alarm subscribers. SiteRuntime/Actors/NativeAlarmActor.cs:251-282 swaps/clears its snapshot buffer on every marker. After A commits its snapshot, B's subsequent completion reaches A with an empty buffer, generating false return-to-normal/removal of still-active conditions. Multiple feeds are ordinary configuration; the scope is lost inside the application. Fix: preserve feed/source identity and refresh epoch on snapshot boundaries, keeping Mx connection-wide stream semantics separate. Test interleaved A/B snapshot/end delivery and assert completing B cannot change A, then validate two real OPC UA alarm sources on the rig. Medium.

2.8 Cross-cutting (XC) and orchestrator (ORCH)

XC-1 — Central's entire HTTP endpoint graph is served on the site-facing h2c gRPC listener, bypassing Traefik [Medium][CONFIRMED, orch-verified] — Host/Program.cs:228-262 binds the HTTP ports (Http1AndHttp2) and centralGrpcPort (Http2) on one WebApplication; endpoint routing is not listener-scoped and there is no RequireHost/MapWhen anywhere in src/. Every endpoint mapped at :711-748 — Central UI, /management, /api/*, /api/audit, /metrics, /health/*, the SignalR hub — is also served on :8083, the port every site dials and the rig publishes (docker-compose.yml:131, 198). curl --http2-prior-knowledge http://central-a:8083/metrics reaches metrics and health anonymously, and the authenticated surfaces with credentials, on a port not behind Traefik — so the /health/active active-node routing is bypassed. Register row 56 deliberately made central /metrics opt-in on a separate port for this reason. Fix: enforce listener ownership using the actual HttpContext.Connection.LocalPort or separate pipelines/listeners, rejecting non-control-plane endpoints before dispatch. RequireHost matches caller-supplied Host/authority and is not a port-isolation boundary. Test an h2c call to the gRPC socket with authority set to the HTTP port, and the inverse; preserve intentional metrics/health access. The original curl example is a reproduction recipe, not a live probe run in this pass. Small.

XC-2 — Four SMTP keys in all four rig central appsettings bind to nothing [Medium][CONFIRMED, orch-verified] — docker/central-node-*/appsettings.Central.json:84-89 and env2 set Notification:{SmtpServer, SmtpPort, AuthMode, FromAddress}; NotificationOptions (NotificationService/NotificationOptions.cs:13-25) has only ConnectionTimeoutSeconds and MaxConcurrentConnections; zero code hits. Real SMTP config is the DB SmtpConfiguration row. An operator editing the host/port here changes nothing, silently. Fix: delete the keys, leave a _comment pointing at notification smtp / /notifications/smtp. Small.

XC-3 — SiteEventLog:PurgeScheduleCron is set on all 8 site nodes and read by nothing [Medium][CONFIRMED, orch-verified] — docker/site-*/appsettings.Site.json:75 ("0 2 * * *"); no Cron in src/. The purge is a PurgeInterval delay loop anchored to the previous purge's end, so it drifts to whatever time the node last restarted, and a 2.7 M-row retention burst can land mid-shift while the file promises 02:00. Fix: remove the key (small) or implement it (medium).

XC-4 — The vendored gRPC C# has no guard tying it to the .proto [Medium][CONFIRMED] — Communication.csproj:64-70 (commented-out <Protobuf>, correctly); no test reads a .proto. Editing a proto and forgetting regen-proto.sh builds green and ships the stale wire shape — invisible because both ends compile from the vendored file, until a third party generates from the real proto. Checked by hand: 565 fields, 0 mismatches today. Fix: a ~40-line test that parses Protos/*.proto for name = n; and asserts each PascalCase(name) + "FieldNumber" == n on the generated types. Small.

XC-5 — README, HighLevelReqs, Component-ManagementService and the components skill still describe ClusterClient as the live transport [Medium][CONFIRMED] — README.md:83, 96; HighLevelReqs.md:49-51; Component-ManagementService.md:5, 16, 21, 48, 50, 301, 307 (a whole "ClusterClientReceptionist Registration" section, contradicted by line 27 of the same file); Component-NotificationOutbox.md:31; Component-Commons.md:292; .claude/skills/scadabridge-components/SKILL.md entries 5 and 18. These are the entry-point documents an agent loads first. Fix: rewrite to gRPC both directions + SiteStreamService + token-gated HTTP config fetch; delete the receptionist section. Small.

XC-6 — Dead site-hosted ingest RPCs [MERGED into CM-5 — not separately counted] This was the same pair of handlers already recorded under CM-5, assigned a different severity by a second reviewer. Retain CM-5 as the single Low-priority implementation finding. Preserve proto compatibility when retiring handlers; return Unimplemented rather than silently acknowledging.

XC-7 — Migration-only legacy keys still shipped in every rig site appsettings [Low][CONFIRMED] — ScadaBridge:Database:SiteDbPath and ScadaBridge:StoreAndForward:SqliteDbPath in all 8 site files (both rigs), which CLAUDE.md says to delete once migrated; the other two named keys are already gone. Fix: delete. Small.

XC-8 — Both gRPC auth interceptors are allow-by-default for unlisted service prefixes, with no coverage test [Low][CONFIRMED] — ControlPlaneAuthInterceptor.cs:164-168, 197-207; CentralControlAuthInterceptor.cs:50-51, 197-201 (if (!IsGated(method)) return; over a hard-coded allowlist). The existing tests assert the prefixes match descriptor names, not that every MapGrpcService is covered; a fourth service ships unauthenticated with no failing test. Fix: a test reflecting over the mapped services against the union of gated sets. Small.

XC-9 — Package version skew: Microsoft.Data.SqlClient pinned below EF's floor, SignalR.Client one major behind, one prerelease [Low][CONFIRMED] — Directory.Packages.props:41 (6.0.2) with VersionOverride="6.1.1" in three test csprojs ("EF SqlServer 10.0.7 needs ≥ 6.1.1 … override is test-only"): ESG compiles against 6.0.2 while the Host resolves 6.1.1 at runtime; SignalR.Client 9.0.3 in a 10.0.7 family; bunit 2.0.33-preview. Fix: raise the central pin to 6.1.1 and delete the overrides; evaluate SignalR 10. Small.

XC-10 — OpcUaGlobalOptions / MxGatewayGlobalOptions are the only bound options with neither ValidateOnStart nor a validator [Low][CONFIRMED] — DataConnectionLayer/ServiceCollectionExtensions.cs:32-36; every sibling chains both (arch-review 08 §1.5). A bad timeout surfaces as a hang at first connect, not at boot; masked today because neither section is set on either rig. Fix: validators + ValidateOnStart. Small.

XC-11 — Umbrella index is two features behind, and main is 47 commits ahead of the local origin/main ref [Low][CONFIRMED] — scadaproj/CLAUDE.md:41 pins main @ 789377ef (2026-09-06); local main = c078bc8f; the instance picker and v1.4 step logs are unrecorded; origin/main..HEAD = 47 against the locally cached ref (no remote fetch in this review). Fix during a separately authorized integration/push: update the row (short, per the index's own rule). Small.

XC-12 — RequiredSingletonsHealthCheck's doc miscounts the central singletons [Low][CONFIRMED] — Host/Health/RequiredSingletonsHealthCheck.cs:32-38 says "all five … unconditional"; there are seven (AkkaHostedService.cs:518, 551, 619, 664, 687, 718, 744), two excluded as best-effort — a reason the comment's two-branch rule would get wrong. Fix: amend the remark. Small.

ORCH-1 — OperationTrackingStore.Dispose() disposes the write connection under a live pump transaction [Medium][CONFIRMED, observed in the run] — SiteRuntime/Tracking/OperationTrackingStore.cs:616-634 completes the channel then _writeConnection.Dispose() immediately, while the Task.Run(PumpAsync) pump may be inside using var tx = _writeConnection.BeginTransaction() (:388, :407). SqliteConnection.Dispose rolls back the live transaction on the disposing thread while the pump concurrently completes it → NullReferenceException inside SqliteTransaction.Complete(). Recorded in the first-pass suite run: OperationTrackingStoreTests.SR024_SyncDispose_DoesNotDeadlock_WhenInvokedFromFreshThread failed with exactly that stack (§7). Production disposes the host singleton through DisposeAsync (which drains), so this is a latent hazard on the sync path plus a flaky test, not an outage. Fix: complete/cancel and join the pump before disposing its resources. If a bounded wait expires, defer disposal until completion; never close the connection/gate beneath a live pump. Pin the race with a controlled pump barrier and include sync-context safety; a passing timing-sensitive test does not disprove the source race. Small.


3. Verified strengths and retained first-pass inventories

These inventories originate in the first pass. Production source has not changed, and the second pass rechecked the implicated boundaries plus the dependency graph. This is not a second exhaustive rerun of every historical inventory check. Qualifications below and the current findings take precedence:

  • Site Runtime: a stop does not cancel an in-flight run; deadlines armed at enqueue and shed at dequeue on every path; watchdog detach/replace bookkeeping (ScriptExecutionScheduler.cs:275-379); MaxConcurrentRunsPerScript counter cannot leak; no awaited audit write on any script path (eight SiteRuntime sites + the forwarder, all enqueue-and-observe on TaskScheduler.Default); TraceCapturingLogger double-wrap guard and the shared-script logger sharing; TraceStepScope restore; log caps + FlushDropped once + collector reserve arithmetic; Lever 1 in full; OperationTrackingStore read-your-writes barrier; Notify.Send maxRetries: 0 + the MaxRetries > 0 park test; purge pacing pure and faulted ticks don't arm catch-up; trigger eval off-pool with coalescing.
  • Communication / Host: every mapped gRPC service is behind a fail-closed FixedTimeEquals interceptor (CentralControlAuthInterceptor, SecretsHubAuthInterceptor via the shared UsesGrpcHub predicate, ControlPlaneAuthInterceptor on stream + command, LocalDbSyncAuthInterceptor); proto diff vs ee193cd2 is additive-only with vendored C# in sync and regen-proto.sh restoring the csproj on every exit path; batching negotiation via the proto3 default; ActiveNodeEvaluator is the sole active definition (State.Leader appears only in two "never use" comments); BuildHocon emits AutoDowning, min-nr-of-members = 1 pinned, empty seed list under the guard; seed self-first enforced and true in all 14 appsettings; channel park-then-sweep, (site, endpoint) factory key, TryGet on teardown; reconnect on OK completion wired through both consumers; single-endpoint sites collapse correctly; bounded DropOldest channels; options validated on start everywhere but XC-10; correlation-id path-element guard; Traefik probes /health/active.
  • Management / Deployment / Templates: deployment-config token boundary (existence → TTL → token, 256-bit CSPRNG, FixedTimeEquals); secured writes CAS on Pending for approve/reject with self-approval and scope checks before the CAS (no oracle); bulk deploy failure isolation end to end (P7-3/5/6), Disabled wins under --force, conservative IsUpToDateAsync; OperationLockManager ref-counted with CAS-idempotent release on exactly the four mutating commands; PendingDeployment per-instance under a 1-permit gate; revision-hash determinism guarded by test; acyclicity via both cycle detectors; all four Roslyn surfaces attach CachingScriptMetadataResolver.Instance; compile-surface parity pinned; no Task.Run/.Result/.Wait() in the four projects; RequiredRoleMatrixTests (145 entries) + DispatchCoverageTests cover the four FlowDebug* commands.
  • Central UI / Security: no third-party component framework; no sync-over-async on any render/event/lifecycle path (the 4769e192 fix intact, MaxPredictionPasses = 8); DisableLoginGuard refuses outside Development, runs before AddSecurity; cookie hardening and the two validator inequalities; LDAP-failure semantics (Keep on refresh fault, DB-only refresh); JWT key ≥ 32 bytes, ClockSkew = Zero; LoginThrottle per user|ip and per-ip, bounded; forwarded headers opt-in, ForwardLimit = 1, mounted first; sandbox trust gate before execution and for nested CallShared; InstancePicker's reference contract honoured by both callers; report pages server-paged; bundle upload size-capped. All 43 routable pages carry an authorization attribute (login AllowAnonymous; dashboard + health bare Authorize; admin/, certificates, transport import, SMTP/SMS RequireAdmin; design/ + notification lists RequireDesign; deployment/, monitoring/, reports RequireDeployment; flow debugger RequireFlowDebugger; audit/* OperationalAudit; secured writes RequireSecuredWriteAccess; /admin/secrets external).
  • Edge / audit backbone: enqueue-and-observe on all eleven script/request-thread audit writes (awaited only on actor paths); redactor on every persistence path including the inline Live copy, failing closed on a bad pattern; site retention SQL purges only Forwarded/Reconciled, MarkForwarded after the central ack; central append-only guard + per-channel purge validated to [30, RetentionDays]; SiteCallAudit insert-first then rank-guarded update with COALESCE(SourceNode); inbound auth constant-time with single 401/403 bodies and the scope check before the DB; ESG single deadline, ordered catch filters, bounded read, verb allowlist, escaping, auth never logged, poison parks; EWS https at the sender, CredentialRedactor.Scrub on every adapter error, adapters resolved per delivery scope; exactly one Meter in src/ and it plus LocalDbMetrics are allowlisted on both roots; health sequence numbers seeded from unix ms; the middleware rewinds the body in finally so the relay sees it; KPI per-source fault isolation.
  • Data layer: Commons has zero project references and the graph is an acyclic DAG (§6.A); every changed record since August adds trailing default(T)-safe parameters only; AuditKind.ScriptLog and AuditChannel.ScriptTrace are last and stored as strings; all 8 migrations non-empty with faithful Down and no destructive statements; model snapshot == last Designer target model; hot-path index/query matches on SiteCalls, Notifications, KpiSample, DeploymentRecords; UTC converters on the audit read path; no FK on any audit-bearing table, Restrict on the instance/site/connection edges; idempotent ingest on all three central stores; bundle import inside the execution strategy with tracker clears and explicit rollback; the import trust gate is Pass 0, fail-closed, over all six script kinds; secrets presence-only in diffs; LineDiffer cap before Myers; ScriptArtifactsChanged covers Add; DeploymentRecord rowversion real including the delete stub path.
  • DCL / CLI / scripts: adapter-generation fencing on every completion message and the P2-6 demotion; no overlapping reconnects; OPC UA item accounting leak-free under AsyncSerialGate; adapter dispose/reset order; health counts derived; the verify probe never trusts; TrustServerCertCommand fans to a per-node CertStoreActor, Admin-gated; AckTime semantics per spec; CLI timeout honesty; no secrets echoed anywhere; README ↔ code parity on 134 leaf commands (DC-8 the only gap); all ten docker scripts set -euo pipefail, rig test creds only, worktree guard, DELMIA_API_KEY required.

4. Doc drift (consolidated)

Doc Says Actually Finding
README.md:83, 96; HighLevelReqs.md:49-51; Component-ManagementService.md:5,16,21,48-50,301,307; Component-NotificationOutbox.md:31; Component-Commons.md:292; components skill entries 5, 18 ClusterClient / ClusterClientReceptionist is the command/control transport gRPC both directions since Phase 4 (2026-07-23); Component-ManagementService.md contradicts itself at line 27 XC-5
Component-SiteRuntime.md:482 full Script Runtime API available to alarm on-trigger runs no integrations wired SR-2
Component-StoreAndForward.md:79 retry-exhausted notifications park maxRetries: 0, park test MaxRetries > 0; contradicts line 52 —
Component-StoreAndForward.md sweep section ordered created_at ASC drain per-(Category, Target) lanes at SweepTargetParallelism = 4, lane abandoned on first transient failure — undocumented —
Component-DataConnectionLayer.md:421 vs :382/384 no buffering of device writes / writes are stashed stashed and replayed with no TTL DC-3
Component-DataConnectionLayer.md:447, 455, 358 Site Runtime sends unsubscribes no production sender DC-1
Component-DataConnectionLayer.md:412, 318, 323 volatile bool; alarm requests stashed while Reconnecting; prefix restart costs bandwidth only int+Interlocked; unsubscribe handled; also costs the snapshot DC-9, DC-5
Component-ScriptAnalysis.md:38 reflection forbidden entirely RuntimeHelpers/Unsafe/AssemblyLoadContext allowed MG-1
Component-ManagementService.md command catalog missing all four FlowDebug* MG-10
CLAUDE.md "frozen 138-command authorization matrix" 145 entries —
Component-InboundAPI.md:366; CLAUDE.md relayed call audited once; peer response replayed unchanged zero rows when no peer served; headers dropped EI-1, EI-8
Component-NotificationOutbox.md:112 one duplicate on a rare failover continuous duplication under dual-active EI-9
Component-CentralUI.md:89, 285, 311, 317 data-connection pages Admin; audit pages Admin/Audit; LDAP mapping three roles RequireDesign; OperationalAudit = {Admin, Viewer}; six roles —
Component-CentralUI.md:311-315 configuration audit shows before/after per change many direct UI mutations lack rows; audited service paths exist; only AfterStateJson exists UI-1
Component-Security.md:54; SessionExpiry.razor:24-26 idle session rejected; ping never keeps a session alive ping slides the cookie every minute UI-2
Component-CentralUI.md — Test Run / sandbox surface undocumented —
Component-Commons.md:171-208, 309-327 Types/ subfolder list; pure helpers only Types/Debugging/ missing; FlowTraceCollector per-run state needs documentation, already compatible with the owned-state carve-out DL-9
Component-ConfigurationDatabase.md — encrypted vs plaintext secret columns never enumerated DL-2
Component-Transport.md:298, 306-315 UniqueName identity key; FK rewiring list not DB-enforced globally; SiteId rewrite omitted DL-1, DL-6
Component-Communication.md:114 IngestCachedTelemetry live dead CM-5
docker/*/appsettings* SMTP keys; 02:00 cron purge; legacy DB paths none bound XC-2, XC-3, XC-7
RequiredSingletonsHealthCheck.cs:32-38; ScopeAccessors.cs:12-21; TriggerEvalGate.cs:25-26; StaleInstanceProbe.cs:28-33; FlowDebugRunner.cs:56-63; ManagementActor.cs:685-686; SitePairChannelProvider.cs:57-62; AkkaHostedService.cs:565-568; CommandTreeTests.cs:22-24 code comments each contradicted by the code beside it XC-12, SR-1, SR-9, MG-3, MG-5, MG-9, CM-7, CM-5, DC-8
scadaproj/CLAUDE.md:41 main @ 789377ef c078bc8f, two features later, 47 commits ahead of cached origin/main XC-11

Grouped in the shape of 2026-08-14-arch-review-remediation-plan.md. Packages now overlap in shared wrappers, actors, and security paths; assign file ownership and sequence those changes before parallel implementation. Package numbers remain stable, but the second-pass order is risk-based: start WP1/WP2 and CM-9 (WP4), CM-10 (WP6), UI-13 (WP5) before lower-severity hygiene. Scope WP5 sandbox containment explicitly; do not implement its withdrawn release-on-timeout shortcut.

WP Findings Theme Effort
WP1 — DCL subscription lifecycle DC-1, DC-2, DC-3, DC-5, DC-12, SR-3, SR-5, SR-12, DC-4, DC-10 owner-fenced unsubscribe + DeathWatch; source-scoped alarm snapshots + rehydration fence; async stop/flush ordering; profile shard-planning allocation; identity-checked handle release + _resolutionInFlight in the partition; fail writes while disconnected; snapshot on every new alarm source; port Lever 1 to NativeAlarmActor; no silent security downgrade medium — needs a rig rebuild + the Lever-1-style live gate (subscribe count on a redeploy, frozen-tag probe)
WP2 — Script pool + trust SR-1, SR-2, MG-1, SR-4, SR-11, MG-8, SR-7, SR-8, SR-9, SR-10 ConfigureAwait(false) on the accessor paths; three deny-list entries; close connection/transaction escape surfaces; wire alarm integration scope; gate shared scripts at save small/medium; unit and integration tests
WP3 — gRPC sizing CM-1, CM-3, XC-4, XC-8 explicit receive limits both roots + channels; byte budget on the audit drain/pull; batch-size ceilings; proto↔C# guard test; interceptor coverage test medium
WP4 — Audit completeness + scope UI-1, EI-1, EI-2, EI-3, EI-5, EI-11, MG-2, CM-9, UI-8 UI mutations through the management envelope (or the IAuditService stop-gap); relay audit ownership; redact/suppress all sensitive error fields; trusted caller IP; bind PSK identity to payload ownership; site scope on tree/Replay/relay large UI boundary work plus small/medium security fixes
WP5 — Session + UI hygiene UI-2, UI-13, UI-14, UI-4, UI-5, UI-6, UI-7, UI-10, UI-12, UI-9, UI-11 real activity tracking; circuit grant revalidation; bounded output; observed poll cleanup; isolated/quarantined sandbox work; service auth; route sweep; explicit login antiforgery mixed; sandbox isolation requires its own design
WP6 — Failover-safe apply MG-3, MG-4, CM-10, EI-4, SR-13, DL-1, DL-6, ORCH-1, EI-9, DL-2 cross-node/shared revision invalidation; batch-derived bulk-deploy deadline or submit-and-poll; aligned deadlines + operation deduplication; recheck S&F gate per attempt; global UniqueName index; block cross-site Overwrite; sync-dispose ordering; outbox doc/claim; credential encryption migration or explicit DL-2 deferral mixed; includes one migration and operation-idempotency work
WP7 — Flow Debugger follow-ups DL-3, MG-7, MG-6, MG-5, DL-4, DL-5, DL-7, DL-8, DL-10, DL-11, SR-6 cap Replay + write-kind results; page the site pull (closes register row 117); root id from anchor; preserve echo edge; order-aware merge; final exception detail; parse once; enum tests small changes across the listed paths
WP8 — Central listener + config hygiene XC-1, EI-10, XC-2, XC-3, XC-7, CM-2, CM-6, XC-10, XC-9, CM-5 (alias XC-6), CM-4, CM-7, CM-8, MG-9, EI-6, EI-7, EI-8, DC-6, DC-7, DC-11 actual local-port enforcement + body cap before audit reads; delete inert keys; env2 bootstrap guard; retire dead handlers compatibly; validators; positive page-size clamp; SqlClient pin; CLI exit codes; notifier safe pre-dispatch failover mostly small changes plus integration tests and a rig config sweep
WP9 — Docs sweep XC-5, XC-11, XC-12, MG-10, DC-8, DC-9, DL-9, SR-2 doc half, every row of §4 ClusterClient purge from the entry-point docs; register rows for anything deferred (DL-2 if deferred, EI-9); umbrella index/integration status review small

Phase gate after WP1–WP3 and WP6: full solution build + test, bash docker/deploy.sh, then the existing live probes (failover-drill.sh, verify-delmia-notifier.sh A–J) plus these required new regression gates: narrowed redeploy drops subscriptions without removing same-name replacement ownership; disconnected writes never replay; two OPC UA alarm feeds retain independent snapshots; cross-site payload with another site's credential yields no fetch token; response loss after write produces no second effect; revoked grants cannot act from an existing circuit; transaction-derived commands remain audited; chunked oversized input is rejected before handler/relay dispatch. These are remediation acceptance gates, not tests claimed to have run against the current rig.


6. Inventories (from the cross-cutting pass)

6.A Project dependency graph (ZB.MOM.WW.ScadaBridge. stripped)

Commons               -> (none)                 [PackageRef: ZB.MOM.WW.Audit only]
DelmiaNotifier        -> (none)                 [standalone AOT exe]
CLI, ClusterInfrastructure, ConfigurationDatabase, KpiHistory, ScriptAnalysis,
Security, SiteEventLogging                      -> Commons
StoreAndForward       -> Commons, SiteEventLogging
HealthMonitoring      -> Commons, StoreAndForward
Communication         -> Commons, HealthMonitoring
TemplateEngine        -> Commons, ScriptAnalysis
NotificationService   -> Commons, StoreAndForward
ExternalSystemGateway -> Commons, StoreAndForward
DataConnectionLayer   -> Commons, HealthMonitoring, SiteEventLogging
AuditLog              -> Commons, Communication, ConfigurationDatabase
DeploymentManager     -> Commons, Communication, TemplateEngine
NotificationOutbox    -> Commons, KpiHistory, NotificationService
InboundAPI            -> AuditLog, Commons, Communication, ScriptAnalysis
Transport             -> Commons, ConfigurationDatabase, ScriptAnalysis, TemplateEngine
SiteCallAudit         -> AuditLog, Commons, Communication, ConfigurationDatabase, KpiHistory
SiteRuntime           -> Commons, Communication, DataConnectionLayer, HealthMonitoring,
                         ScriptAnalysis, SiteEventLogging, StoreAndForward
ManagementService     -> Commons, Communication, DeploymentManager, HealthMonitoring,
                         InboundAPI, Security, TemplateEngine
CentralUI             -> Commons, Communication, DeploymentManager, HealthMonitoring, KpiHistory,
                         ManagementService, NotificationOutbox, ScriptAnalysis, Security,
                         TemplateEngine, Transport
Host                  -> all 22 component projects

No cycles. Nothing but tests references Host. Two cross-role assembly loads are noted, not defects: AuditLog → ConfigurationDatabase (central-only EF, loaded on sites via AddAuditLog) and Communication → HealthMonitoring → StoreAndForward (site-only, loaded on central).

Shared-lib versions in use: Health 0.3.0 · Telemetry 0.1.0 · MxGateway.Client 0.2.1 · Configuration 0.1.0 · Auth.* 0.3.0 · Audit 0.1.0 · Theme 0.4.1 · Secrets.* 0.6.2 · LocalDb.* 0.3.4 — all matching the umbrella index. Akka 1.5.62, Grpc 2.76.0 / Protobuf 3.34.1, Microsoft.* 10.0.7 uniform; skew only as XC-9.

6.B Composition roots

Central Site
Cluster singletons notification-outbox, audit-log-ingest, site-call-audit, audit-log-purge, site-audit-reconciliation (required-for-readiness), kpi-history-recorder, pending-deployment-purge (best-effort, excluded) deployment-manager, event-log-handler (both role: site-{SiteId})
Per-node actors dead-letter-monitor, central-communication, management dead-letter-monitor, site-communication, dcl-manager, cert-store, parked-message-handler, SiteReconciliationActor, site-audit-telemetry
Hosted services Akka, bootstrap coordinator, inbound key import (SqlServer), script-artifact subscriber, audit partition maintenance, bundle session eviction, health aggregator, health report loop Akka, bootstrap coordinator, health report sender, storage initializer, event-log purge, audit backlog/retention reporters, scheduler stats, stream drop reporter, run-summary flush, event-log failure counter, LocalDb replication status
gRPC CentralControlGrpcService + secrets hub SiteStreamGrpcServer, SiteCommandGrpcService, LocalDb sync
Health checks database, akka-cluster, required-singletons (Ready); active-node + database (Active) akka-cluster, localdb (Ready); SitePairActiveNodeHealthCheck (Active)

No duplicate hosted services; TryAddEnumerable used correctly; only XC-10's two options classes lack validation.

6.C Cross-cluster RPC inventory

Service (host) RPCs Interceptor Caller
SiteStreamService (site) SubscribeInstance, SubscribeSite, PullAuditEvents, PullTraceEvents, PullSiteCalls — and dead IngestAuditEvents, IngestCachedTelemetry ControlPlaneAuthInterceptor central stream client / audit pull clients
SiteCommandService (site) ExecuteLifecycle/Opcua/Query/Parked/Route, TriggerFailover ControlPlaneAuthInterceptor GrpcSiteTransport
CentralControlService (central) SubmitNotification, QueryNotificationStatus, IngestAuditEvents, IngestCachedTelemetry, ReconcileSite, ReportSiteHealth, Heartbeat CentralControlAuthInterceptor GrpcCentralTransport
LocalDbSync (site) library LocalDbSyncAuthInterceptor peer site node
Secrets hub (central) library SecretsHubAuthInterceptor site secrets sweep

Zero removed field lines in git log -p -- Protos/ since 2026-06-01; 565 fields, 0 proto↔C# mismatches.

6.D Config contract deltas

Read by code, set on no rig (silently defaulted): ScadaBridge:{OpcUa, MxGateway, DeploymentManager, SiteCallAudit, ExternalSystemGateway, Sms, SiteRuntime, OperationTracking}, Telemetry:{Exporter, OtlpEndpoint}, AuditLog:{SiteTelemetry, Purge, Reconciliation, PartitionMaintenance, SiteRetention}, SiteEventLog:{PurgeInterval, MaxPurgedRowsPerTick, PurgeCatchUpInterval, QueryPageSize, WriteQueueCapacity}. Set on the rig, read by nothing: XC-2 (4 SMTP keys), XC-3 (PurgeScheduleCron), XC-7 (2 legacy paths). Required keys not checked by StartupValidator: LocalDb:Path (delegated to AddZbLocalDb), Node:CentralGrpcPort (defaulted). Every credential literal on both rigs is the documented dev set with its ${secret:} form named inline.

6.E Deferred-work register (rows 90–118)

Open: 92, 94, 107, 109, 110, 112, 113, 115, 116, 117, 118 (114 never issued). None should be closed; every open premise re-verified. Row 112 should be re-worded (central now has Truncated + LogsTruncated; the site half is still unflagged). Row 117 confirmed and extended by MG-6. No dangling row citations in code. New rows to mint from this review if deferred: DL-2, EI-9, XC-3 (if not implemented).

6.F Test-suite shape (first-pass inventory; fresh execution in §7)

≈ 7 579 [Fact]/[Theory]/skippable attributes across 32 test projects, all in the slnx; zero static Skip=; runtime Skip.If concentrated in the MSSQL-fixture and Playwright suites (environment-conditional, not visible in a count); no retry/flaky markers; 56 assertion-less candidates all sampled to throwing helpers — no genuine no-op test found.

6.H Design commitments

All twelve checked HOLD with a code anchor: active-node definition (ActiveNodeEvaluator.cs:43, no State.Leader use); single "scadabridge" ActorSystem (AkkaHostedService.cs:190); UTC (DateTime.Now/DateTimeOffset.Now/Today → 0 hits in src/); no ClusterClient; notification_lists/smtp_configurations unregistered (SiteLocalDbSetup.cs:60-72); auto-down emitted (AkkaHostedService.cs:283-285); resolver on all four compile surfaces; no IHealthCheckPublisher; central never buffers; single trust boundary at five call sites; additive protos; no active <Protobuf> item.


7. Verification evidence

7.A Fresh second-pass commands at c078bc8f

All commands ran from the repository root on 2026-09-09. Build/test used existing restored assets; package listing queried the configured feeds. No production source, tests, package pins, or rig configuration were changed by this review.

Check Exact command Result
Build dotnet build ZB.MOM.WW.ScadaBridge.slnx --no-restore exit 0; 0 warnings, 0 errors, 34.02 s
Selected solution tests dotnet test ZB.MOM.WW.ScadaBridge.slnx --no-build --no-restore --filter 'FullyQualifiedName!~PlaywrightTests' --logger trx --results-directory /tmp/scadabridge-review-tests exit 0; 9,232 passed, 0 failed, 0 skipped in 29 nonempty assemblies; 30 TRX files include the empty filtered Playwright assembly
Formatting dotnet format ZB.MOM.WW.ScadaBridge.slnx --verify-no-changes --no-restore --verbosity quiet exit 2; 1,290 WHITESPACE diagnostics across 60 files; no formatter mutations
Vulnerabilities dotnet package list --project ZB.MOM.WW.ScadaBridge.slnx --include-transitive --vulnerable --format json --no-restore exit 0; 58 projects inspected; one reported dependency: AngleSharp 1.1.1, Moderate, test-only CentralUI.Tests
Deprecations dotnet package list --project ZB.MOM.WW.ScadaBridge.slnx --deprecated --format json --no-restore exit 0; xunit 2.9.3 marked Legacy in 30 test projects; feed suggests xunit.v3
Dependency graph XML parsing of all source .csproj ProjectReference elements, DFS cycle check 26 nodes, 91 edges, no cycles; Commons has zero project references
Report integrity parse finding headings, verify active tally/unique IDs, git diff --check, inspect final diff 92 active IDs; 13 High / 33 Medium / 46 Low; UI-3 and XC-6 excluded

AngleSharp's advisory is already explicitly suppressed only in tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests.csproj:64, with compatibility rationale recorded in that file and Directory.Packages.props. This pass reports that accepted test-only exposure; it does not assert current patched bUnit compatibility or recommend silently overriding its dependency. xUnit deprecation is a migration-planning signal, not proof of a current runtime defect. No version changes were made.

The first pass recorded 9,231 passed / 1 failed, with a NullReferenceException in OperationTrackingStoreTests.SR024_SyncDispose_DoesNotDeadlock_WhenInvokedFromFreshThread. That same test passed in this fresh run. ORCH-1 remains a source-confirmed race; this pass did not reproduce the historical stack and does not claim it is fixed. Passing tests also do not cover the newly identified unsafe paths unless explicitly noted below.

Raw evidence is local to this review session: /tmp/scadabridge-review-build.log, /tmp/scadabridge-review-test.log, /tmp/scadabridge-review-tests/, /tmp/scadabridge-review-format.log, /tmp/scadabridge-review-vulnerable.json, and /tmp/scadabridge-review-deprecated.json. The results and reproduction recipes below are retained in this document; temporary files are not durable repository artifacts.

7.B Isolated behavior probes

These probes used local compiled assemblies or real loopback Kestrel with synthetic data. They were created under /tmp, not added as regression tests. They neither touched live devices nor fetched production credentials.

Finding Executed probe / observation Scope of proof
MG-1 ScriptTrustValidator.FindViolations returned 0 for RuntimeHelpers.GetUninitializedObject, managed Unsafe.As, and new AssemblyLoadContext snippets Real policy permits each referenced API; not a full sandbox escape exploit
CM-1 AuditEventBatch.CalculateSize() for 256 ErrorDetail-only rows of 65,536 ASCII bytes returned 16,779,264; actual AddGrpc options resolve receive default 4,194,304 Real protobuf size and framework default; no disk-growth or end-to-end drain experiment
CM-9 Real central auth interceptor with site-A metadata/key accepted reconciliation payload SiteIdentifier=site-b; real mapper preserved B Auth/request binding gap, followed in source to token staging; no cross-site secret fetch executed
CM-10 Provider callback applied a synthetic effect, threw Unavailable, then succeeded on peer: applications=2, returned ok Fault-injected retry path, not physical network/device duplication
SR-11 In-memory SQLite: normal wrapped write audit count 1; tx.Connection was original provider, not wrapper; raw write affected 1 row with 0 audit emissions, persisted value 42 Actual ADO escape and missing audit; reflection only instantiated internal wrapper in harness, not used by escape; not SQL Server/Roslyn end-to-end
EI-10 Implemented; verification pending Early bounded body reader applies even with auditing disabled; real Kestrel gate pending.

Probe sources/output: /tmp/scadabridge-review-probes/Program.cs and /tmp/scadabridge-review-probes.log; /tmp/scadabridge-sr11-probe/Program.cs; /tmp/scadabridge-cap-probe/Program.cs and /tmp/scadabridge-cap-probe/output.txt. Repeatable regression recipes are attached to each finding and the §5 acceptance gates.

8. Second-pass review decisions and architecture fitness

8.A Corrections that change remediation

IDs Decision
UI-3 Withdraw: caller awaits retain renderer synchronization context; no demonstrated off-dispatcher entry
XC-6 / CM-5 One defect, retain CM-5 Low; XC-6 is a historical alias
UI-1 Many bypass paths in 16 files, not every UI edit; template/shared-script service paths already audit
MG-3 Implemented; verification pending
UI-4/6/7 Low hardening/prevention gaps; no continuing-circuit refresh outage or unauthorized handler exploit demonstrated
UI-2 / UI-13 Passive cookie renewal and stale circuit authority are separate failures; both need server-side handling
UI-5 / UI-14 Timeout is not termination or a memory bound; never recycle an admission slot while runaway work remains
DC-1/2/5/12 Cleanup must fence old owners and live handles; snapshots must cover every subscriber without broadcasting per-feed completion to unrelated sources
SR-4 / SR-11 Retargeting restrictions must cover transaction-derived connections; the class is already sealed
EI-2/3/5/11 Free-text messages may contain secrets; SQL name patterns do not redact values; caller-controlled forwarding markers are not identity proof
EI-4 / CM-10 / DC-7 Align deadlines but distinguish safe pre-dispatch rejection from an uncertain already-applied request
ORCH-1 / EI-7 A timeout cannot justify disposing resources still owned by unfinished work or admitting unlimited abandoned tasks
MG-6 / DL-3/4/8/11 Stable keysets + bytes + true root + echo-edge priority + latest detail are separate correctness requirements
DL-9 Document bounded owned state; statefulness alone is not a Commons architecture violation
XC-1 Use actual listener/local port, not spoofable Host/authority matching, for port isolation

All previous SR/DC/MG/UI/DL/EI/CM/XC IDs and ORCH-1 were source-revisited or, for inventory/doc observations, checked against their current files. Historical production/QA statements, performance estimates, and migration/deferred-register inventories are not newly measured simply because source is unchanged. The original twelve design commitments describe implementation structure; they are not evidence that the script trust gate is a complete security sandbox or that per-site keys enforce payload ownership.

8.B CheckIt matrix

The intended design is a modular Akka hub-and-spoke system with a framework-light Commons core, not a framework-free application layer. Checks are interpreted against that declared architecture. A pass is limited to the cited check, not a blanket claim that the component is bug-free.

Check Status Evidence / interpretation
CA-01 dependency direction pass Commons defines entities/ports, no project references; EF/UI/Host remain outside core; §6.A graph
CA-02 framework independence pass (Commons scope) Commons directly references only shared Audit; no EF/ASP.NET/Akka core imports found; actor application components intentionally depend on Akka
CA-03 ports/adapters pass External gateway/DB/device/notification ports and test doubles remain; SR-11 is an adapter escape defect, not absence of ports
CA-04 boundary data shape pass (structural) Typed message/proto DTO mapping and additive contracts retained; CM-9 shows shape alone does not validate ownership
CA-05 rule placement fail UI-1 bypasses audited application mutation boundaries and their guards
CA-06 cycles pass Fresh DFS: 26 source projects / 91 edges / zero cycles
CA-07 testability pass Runtime, management, adapter, and transport suites use probes/test doubles; selected solution tests run locally
BP-01 separation of concerns fail UI-1 duplicates mutation/audit responsibility outside audited service path
BP-02 explicit dependencies / lifetime fail MG-3 process-global memo, CM-8 mutable static seam, SR-2 omitted alarm dependencies, ORCH-1 resource lifetime race
BP-03 repeatable self-checking tests fail Current run passes; ORCH-1 historical failure + CM-8 shared static test seam still lack deterministic guards
BP-04 integration boundaries pass Separate Transport.IntegrationTests and host/communication boundary suites; new negative gates needed for reported defects
BP-05 static-analysis health pass (executed build diagnostics only) Warnings-as-errors configured; build 0/0; no separate security/static-analysis engine run, format fails below
BP-06 automated style fail No checked-in root .editorconfig or CI workflow found; formatter reports 1,290 diagnostics in 60 files
BP-07 dependency-risk process pass with documented exception Fresh vulnerable/deprecated queries completed; known test-only AngleSharp suppression has rationale; xUnit Legacy recorded; XC-9 version skew remains
BP-08 security baseline fail MG-1, MG-2, UI-1/2/13, SR-11, CM-9, EI-10/11 show concrete gaps despite existing auth/redaction controls
BP-09 continuous architecture fitness fail Role/dispatch/compile-contract/captive-dependency tests exist; whole-graph, mapped-service-auth, proto-source sync and route-policy guards are incomplete; no checked-in CI workflow found
LOG-01 organization logging baseline fail Host uses Serilog (Program.cs:181,795) and structured node/trace enrichment (LoggerConfigurationFactory.cs:85-95); actors also use Akka ILoggingAdapter (CentralCommunicationActor.cs:27). No application LogContext push/FromLogContext or request/user/operation scope setup found; only TraceCapturingLogger delegates BeginScope. Thus the stricter ILogger + Serilog + LogContext baseline is not fully implemented. Existing trace IDs are not absent, and this is not a new security defect count

8.C Fitness functions to add

  1. Actual mapped gRPC services must have an authentication gate; verified principal ownership must match every request/row site. Combine prefix coverage with adversarial metadata/payload tests.
  2. Source project graph must stay acyclic and Commons must not acquire outer-layer references; freeze deliberate exceptions, not incidental transitive package names.
  3. Generated descriptors must match proto messages, fields, types, oneofs, and service signatures. Field-number-only regex checking is a useful start but misses renamed/retyped/service drift.
  4. Every UI mutation goes through an audited application boundary and uses current grants. Test representative create/update/delete rollback and revocation on a live circuit; don't rely only on file-name/string sweeps or role attributes.
  5. Every bounded surface enforces its limit before allocation/read: audit serialized batches, Replay payloads, sandbox writers, and chunked HTTP bodies. Include single oversize rows.
  6. Lifecycle tests control the race: owner replacement/unsubscribe, interleaved source snapshots, late rehydration, demotion between deliveries, pump shutdown, and response loss after commit.

Add these to the repository's chosen CI system with existing build/test gates; keep formatting cleanup and framework/package migration in separate maintenance work so they do not obscure the correctness/security remediation.

9. High-severity remediation completion

Completed 2026-09-09. All 13 High findings are resolved and verified. Implementation started from 8bfe6551 on codex/resolve-high-review-findings, with parallel device/runtime, transport, and UI/security agents plus primary-agent integration and independent reviews. The findings above preserve the reviewed baseline; this section supersedes their open status. Medium/Low findings remain outside this remediation unless a change was required for a High fix.

Finding Status Implementation / verification
DC-1 Completed Owner-checked PostStop unsubscribe and DeathWatch release dead demand. Shared/replacement demand survives old-owner termination and in-flight completion. Deterministic lifecycle/race regressions pass.
DC-2 Completed Initial subscribe and resolution exclude each other’s in-flight tags; equal live handles are never released. Mx reverse-map removal is atomic and identity-checked. Adapter and controlled-race regressions pass.
DC-3 Completed Connecting/reconnecting reject single and batch writes immediately; subscription retries are deduplicated by owner/source. No stale setpoint replay after reconnect. Full DCL suite: 365 passed.
DC-12 Completed OPC snapshot start/entries/end retain source and epoch; NativeAlarmActor ignores unrelated/duplicate ends and drops partial snapshots on disconnect. Legacy Mx framing remains supported. Actor tests prove standing-alarm preservation; live two-boiler feed verifies distinct wire scopes and epochs.
CM-1 Completed Servers and all six application channels use explicit 32 MiB send/receive limits. Protobuf-exact 2 MiB audit budgets cover pending/cached drain and reconciliation paging. Both 256×64 KiB lanes drain without loss; real gRPC 17 MiB response passes. Oversize-row policy is explicit below.
CM-9 Completed Authenticated site identity guards RPC payloads, audit rows and notification status. SQL upsert rejects another site’s stored operation ID, including duplicate-insert retry. Real gRPC denial, outbox ownership and 29 SQL Server lifecycle/ownership tests pass.
CM-10 Completed Reads/idempotent ingests may retry; mutations retry only after explicit server pre-dispatch rejection. Ambiguous primary/secondary response loss returns OUTCOME_UNKNOWN without replay. Real transport and effect-count tests pass.
UI-1 Completed All 16 configuration repository-bypass pages use typed commands. One transaction spans entity saves and final scrubbed audit flush, including generated IDs; cache publication follows commit. Direct instance/area paths retain domain checks within the same boundary. LDAP CRUD and audit-failure rollback regressions pass; deployed browser CRUD passes.
UI-2 Completed Passive ping/Blazor validation neither slides cookies nor advances activity. Trusted browser input posts with antiforgery; per-login activity reaches existing circuits. Stable NameIdentifier preserves antiforgery across role refresh. Real HTTP fake-clock tests and deployed trusted/synthetic-input browser gate pass.
UI-13 Completed Circuit authority remaps stored groups in fresh scopes; mutations require current roles and persisted-site scope. API keys, reports, failover, parked messages, instances and Topology recheck before dispatch. Revoked-scope and authorized-positive tests pass, including nonnumeric site identifiers. Explicit server-configured auto-login retains synthetic grants under its existing startup guard.
SR-1 Completed Attribute get/set/wait/batch continuations use ConfigureAwait(false). Saturated two-worker synchronous read/write regressions pass without watchdog assistance; wait/trace regression coverage remains green.
SR-11 Completed Sync/async transaction and command connection/transaction readback retain auditing wrappers. SQLite regressions prove persisted writes and exactly one audit through each former escape.
MG-1 Completed Trust gate denies Unsafe, RuntimeHelpers and Runtime.Loader, including alias/static imports, while preserving async and CallerMemberName. ScriptAnalysis suite: 81 passed.

Final verification

Gate Result / evidence
Final solution build dotnet build ZB.MOM.WW.ScadaBridge.slnx --no-restore: 0 warnings, 0 errors. /tmp/high-final-build3.log
Selected full solution dotnet test ZB.MOM.WW.ScadaBridge.slnx --no-build --no-restore --filter 'FullyQualifiedName!~PlaywrightTests' --logger trx --results-directory /tmp/scadabridge-high-final-green: 9,310 passed, 0 failed, 0 skipped; exit 0. /tmp/high-final-tests2.log
Final UI follow-up After the final auto-login compatibility fix: 1,392 passed, 0 failed, 0 skipped in the full CentralUI suite. This reruns the 1,391 UI cases included above plus the new regression; counts are not additive. /tmp/high-ui-final.log
Deployed browser LDAP create/edit/delete round trip and trusted-input/passive-input/antiforgery/throttle scenario: 2 passed, 0 failed, 0 skipped. /tmp/high-browser-final.log
Independent reviews Runtime/trust, transport/ownership, and UI transaction/authorization reviews passed after corrections; final auto-login guard follow-up also passed. /tmp/scadabridge-ui-independent-review.md
Local rig Final image sha256:b9d6b60ab77093ee537470992fb891f78cc9888170d290021855c902c0115eb6 on all eight nodes. All ready (200), one active per pair (200) and four standbys (503), two members per pair, zero unreachable/restarts, all five central singletons reachable. Site-A replication connected with backlog 0. /tmp/scadabridge-final-loginfix-postdeploy-health.json
Change hygiene git diff --check clean. Canonical original-repository config/log mounts and named data volumes preserved; env2 remains untouched. Component requirements updated with implementation.

The local image was built with bash docker/build.sh from the implementation worktree, then all eight nodes were recreated using the canonical original repository’s Compose file. This keeps existing configuration, logs and data in place rather than rebinding them to the worktree. No device writes, failover drill, remote deployment, commit or push was performed by this remediation.

Regression evidence and limits

  • SR-1/SR-11 reproduced five failures before their fixes; the runtime follow-up passed 39 targeted tests. MG-1 reproduced five forbidden-call failures while two benign cases passed; all 81 ScriptAnalysis cases pass afterward. Full SiteRuntime: 774 passed.
  • CM-1’s 17 MiB response failed at the previous default limit. CM-9’s stored-owner SQL regression failed before its guard. Full Communication 756, ConfigurationDatabase 411, AuditLog 396, Host 590, and integration 117 tests pass. Two historical Host tests expected blind mutation retry on Unavailable; they now model explicit pre-dispatch rejection, with a separate real-transport test proving ambiguous failure never dispatches to the peer.
  • Device tests control owner death, replacement, shared-tag demand, subscribe/resolve races, disconnected writes and interleaved snapshots. A stale failover test assertion was corrected: backup demand must be restored while an original subscribe remains pending. The full DCL suite subsequently passed. The local two-boiler snapshots were empty; standing-condition preservation is proven by deterministic actor tests, not by live raised alarms.
  • UI revocation and Topology denied-mutation tests reproduced failures before correction. The first LDAP baseline test stopped at an incorrect seeded-row count, so it does not establish a before-fix missing-audit reproduction. Corrected real-SQLite CRUD/audit/rollback tests pass. A real HTTP regression found antiforgery failure after role refresh; stable user identity fixed it. The deployed browser found the synthetic auto-login role-loss regression; the explicit trusted-configuration guard passed its red/green test and repeat live CRUD gate.
  • A single audit row above 2 MiB travels alone up to the 32 MiB wire ceiling. A larger row remains durable/unacknowledged with an explicit operator-action error, without truncation or silent loss. Stored LDAP groups are remapped live; directory group-membership changes still require login. Pre-upgrade cookies without stable NameIdentifier may require a fresh sign-in.
  • No separate formatter cleanup or framework/package migration was included. The original Medium/Low register and baseline formatting debt remain. The local rig has an existing zztest-conn-5d93ea81 test connection retrying unreachable localhost:4840; healthy cluster readiness does not assert that every configured device endpoint is reachable.

10. Medium-severity remediation completion

All 33 Medium findings are completed and verified. The High fixes were committed first as 146a1323 (Resolve all high-severity architecture review findings). Medium implementation, tests and documentation remain uncommitted on codex/resolve-high-review-findings in /Users/dohertj2/Desktop/ScadaBridge-high-findings; no push was requested for this phase. Parallel subagents handled runtime/devices, management/UI and edge integrations; the primary agent handled persistence, listener/contracts, configuration and final integration. Independent cross-reviews rechecked lifecycle, sandbox containment, relay provenance and migration safety.

Finding Status Progress / evidence
SR-2 Completed Fresh alarm execution scope supplies database and tracking dependencies; runtime focused and full solution suites passed.
SR-3 Completed Correlated bounded retry restores failed alarm subscriptions; full solution suite passed.
SR-4 Completed Implemented; immutable audited target regression passed.
SR-5 Completed Ordered persistence drain and same-name stop barriers; independent review passed; runtime suite 102 passed including locked delete/disable and queued cancellation.
SR-12 Completed Implemented; delayed rehydration fencing regression passed.
SR-13 Completed Authority rechecked after each lane admission and before every delivery; StoreAndForward suite 38 passed.
CM-2 Completed Implemented; all four env2 node configurations enable deterministic bootstrap; parsed configuration validation passed.
MG-2 Completed Mandatory fresh caller scope filters audit trees and replay; management regressions and full UI suite passed.
MG-3 Completed Removed stale deployment probe memoization; peer-change regressions passed.
MG-4 Completed Server-owned bulk deadline stops subsequent dispatch and returns unfinished failures; targeted deployment tests passed.
UI-5 Completed Isolated child processes killed and joined before admission reuse; broker-deadline and runaway-worker tests included in 97 passing UI cases. Published Linux image contains worker DLL/deps/runtimeconfig; deployed browser termination/recovery check passed.
UI-14 Completed Console, return serialization and IPC bounded; oversized-output regressions passed in the full UI suite and deployed browser.
EI-1 Completed Relay ownership and uncertain-outcome audit completion implemented; InboundAPI suite passed.
EI-2 Completed Body-capture opt-out also suppresses free-form failure details and companion events; InboundAPI suite passed.
EI-3 Completed SQL exception details respect sensitive-parameter redaction policy; audit redaction regressions passed.
EI-4 Completed Single relay deadline and explicit pre-execution-only retry; ambiguous outcomes are not replayed; InboundAPI suite passed.
EI-5 Completed Authenticated request-bound relay provenance replaces trust in caller-supplied forwarding headers; independent review and InboundAPI suite passed.
EI-10 Completed Early body cap applies with auditing disabled; real Kestrel oversized/chunked cases included in six passing Host regressions.
EI-11 Completed All retained error-message/detail fields are capped and redacted; targeted audit suite passed.
DL-1 Completed Global unique index, domain conflict translation and duplicate preflight migration; real SQL concurrency/upgrade guard tests passed.
DL-2 Completed Protected primary/backup configuration with per-provider model cache and idempotent bounded startup upgrade; real SQL and SQLite regressions passed.
DL-3 Completed Eight-row keyset paging, 500-event ceiling, UTF-8 budget and preserved root/truncation metadata; all six real SQL regressions passed.
DL-6 Completed Deployed cross-site overwrite rejected; both real import cases passed after a valid failing baseline.
DC-4 Completed Exact requested OPC UA security mode enforced during discovery/connect; focused device suite passed.
DC-5 Completed Shared alarm cache merges live events with snapshots and fences stale epochs; independent review and 34 device regressions passed.
DC-6 Completed CLI returns failure for nonzero bulk failureCount and malformed responses; all three actual command/socket regressions passed.
DC-7 Completed Notifier retries only explicit standby rejection; notifier suite passed.
XC-1 Completed Actual socket and gRPC metadata enforce endpoint ownership despite forged authority; six combined Host/Kestrel regressions passed.
XC-2 Completed Implemented; removed four ignored SMTP keys from all four central rig settings; database configuration guidance replaces them.
XC-3 Completed Implemented; removed ignored cron key from all eight site settings; documents interval-based purge.
XC-4 Completed Actual protoc descriptors match vendored message/service graphs; four tests passed including three name/type/service drift controls. Implicit JSON names normalized using effective runtime descriptors.
XC-5 Completed Implemented; README, high-level/component requirements and component skill now describe actual gRPC/HTTP transport.
ORCH-1 Completed Synchronous disposal joins context-free persistence drain; runtime 102-case suite passed.

Final verification

Gate Result / evidence
Full solution build 0 warnings, 0 errors; /tmp/medium-full-build2.log. Final deployed-browser project build also passed 0/0 after its test-only update (/tmp/medium-browser-build.log).
Selected full solution dotnet test ZB.MOM.WW.ScadaBridge.slnx --no-restore --filter 'FullyQualifiedName!~PlaywrightTests' --logger trx --results-directory /tmp/scadabridge-medium-full: 9,397 passed, 0 failed, 0 skipped, exit 0, 30 result files. /tmp/medium-full-tests.log. Includes 1,399 UI, 596 Host, 125 Transport integration and 117 cross-component integration cases plus 13 performance checks.
Deployed browser 2 passed, 0 failed, 0 skipped: Linux sandbox normal execution, bounded console/return output, noncooperative infinite-loop timeout and subsequent successful execution; trusted/passive session input and antiforgery. /tmp/medium-browser-final.log. No child worker remained afterward on either central node.
Real persistence Global-name concurrency and duplicate-upgrade rejection, primary/backup encryption and idempotent conversion passed against SQL Server; key-ring isolation and guards passed against SQLite. /tmp/medium-persistence-final.log. Final replay keyset/Unicode/oversized-root SQL checks also passed and are included in the full solution run.
Transport contract Six real Kestrel listener/body-cap cases passed (/tmp/medium-host-green.log); actual protoc descriptor comparison and three drift controls passed (/tmp/medium-proto-green2.log).
Local rig Image sha256:b8d158309b5d850fc01a3d32e7325e2238611b039e37f5fa0d51e7a488ddee36 on all eight nodes; all ready, one active per pair, two members per pair, zero unreachable members or restarts. Site-A replication connected, backlog 0. /tmp/scadabridge-medium-postdeploy-health.json.
Upgrade Coordinated central-pair stop, successful COPY_ONLY/CHECKSUM backup and RESTORE VERIFYONLY, then migration 20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration. All 4 protocol rows retained; 3 non-null primary values protected, 0 unprotected values, existing 1-entry key ring preserved. /tmp/medium-rig-backup.log, /tmp/medium-rig-before-counts.log, /tmp/medium-rig-after-counts.log. Live Site-A OPC UA connection reconnected after upgrade.
Configuration/docs All 12 rig JSON files parse; four env2 bootstrap guards enabled; obsolete SMTP/cron keys removed; transport entry-point documentation corrected. Component requirements and umbrella project index updated.

Operational notes and verification limits

  • The SQL backup remains inside the existing SQL Server data volume at /var/opt/mssql/backup/ScadaBridgeConfig_before_medium_20260909_211100.bak. Protocol encryption is a coordinated central upgrade: old binaries cannot read the new values. A rollback requires the coordinated pre-upgrade database/key-ring backup, as documented in Component-ConfigurationDatabase.md; the migration refuses a downgrade over protected rows.
  • The image was built from the implementation worktree and deployed using the original repository's canonical Compose file. Existing configuration/log bind mounts and named data volumes were preserved. Env2 bootstrap settings were verified statically; env2 containers remain untouched. No device writes, live failover drill or remote deployment was performed.
  • Sandbox isolation provides a process resource/lifetime boundary, not a separate OS identity. The worker is killed at the deadline. If a parent-side broker ignores cancellation, its slot and scope remain owned until that operation actually completes; no orphan broker operation or early capacity reuse is introduced. Already accepted remote work cannot be undone by killing its client. Console retains 32,768 characters plus a fixed truncation marker; return serialization is capped at 32 KiB bytes, IPC at 1 MiB, managed worker heap at 512 MiB.
  • Replay is capped at 500 events and a 1 MiB serialized UTF-8 budget, with eight-row keyset pages bounding EF buffering. Oversized events are omitted whole with truthful truncation and separately retained root metadata. Shared snapshot/lifecycle races are covered by deterministic tests, not a live raised-alarm scenario. The pre-existing unreachable zztest-conn-5d93ea81 test connection remains outside this remediation.
  • The first proto comparison exposed implicit JSON-name representation differences, not wire drift. It now compares effective runtime names against compiler output while preserving explicit field/service metadata. Earlier fixture/build failures were corrected without weakening production guards: audit test doubles accept caller scope, and protocol-writing fixtures use stable ephemeral key rings. These are not claimed as before-fix reproductions.
  • Low findings are tracked in §11. Focused test counts overlap the full suite and are not additive.

11. Low-severity remediation progress

Completed: 46 / 46 Low findings. All 46 Low findings are in scope. This register supersedes the baseline finding status only when a row is marked Completed with verification. High and Medium completion records in §§9–10 remain authoritative.

Implementation follows the existing finding-specific fixes: preserve wire compatibility, bound maintenance and debugging work, enforce authorization at application boundaries, and correct documentation against current source. Independent UI, Flow Debugger, and runtime/device groups run in parallel with exclusive file ownership. Transport, maintenance, package/configuration work, and this register have one coordinating owner. Behavior changes receive focused regression checks; documentation and configuration changes receive source/structure validation. Final gates are full build, relevant/full solution tests, diff review, and a local rig rebuild for shipping runtime changes.

Finding Status Progress / evidence
SR-6 Completed Device-to-script linkage is explicitly inferred; alarm causal parents remain reserved for actual writes. Runtime/Commons regressions pass.
SR-7 Completed Trace failure diagnostics use the undecorated logger; recursive trace capture regression passes.
SR-8 Completed Thread-static scheduler identity prevents cross-scheduler inlining; saturation regression passes.
SR-9 Completed Shared trigger gate has explicit process ownership; consumer Dispose cannot poison later callers. Lifecycle regression passes.
SR-10 Completed Per-context helpers and guard closures are lazily cached; identity regression passes.
CM-3 Completed Raw audit/site-call pulls reject limits outside 1–2000 before reading; both reconciliation option validators enforce the same cap. Boundary regressions pass.
CM-4 Completed Bootstrap checks cancellation before resolving the actor system or joining seeds, and propagates canceled probes. Host suite 598 passed.
CM-5 Completed Retired site ingest RPCs always return Unimplemented, including empty payloads; dead actor wiring removed. Contract regressions pass.
CM-6 Completed Central and site HTTP/2 listeners share finite keepalive settings; metrics listener isolation remains enforced. Build and Host 598 tests pass.
CM-7 Completed Channel graveyard retains active calls beyond grace; lifecycle locking closes shutdown/requeue races. Completion and shutdown regressions pass.
CM-8 Completed Connected-header timeout is immutable per client, bound through validated CommunicationOptions. Parallel-instance regression passes.
MG-5 Completed Total live-command budget includes lookup/write/audit/pull, reserves response grace, and propagates audit cancellation. Management suite 658 passed.
MG-6 Completed Clock-independent keyset rescans capture late/backdated completion and descendant rows. Indexed key union bounds payload pages; real SQLite plans cover 1/64 IDs and malformed legacy JSON. Management 660 and AuditLog 407 tests pass.
MG-7 Completed Live write results use bounded collector with explicit event/log truncation; Management suite 658 passed.
MG-8 Completed Shared-script create/update uses cached compile/trust validation; TemplateEngine suite 483 passed.
MG-9 Completed Positive page sizes clamp to 1000; null/nonpositive semantics preserved and boundaries tested.
MG-10 Completed Management component catalog now documents all four FlowDebug commands, authority, paging and budget.
UI-4 Completed Poll cleanup runs in outer finally and observes rejected renderer work; full UI suite 1416 passed.
UI-6 Completed Scoped failover facade requires current Administrator authority before invoking the host backend; positive/negative tests pass.
UI-7 Completed Exact route-policy sweep covers UI and external Secrets components; full UI suite 1416 passed.
UI-8 Completed Shared parked-call relay resolves site ownership and denies unknown/out-of-scope targets for scoped principals; regressions pass.
UI-9 Completed Tree value snapshots skip unchanged forest rebuilds while preserving in-place edits, child metadata and filtered key/inheritance behavior.
UI-10 Completed Cookie login explicitly validates antiforgery; missing token rejected and valid real token issues cookie. CLI bearer-token endpoint preserved.
UI-11 Completed Lazy site/area queries retain one untracked 50-row instance page per loaded branch; shared async gate serializes branch/refresh/staleness. SQLite paging and controlled interleaving regressions pass.
UI-12 Completed Viewer sees no empty Notifications heading and Flow Debugger navigation matches its policy; regression passes.
EI-6 Completed Purge actor forwards per-channel 50000-row tick budget; repository caps final partial batch. Purge/options 20 and repository/guard 10 tests pass.
EI-7 Completed Root-DI admission registry retains one unfinished collection per registration across actor recreation; actual wait is bounded and task owns scope until completion. Full KPI suite 57 passed.
EI-8 Completed Relay strips Accept-Encoding because it forwards raw response bytes without Content-Encoding; regression and full InboundAPI suite 360 pass.
EI-9 Completed Outbox contract explicitly documents repeated duplicate batches during dual-active operation and lack of row claims.
DL-4 Completed Echo edges replace ordinary duplicate endpoint pairs; unique-edge and echo-semantic regressions pass.
DL-5 Completed Refresh bases graph order on authoritative Replay and appends live-only rows; order regression passes.
DL-7 Completed Graph build parses each audit payload once and reuses projections; Commons suite 851 passed.
DL-8 Completed Existing Medium chain-anchor fix revalidated by timestamp-skewed child/root regression.
DL-9 Completed Commons Debugging namespace, per-run bounded collector ownership and redaction documented.
DL-10 Completed Enum name/value serialization contracts and display ordering have separate regression coverage.
DL-11 Completed Folded steps/lifecycle records retain latest exception detail and clear stale details after success; regressions pass.
DC-8 Completed CLI harness covers all 19 groups plus browse/search/verify/certs; secured-write README added; CLI suite 475 passed.
DC-9 Completed DCL docs corrected against current unsubscribe, write rejection, atomic disconnect and snapshot behavior.
DC-10 Completed OPC shard plans are computed once per resolved chunk and reused for each monitored item; DCL suite 379 passed.
DC-11 Completed All notifier base URLs are validated as absolute HTTP(S) before delivery; notifier suite 36 passed.
XC-7 Completed Removed both migration-only sections from all eight rig site JSON configurations; parsed and verified each file.
XC-8 Completed Source-discovered service maps and external LocalDb/Secrets mappings are compared against generated method descriptors and auth gates, with gate-removal negative control. Host suite 598 passed.
XC-9 Completed Central SqlClient 6.1.1 removes per-test overrides; SignalR.Client 10.0.7 aligns with runtime; bUnit 2.7.2 uses stable syntax. Restore, zero-warning build and all solution suites pass.
XC-10 Completed Global OPC/Mx options validate required identity strings at startup, preserving optional path defaults; DCL suite 379 passed.
XC-11 Completed Umbrella index now includes instance picker/v1.4 logs and live remediation status. Fresh origin/main fetch confirms current main fef3e767 is already pushed (0 ahead/behind); no new push needed for the historical finding.
XC-12 Completed Health-check remarks now distinguish seven central singleton proxies, five required and two best-effort exclusions.

Final verification — completed 2026-09-09

  • Full solution restore succeeded after centralizing Microsoft.Data.SqlClient 6.1.1, Microsoft.AspNetCore.SignalR.Client 10.0.7, and stable bUnit 2.7.2.
  • Full solution build: 0 warnings, 0 errors. Canonical docker/regen-proto.sh sitestream preserves the source descriptor path for the additive trace cursor; all 766 Communication tests pass, including the complete source/vendored descriptor comparison.
  • Initial solution run (excluding deployed Playwright tests): 9474 passed, three failures. One was the generated descriptor path, corrected and verified above. Two login integration fixtures omitted the now-required antiforgery token; they now fetch the real rendered token and cookie, preserve the original login assertions, and add missing-token rejection coverage. Full Integration rerun: 118 passed, zero failed/skipped. Across the full run with corrected Communication and Integration suite reruns: 9478 passed, zero outstanding failures or skips. Counts replace earlier suite results rather than adding repeated tests. The deployed Playwright smoke adds 1 passed: real antiforgery login, initially unloaded topology, bounded branch loading/page controls, and Flow Debugger navigation without browser errors.
  • bash docker/deploy.sh succeeded from the canonical repository. All eight primary rig nodes run image sha256:cfa919dd343ffa090995336f2854ac4d79d4c2bb919514aa7e72abf579b0b4ed, with zero restarts, /health/ready 200, two members and zero unreachable members per pair, and exactly one /health/active 200 per pair (standby 503). Existing bind mounts and volumes were preserved. Env2 containers were not redeployed.
  • Final completion audit verifies all 46 unique Low IDs have Completed rows, with no remaining Low findings. git diff --check passes. The verified remediation is committed directly on main.
  • Validation logs: /tmp/low-final-build2.log, /tmp/low-full-tests.log, /tmp/low-comm-final.log, /tmp/low-integration-final.log, /tmp/low-rig-deploy.log, /tmp/low-rig-health.json, and /tmp/low-smoke.log. Focused regression evidence above overlaps the full suite and is not additive.

Operational notes

  • MG-6 intentionally rescans matching execution-ID batches on each poll to capture completion rows inserted late with earlier timestamps. Indexed SQLite key queries avoid scanning unrelated retained audit payloads. Discovery memory grows with the traversed tree during the bounded command; returned results have fixed count/byte caps. No central/site clock agreement is assumed.
  • EI-7 bounds the caller wait and admits at most one unfinished source collection per registration across actor recreation on the same root provider. A source that ignores cancellation retains its task and scope until actual completion; subsequent ticks do not create duplicate work.
  • EI-9 is resolved through the requested contract clarification: dual-active outbox writers can deliver repeated duplicate batches; database row claims are not implemented by this change.