Files
ScadaBridge/docs/plans/2026-08-14-arch-review-remediation-plan.md
T

37 KiB
Raw Blame History

Architectural Review Remediation Plan

Date: 2026-08-14 · Status: PROPOSED · Source: 2026-08-14 performance/architecture review (eight-pass, all 27 projects + ZB.MOM.WW.LocalDb; report artifact https://claude.ai/code/artifact/8986cd89-6ab6-48aa-973f-119fddd5cafb).

This plan resolves every finding from the review — the ten ranked findings plus all section-level Med/Low items — as an automated, multi-phase program executed by subagents, parallel where file-ownership makes it safe. It is written so that each work package (WP) is a self-contained subagent assignment: an executor agent should be able to complete a WP from its spec here plus the code, without further orchestrator input.


1. Execution model

Orchestrator: the interactive Claude session (Fable) in this repo. It spawns one subagent per work package, merges results, runs phase gates, and commits.

Isolation: every code-writing package runs in its own git worktree (Agent tool, isolation: "worktree"). Packages within a phase touch disjoint file sets (see the conflict matrix, §7), so merges are clean; the orchestrator resolves any residual conflict itself. Worktrees also make concurrent dotnet build/test safe (no shared obj/).

Per-package protocol. Each executor agent must:

  1. Read this plan's WP section and the referenced source files first; verify the finding still reproduces in code (line numbers may have drifted — the description is authoritative).
  2. Implement per the spec. Repo rules apply: edit in place, no backup copies; design doc + code + tests + docker config travel together.
  3. Build the touched projects and run the listed test suites in the worktree (dotnet build ZB.MOM.WW.ScadaBridge.slnx, dotnet test filtered to relevant projects).
  4. Commit on the worktree branch with the message given in the WP spec.
  5. Report back: commit SHA, test summary (counts, any failures), and any spec deviations with rationale.

Phase gates (orchestrator, serial). After merging a phase's branches into the remediation branch: full dotnet build ZB.MOM.WW.ScadaBridge.slnx + full dotnet test must be green; for phases touching cluster runtime (2 and 3), rebuild the rig (bash docker/deploy.sh) and run the phase's live probes (§6). A red gate stops the program — the orchestrator fixes or reverts the offending package before proceeding.

Branching. All work lands on arch-review-remediation (branched from main in Phase 0). One commit per package (the worktree commit, merged --no-ff), plus one docs commit in Phase 4. Cross-repo work in ~/Desktop/scadaproj gets its own branch there (WP3.3). Nothing is pushed without the user.

Model matrix

Model Used for Rationale
fable Orchestration; design docs for structural seams (WP2.1a, WP3.1a, WP3.2a); final review synthesis Hardest open design decisions and cross-package judgment
opus Concurrency-sensitive implementation (streams, DCL, actors, ingest, UI coalescing, LocalDb library) Subtle correctness work; failure modes are silent
sonnet Mechanical/localized fixes, index migrations, schema tweaks, docs updates Well-specified, verifiable by tests
opus (code-reviewer agents) Phase 4 adversarial diff review Independent verification pass

Approximate agent budget: Phase 1 = 7 executors; Phase 2 = 6 executors + 1 design; Phase 3 = 2 design + 3 executors; Phase 4 = 6 reviewers + 1 docs agent. ~26 agents total.


2. Phase 0 — Preflight (orchestrator, serial, ~minutes)

  1. git checkout -b arch-review-remediation (current main; note the pre-existing dirty docs/requirements/Component-SiteRuntime.md — stash or leave untouched, do not absorb it).
  2. Baseline: full build + full test run; record pass/fail counts as the reference state.
  3. Confirm the rig is deployable (docker/deploy.sh last known good) but do not redeploy yet.

Gate: baseline green. If baseline has pre-existing failures, record them — packages are judged against the baseline, not absolute green.


3. Phase 1 — Quick wins (7 packages, ALL PARALLEL)

Highest leverage per line changed. All packages are independent; the two silent-failure defects (WP1.1, WP1.2) lead.

WP1.1 — Stream OK-completion reconnect [opus]

Findings: #1 (High) — streams silently die at the 4-hour max lifetime; OK completion is invisible to reconnect logic. Files: Communication/Grpc/SiteStreamGrpcClient.cs, Communication/Actors/SiteAlarmAggregatorActor.cs, Communication/Actors/DebugStreamBridgeActor.cs (+ tests). Changes:

  • Add an onCompleted callback to the client subscribe surface (preferred over an error sentinel — callers can distinguish graceful end from fault). Invoke it when the await foreach ends without exception, including server OK completion at max lifetime and graceful site shutdown.
  • SiteAlarmAggregatorActor: on completion, Tell self a new GrpcAlarmStreamCompleted(generation) message → set _streamDown = true, mark IsLive = false, and let the reconcile tick reopen without consuming error-retry/backoff budget (completion is not a fault). Also observe the Task.Run subscription task itself (ContinueWith faulted → route into the existing error path) so no completion or fault is ever unobserved again.
  • Same treatment for the debug stream path in DebugStreamBridgeActor.
  • Generation-fence everything (the fencing pattern already exists in the actor — reuse it). Tests: unit test on the client (fake stream that completes OK → onCompleted fires, onError does not); actor test (stream completes OK → IsLive false → reopen on next reconcile tick → backoff counter unchanged). Regression: error path still burns retry budget. Commit: fix(comms): reconnect on graceful stream completion — kills the 4h silent stream death

WP1.2 — Site audit DB placement + flush [sonnet]

Findings: #2 (High) — auditlog.db on container overlayfs; dead FlushIntervalMs; fsync per event. Files: AuditLog/Site/SqliteAuditWriterOptions.cs, AuditLog/Site/SqliteAuditWriter.cs, all site-node appsettings under docker/ and docker-env2/ (+ tests). Changes:

  • Make DatabasePath required on Site nodes (drop the CWD-relative default; ValidateOnStart), mirroring the LocalDb:Path precedent — a healthy-looking node silently writing to overlayfs is the failure mode being closed. Update all rig site-node configs to /app/data/auditlog.db. Update any tests that relied on the implicit default to pass an explicit temp path.
  • Implement FlushIntervalMs as designed: the writer loop groups commits within the interval (batch already drains up to 256 — add the time-based commit coalescing). Additionally set PRAGMA synchronous=NORMAL on the audit connection — audit is best-effort by design (CLAUDE.md: "audit-write failure NEVER aborts the user-facing action"), so NORMAL's power-loss window is acceptable; say so in a code comment.
  • One-time migration note in the commit message: the existing container-local file is abandoned; already-forwarded rows are safe centrally, pending rows on the old path are lost once — accepted, this is the bug being fixed. (Cross-reference docs/known-issues/2026-07-20-cached-telemetry-drain-hot-loop.md, which this placement bug caused.) Tests: options validation test (site role boots ⇒ path required); writer test asserting multi-event single-transaction commit under trickle load. Commit: fix(auditlog): site audit DB onto the data volume; required path + soft flush

WP1.3 — Conditional CDC registration [opus]

Findings: #5 (High), partial — non-replicating nodes pay full CDC capture forever. (The library half — trigger cleanup API, O(1) backlog — is WP3.3.) Files: Host/SiteLocalDbSetup.cs (+ tests). Read ~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/ and the site-a rig configs first to confirm the exact replication-config shape on both the active and passive peer. Changes:

  • Skip all ten RegisterReplicated calls when the node has no replication configured (neither LocalDb:Replication:PeerAddress nor ApiKey — verify the passive-side shape on the rig before choosing the predicate; the guard must be true on BOTH members of a replicated pair and false on site-b/site-c).
  • Preserve the load-bearing ordering documented in the file: when registration is skipped, the legacy migrator still runs (its rows simply don't replicate — consistent with an unreplicated node).
  • Known residual, documented in-file: a database file that was registered under an older build keeps its stale triggers until WP3.3 ships the library cleanup API; on the docker rig this is moot (volumes are recreated on redeploy of a schema change) — state this in a comment and in the commit message. Tests: setup test with and without replication config, asserting trigger presence/absence via sqlite_master (name LIKE '%localdb%' — confirm the library's trigger naming first). Commit: perf(host): install CDC capture only when replication is configured

WP1.4 — SQL quick indexes + sliced purge [sonnet]

Findings: S&F sweep O(N) scan (High); audit KPI window key-lookup; Notification KPI delivered-interval scan; unbounded terminal purge (all part of #7/#8 support). Files: StoreAndForward/StoreAndForwardSchema.cs (+ its versioned-upgrade path), ConfigurationDatabase/Migrations/ (+ manual SQL script for production per repo convention), ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs. Changes:

  • sf_messages: add (status, created_at) index so the ordered due-scan terminates at LIMIT; follow the schema's existing versioned-upgrade mechanism so existing site files get it.
  • IX_AuditLog_OccurredAtUtc → add INCLUDE (Status) (EF migration; remember the repo gotcha — build first, never --no-build, delete empty migrations if scaffolded).
  • Notifications: filtered index (DeliveredAt) WHERE Status = 'Delivered' (or the enum's stored value — check the column type) covering the delivered-last-interval KPI count.
  • DeleteTerminalOlderThanAsync: slice into batched deletes — copy the slicing pattern used twice elsewhere in the same layer (per-channel audit retention delete is one). Tests: migration applies cleanly (dev auto-apply path); a query-shape test is not required — Phase 4 live probes verify plans via EXPLAIN/showplan. Commit: perf(sql): sweep/KPI covering indexes + sliced notification terminal purge

WP1.5 — Mechanical hot-path fixes [sonnet]

Findings: InstanceActor nits (Med); per-call JsonSerializerOptions ×4 (Med). Files: SiteRuntime/Actors/InstanceActor.cs, ExternalSystemGateway/ExternalSystemClient.cs, SiteRuntime/Repositories/SiteExternalSystemRepository.cs (options instance only — the query redesign is WP2.6), ManagementService/ManagementEndpoints.cs, CentralUI/.../ScriptAnalysisService.cs. Changes:

  • HandleSetStaticAttribute: use the existing _resolvedAttributeByName TryGetValue instead of the LINQ scan (InstanceActor.cs:373).
  • HandleTagValueUpdate: precompute the parsed DataType into the resolved-attribute index entry at config-apply time; stop re-Enum.TryParse-ing per update. Intern/cache the quality strings (Quality.ToString() per update → static readonly map).
  • Static-override writes: coalesce per instance on a short timer, reusing the batched-flush pattern already present for native alarms in the same codebase (copy that shape, don't invent).
  • One static readonly JsonSerializerOptions per assembly at the four verified sites. Tests: existing InstanceActor suite must stay green; add a test pinning single-flush coalescing of N rapid static writes. Commit: perf(runtime): O(1) attribute resolution, precomputed types, coalesced static writes, shared JSON options

WP1.6 — Transport fail-fast [sonnet]

Findings: known-failure sends burn full Ask timeouts (Med, first half — the timeout stagger and double-hop are WP2.2). Files: Communication/Grpc/GrpcSiteTransport.cs (+ tests). Changes: a send to a site with no configured channel replies Status.Failure (with a descriptive exception) to the asker immediately instead of warn-and-drop. Audit every warn-and-drop in the file for the same pattern. Tests: Ask against an unconfigured site faults in <1s, not at the 30s timeout. Commit: fix(comms): fail known-dead sends immediately instead of burning Ask timeouts

WP1.7 — Event-log writer batching + sliced retention [sonnet]

Findings: per-event transactions in SiteEventLogger (part of the site_events High — the volume policy is WP3.2); unbatched site_events retention DELETE (Med). Files: SiteEventLogging/SiteEventLogger.cs, SiteEventLogging/EventLogPurgeService.cs (+ tests). Changes:

  • Writer loop: drain the existing batching channel into one transaction per drain (up to 256), exactly the SqliteAuditWriter shape — cite it in a comment.
  • Retention purge: batch the DELETE at 1000 rows/iteration like the cap purge next to it. Tests: writer test (N queued events → 1 transaction); purge test over >1000 expired rows. Commit: perf(sitelog): batched event-log commits and sliced retention purge

Phase 1 gate: merge all seven, full build + test, then rebuild the rig (docker/deploy.sh — WP1.2 changed site configs) and run the Phase 1 live probes (§6).


4. Phase 2 — Seam rework (6 packages, PARALLEL after WP2.1a)

WP2.1 — DCL batch seam [design: fable → implement: opus] (largest package)

Findings: #3 (High, four collapsed findings) + MxGateway 2-RPC subscribe / no-backoff retry (Med) + quality-counter and alarm-fanout costs (Med). Stage a — design memo (fable, ~1 page, committed as docs/plans/2026-08-XX-dcl-batch-seam-design.md): the batch API shape on IDataConnection (SubscribeBatchAsync(IReadOnlyList<TagSubscription>) returning per-tag results; true bulk ReadBatchAsync/WriteBatchAsync), per-subscription monitored-item budget + sharding scheme, reconnect chunking/stagger parameters (configurable; defaults sized for 37,500 tags), re-seed deadline semantics, and what the MxGateway adapter can do without a cross-repo mxaccessgw change (fallback: client-side chunked pipelining; note the cross-repo bulk-RPC follow-up if the proto lacks one — check ~/Desktop/MxAccessGateway first). Stage b — implementation (opus):

  • RealOpcUaClient: N×AddItem + one ApplyChangesAsync per batch; serialize all ApplyChangesAsync on the shared Subscription behind a SemaphoreSlim (batching makes contention rare; the lock makes it safe); shard monitored items across subscriptions above the per-subscription budget (default ~5,000, configurable).
  • OpcUaDataConnection: implement the batch interface; ReadBatchAsync/WriteBatchAsync become single OPC UA service calls (the MxGateway adapter's ReadBulk/WriteBulk is the in-repo reference).
  • DataConnectionActor: HandleSubscribe/ReSubscribeAll pass whole tag lists; reconnect becomes bounded chunked batches honoring the existing failover stagger; SeedTagsAsync becomes chunked batch reads with bounded parallelism and an overall deadline (no more 30s-per-tag serial worst case). Tag-resolution retry gains exponential backoff with a ceiling and batched probes. Quality counters flush on transitions or a short timer instead of per-change; alarm subscriber matching gets a source-prefix index and pushes the union filter to the gateway feed. Tests: the existing DCL suite (Become/Stash lifecycle, generation fencing) must stay green; new tests with a fake client: batch subscribe issues one apply per chunk; concurrent applies serialize; re-seed respects the deadline; backoff caps. Commit: perf(dcl): batch subscribe/read/write seam, bounded reconnect, sharded subscriptions

WP2.2 — Central ingest & SQL set-based rework [opus]

Findings: #6 (High, partition purge), #7 (High, row-at-a-time ingest), #8 (High, KPI scans — query-shape half), KPI rollup N+1 / read-path hygiene / no pooling (Med). Files: AuditLog/.../AuditLogIngestActor.cs, ConfigurationDatabase/Repositories/AuditLogRepository.cs, SiteCallAudit/.../SiteCallAuditActor.cs + SiteCallAuditRepository.cs, ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs, KpiHistoryRepository.cs, ConfigurationDatabase/ServiceCollectionExtensions.cs, gRPC ingest entry in Communication (+ migration + manual SQL script). Changes:

  • Ingest batching: one TVP (or multi-row INSERT … SELECT … WHERE NOT EXISTS batch) per telemetry packet with per-row fallback on constraint violation; cached-telemetry entry = one transaction, not ~5 round trips.
  • SiteCalls upsert: single statement (UPDATE … ; IF @@ROWCOUNT = 0 INSERT … guarded for the newer-status predicate, or MERGE with the usual HOLDLOCK hygiene).
  • Reconciliation off-mailbox: the drain moves out of ReceiveAsync to the PipeTo + in-flight-guard shape — NotificationOutboxActor next door is the in-repo reference; ingest/queries/KPI asks must not queue behind post-outage catch-up.
  • Timeout stagger + hop removal: inner budgets strictly less than outer (site-ask 25s → ingest 20s → SQL 15s, or similar); the gRPC handler Asks the ingest singleton proxy directly, removing the redundant intermediate actor hop.
  • Partition purge: prefer making EventId uniqueness partition-aligned (UNIQUE (EventId, OccurredAtUtc)) so SWITCH needs no index drop at all; EventId is globally unique by construction (GUID minted at source), so the idempotency probe still seeks correctly — document this reasoning in the migration. If alignment proves incompatible with the probe shape, fall back to ONLINE = ON rebuild outside the switch transaction (Standard-edition caveat: check target SQL edition; if ONLINE is unavailable, alignment is the only acceptable option).
  • KPI query shapes: split the live-queue seeks from the delivered-interval count (filtered index from WP1.4); rollup fold preloads existing rows per window into a dictionary (kill the per-(series,hour) existence SELECT).
  • Hygiene: AsNoTracking + keyset paging on the Notifications read path (mirror the two sibling repos); targeted ExecuteUpdate for delivery-attempt status writes instead of detached full-row Update(); AddDbContextPoolfirst verify the context registers no scoped state/injected services that break pooling; if it does, fix or document why pooling is skipped. Tests: ingest idempotency under batch (duplicate EventIds in one packet + across packets); upsert newer-status semantics; reconciliation running while an ingest Ask completes promptly. Commit: perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene

WP2.3 — Wire efficiency [opus]

Findings: #10 (Med, alarm seed waste + per-attempt re-fan-out), unbounded pre-snapshot buffers / lossy shared channel (Med), PullAuditEvents at-most-once (Low). Files: Communication/Actors/SiteAlarmAggregatorActor.cs, SiteAlarmLiveCacheService.cs, SiteCommandDtoMapper.cs, DebugStreamBridgeActor.cs, Communication/Grpc/SiteStreamGrpcServer.cs, site audit queue (ISiteAuditQueue impl) (+ tests). Changes:

  • Alarms-only snapshot: the seed travels the SiteCommandService query surface (QueryReply.DebugViewSnapshot), so add an additive alarms-only flag on the query DTO — verify whether the vendored proto is involved at all; if it is, follow the documented manual toggle-build-copy-untoggle regeneration, additive field numbers only.
  • Seed once per successful (re)connect plus the existing reconcile as the diff/backstop — not a full unconditional snapshot every 60s. Jitter the per-site aggregator timers so 10 sites don't fan out in lockstep; give the seed-retry leg backoff.
  • Cap the pre-snapshot buffers (drop-oldest + a dropped-count surfaced on the health report); hard timer on snapshot arrival that fails the session rather than buffering forever; stream events must not reset the receive-timeout that ends a stuck session.
  • SubscribeSite gets its own channel (larger, or wait-mode) instead of sharing the debug view's 1000-slot DropOldest — an alarm burst during a WAN stall must not silently drop transitions. Batch multiple events per proto message where the contract allows (additive).
  • PullAuditEvents: mirror PullSiteCalls' composite (timestamp, id) keyset cursor; flip rows to Reconciled only when the next pull's cursor proves receipt (at-least-once restored). Tests: cursor-proof flip semantics (fault between response and next pull → rows re-served); seed-once behavior across a simulated reconnect; buffer cap + counter. Commit: perf(comms): alarms-only seed, capped buffers, at-least-once audit pull

WP2.4 — Central UI performance [opus]

Findings: #8 (High, per-circuit KPI polling half), AlarmSummary/Health/DebugView per-circuit costs (High), push-triggered full reloads / serial Asks / no virtualization (Med), sandbox thread-parking (Low). Files: CentralUIHealth.razor, AlarmSummary.razor, DebugView.razor, Deployments.razor, a new process-level KPI snapshot service, sandbox host (+ tests where the project has them). Changes:

  • Process-level memoized KPI snapshot service (singleton, 510s TTL, single-flight so N circuits share one SQL round per interval) consumed by Health + Notification Outbox + Site Calls pages. This plus WP2.2's query shapes closes finding #8 from both ends.
  • AlarmSummary derives not-reporting from the alarm aggregator's own reconcile state instead of re-running the per-instance snapshot fan-out per circuit per 15s.
  • DebugView: coalesce renders at ~250ms; rebuild the trees behind a version stamp instead of per event; marshal off the gRPC thread once per coalesce window.
  • Deployments: apply the pushed delta (or debounce reloads ~500ms) instead of reloading all records + instances per push; server-side paging.
  • Health tick: Task.WhenAll with short per-tile timeouts so one hung singleton degrades one tile, not the tick. Virtualize on the flat alarm table.
  • Sandbox Test Runs: bound with a semaphore instead of parking thread-pool threads on sync accessors.
  • Preserve the existing visual design exactly — these are behavioral changes only, no restyling, no new component frameworks (repo rule). Tests: KPI cache single-flight test; render-coalescing unit test if the code shape allows. Commit: perf(ui): shared KPI cache, live-cache-backed alarm summary, coalesced debug renders

WP2.5 — Deployment & authoring pipeline [opus]

Findings: #9 (High, both halves), verdict-cache Clear() leak + unpaged management queries (Med). Files: TemplateEngine/.../FlatteningPipeline.cs, TemplateEngineRepository.cs, TemplateService.cs, DeploymentManager/.../DeploymentService.cs, ManagementService/ManagementActor.cs, ScriptAnalysis/.../ScriptCompileVerdictCache.cs, CLI (+ tests). Changes:

  • Flatten-session cache: within one deploy/validation session, memoize template chain and composition loads keyed by template id + version; hoist the three global queries (shared scripts, schemas, connections) once per session — ArtifactDeploymentService is the in-repo reference shape.
  • DeploySiteAsync: bulk orchestration over a site's instances with bounded parallel fan-out and per-instance timeouts, reusing the per-instance operation lock; surfaced via ManagementActor command + CLI (instance deploy-site <site> or consistent naming — follow the CLI's existing verb conventions); UI wiring optional, defer if it inflates scope.
  • Staleness watermark: per-template monotonic version bumped on any template-graph mutation; staleness detection compares watermarks before paying a full flatten.
  • Authoring: slim projections for acyclicity/collision checks (no script bodies); AsNoTracking on read-only walks; ReconcileDescendantsAsync receives the already-loaded graph instead of re-calling GetAllTemplatesAsync.
  • Management queries: QueryDeployments pages DB-side with a summary projection; ListTemplates pages DB-side without full script bodies; batch override apply becomes one read + one commit.
  • Verdict cache: LRU/segmented eviction instead of wholesale Clear() (the Clear re-opens the non-collectible InteractiveAssemblyLoader leak the cache exists to bound); add a retention policy for terminal deployment records (config, default generous). Tests: flatten count test (N instances, same template ⇒ chain loaded once); DeploySiteAsync fan-out with an injected slow site (bounded, others complete); verdict-cache eviction keeps hot entries. Commit: perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

WP2.6 — Cross-cutting misc [sonnet]

Findings: site external-system resolution (Med), Inbound API per-request SQL (Low), unbounded S&F observer queue (Low), CLI timeout (Low), failback-probe heartbeat pollution (Low), site stream alarm-vs-attribute shared buffer (Med). Files: SiteRuntime/Repositories/SiteExternalSystemRepository.cs, InboundAPI method-lookup path, StoreAndForward observer queue, SiteRuntime/Streaming/SiteStreamManager.cs, CLI HttpClient setup, failback probe (+ tests). Changes:

  • External-system resolution: name-keyed lookup (the ID reverse-map scan goes); cache the parsed method list per system, invalidated on redeploy — honoring the documented "indexed query" contract.
  • Inbound API: short-TTL ApiMethod cache invalidated by the existing ScriptArtifactChangeSubscriber bus (the invalidation plumbing already exists — lean on it).
  • S&F observer queue: bound it DropOldest like its siblings (it is the one unbounded channel in the system) + a dropped counter.
  • SiteStreamManager: separate alarm publish source (or priority path) so attribute storms can't evict alarm transitions; drop counter on the site health report; skip publish at zero subscribers.
  • CLI HttpClient: explicit timeout (30s, config-overridable).
  • Failback probe: mark the probe heartbeat synthetic (flag or reserved SiteId) so it stops polluting liveness data — check what consumes heartbeats before choosing the mechanism. Commit: perf(misc): cached hot-path lookups, bounded observer queue, alarm-priority stream path

Phase 2 gate: merge order WP2.2 → WP2.3 → WP2.1 → WP2.4 → WP2.5 → WP2.6 (Communication-heavy first — likeliest residual conflicts surface early). Full build + test; rig rebuild; Phase 2 live probes (§6).


5. Phase 3 — Structural (design-first; 3 tracks)

Design stages run in parallel (fable); implementations start as each design lands. Defaults are pre-decided below so automation never blocks — the design memos may refine but not stall.

WP3.1 — Script execution pool split [design: fable → implement: opus]

Findings: #4 (High) + actor-per-execution overhead / compile-cache Clear (Med). Stage a — design doc (docs/plans/2026-08-XX-script-pool-split-design.md), deciding:

  • Trigger-expression evaluation leaves the blocking pool — default decision: evals are non-blocking by construction, so run them as plain async on the default dispatcher with a concurrency gate, no second dedicated pool.
  • Blocking-script pool sizing scales with deployed instance count (default: max(8, instances/8) capped at a config ceiling); stuck-script watchdog replaces lost threads.
  • Timeout budget includes queue wait (deadline captured at enqueue).
  • Per-script in-flight cap — default policy: bounded queue per script (cap 4), overflow sheds newest with a site event + counter (an alarm-triggered run that can't start within its deadline is stale anyway); design memo must justify or amend.
  • ScriptExecutionActor elimination: run the guarded task directly under ScriptActor, preserving supervision semantics (Stop-on-failure equivalence), telemetry, and the audit ExecutionId/ParentExecutionId threading — this is the riskiest part; the design memo maps every behavior the actor currently provides to its replacement.
  • SiteScriptCompileCache: LRU eviction; cache-miss compiles move off-dispatcher (the DeploymentManager shared-script path is the in-repo reference). Stage b — implement + tests: starvation regression test (8 blocked scripts ⇒ alarm eval still completes <2s); queue-time-inclusive timeout test; watchdog replacement test; supervision-parity tests for the actor removal. Commit: perf(runtime): split trigger evals from the blocking pool; bounded, deadline-aware execution

WP3.2 — site_events volume policy [design: fable → implement: sonnet]

Findings: #High site_events (policy half; mechanics done in WP1.7). Stage a — design memo deciding: per-run Started/Completed Info events become sampled/opt-in (default: off per-run, aggregate counters per interval instead; per-script opt-in flag for debugging); whether site_events should replicate at all (default recommendation: keep replicated — central event-log queries hit either node — but the memo must check what actually reads site_events on the peer before confirming; if nothing does, deregister it and use clock-based local purges that never enter the oplog, coordinating with WP3.3's dereg API). Stage b: implement per memo; update Component-SiteEventLogging doc (Phase 4 collects). Commit: perf(sitelog): sampled per-run events; site_events replication policy

WP3.3 — LocalDb library (CROSS-REPO: ~/Desktop/scadaproj) [opus]

Findings: #5 (library half), replication row-count batching (Med), library-level costs (Low). Files: ~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/ on its own branch; then the consuming update here. Changes (library):

  • DeregisterReplicated(table) / cleanup API: idempotently drops capture triggers (and optionally prunes that table's oplog rows) — consumed by SiteLocalDbSetup when replication is unconfigured, closing WP1.3's stale-trigger residual.
  • Backlog depth becomes O(1) (MAX(seq) last_acked_seq or a maintained counter) — it currently runs an O(backlog) COUNT(*) per minute and per metrics scrape.
  • Replication batching by summed bytes (budget under the 4 MB gRPC cap, row-count as a secondary cap) + ack coalescing; the rig's MaxBatchSize=16 pin can then be retired.
  • LwwApplier prepare-once-rebind per table; drop the unused __localdb_oplog_hlc index (handle existing files in the schema-version upgrade). Changes (this repo, after the library lands): bump the package/project reference; call the cleanup API from SiteLocalDbSetup when unregistered; relax the rig MaxBatchSize pin to the byte budget; update the CLAUDE.md LocalDb bullet and the umbrella ../scadaproj/CLAUDE.md ScadaBridge entry in the same change (repo rule). Tests: library suite for dereg idempotency, O(1) depth, byte-budget batching; ScadaBridge integration: site-b boots with zero triggers and zero oplog growth. Commits: library commit in scadaproj + chore(deps): LocalDb <version> — dereg API, byte-budget replication here.

Phase 3 gate: full build + test in BOTH repos; rig rebuild; live probes.


6. Phase 4 — Verification, review, docs (mixed parallel)

Live probes (orchestrator + rig, accumulate per phase gate)

  • Stream lifetime: set max stream lifetime to ~2 min on the rig; verify the alarm stream reconnects within one reconcile tick of OK completion and IsLive reflects the gap (WP1.1).
  • CDC: on site-b/site-c nodes, sqlite_master shows no capture triggers; oplog table empty and not growing (WP1.3/WP3.3). Site-a pair still converges (replication regression).
  • Audit path: site auditlog.db present under /app/data; survives docker compose rm+recreate of one site container (WP1.2).
  • Query plans: EXPLAIN QUERY PLAN on the S&F due-sweep (index-terminated); SQL Server showplan on the notification KPI and audit KPI queries (seeks, no full scans) (WP1.4/WP2.2).
  • Deploy timing: bulk-redeploy a site on the rig before/after; record wall clock (WP2.5).
  • Failover drill: docker/failover-drill.sh still passes (~25s takeover) — protects against regressions from the actor/timeout changes.
  • Load validation: the deferred target-scale load test (deferred-work register #25) is the final proof the ceilings moved — schedule as its own follow-on; this plan's exit criterion is the probes above, not #25.

Adversarial review [6 parallel code-reviewer agents, opus; fable synthesis]

One reviewer per area over the full remediation diff (site runtime, DCL, comms, site persistence, central SQL, UI/deploy), each instructed to try to refute the fixes (regressions, changed semantics, missed call sites, test gaps). Confirmed findings are fixed by targeted follow-up agents before the docs commit.

Documentation propagation [sonnet]

  • Component docs touched: Component-DataConnectionLayer, Component-SiteRuntime, Component-CentralSiteCommunication, Component-AuditLog, Component-SiteEventLogging, Component-StoreAndForward, Component-NotificationOutbox, Component-SiteCallAudit, Component-TemplateEngine, Component-DeploymentManager, Component-CentralUI, Component-ClusterInfrastructure (only where behavior/config changed — no cosmetic edits).
  • CLAUDE.md Key Design Decisions: stream-completion reconnect, conditional CDC, required site audit DB path, DCL batch seam, DeploySiteAsync, script-pool split, LocalDb byte-budget replication (retire the MaxBatchSize=16 note).
  • Umbrella ../scadaproj/CLAUDE.md: LocalDb entry (done with WP3.3).
  • Close/annotate any related docs/known-issues/ entries; README component table needs no change (no components added/removed).
  • Final git diff review by the orchestrator, then the docs commit. Push only on user request.

7. Parallelism & conflict matrix

Phase Package Projects touched Conflicts within phase
1 WP1.1 Communication (client+actors) WP1.6 same project, different files — merge-safe
1 WP1.2 AuditLog(site), docker configs none
1 WP1.3 Host none
1 WP1.4 ConfigurationDatabase, StoreAndForward none
1 WP1.5 SiteRuntime, ESG, ManagementService, CentralUI single-line edits, none
1 WP1.6 Communication (transport) see WP1.1
1 WP1.7 SiteEventLogging none
2 WP2.1 DataConnectionLayer none
2 WP2.2 AuditLog(central), SiteCallAudit, ConfigurationDatabase, NotificationOutbox none (WP2.3 owns Communication)
2 WP2.3 Communication, site audit queue none
2 WP2.4 CentralUI none
2 WP2.5 TemplateEngine, DeploymentManager, ManagementService, ScriptAnalysis, CLI none
2 WP2.6 SiteRuntime(streaming+repos), InboundAPI, S&F, CLI(http) CLI touched by WP2.5 (commands) and WP2.6 (HttpClient) — different files; merge WP2.5 first
3 WP3.1 SiteRuntime(scripts) none
3 WP3.2 SiteEventLogging, SiteRuntime(one call site) coordinate with WP3.1 merge order (3.1 first)
3 WP3.3 scadaproj (separate repo) + Host/config here in-repo half lands after 3.1/3.2

8. Risks & mitigations

  • Vendored proto drift (WP2.3): additive fields only, never reuse numbers; follow the documented manual regeneration toggle. Mitigation: the executor verifies whether the change is DTO-only before touching proto at all.
  • Partition-aligned uniqueness (WP2.2) changes the enforcement scope of EventId uniqueness from global to per-partition. GUID collision across partitions is not a real risk, but the ingest probe's semantics must be re-tested for duplicate delivery across a month boundary.
  • Pooled DbContext (WP2.2): pooling breaks contexts with per-scope injected state — verify before switching; skipping with a documented reason is an acceptable outcome.
  • ScriptExecutionActor removal (WP3.1) is the highest-semantic-risk change: supervision, telemetry, and audit-correlation parity are explicit design-memo obligations, and it ships in its own commit so it can be reverted independently.
  • Rolling-upgrade caveat (WP3.3): site pairs already require stop/start together (repo rule); the LocalDb schema-version bump rides that existing constraint — call it out in the topology guide.
  • EF migration gotcha: always build before dotnet ef migrations add; delete empty scaffolds.
  • Merge conflicts: worktree isolation + the matrix above; residual conflicts are resolved by the orchestrator, never by re-running an executor blindly.

9. Exit criteria

  1. Full solution build + tests green in both repos; baseline test count not reduced.
  2. All §6 live probes pass on the rig; failover drill unchanged (~25s).
  3. Adversarial review pass complete with all CONFIRMED findings resolved.
  4. Docs propagated (component docs, both CLAUDE.mds, known-issues) with no stale cross-references.
  5. One reviewed commit per package on arch-review-remediation; branch ready for the user to merge/push; target-scale load test (#25) scheduled as the follow-on validation.