fix(review): full code-review remediation — 5 High + Medium/Low across 16 modules

Remediation from the full per-module code review at 4307c381 (findings recorded
separately in code-reviews/).

Highs fixed:
- DeploymentManager-025/SiteRuntime-031: stop broadcasting notification lists + SMTP
  configs (incl. credentials) to sites; site purges already-persisted rows on apply
  (enforces the central-only delivery design; clears plaintext SMTP creds at rest).
- DataConnectionLayer-023: guard the native-alarm subscribe path against the
  mid-flight-unsubscribe adapter-feed leak (mirrors the DCL-021 tag-path fix).
- SiteEventLogging-024: normalize From/To query bounds to UTC (the -016 fix the
  audit trail claimed but never committed).
- KpiHistory-001: add an in-flight guard to the recorder sample tick.
- ScriptAnalysis-001: harden the trust analyzer's TPA-absent fallback (resolve
  forbidden anchors in the minimal reference set; warn on degraded mode) — anchors
  added to validation references only, never the compile gate.
(InboundAPI-026 left to the feat/ipsen-movein effort per owner decision.)

Medium/Low: DM-026 deterministic deploy-status tiebreaker; SR-027/028/029/030
native-alarm leak/phantom-active/delete-during-redeploy fixes; AL-013/014/016;
TE-024 (folder-mutation audit rows now persisted)/025; SF-025 gauge-provider
clear-on-stop; ESG-025/026; SEC-023/024/025; SCA-007/008/009; plus doc/test
accuracy COM-023/024, HOST-025/026, HM-024/025, NS-027/028.

