docs(comments): strip internal task/milestone/bundle bookkeeping from code comments

Remove project bookkeeping citations from shipped code comments across the
solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/
issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels,
and C/D/K/S/T phase labels.

Comment text only — no code logic, string/log literals, or XML-doc structure
changed. Genuine descriptions are preserved (only the citation is stripped),
and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365,
UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker
TaskReferenceInComment / TrackingReferenceInComment checks plus targeted
grep passes; full solution builds clean, append-only guard tests pass.
This commit is contained in:
Joseph Doherty
2026-07-07 11:03:26 -04:00
parent 67005ca4c0
commit 9cff87fe85
435 changed files with 2338 additions and 2547 deletions
@@ -24,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Actors;
/// Creates the actor system on start, registers actors, and triggers
/// CoordinatedShutdown on stop.
///
/// WP-3: Transport heartbeat is explicitly configured in HOCON from CommunicationOptions.
/// Transport heartbeat is explicitly configured in HOCON from CommunicationOptions.
/// </summary>
public class AkkaHostedService : IHostedService
{
@@ -39,7 +39,7 @@ public class AkkaHostedService : IHostedService
/// Guards the one-time creation of <see cref="_actorSystem"/> in
/// <see cref="GetOrCreateActorSystem"/> so <see cref="StartAsync"/> and a concurrent
/// health-probe resolution of the DI <see cref="ActorSystem"/> singleton race to create
/// it exactly once (HOST-021).
/// it exactly once.
/// </summary>
private readonly object _actorSystemLock = new();
/// <summary>
@@ -51,14 +51,14 @@ public class AkkaHostedService : IHostedService
private readonly List<IDisposable> _trackedDisposables = new();
/// <summary>
/// NotificationService-020 guard: sentinel that flips to <c>true</c> the
/// Sentinel that flips to <c>true</c> the
/// first time a Notification-category S&amp;F delivery handler is registered
/// on this hosted service instance. <see cref="StoreAndForwardService.RegisterDeliveryHandler"/>
/// is last-write-wins on category, so a future code change that introduces
/// a second registration path (e.g. a role-branch + helper that both call
/// the registration) would silently overwrite the canonical
/// <c>NotificationForwarder</c> handler with whatever the loser registers —
/// the prior NS-001 fix did exactly this, and was silently superseded
/// a prior fix did exactly this, and was silently superseded
/// when the central-only redesign moved delivery to <c>NotificationOutbox</c>.
/// This sentinel makes the duplicate noisy at startup so a maintainer
/// re-introducing the second path sees it immediately.
@@ -99,7 +99,7 @@ public class AkkaHostedService : IHostedService
/// <returns>A task representing the asynchronous operation.</returns>
public async Task StartAsync(CancellationToken cancellationToken)
{
// HOST-021: create (or reuse) the externally-owned, process-singleton ActorSystem. A
// Create (or reuse) the externally-owned, process-singleton ActorSystem. A
// health probe may already have created it via the DI singleton bridge
// (GetOrCreateActorSystem) before this hosted service's StartAsync ran; either way the
// call yields the one instance and sets _actorSystem. Actor registration below then
@@ -132,7 +132,7 @@ public class AkkaHostedService : IHostedService
/// whichever runs first creates the system exactly once.
/// </summary>
/// <remarks>
/// HOST-021: the <see cref="ActorSystem"/> is an externally-owned process singleton — its
/// The <see cref="ActorSystem"/> is an externally-owned process singleton — its
/// lifecycle is this hosted service's (created here, torn down via
/// <c>CoordinatedShutdown</c> in <see cref="StopAsync"/>). It MUST be registered in DI as a
/// <b>singleton resolved through this method</b>, never as a transient/scoped factory:
@@ -165,7 +165,7 @@ public class AkkaHostedService : IHostedService
// For site nodes, include a site-specific role (e.g., "site-SiteA") alongside the base role
var roles = BuildRoles();
// Host-006: HOCON is assembled in a dedicated builder that quotes/escapes every
// HOCON is assembled in a dedicated builder that quotes/escapes every
// interpolated value, so a hostname, seed node or strategy containing a quote,
// backslash or whitespace cannot corrupt the configuration document.
var hocon = BuildHocon(_nodeOptions, _clusterOptions, roles,
@@ -195,13 +195,13 @@ public class AkkaHostedService : IHostedService
/// Builds the Akka HOCON configuration document. Every interpolated value is
/// routed through <see cref="QuoteHocon"/> (string values) so a hostname,
/// seed-node URI, role or split-brain strategy containing a quote, backslash or
/// whitespace cannot corrupt the document or be silently misparsed (Host-006).
/// whitespace cannot corrupt the document or be silently misparsed.
///
/// Host-012: the <c>keep-oldest down-if-alone</c> flag is emitted from
/// The <c>keep-oldest down-if-alone</c> flag is emitted from
/// <see cref="ClusterOptions.DownIfAlone"/> rather than hard-coded, so the bound
/// configuration value is actually consumed.
///
/// Host-013: every duration is rendered via <see cref="DurationHocon"/> in
/// Every duration is rendered via <see cref="DurationHocon"/> in
/// milliseconds, so sub-second cluster timing values (e.g. a 750ms heartbeat) are
/// preserved exactly instead of being rounded to whole seconds.
/// </summary>
@@ -275,7 +275,7 @@ akka {{
/// HOCON parser accepts a <c>ms</c> suffix, so emitting whole milliseconds
/// preserves sub-second configuration exactly — a 750ms heartbeat stays 750ms
/// rather than being rounded to <c>1s</c> (or, for sub-half-second values,
/// silently collapsing to a degenerate <c>0s</c>) — Host-013.
/// silently collapsing to a degenerate <c>0s</c>).
/// </summary>
private static string DurationHocon(TimeSpan duration)
{
@@ -354,7 +354,7 @@ akka {{
/// <summary>
/// Registers central-side actors including the CentralCommunicationActor.
/// WP-4: Central communication actor routes all 8 message patterns to sites.
/// Central communication actor routes all 8 message patterns to sites.
/// </summary>
private void RegisterCentralActors()
{
@@ -400,7 +400,7 @@ akka {{
.GetRequiredService<IOptions<ZB.MOM.WW.ScadaBridge.NotificationOutbox.NotificationOutboxOptions>>().Value;
var outboxLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ZB.MOM.WW.ScadaBridge.NotificationOutbox.NotificationOutboxActor>();
// M4 Bundle B: central direct-write audit writer for dispatcher attempt
// Central direct-write audit writer for dispatcher attempt
// + terminal events. Resolved once from the root provider — the writer
// is a singleton and stateless, opening per-call DI scopes internally
// to resolve the scoped IAuditLogRepository.
@@ -433,12 +433,12 @@ akka {{
commService?.SetNotificationOutbox(outboxProxy);
_logger.LogInformation("NotificationOutbox singleton created and registered with CentralCommunicationActor");
// Audit Log (#23) — central singleton mirrors the Notification Outbox
// Audit Log — central singleton mirrors the Notification Outbox
// pattern. The IngestAuditEvents gRPC handler lives on SiteStreamGrpcServer
// (Communication.Grpc); a central node hosting that server (M6 reconciliation
// (Communication.Grpc); a central node hosting that server (reconciliation
// path) hands the proxy in via SetAuditIngestActor below. When the gRPC
// server is not registered (current central topology), the host still
// brings the singleton up so a Bundle H in-process test (or a future
// brings the singleton up so an in-process test (or a future
// direct caller) can Ask the proxy without further wiring.
// IAuditLogRepository is a SCOPED EF Core service, so the singleton
// actor takes the root IServiceProvider and creates a fresh scope per
@@ -471,15 +471,15 @@ akka {{
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
// so the IngestAuditEvents RPC routes incoming site batches to the singleton.
// The gRPC server is currently only registered on Site nodes; on a central
// node this resolves to null and the wiring is a no-op until M6 (which
// brings central-hosted gRPC + a real site→central client).
// node this resolves to null and the wiring is a no-op unless a future
// central-hosted gRPC server (or a real site→central client) is added.
var grpcServer = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
grpcServer?.SetAuditIngestActor(auditIngestProxy);
_logger.LogInformation(
"AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})",
grpcServer is not null);
// Audit Log (#23) M6 Bundle E (T7): subscribe the per-site stalled
// Subscribe the per-site stalled
// telemetry tracker to the actor system EventStream NOW that the
// system exists. The tracker mirrors every
// SiteAuditTelemetryStalledChanged publication (from
@@ -504,11 +504,11 @@ akka {{
_logger.LogInformation("SiteAuditTelemetryStalledTracker subscribed to EventStream");
}
// Site Call Audit (#22) — central singleton mirrors the AuditLogIngest
// and NotificationOutbox patterns. M3's dual-write transaction routes
// Site Call Audit — central singleton mirrors the AuditLogIngest
// and NotificationOutbox patterns. The dual-write transaction routes
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message
// ISiteCallAuditRepository resolution, so this singleton is not on the
// M3 happy-path hot path; it exists so direct-write callers Ask through
// happy-path hot path; it exists so direct-write callers Ask through
// a stable cluster proxy without further wiring. The central→site
// Retry/Discard relay now lives in this actor (see the
// RegisterCentralCommunication wiring below); the reconciliation puller
@@ -532,12 +532,12 @@ akka {{
var siteCallAuditSingletonManager =
_actorSystem!.ActorOf(siteCallAuditSingletonProps, "site-call-audit-singleton");
// SiteCallAudit-002 graceful-handover hook. The default singleton handover
// Graceful-handover hook. The default singleton handover
// path waits for the actor's `ReceiveAsync` task to complete before
// signalling `HandOverDone` to the new oldest node — so an in-flight
// EF `UpsertAsync` IS waited for during a *clean* coordinated shutdown
// (the cluster-leave phase below fires before the singleton terminates).
// The risk the finding tracks is the seam between in-flight async work
// The risk here is the seam between in-flight async work
// and the cluster-leave + singleton-stop sequence: we bound it by
// issuing an explicit `GracefulStop` to the singleton manager early
// in `cluster-leave`, with a timeout that lets the running upsert + SQL
@@ -546,7 +546,7 @@ akka {{
// coordinated shutdown indefinitely — exceeding it falls through to
// the existing PoisonPill termination path. Same pattern is suitable
// for the NotificationOutbox singleton; not added here to keep this
// change minimal (out of NS-020's scope).
// change minimal.
var siteCallAuditShutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem);
siteCallAuditShutdown.AddTask(
Akka.Actor.CoordinatedShutdown.PhaseClusterLeave,
@@ -577,7 +577,7 @@ akka {{
// SetNotificationOutbox wiring above.
commService?.SetSiteCallAudit(siteCallAuditProxy);
// Task 5 (#22): hand the CentralCommunicationActor to the SiteCallAudit
// Hand the CentralCommunicationActor to the SiteCallAudit
// actor so it can relay operator Retry/Discard on parked cached calls to
// the owning site (over the per-site ClusterClient via SiteEnvelope).
// Mirrors the RegisterAuditIngest / RegisterNotificationOutbox wiring;
@@ -588,7 +588,7 @@ akka {{
_logger.LogInformation(
"SiteCallAuditActor singleton created and registered with CentralCommunicationActor");
// Audit Log (#23) M6 Bundle B/C — start the two central-only maintenance
// Start the two central-only maintenance
// singletons that were fully implemented but never instantiated: the
// daily AuditLog partition-switch purge (AuditLogPurgeActor) and the
// periodic per-site audit-event reconciliation pull
@@ -699,7 +699,7 @@ akka {{
_actorSystem.ActorOf(auditReconProxyProps, "site-audit-reconciliation-proxy");
_logger.LogInformation("SiteAuditReconciliationActor singleton created");
// KPI History (#26, M6) — central singleton that periodically samples the
// KPI History — central singleton that periodically samples the
// Notification Outbox / Site Call Audit point-in-time KPIs into the
// KpiHistorySamples table and runs the daily retention purge. Mirrors the
// audit-log-purge singleton pattern above: a ClusterSingletonManager pins
@@ -832,7 +832,7 @@ akka {{
var dmLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<DeploymentManagerActor>();
// WP-34: Create DCL Manager Actor for tag subscriptions
// Create DCL Manager Actor for tag subscriptions
var dclFactory = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.DataConnectionLayer.IDataConnectionFactory>();
var dclOptions = _serviceProvider.GetService<IOptions<ZB.MOM.WW.ScadaBridge.DataConnectionLayer.DataConnectionOptions>>()?.Value
?? new ZB.MOM.WW.ScadaBridge.DataConnectionLayer.DataConnectionOptions();
@@ -841,7 +841,7 @@ akka {{
{
var healthCollector = _serviceProvider.GetRequiredService<ZB.MOM.WW.ScadaBridge.HealthMonitoring.ISiteHealthCollector>();
var siteEventLogger = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.SiteEventLogging.ISiteEventLogger>();
// T17: the verify-endpoint probe builds an OPC UA ApplicationConfiguration directly,
// The verify-endpoint probe builds an OPC UA ApplicationConfiguration directly,
// so the manager needs the same deployment-wide OpcUaGlobalOptions the
// DataConnectionFactory feeds to RealOpcUaClient when creating connections.
var opcUaGlobalOptions = _serviceProvider
@@ -854,7 +854,7 @@ akka {{
_logger.LogInformation("Data Connection Layer manager actor created");
}
// T17 / D6 — per-node OPC UA certificate-store actor. Created on EVERY
// Per-node OPC UA certificate-store actor. Created on EVERY
// site node (NOT a singleton) at a well-known name so the Deployment
// Manager singleton can fan a trust/remove out to BOTH nodes' PKI stores
// (node-a + node-b) and keep them in lock-step across failover. It needs
@@ -875,7 +875,7 @@ akka {{
// Notify-and-fetch: the deployment config fetcher pulls a deployment's flattened
// config from central over HTTP. Used by BOTH the active singleton
// (RefreshDeploymentCommand, Task 10) AND the standby replication path — the active
// (RefreshDeploymentCommand) AND the standby replication path — the active
// node now replicates only the deployment id and the standby fetches the config
// itself, so a large config never crosses the intra-site Akka hop. Resolve once.
var deploymentConfigFetcher =
@@ -933,7 +933,7 @@ akka {{
var dmProxy = _actorSystem.ActorOf(proxyProps, "deployment-manager-proxy");
// WP-4: Create SiteCommunicationActor for receiving messages from central
// Create SiteCommunicationActor for receiving messages from central
var siteCommActor = _actorSystem.ActorOf(
Props.Create(() => new SiteCommunicationActor(
_nodeOptions.SiteId!,
@@ -974,7 +974,7 @@ akka {{
if (storeAndForwardService != null)
{
// Initialize SQLite schema and start the retry timer. Must complete before
// any actor or HTTP handler touches the service. Host-005: awaited rather
// any actor or HTTP handler touches the service. Awaited rather
// than blocked via GetAwaiter().GetResult() — no thread-pool starvation /
// sync-context deadlock risk, and exceptions surface as their original type.
cancellationToken.ThrowIfCancellationRequested();
@@ -1010,9 +1010,9 @@ akka {{
// NotificationSubmitAck as the outcome (accepted → delivered; not accepted
// or timeout → throw → transient → keep buffering). Central owns SMTP.
//
// NotificationService-020: register exactly once. The sentinel guard
// Register exactly once. The sentinel guard
// catches a second registration path that re-introduces the dead
// NS-001 site-SMTP handler — see the sentinel's XML doc above for the
// site-SMTP handler — see the sentinel's XML doc above for the
// historical context. Throwing here is intentional: a silent overwrite
// by a future maintainer would invert the design back to site-side
// delivery (NotificationForwarder vs. NotificationDeliveryService).
@@ -1070,7 +1070,7 @@ akka {{
contacts.Count, _nodeOptions.SiteId);
}
// Task 18c — per-node startup reconciliation. Created on EVERY site node (NOT a
// Per-node startup reconciliation. Created on EVERY site node (NOT a
// singleton) so a standby that was DOWN during a deploy self-heals on its next
// restart: it reports its local deployed inventory to central via the
// SiteCommunicationActor Ask, fetches the gap (missing/stale) over HTTP, and
@@ -1103,7 +1103,7 @@ akka {{
+ "startup self-heal disabled (replication remains the primary path)");
}
// Audit Log (#23) — site-side telemetry actor that drains the SQLite
// Audit Log — site-side telemetry actor that drains the SQLite
// Pending queue and pushes to central via IngestAuditEvents. Not a
// cluster singleton: each site is its own cluster, and the actor reads
// node-local SQLite (no replication). The Props are bound to the
@@ -1111,7 +1111,7 @@ akka {{
// batch SQLite read + gRPC push never contend with the default
// dispatcher used by hot-path actors.
//
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its
// The SiteAuditTelemetryActor takes its
// collaborators through its constructor, so we resolve them from DI
// and pass them in via Props.Create rather than relying on a future
// FactoryProvider. The real site→central client is constructed and
@@ -1122,7 +1122,7 @@ akka {{
.GetRequiredService<IOptions<ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
var siteAuditQueue = _serviceProvider
.GetRequiredService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ISiteAuditQueue>();
// Audit Log (#23) Task 2 follow-up: the production site→central audit
// Audit Log follow-up: the production site→central audit
// push uses the ClusterClient transport via the SiteCommunicationActor,
// not the DI-resolved NoOpSiteStreamAuditClient. The NoOp default stays
// correct for central/test composition roots (no SiteCommunicationActor);
@@ -1137,7 +1137,7 @@ akka {{
var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>();
// AuditLog-001: resolve the site-local operation tracking store so the
// Resolve the site-local operation tracking store so the
// actor can run the combined-telemetry cached-drain in parallel with
// the audit-only drain. The store is registered by AddSiteRuntime on
// site composition roots; on central it is intentionally absent and
@@ -1162,9 +1162,9 @@ akka {{
siteTrackingStore is not null);
// Gate gRPC subscriptions until the actor system and SiteStreamManager are
// initialized (REQ-HOST-7).
// initialized.
//
// Host-009: SetReady asserts a deliberately narrow contract. By this point the
// SetReady asserts a deliberately narrow contract. By this point the
// actor system exists, SiteStreamManager.Initialize has run, and every
// role actor (SiteCommunicationActor, deployment-manager singleton,
// SiteReplicationActor, the ClusterClient) has been created with ActorOf —
@@ -1178,14 +1178,14 @@ akka {{
// handshake has completed". Streams opened before SetReady are already
// rejected by SiteStreamGrpcServer with StatusCode.Unavailable.
var grpcServer = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
// Audit Log (#23 M6): hand the site-local SqliteAuditWriter (which
// Audit Log: hand the site-local SqliteAuditWriter (which
// implements ISiteAuditQueue) to the gRPC server so the PullAuditEvents
// reconciliation RPC can serve central's pulls. Both the writer and the
// gRPC server are singletons — wiring this here keeps the dependency
// direction one-way (Host knows both; Communication doesn't reach back
// into AuditLog).
grpcServer?.SetSiteAuditQueue(siteAuditQueue);
// Site Call Audit (#22): hand the site-local OperationTrackingStore to
// Site Call Audit: hand the site-local OperationTrackingStore to
// the gRPC server so the PullSiteCalls reconciliation RPC can serve
// central's self-heal pulls. siteTrackingStore is resolved above with
// GetService — present on site composition roots, null on central — so
@@ -11,7 +11,7 @@
<link href="/ZB.MOM.WW.ScadaBridge.Host.styles.css" rel="stylesheet" />
<link href="_content/ZB.MOM.WW.ScadaBridge.CentralUI/css/site.css" rel="stylesheet" />
<script>
// T34b: apply the persisted theme to <html> BEFORE first paint so a
// Apply the persisted theme to <html> BEFORE first paint so a
// dark-mode user never sees a light flash. Mirrors window.sbTheme.get/apply
// (theme.js) — same key, same rule — but inlined so it runs without waiting
// on the deferred script include. localStorage access is guarded for
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.InboundAPI;
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
/// <summary>
/// InboundAPI-008 / InboundAPI-022: production implementation of
/// Production implementation of
/// <see cref="IActiveNodeGate"/> backed by the running Akka.NET cluster.
///
/// The inbound API is "Central cluster only (active node)" — a standby central
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
/// <summary>
/// M2.14 (#28): readiness check that verifies every <b>required central cluster
/// Readiness check that verifies every <b>required central cluster
/// singleton</b> is reachable from this node, satisfying the "required cluster
/// singletons running (if applicable)" clause of REQ-HOST-4a. Register it
/// <see cref="ZB.MOM.WW.Health.ZbHealthTags.Ready"/>-tagged in the Central-role
@@ -7,17 +7,17 @@ namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Builds the Serilog <see cref="LoggerConfiguration"/> for the Host process.
///
/// REQ-HOST-8 / Host-011: the configured minimum level comes from
/// The configured minimum level comes from
/// <c>ScadaBridge:Logging:MinimumLevel</c> (bound to <see cref="LoggingOptions"/>) so an
/// operator editing that key changes the effective log level.
///
/// REQ-HOST-8 / Host-014: the console and file sinks are read from the standard
/// The console and file sinks are read from the standard
/// <c>Serilog</c> configuration section via <c>ReadFrom.Configuration</c> — the sink
/// set, console output template, file path and rolling interval are all
/// configuration-driven (defined in <c>appsettings.json</c>), not hard-coded. The
/// explicit <c>MinimumLevel.Is</c> below pins the floor from <see cref="LoggingOptions"/>.
///
/// Host-020: <c>ScadaBridge:Logging:MinimumLevel</c> is the single source of truth
/// <c>ScadaBridge:Logging:MinimumLevel</c> is the single source of truth
/// for the floor — the explicit <c>MinimumLevel.Is</c> call deliberately runs
/// AFTER <c>ReadFrom.Configuration</c> so a <c>Serilog:MinimumLevel</c> entry in
/// configuration is overridden. To make that precedence visible (so an operator
@@ -43,7 +43,7 @@ public static class LoggerConfigurationFactory
/// <summary>
/// Test-visible overload of <see cref="Build(IConfiguration, string, string, string)"/>
/// that routes the Host-020 precedence warning through a caller-supplied
/// that routes the precedence warning through a caller-supplied
/// writer so unit tests can capture it. Production calls the four-arg
/// overload which uses <see cref="Console.Error"/>.
/// </summary>
@@ -51,7 +51,7 @@ public static class LoggerConfigurationFactory
/// <param name="nodeRole">Role label added as a log enrichment property.</param>
/// <param name="siteId">Site identifier added as a log enrichment property.</param>
/// <param name="nodeHostname">Hostname added as a log enrichment property.</param>
/// <param name="warningWriter">Writer that receives the one-shot Host-020 override-warning when both keys are present.</param>
/// <param name="warningWriter">Writer that receives the one-shot override-warning when both keys are present.</param>
/// <returns>The fully configured <see cref="LoggerConfiguration"/> ready to create the Serilog logger.</returns>
internal static LoggerConfiguration Build(
IConfiguration configuration,
@@ -65,7 +65,7 @@ public static class LoggerConfigurationFactory
var minimumLevel = ParseLevel(loggingOptions.MinimumLevel, warningWriter);
// Host-020: warn once if the operator also set a Serilog:MinimumLevel —
// Warn once if the operator also set a Serilog:MinimumLevel —
// they almost certainly expected it to take effect, but the explicit
// MinimumLevel.Is call below silently overrides it. The warning is
// emitted only when the conflicting key is actually present (a bare
@@ -95,7 +95,7 @@ public static class LoggerConfigurationFactory
/// Parses a Serilog <see cref="LogEventLevel"/> name, falling back to
/// <see cref="LogEventLevel.Information"/> for null/blank/unrecognised values.
///
/// Host-022: when an operator sets <c>ScadaBridge:Logging:MinimumLevel</c> to a
/// When an operator sets <c>ScadaBridge:Logging:MinimumLevel</c> to a
/// value that doesn't parse (e.g. the typo "Informaiton"), the helper must NOT
/// throw — startup has to succeed so the rest of the system can come up — but
/// it MUST make the silent fallback visible. The logger is not yet built at
+31 -31
View File
@@ -40,7 +40,7 @@ var configuration = new ConfigurationBuilder()
.AddCommandLine(args)
.Build();
// WP-11: Full startup validation — fail fast before any DI or actor system setup
// Full startup validation — fail fast before any DI or actor system setup
StartupValidator.Validate(configuration);
// Read node options for Serilog enrichment
@@ -48,9 +48,9 @@ var nodeRole = configuration["ScadaBridge:Node:Role"]!;
var nodeHostname = configuration["ScadaBridge:Node:NodeHostname"] ?? "unknown";
var siteId = configuration["ScadaBridge:Node:SiteId"] ?? "central";
// WP-14: Serilog structured logging.
// Host-011: minimum level is driven by ScadaBridge:Logging:MinimumLevel (LoggingOptions).
// Host-014: console and file sinks are defined in the `Serilog` configuration
// Serilog structured logging.
// Minimum level is driven by ScadaBridge:Logging:MinimumLevel (LoggingOptions).
// Console and file sinks are defined in the `Serilog` configuration
// section (appsettings.json) and applied via ReadFrom.Configuration inside the
// factory — the sink set, output template, file path and rolling interval are all
// configuration-driven per REQ-HOST-8, not hard-coded here.
@@ -67,10 +67,10 @@ try
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
// WP-14: Serilog
// Serilog
builder.Host.UseSerilog();
// WP-17: Windows Service support (no-op when not running as a Windows Service)
// Windows Service support (no-op when not running as a Windows Service)
builder.Host.UseWindowsService();
// Shared components
@@ -86,32 +86,32 @@ try
// AddNotificationService() SMTP machinery above. AddNotificationOutbox binds
// NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed.
builder.Services.AddNotificationOutbox();
// Transport (#24) — central-only bundle export/import pipeline. Binds
// Transport — central-only bundle export/import pipeline. Binds
// TransportOptions from ScadaBridge:Transport via BindConfiguration; no
// explicit Configure needed.
builder.Services.AddTransport();
// Audit Log (#23) — central node owns the AuditLogIngestActor singleton +
// Audit Log — central node owns the AuditLogIngestActor singleton +
// IAuditLogRepository. The site writer chain is still registered (lazy
// singletons) but is never resolved on a central node.
builder.Services.AddAuditLog(builder.Configuration);
// #23 M6-T5 Bundle D — central-only hosted service that rolls
// Central-only hosted service that rolls
// pf_AuditLog_Month forward monthly. Depends on IPartitionMaintenance
// (registered below by AddConfigurationDatabase).
builder.Services.AddAuditLogCentralMaintenance(builder.Configuration);
// #23 M6 Bundle B/C — central-only registration backing the two
// Central-only registration backing the two
// maintenance singletons started in AkkaHostedService: the production
// ISiteEnumerator + IPullAuditEventsClient (gRPC) used by the
// SiteAuditReconciliationActor, plus the AuditLogPurgeOptions /
// SiteAuditReconciliationOptions bindings consumed by both singletons.
// Central-only by design (it dials sites), kept out of AddAuditLog.
builder.Services.AddAuditLogCentralReconciliationClient(builder.Configuration);
// Site Call Audit (#22) — central node owns the SiteCallAuditActor
// singleton (M3 Bundle F). The extension itself currently registers
// Site Call Audit — central node owns the SiteCallAuditActor
// singleton. The extension itself currently registers
// nothing — actor Props are constructed inline in AkkaHostedService —
// but the call is here for symmetry with the other audit composition
// roots so future per-actor DI lands without touching Program.cs.
builder.Services.AddSiteCallAudit();
// KPI History (#26, M6) — central-only. Binds KpiHistoryOptions from
// KPI History — central-only. Binds KpiHistoryOptions from
// ScadaBridge:KpiHistory and registers the validated options consumed by
// the KpiHistoryRecorderActor cluster singleton (started in
// AkkaHostedService). Observability/best-effort: NOT readiness-gated.
@@ -144,7 +144,7 @@ try
// The POST /api/{methodName} endpoint authenticates Bearer tokens
// (sbk_<keyId>_<secret>) and authorizes by scope == method name through this
// verifier. The legacy peppered-HMAC X-API-Key path — the SQL Server ApiKey
// entity, ApiKeyValidator, and IApiKeyHasher — was retired in re-arch C5; the
// entity, ApiKeyValidator, and IApiKeyHasher — was retired; the
// ScadaBridge:InboundApi:ApiKeyPepper config key is now consumed only as the
// library verifier's pepper secret (PepperSecretName below).
//
@@ -175,7 +175,7 @@ try
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
// Inbound-API key re-arch (C1), additive: expose the library admin facade
// Inbound-API key re-arch, additive: expose the library admin facade
// (ApiKeyAdminCommands) and the app-side management seam (IInboundApiKeyAdmin)
// in the SAME container as AddZbApiKeyAuth, so CLI + CentralUI later create /
// list / enable / disable / delete inbound keys and edit their method-scopes
@@ -201,7 +201,7 @@ try
?? throw new InvalidOperationException("ScadaBridge:Database:ConfigurationDb connection string is required for Central role.");
builder.Services.AddConfigurationDatabase(configDbConnectionString);
// WP-12: Health checks for readiness gating — shared ZB.MOM.WW.Health probes.
// Health checks for readiness gating — shared ZB.MOM.WW.Health probes.
// Check names and the ready/active tier split are preserved: database + akka-cluster
// carry the Ready tag (/health/ready), active-node carries the Active tag (/health/active).
// The Akka checks resolve ActorSystem from DI via the transient bridge registered below;
@@ -216,7 +216,7 @@ try
failureStatus: null,
tags: new[] { ZbHealthTags.Ready },
args: AkkaClusterStatusPolicy.Default)
// M2.14 (#28): readiness ALSO reflects "required cluster singletons running"
// Readiness ALSO reflects "required cluster singletons running"
// (REQ-HOST-4a). Probes each central singleton's local ClusterSingletonProxy
// with a bounded Identify and degrades to Unhealthy if any required singleton
// is unreachable. Registered inside the Central-role branch (this is it) so the
@@ -233,11 +233,11 @@ try
failureStatus: null,
tags: new[] { ZbHealthTags.Active });
// WP-13: Akka.NET bootstrap via hosted service
// Akka.NET bootstrap via hosted service
builder.Services.AddSingleton<AkkaHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// HOST-021: bridge the AkkaHostedService-owned ActorSystem to DI as a SINGLETON via
// Bridge the AkkaHostedService-owned ActorSystem to DI as a SINGLETON via
// GetOrCreateActorSystem(). The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem
// from DI, per probe, inside a child scope. ActorSystem is IDisposable, so a TRANSIENT
// (or scoped) bridge is captured-and-disposed by each probe's scope — disposing the live
@@ -249,7 +249,7 @@ try
builder.Services.AddSingleton<Akka.Actor.ActorSystem>(sp =>
sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem());
// InboundAPI-022: register the production IActiveNodeGate implementation so
// Register the production IActiveNodeGate implementation so
// standby-node gating is actually enforced (the InboundApiEndpointFilter
// consults IActiveNodeGate and defaults to "allow" when none is registered,
// which leaves the design's "central cluster only (active node)" guarantee
@@ -285,12 +285,12 @@ try
.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(MigrationHelper).FullName!);
// Host-010: tolerate a database that is briefly unreachable at boot
// Tolerate a database that is briefly unreachable at boot
// (e.g. app and DB containers starting together) with a bounded
// exponential backoff before failing fatally.
// Host-015: only connection-class (transient) faults are retried — a
// Only connection-class (transient) faults are retried — a
// schema-version mismatch is permanent and must fail fast on attempt 1.
// Host-019: thread the host's ApplicationStopping token into both the
// Thread the host's ApplicationStopping token into both the
// migration call itself and the inter-attempt Task.Delay so a SIGTERM
// during the bounded-retry window (~2 min worst-case) tears down
// cleanly instead of being ignored until the loop exhausts.
@@ -316,7 +316,7 @@ try
app.UseAuthorization();
app.UseAntiforgery();
// Audit Log #23 (M4 Bundle D, T8): emit one InboundRequest/InboundAuthFailure
// Emit one InboundRequest/InboundAuthFailure
// audit row per call into the inbound API. Placed AFTER UseAuthentication/
// UseAuthorization so any HttpContext.User the framework populates is in
// place, and scoped to the /api/ prefix so it never observes the Central UI,
@@ -324,7 +324,7 @@ try
// is responsible for stashing the resolved API key name on
// HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its
// in-handler API key validation succeeds.
// InboundAPI-025: scope the audit middleware to the inbound API method
// Scope the audit middleware to the inbound API method
// route (/api/{methodName}) and explicitly exclude the management/audit
// sub-trees that share the /api prefix. Without these exclusions the
// middleware would emit a spurious ApiInbound audit row for every
@@ -343,7 +343,7 @@ try
&& HttpMethods.IsPost(ctx.Request.Method),
branch => branch.UseAuditWriteMiddleware());
// WP-12: Map the canonical three-tier health endpoints in one call:
// Map the canonical three-tier health endpoints in one call:
// /health/ready — Ready-tagged checks (database + akka-cluster). REQ-HOST-4a defines
// readiness as cluster membership + DB connectivity, explicitly NOT
// cluster leadership, so the leader-only active-node check is excluded
@@ -365,11 +365,11 @@ try
app.MapCentralUI<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
app.MapInboundAPI();
app.MapManagementAPI();
// Audit Log #23 (M8): CLI-facing /api/audit/{query,export} routes. Same
// Audit Log: CLI-facing /api/audit/{query,export} routes. Same
// Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit
// / AuditExport role sets.
app.MapAuditAPI();
// Notify-and-fetch deploy (#2/#3): site-facing token-gated fetch of a staged
// Notify-and-fetch deploy: site-facing token-gated fetch of a staged
// deployment's flattened config. Machine-to-machine — AllowAnonymous, gated
// solely by the per-deployment X-Deployment-Token (no central FallbackPolicy).
app.MapDeploymentConfigAPI();
@@ -394,10 +394,10 @@ try
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
// WP-14: Serilog
// Serilog
builder.Host.UseSerilog();
// WP-17: Windows Service support (no-op when not running as a Windows Service)
// Windows Service support (no-op when not running as a Windows Service)
builder.Host.UseWindowsService();
// Read GrpcPort from config (NodeOptions already has default 8083)
@@ -449,7 +449,7 @@ try
// Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
// Host-017 / REQ-HOST-7: site-shutdown ordering. ApplicationStopping
// REQ-HOST-7: site-shutdown ordering. ApplicationStopping
// fires BEFORE IHostedService.StopAsync runs, so the gRPC server
// refuses new streams (Unavailable) and cancels every active stream
// here — clients observe a clean Cancelled and reconnect — and only
@@ -47,7 +47,7 @@ public static class SiteServiceRegistration
var siteDbPath = config["ScadaBridge:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}");
services.AddDataConnectionLayer();
// Audit Log #23 (M3 Bundle F): adapter that surfaces the site id to
// Adapter that surfaces the site id to
// StoreAndForwardService through DI WITHOUT introducing a
// StoreAndForward → HealthMonitoring project-reference cycle. Must be
// registered BEFORE AddStoreAndForward so the S&F factory resolves a
@@ -58,7 +58,7 @@ public static class SiteServiceRegistration
services.AddStoreAndForward();
services.AddSiteEventLogging();
// Site Event Logging (#12) M2.16 (#30) — bridge ISiteEventLogger.FailedWriteCount
// Site Event Logging — bridge ISiteEventLogger.FailedWriteCount
// into the site health report as a point-in-time SiteEventLogWriteFailures field.
// Must come AFTER both AddSiteHealthMonitoring (registers ISiteHealthCollector) and
// AddSiteEventLogging (registers ISiteEventLogger). The outer Func<IServiceProvider, …>
@@ -68,24 +68,24 @@ public static class SiteServiceRegistration
services.AddSiteEventLogHealthMetricsBridge(
sp => () => sp.GetRequiredService<ISiteEventLogger>().FailedWriteCount);
// Audit Log (#23) — site-side hot-path writer + telemetry collaborators.
// Audit Log — site-side hot-path writer + telemetry collaborators.
// The SiteAuditTelemetryActor itself is registered by AkkaHostedService
// in the site-role block; this call wires every DI dependency it (and
// ScriptRuntimeContext, when Bundle F lands) reaches for.
// ScriptRuntimeContext) reaches for.
services.AddAuditLog(config);
// Audit Log (#23) M2 Bundle G — bridge FallbackAuditWriter primary
// Bridge FallbackAuditWriter primary
// failures into the site health report payload as
// SiteAuditWriteFailures. Must come AFTER both AddSiteHealthMonitoring
// (registers ISiteHealthCollector) and AddAuditLog (registers the
// NoOp default this call replaces).
services.AddAuditLogHealthMetricsBridge();
// WP-13: Akka.NET bootstrap via hosted service
// Akka.NET bootstrap via hosted service
services.AddSingleton<AkkaHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// HOST-021: bridge the AkkaHostedService-owned ActorSystem to DI as a SINGLETON via
// Bridge the AkkaHostedService-owned ActorSystem to DI as a SINGLETON via
// GetOrCreateActorSystem(). The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem
// from DI, per probe, inside a child scope. ActorSystem is IDisposable, so a TRANSIENT
// (or scoped) bridge is captured-and-disposed by each probe's scope — disposing the live
@@ -106,7 +106,7 @@ public static class SiteServiceRegistration
return new AkkaClusterNodeProvider(akkaService, siteRole);
});
// SiteEventLogging-019 / #29 (M2.15): the EventLogPurgeService runs on every
// The EventLogPurgeService runs on every
// site host node but consults this optional gate each tick and early-exits on
// the standby. Register it to delegate to IClusterNodeProvider.SelfIsPrimary
// (the canonical "this node is Up AND cluster leader" check) so purge runs ONLY
@@ -146,7 +146,7 @@ public static class SiteServiceRegistration
services.Configure<NotificationOptions>(config.GetSection("ScadaBridge:Notification"));
services.Configure<LoggingOptions>(config.GetSection("ScadaBridge:Logging"));
// Audit Log (#23) — exposes ScadaBridge:Node:NodeName to downstream audit
// Audit Log — exposes ScadaBridge:Node:NodeName to downstream audit
// writers so they can stamp the SourceNode column. Registered here in
// shared bootstrap because every node (central + site) needs it.
services.AddSingleton<INodeIdentityProvider, NodeIdentityProvider>();
@@ -3,14 +3,14 @@ namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Bounded retry-with-backoff for startup preconditions.
///
/// Host-010 / REQ-HOST-4a: a Central node applies/validates database migrations
/// REQ-HOST-4a: a Central node applies/validates database migrations
/// before the host begins serving traffic. In container orchestration the database
/// and the app frequently start together, so the database may be briefly
/// unreachable. Rather than crashing the process on the first connection failure,
/// the migration step is wrapped in this bounded exponential backoff: it tolerates a
/// short outage and only fails fatally once attempts are exhausted.
///
/// Host-015: only <em>transient</em> faults are retried. The optional
/// Only <em>transient</em> faults are retried. The optional
/// <c>isTransient</c> predicate classifies each exception; a permanent failure
/// (e.g. a database schema-version mismatch — which no amount of waiting can fix)
/// is rethrown immediately rather than being retried for minutes before the
@@ -42,7 +42,7 @@ public static class StartupRetry
/// <summary>
/// Executes an asynchronous operation with bounded exponential backoff, retrying only transient faults.
/// Overload that forwards the retry-loop cancellation token to the operation itself —
/// Host-019: needed so callers (e.g. the database-migration step) can honour
/// needed so callers (e.g. the database-migration step) can honour
/// <c>IHostApplicationLifetime.ApplicationStopping</c> inside the operation as well
/// as inside the inter-attempt <c>Task.Delay</c>.
/// </summary>
@@ -63,7 +63,7 @@ public static class StartupRetry
Func<Exception, bool>? isTransient = null,
CancellationToken cancellationToken = default)
{
// Default: treat every exception as transient (preserves the pre-Host-015
// Default: treat every exception as transient (preserves the pre-existing
// behaviour for callers that do not classify faults).
isTransient ??= static _ => true;
@@ -94,7 +94,7 @@ public static class StartupRetry
}
/// <summary>
/// Transient-fault classifier for the database-migration startup step (Host-015).
/// Transient-fault classifier for the database-migration startup step.
/// Returns <c>true</c> only for connection-class faults that a brief wait can
/// resolve — a SQL connection/transport error or a timeout — and <c>false</c>
/// for everything else (notably schema-validation <see cref="InvalidOperationException"/>s
@@ -63,7 +63,7 @@ public static class StartupValidator
.Require("ScadaBridge:Database:MachineDataDb",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["MachineDataDb"]),
"connection string required for Central")
// Task 1.4: the LDAP server key moved into the nested Security:Ldap
// The LDAP server key moved into the nested Security:Ldap
// sub-section (bound to the shared LdapOptions). Validate the nested key so
// the pre-host preflight still fails fast on a missing LDAP server for
// Central. The full LDAP option set (SearchBase / ServiceAccountDn /
@@ -75,7 +75,7 @@ public static class StartupValidator
.Require("ScadaBridge:Security:JwtSigningKey",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
"required for Central")
// Review #4 (fail-fast pepper validation): the inbound API-key pepper
// The inbound API-key pepper
// backs the peppered-HMAC secret compare in the shared
// ZB.MOM.WW.Auth.ApiKeys verifier (wired by AddZbApiKeyAuth at the
// Central composition root). A missing or too-short pepper does not
@@ -97,13 +97,13 @@ public static class StartupValidator
// collisions + SiteDbPath + seed-node-port loop, in the original order.
.When(role == "Site", p =>
{
// Host-007 / REQ-HOST-4: GrpcPort range, then GrpcPort vs RemotingPort.
// GrpcPort range, then GrpcPort vs RemotingPort.
p.Require("ScadaBridge:Node:GrpcPort", _ => grpcValid, "must be 1-65535");
// Identical GrpcPort/RemotingPort make Kestrel and Akka.Remote contend
// for the same TCP port. Uses the resolved GrpcPort, including 8083.
p.Require("ScadaBridge:Node:GrpcPort", _ => port != grpcPort, "must differ from RemotingPort");
// Host-007 / REQ-HOST-4: MetricsPort range, then MetricsPort vs both ports.
// MetricsPort range, then MetricsPort vs both ports.
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsValid, "must be 1-65535");
// The Kestrel metrics (HTTP/1.1) listener port must differ from BOTH the
// Akka remoting port and the gRPC port. Uses the resolved MetricsPort (8084 default).
@@ -114,7 +114,7 @@ public static class StartupValidator
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["SiteDbPath"]),
"required for Site nodes");
// Host-004: a seed node must reference an Akka.Remote endpoint, never the
// A seed node must reference an Akka.Remote endpoint, never the
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's
// GrpcPort would make a joining node attempt an Akka.Remote TCP
// association against the gRPC listener and fail.
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Audit Log #23 (M3 Bundle F): Host-side adapter implementing the
/// Host-side adapter implementing the
/// optional <see cref="IStoreAndForwardSiteContext"/> the Store-and-Forward
/// service consults to stamp cached-call audit telemetry with the site id.
/// </summary>