Full-solution build 0 warnings; ~3560 tests across 18 touched suites green.
This commit is contained in:
Joseph Doherty
2026-06-20 17:55:12 -04:00
parent 4307c38117
commit fd618cf1dc
52 changed files with 2239 additions and 313 deletions
@@ -11,12 +11,14 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Central-side singleton (per Bundle E wiring) that ingests batches of
/// Central-side cluster singleton that ingests batches of
/// <see cref="AuditEvent"/> rows pushed from sites via the
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
/// silently swallowed (first-write-wins per Bundle A's hardening).
/// ingest timestamp into DetailsJson (there is no promoted IngestedAtUtc
/// column — the value is a DetailsJson field set via
/// <see cref="AuditRowProjection.WithIngestedAtUtc"/>) and inserted idempotently
/// via <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
/// silently swallowed (first-write-wins).
/// </summary>
/// <remarks>
/// <para>
@@ -25,23 +27,24 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// consistent and the site is free to flip its local row to <c>Forwarded</c>.
/// </para>
/// <para>
/// Per Bundle D's brief, audit-write failures must NEVER abort the user-facing
/// action. The actor wraps each repository call in its own try/catch so a
/// single bad row cannot cause the rest of the batch to be lost — that
/// per-row catch is what keeps this actor alive across handler throws, not
/// the supervisor strategy. The <see cref="SupervisorStrategy"/> override
/// returns the Akka default decider (Restart for most exceptions) and
/// governs children only; this actor has no children today, so the override
/// is a forward-compat placeholder.
/// Audit-write failures must NEVER abort the user-facing action. The actor
/// wraps each repository call in its own try/catch so a single bad row cannot
/// cause the rest of the batch to be lost, and it guards scope/repository
/// resolution so a transient DI fault cannot restart the singleton — those
/// catches are what keep this actor alive across handler throws, not the
/// supervisor strategy. The <see cref="SupervisorStrategy"/> override returns
/// the Akka default decider (Restart for most exceptions) and governs children
/// only; this actor has no children today, so the override is a forward-compat
/// placeholder.
/// </para>
/// <para>
/// Two constructors exist for a deliberate reason: Bundle D's tests inject a
/// Two constructors exist for a deliberate reason: the test ctor injects a
/// concrete <see cref="IAuditLogRepository"/> against a per-test MSSQL fixture
/// (the only way to verify the IngestedAtUtc stamp + duplicate-key idempotency
/// end to end), while Bundle E's host wiring registers the actor as a cluster
/// singleton and must therefore resolve the repository — which is a scoped EF
/// Core service — from a fresh DI scope per message. Mirroring the Notification
/// Outbox actor's pattern.
/// (the only way to verify the ingest-timestamp stamp + duplicate-key
/// idempotency end to end), while the production host wiring registers the
/// actor as a cluster singleton and must therefore resolve the repository —
/// which is a scoped EF Core service — from a fresh DI scope per message.
/// Mirroring the Notification Outbox actor's pattern.
/// </para>
/// </remarks>
public class AuditLogIngestActor : ReceiveActor
@@ -53,7 +56,7 @@ public class AuditLogIngestActor : ReceiveActor
/// <summary>
/// Test-mode constructor — injects a concrete repository instance whose
/// lifetime exceeds the test, so the actor reuses the same instance across
/// every message. Used by Bundle D's MSSQL-backed TestKit fixture.
/// every message. Used by the MSSQL-backed TestKit fixture.
/// </summary>
/// <param name="repository">Audit log repository instance shared across all messages.</param>
/// <param name="logger">Logger for ingest diagnostics.</param>
@@ -116,13 +119,12 @@ public class AuditLogIngestActor : ReceiveActor
// Resolve the repository for the whole batch — one DbContext per
// message, mirroring NotificationOutboxActor. The injected-repository
// mode (Bundle D tests) skips the scope entirely.
// Bundle C (M5-T6): the IAuditRedactor is also resolved from the
// per-message scope when one is available so the row is truncated +
// redacted before InsertIfNotExistsAsync. The single-repository test
// ctor has no service provider — it falls through with no redactor,
// which preserves the small-payload assumptions baked into the
// existing D2 fixtures.
// mode (test ctor) skips the scope entirely.
// The IAuditRedactor is also resolved from the per-message scope when
// one is available so the row is truncated + redacted before
// InsertIfNotExistsAsync. The single-repository test ctor has no
// service provider — it falls through with no redactor, which preserves
// the small-payload assumptions baked into the existing fixtures.
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup.
@@ -133,15 +135,42 @@ public class AuditLogIngestActor : ReceiveActor
}
else
{
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
.ConfigureAwait(false);
// AuditLog-014: guard scope-creation + repository resolution in a
// try/catch, mirroring OnCachedTelemetryAsync. A transient DI /
// DbContext-factory fault (pooled-context init, SQL-connection
// exhaustion, a resolution race during host churn) would otherwise
// propagate out of the ReceiveAsync handler, trip the parent's
// supervision, and RESTART this central singleton over a transient
// fault — dropping the captured reply so the site's Ask times out.
// Best-effort audit must never wedge the singleton: log, optionally
// bump the failure counter, and still reply with whatever was
// accepted (empty on an up-front scope-resolution throw) so the
// site keeps its rows Pending and retries on the next drain.
try
{
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
.ConfigureAwait(false);
}
catch (Exception ex)
{
// Scope creation or a required-service resolution threw before
// (or while) processing the batch. Surface a sustained fault on
// the dashboard if the counter is registered, but never let the
// throw escape the handler and restart the singleton.
try { _serviceProvider!.GetService<ICentralAuditWriteFailureCounter>()?.Increment(); }
catch { /* counter must never throw — defence in depth */ }
_logger.LogError(
ex,
"Audit event batch ingest failed before/while resolving the repository scope; replying with {Accepted} accepted row(s). The site keeps unaccepted rows Pending and retries on the next drain.",
accepted.Count);
}
}
replyTo.Tell(new IngestAuditEventsReply(accepted));
@@ -159,10 +188,10 @@ public class AuditLogIngestActor : ReceiveActor
{
try
{
// Stamp IngestedAtUtc here, not at the site. Bundle A's
// repository hardening already swallows duplicate-key races,
// so the same id arriving twice (site retry, reconciliation)
// is a silent no-op.
// Stamp the ingest timestamp here, not at the site. The
// repository's duplicate-key hardening already swallows
// duplicate-key races, so the same id arriving twice (site
// retry, reconciliation) is a silent no-op.
// Redact BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. The redactor
// is contract-bound to never throw. AuditLog-008: a null
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
@@ -37,6 +39,14 @@ public sealed class AuditLogPurgeOptions
/// a large backlog within a tick. Clamped to a sane minimum in
/// <see cref="ChannelPurgeBatchSize"/>.
/// </summary>
/// <remarks>
/// AuditLog-013: the operator-facing config key is <c>ChannelPurgeBatchSize</c>
/// (per Component-AuditLog.md), so the binder maps that documented key onto this
/// backing property via <see cref="ConfigurationKeyNameAttribute"/>. The unattributed
/// property name (<c>ChannelPurgeBatchSizeConfigured</c>) would otherwise have been
/// the bind key, silently ignoring the documented section.
/// </remarks>
[ConfigurationKeyName("ChannelPurgeBatchSize")]
public int ChannelPurgeBatchSizeConfigured { get; set; } = 5000;
/// <summary>