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
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Audit Log (#23) M6 Bundle E (T8, T9) — central singleton implementation of /// Central singleton implementation of
/// <see cref="IAuditCentralHealthSnapshot"/>. Owns thread-safe /// <see cref="IAuditCentralHealthSnapshot"/>. Owns thread-safe
/// <see cref="System.Threading.Interlocked"/> counters for /// <see cref="System.Threading.Interlocked"/> counters for
/// <c>CentralAuditWriteFailures</c> + <c>AuditRedactionFailure</c> and a /// <c>CentralAuditWriteFailures</c> + <c>AuditRedactionFailure</c> and a
@@ -71,7 +71,7 @@ public class AuditLogIngestActor : ReceiveActor
_logger = logger; _logger = logger;
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync); ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
// The single-repository test ctor cannot service the M3 dual-write — // The single-repository test ctor cannot service the dual-write —
// it has no SiteCalls repo and no DbContext. The handler still // it has no SiteCalls repo and no DbContext. The handler still
// registers (so callers don't dead-letter) but replies empty so the // registers (so callers don't dead-letter) but replies empty so the
// test surface stays explicit about what this ctor supports. // test surface stays explicit about what this ctor supports.
@@ -125,7 +125,7 @@ public class AuditLogIngestActor : ReceiveActor
// InsertIfNotExistsAsync. The single-repository test ctor has no // InsertIfNotExistsAsync. The single-repository test ctor has no
// service provider — it falls through with no redactor, which preserves // service provider — it falls through with no redactor, which preserves
// the small-payload assumptions baked into the existing fixtures. // the small-payload assumptions baked into the existing fixtures.
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core // Use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously // services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup. // without blocking on sync Dispose() of pending connection cleanup.
if (_injectedRepository is not null) if (_injectedRepository is not null)
@@ -135,7 +135,7 @@ public class AuditLogIngestActor : ReceiveActor
} }
else else
{ {
// AuditLog-014: guard scope-creation + repository resolution in a // Guard scope-creation + repository resolution in a
// try/catch, mirroring OnCachedTelemetryAsync. A transient DI / // try/catch, mirroring OnCachedTelemetryAsync. A transient DI /
// DbContext-factory fault (pooled-context init, SQL-connection // DbContext-factory fault (pooled-context init, SQL-connection
// exhaustion, a resolution race during host churn) would otherwise // exhaustion, a resolution race during host churn) would otherwise
@@ -151,7 +151,7 @@ public class AuditLogIngestActor : ReceiveActor
await using var scope = _serviceProvider!.CreateAsyncScope(); await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>(); var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>(); var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): central health counter is best-effort — // Central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch // unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard. // simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>(); var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
@@ -194,11 +194,11 @@ public class AuditLogIngestActor : ReceiveActor
// retry, reconciliation) is a silent no-op. // retry, reconciliation) is a silent no-op.
// Redact BEFORE the IngestedAtUtc stamp so the redacted // Redact BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. The redactor // copy carries the central-side ingest timestamp. The redactor
// is contract-bound to never throw. AuditLog-008: a null // is contract-bound to never throw. A null
// redactor (test composition root, no IAuditRedactor // redactor (test composition root, no IAuditRedactor
// registered) now falls back to the SafeDefault rather than // registered) now falls back to the SafeDefault rather than
// pass-through, so HTTP header redaction always runs. // pass-through, so HTTP header redaction always runs.
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on // IngestedAtUtc is a DetailsJson field on
// the canonical record, so stamp it via the projection helper. // the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance; var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filtered = safeRedactor.Apply(evt); var filtered = safeRedactor.Apply(evt);
@@ -210,7 +210,7 @@ public class AuditLogIngestActor : ReceiveActor
{ {
// Per-row catch — one bad row never sinks the whole batch. // Per-row catch — one bad row never sinks the whole batch.
// The row stays Pending at the site; the next drain retries. // The row stays Pending at the site; the next drain retries.
// M6 Bundle E (T8): bump the central health counter so a // Bump the central health counter so a
// sustained insert-throw failure surfaces on the dashboard. // sustained insert-throw failure surfaces on the dashboard.
try { failureCounter?.Increment(); } try { failureCounter?.Increment(); }
catch { /* counter must never throw — defence in depth */ } catch { /* counter must never throw — defence in depth */ }
@@ -222,7 +222,7 @@ public class AuditLogIngestActor : ReceiveActor
} }
/// <summary> /// <summary>
/// M3 dual-write handler. For every <see cref="CachedTelemetryEntry"/> the /// Dual-write handler. For every <see cref="CachedTelemetryEntry"/> the
/// actor opens a fresh MS SQL transaction, inserts the AuditLog row /// actor opens a fresh MS SQL transaction, inserts the AuditLog row
/// idempotently AND upserts the SiteCalls row monotonically. Both succeed /// idempotently AND upserts the SiteCalls row monotonically. Both succeed
/// or both roll back, so the audit and operational mirrors never drift /// or both roll back, so the audit and operational mirrors never drift
@@ -248,13 +248,13 @@ public class AuditLogIngestActor : ReceiveActor
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>(); var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>(); var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Bundle C (M5-T6): resolve the redactor for the whole batch from // Resolve the redactor for the whole batch from
// the scope; null = SafeDefault for test composition roots that // the scope; null = SafeDefault for test composition roots that
// skip the redactor registration. The redactor is contract-bound to // skip the redactor registration. The redactor is contract-bound to
// never throw, so we can apply it inside the per-entry try // never throw, so we can apply it inside the per-entry try
// without risking an unbounded blast radius. // without risking an unbounded blast radius.
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>(); var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): same best-effort central health counter as // Same best-effort central health counter as
// the OnIngestAsync path — null on test composition roots that // the OnIngestAsync path — null on test composition roots that
// skip the registration. // skip the registration.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>(); var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
@@ -275,9 +275,9 @@ public class AuditLogIngestActor : ReceiveActor
// Redact the audit half BEFORE the dual-write — only the // Redact the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are redactable; SiteCalls // AuditLog row's payload columns are redactable; SiteCalls
// carries operational state only (status, retry count) and // carries operational state only (status, retry count) and
// is left untouched. AuditLog-008: null redactor falls back // is left untouched. Null redactor falls back
// to SafeDefault so header redaction always runs. // to SafeDefault so header redaction always runs.
// C3 transitional shim: IngestedAtUtc is a DetailsJson field // IngestedAtUtc is a DetailsJson field
// on the canonical record, so stamp it via the projection helper. // on the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance; var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filteredAudit = safeRedactor.Apply(entry.Audit); var filteredAudit = safeRedactor.Apply(entry.Audit);
@@ -298,7 +298,7 @@ public class AuditLogIngestActor : ReceiveActor
// EventId is NOT added to `accepted` so the site keeps its // EventId is NOT added to `accepted` so the site keeps its
// row Pending and retries on the next drain. Other entries // row Pending and retries on the next drain. Other entries
// in the batch continue with their own transactions. // in the batch continue with their own transactions.
// M6 Bundle E (T8): bump the central health counter so a // Bump the central health counter so a
// sustained dual-write failure surfaces on the dashboard. // sustained dual-write failure surfaces on the dashboard.
try { failureCounter?.Increment(); } try { failureCounter?.Increment(); }
catch { /* counter must never throw — defence in depth */ } catch { /* counter must never throw — defence in depth */ }
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Tuning knobs for the central /// Tuning knobs for the central
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service (M6-T5). /// <see cref="AuditLogPartitionMaintenanceService"/> hosted service.
/// Defaults: once every 24 hours, keep at least one future monthly /// Defaults: once every 24 hours, keep at least one future monthly
/// boundary ahead of <see cref="DateTime.UtcNow"/>. /// boundary ahead of <see cref="DateTime.UtcNow"/>.
/// </summary> /// </summary>
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Central <see cref="IHostedService"/> (M6-T5, Bundle D) that rolls /// Central <see cref="IHostedService"/> that rolls
/// <c>pf_AuditLog_Month</c> forward once a day. Each tick opens a fresh DI /// <c>pf_AuditLog_Month</c> forward once a day. Each tick opens a fresh DI
/// scope, resolves <see cref="IPartitionMaintenance"/>, and calls /// scope, resolves <see cref="IPartitionMaintenance"/>, and calls
/// <see cref="IPartitionMaintenance.EnsureLookaheadAsync"/> to SPLIT any /// <see cref="IPartitionMaintenance.EnsureLookaheadAsync"/> to SPLIT any
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Why a hosted service, not an actor.</b> Bundle C's /// <b>Why a hosted service, not an actor.</b>
/// <see cref="AuditLogPurgeActor"/> sits inside the central singleton /// <see cref="AuditLogPurgeActor"/> sits inside the central singleton
/// because it needs supervised lifecycle alongside the rest of the /// because it needs supervised lifecycle alongside the rest of the
/// reconciliation / ingest pipeline. Roll-forward is genuinely a once-a-day /// reconciliation / ingest pipeline. Roll-forward is genuinely a once-a-day
@@ -33,8 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <b>Failure containment.</b> The tick body wraps the maintenance call in /// <b>Failure containment.</b> The tick body wraps the maintenance call in
/// a try/catch so a transient SQL Server error never tears down the hosted /// a try/catch so a transient SQL Server error never tears down the hosted
/// service — the next tick simply retries. The exception is logged with /// service — the next tick simply retries. The exception is logged with
/// the original stack trace at <c>Error</c> level; ops surfaces (M6 Bundle /// the original stack trace at <c>Error</c> level; ops surfaces can subscribe to the logger to alert on
/// E's central health collector) can subscribe to the logger to alert on
/// repeated failures. /// repeated failures.
/// </para> /// </para>
/// <para> /// <para>
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Central singleton (M6 Bundle C) that drives the daily AuditLog partition /// Central singleton that drives the daily AuditLog partition
/// purge. On a configurable timer (default 24 hours) the actor: /// purge. On a configurable timer (default 24 hours) the actor:
/// <list type="number"> /// <list type="number">
/// <item>Queries <see cref="IAuditLogRepository.GetPartitionBoundariesOlderThanAsync"/> /// <item>Queries <see cref="IAuditLogRepository.GetPartitionBoundariesOlderThanAsync"/>
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> which runs /// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> which runs
/// the drop-and-rebuild dance around <c>UX_AuditLog_EventId</c>.</item> /// the drop-and-rebuild dance around <c>UX_AuditLog_EventId</c>.</item>
/// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system /// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system
/// EventStream so the Bundle E central health collector + ops surfaces /// EventStream so the central health collector + ops surfaces
/// can subscribe without coupling to this actor.</item> /// can subscribe without coupling to this actor.</item>
/// </list> /// </list>
/// </summary> /// </summary>
@@ -51,8 +51,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <para> /// <para>
/// <b>EventStream.</b> Publishing <see cref="AuditLogPurgedEvent"/> through /// <b>EventStream.</b> Publishing <see cref="AuditLogPurgedEvent"/> through
/// the EventStream rather than direct messaging avoids coupling this actor /// the EventStream rather than direct messaging avoids coupling this actor
/// to its consumers; M6 Bundle E will subscribe a central health-counter /// to its consumers; a central health-counter bridge will subscribe to
/// bridge that surfaces purge progress on the central health report. /// surface purge progress on the central health report.
/// </para> /// </para>
/// </remarks> /// </remarks>
public class AuditLogPurgeActor : ReceiveActor public class AuditLogPurgeActor : ReceiveActor
@@ -134,7 +134,7 @@ public class AuditLogPurgeActor : ReceiveActor
// restart. // restart.
var threshold = DateTime.UtcNow - TimeSpan.FromDays(_auditOptions.RetentionDays); var threshold = DateTime.UtcNow - TimeSpan.FromDays(_auditOptions.RetentionDays);
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core // Use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously // services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup. // without blocking on sync Dispose() of pending connection cleanup.
await using var scope = _services.CreateAsyncScope(); await using var scope = _services.CreateAsyncScope();
@@ -206,7 +206,7 @@ public class AuditLogPurgeActor : ReceiveActor
} }
} }
// M5.5 (T3): after the channel-blind global partition switch-out, apply any // After the channel-blind global partition switch-out, apply any
// per-channel retention overrides that are SHORTER than the global window via // per-channel retention overrides that are SHORTER than the global window via
// a bounded, batched row DELETE on the same maintenance path. The global // a bounded, batched row DELETE on the same maintenance path. The global
// switch-out has already dropped whole months older than RetentionDays; these // switch-out has already dropped whole months older than RetentionDays; these
@@ -216,7 +216,7 @@ public class AuditLogPurgeActor : ReceiveActor
} }
/// <summary> /// <summary>
/// M5.5 (T3): runs each per-channel retention override whose window is strictly /// Runs each per-channel retention override whose window is strictly
/// shorter than the global <see cref="AuditLogOptions.RetentionDays"/>, deleting /// shorter than the global <see cref="AuditLogOptions.RetentionDays"/>, deleting
/// rows of that channel older than the channel-specific threshold via a bounded, /// rows of that channel older than the channel-specific threshold via a bounded,
/// batched maintenance-path DELETE. Each channel runs inside its own try/catch so /// batched maintenance-path DELETE. Each channel runs inside its own try/catch so
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Tuning knobs for the central <see cref="AuditLogPurgeActor"/> singleton. /// Tuning knobs for the central <see cref="AuditLogPurgeActor"/> singleton.
/// Default cadence is 24 hours per the M6 plan; the retention window itself /// Default cadence is 24 hours; the retention window itself
/// is sourced from <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.RetentionDays"/> /// is sourced from <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.RetentionDays"/>
/// (default 365) so operators tune retention from a single section. /// (default 365) so operators tune retention from a single section.
/// </summary> /// </summary>
@@ -31,7 +31,7 @@ public sealed class AuditLogPurgeOptions
public int IntervalHours { get; set; } = 24; public int IntervalHours { get; set; } = 24;
/// <summary> /// <summary>
/// M5.5 (T3): batch size for the per-channel retention-override row DELETE /// Batch size for the per-channel retention-override row DELETE
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.PurgeChannelOlderThanAsync"/>). /// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.PurgeChannelOlderThanAsync"/>).
/// Each <c>DELETE TOP (@batch)</c> caps the transaction-log and lock footprint /// Each <c>DELETE TOP (@batch)</c> caps the transaction-log and lock footprint
/// per statement; the repository loops batches until no rows remain. Default /// per statement; the repository loops batches until no rows remain. Default
@@ -40,7 +40,7 @@ public sealed class AuditLogPurgeOptions
/// <see cref="ChannelPurgeBatchSize"/>. /// <see cref="ChannelPurgeBatchSize"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// AuditLog-013: the operator-facing config key is <c>ChannelPurgeBatchSize</c> /// The operator-facing config key is <c>ChannelPurgeBatchSize</c>
/// (per Component-AuditLog.md), so the binder maps that documented key onto this /// (per Component-AuditLog.md), so the binder maps that documented key onto this
/// backing property via <see cref="ConfigurationKeyNameAttribute"/>. The unattributed /// backing property via <see cref="ConfigurationKeyNameAttribute"/>. The unattributed
/// property name (<c>ChannelPurgeBatchSizeConfigured</c>) would otherwise have been /// property name (<c>ChannelPurgeBatchSizeConfigured</c>) would otherwise have been
@@ -2,8 +2,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Published on the actor-system EventStream by <see cref="AuditLogPurgeActor"/> /// Published on the actor-system EventStream by <see cref="AuditLogPurgeActor"/>
/// after each successful partition switch-out. Downstream consumers (Bundle E /// after each successful partition switch-out. Downstream consumers (central
/// central health collector, ops dashboards, audit trails) subscribe so a /// health collector, ops dashboards, audit trails) subscribe so a
/// purge action is observable without the actor needing to know about any /// purge action is observable without the actor needing to know about any
/// specific subscriber. /// specific subscriber.
/// </summary> /// </summary>
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Audit Log (#23) M6 Bundle E (T9) — bridges /// Bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by /// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// a header / body / SQL parameter redactor stage throws and the redactor has /// a header / body / SQL parameter redactor stage throws and the redactor has
@@ -13,11 +13,11 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Site vs central.</b> M5 Bundle C wired the SITE-side bridge /// <b>Site vs central.</b> Wired the SITE-side bridge
/// (<see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.HealthMetricsAuditRedactionFailureCounter"/>), /// (<see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.HealthMetricsAuditRedactionFailureCounter"/>),
/// which routes increments into the site health report payload's /// which routes increments into the site health report payload's
/// <c>AuditRedactionFailure</c> field. That handles redactor failures on the /// <c>AuditRedactionFailure</c> field. That handles redactor failures on the
/// site SQLite hot-path (FallbackAuditWriter). M6 Bundle E (T9) adds the /// site SQLite hot-path (FallbackAuditWriter). Adds the
/// MIRROR bridge here so the same payload filter — when it runs on the /// MIRROR bridge here so the same payload filter — when it runs on the
/// central <see cref="CentralAuditWriter"/> / /// central <see cref="CentralAuditWriter"/> /
/// <see cref="AuditLogIngestActor"/> paths — surfaces its failures on the /// <see cref="AuditLogIngestActor"/> paths — surfaces its failures on the
@@ -47,22 +47,22 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
private readonly INodeIdentityProvider? _nodeIdentity; private readonly INodeIdentityProvider? _nodeIdentity;
/// <summary> /// <summary>
/// Bundle C (M5-T6) — the central direct-write path used by the /// The central direct-write path used by the
/// NotificationOutboxActor dispatch and the Inbound API middleware also /// NotificationOutboxActor dispatch and the Inbound API middleware also
/// needs to truncate + redact before the row hits MS SQL. The filter is /// needs to truncate + redact before the row hits MS SQL. The filter is
/// optional so the M4 test composition roots that don't pass one keep /// optional so test composition roots that don't pass one keep
/// working (they only ever write small payloads); production DI registers /// working (they only ever write small payloads); production DI registers
/// the real filter via <see cref="ServiceCollectionExtensions.AddAuditLog"/>. /// the real filter via <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
/// M6 Bundle E (T8) — adds the optional /// Adds the optional
/// <see cref="ICentralAuditWriteFailureCounter"/> so a swallowed repository /// <see cref="ICentralAuditWriteFailureCounter"/> so a swallowed repository
/// throw bumps the central health surface's /// throw bumps the central health surface's
/// <c>CentralAuditWriteFailures</c> counter. Defaults to a NoOp so test /// <c>CentralAuditWriteFailures</c> counter. Defaults to a NoOp so test
/// composition roots that don't wire the counter keep their current /// composition roots that don't wire the counter keep their current
/// behaviour. SourceNode-stamping (Task 12) — adds the optional /// behaviour. SourceNode-stamping — adds the optional
/// <see cref="INodeIdentityProvider"/> so central-origin rows (Notification /// <see cref="INodeIdentityProvider"/> so central-origin rows (Notification
/// Outbox dispatch, Inbound API) carry the writing central node's /// Outbox dispatch, Inbound API) carry the writing central node's
/// identifier when the caller hasn't already supplied one. Optional / /// identifier when the caller hasn't already supplied one. Optional /
/// defaulting-to-null so M4 test composition roots that don't pass a /// defaulting-to-null so test composition roots that don't pass a
/// provider keep working — the caller-wins discipline means an absent /// provider keep working — the caller-wins discipline means an absent
/// provider simply leaves SourceNode at whatever the caller set (often /// provider simply leaves SourceNode at whatever the caller set (often
/// null, which is the legacy behaviour). /// null, which is the legacy behaviour).
@@ -81,8 +81,8 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{ {
_services = services ?? throw new ArgumentNullException(nameof(services)); _services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to null — over-redact instead. // Never default to null — over-redact instead.
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam. // Wired via the canonical IAuditRedactor seam.
// SafeDefaultAuditRedactor applies HTTP header redaction with // SafeDefaultAuditRedactor applies HTTP header redaction with
// hard-coded sensitive defaults so a composition root that omits the // hard-coded sensitive defaults so a composition root that omits the
// real redactor still scrubs Authorization / X-Api-Key / Cookie / // real redactor still scrubs Authorization / X-Api-Key / Cookie /
@@ -106,13 +106,13 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try try
{ {
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The // Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
// redactor contract is "never throws". AuditLog-008: _redactor is // redactor contract is "never throws". _redactor is
// now non-null (SafeDefaultAuditRedactor fallback) so header // now non-null (SafeDefaultAuditRedactor fallback) so header
// redaction always runs even in composition roots that omit the // redaction always runs even in composition roots that omit the
// real redactor. // real redactor.
var filtered = _redactor.Apply(evt); var filtered = _redactor.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins // SourceNode-stamping: caller-provided value wins
// (supports any future direct-write callsite that already has its // (supports any future direct-write callsite that already has its
// own node id); otherwise stamp from the local // own node id); otherwise stamp from the local
// INodeIdentityProvider, when one is wired. Production DI on // INodeIdentityProvider, when one is wired. Production DI on
@@ -126,7 +126,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
await using var scope = _services.CreateAsyncScope(); await using var scope = _services.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>(); var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on the // IngestedAtUtc is a DetailsJson field on the
// canonical record, so stamp it via the projection helper. // canonical record, so stamp it via the projection helper.
var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow); var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow);
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false); await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
@@ -134,7 +134,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
catch (Exception ex) catch (Exception ex)
{ {
// Audit failure NEVER aborts the user-facing action — swallow and log. // Audit failure NEVER aborts the user-facing action — swallow and log.
// M6 Bundle E (T8): also surface the failure on the central health // Also surface the failure on the central health
// counter so a sustained audit-write outage is visible on the // counter so a sustained audit-write outage is visible on the
// health dashboard rather than disappearing into the log file. // health dashboard rather than disappearing into the log file.
try try
@@ -12,7 +12,7 @@ using PullAuditEventsResponse = ZB.MOM.WW.ScadaBridge.Commons.Messages.Integrati
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Production <see cref="IPullAuditEventsClient"/> (Audit Log #23, M6) that the /// Production <see cref="IPullAuditEventsClient"/> that the
/// central <see cref="SiteAuditReconciliationActor"/> uses to pull the next /// central <see cref="SiteAuditReconciliationActor"/> uses to pull the next
/// reconciliation batch from a site over the <c>PullAuditEvents</c> unary gRPC /// reconciliation batch from a site over the <c>PullAuditEvents</c> unary gRPC
/// RPC served by <c>SiteStreamGrpcServer</c>. /// RPC served by <c>SiteStreamGrpcServer</c>.
@@ -13,7 +13,7 @@ using PullSiteCallsResponse = ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Production <see cref="IPullSiteCallsClient"/> (Site Call Audit #22) that the /// Production <see cref="IPullSiteCallsClient"/> (Site Call Audit) that the
/// central reconciliation tick (a separate follow-up component) uses to pull the /// central reconciliation tick (a separate follow-up component) uses to pull the
/// next batch of cached-call operational rows from a site over the /// next batch of cached-call operational rows from a site over the
/// <c>PullSiteCalls</c> unary gRPC RPC served by <c>SiteStreamGrpcServer</c>. /// <c>PullSiteCalls</c> unary gRPC RPC served by <c>SiteStreamGrpcServer</c>.
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Audit Log (#23) M6 Bundle E read-side surface exposing the central-side /// Read-side surface exposing the central-side
/// audit-health counters: <see cref="CentralAuditWriteFailures"/> (every /// audit-health counters: <see cref="CentralAuditWriteFailures"/> (every
/// repository insert throw from <see cref="CentralAuditWriter"/> / /// repository insert throw from <see cref="CentralAuditWriter"/> /
/// <see cref="AuditLogIngestActor"/>), <see cref="AuditRedactionFailure"/> /// <see cref="AuditLogIngestActor"/>), <see cref="AuditRedactionFailure"/>
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <para> /// <para>
/// <b>Read-only contract.</b> Implementations expose a point-in-time snapshot /// <b>Read-only contract.</b> Implementations expose a point-in-time snapshot
/// — increments and tracker updates happen through the dedicated counter / /// — increments and tracker updates happen through the dedicated counter /
/// tracker interfaces, not through this surface. Consumers (M7+ central /// tracker interfaces, not through this surface. Consumers (central
/// health pages) read these properties; they never mutate. /// health pages) read these properties; they never mutate.
/// </para> /// </para>
/// <para> /// <para>
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// failure / redaction-failure counters originate ON central (no site report /// failure / redaction-failure counters originate ON central (no site report
/// carries them), so they live on a dedicated snapshot rather than being /// carries them), so they live on a dedicated snapshot rather than being
/// retro-fitted into a per-site state. The two surfaces will be composed at /// retro-fitted into a per-site state. The two surfaces will be composed at
/// the M7 dashboard layer. /// the dashboard layer.
/// </para> /// </para>
/// </remarks> /// </remarks>
public interface IAuditCentralHealthSnapshot public interface IAuditCentralHealthSnapshot
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Audit Log (#23) M5.3 (T7) counter sink incremented by /// Audit Log counter sink incremented by
/// <see cref="ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware.AuditWriteMiddleware"/> /// <see cref="ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware.AuditWriteMiddleware"/>
/// whenever an inbound request or response body is truncated at the /// whenever an inbound request or response body is truncated at the
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.InboundMaxBytes"/> /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.InboundMaxBytes"/>
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Audit Log (#23) M6 Bundle E (T8) counter sink invoked by central-side audit /// Counter sink invoked by central-side audit
/// writers (<see cref="CentralAuditWriter"/>, <see cref="AuditLogIngestActor"/>) /// writers (<see cref="CentralAuditWriter"/>, <see cref="AuditLogIngestActor"/>)
/// every time a repository <c>InsertIfNotExistsAsync</c> throws. Mirrors the /// every time a repository <c>InsertIfNotExistsAsync</c> throws. Mirrors the
/// site-side <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.IAuditWriteFailureCounter"/> /// site-side <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.IAuditWriteFailureCounter"/>
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// instead. A NoOp default is the correct safe fallback while the central /// instead. A NoOp default is the correct safe fallback while the central
/// health surface is being wired in; <see cref="AuditCentralHealthSnapshot"/> /// health surface is being wired in; <see cref="AuditCentralHealthSnapshot"/>
/// is the production binding that routes increments into the aggregated /// is the production binding that routes increments into the aggregated
/// central health snapshot consumed by future M7+ pages. /// central health snapshot, to be consumed by future central health pages.
/// </remarks> /// </remarks>
public interface ICentralAuditWriteFailureCounter public interface ICentralAuditWriteFailureCounter
{ {
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Mockable abstraction over the central-side <c>PullSiteCalls</c> gRPC client /// Mockable abstraction over the central-side <c>PullSiteCalls</c> gRPC client
/// surface used by the Site Call Audit (#22) reconciliation tick to fetch the /// surface used by the Site Call Audit reconciliation tick to fetch the
/// next batch of cached-call operational rows from a specific site — the /// next batch of cached-call operational rows from a specific site — the
/// documented periodic self-heal pull that backfills the eventually-consistent /// documented periodic self-heal pull that backfills the eventually-consistent
/// central <c>SiteCalls</c> mirror when best-effort push telemetry is lost. /// central <c>SiteCalls</c> mirror when best-effort push telemetry is lost.
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Central singleton (M6 Bundle B) that drives the audit-log reconciliation /// Central singleton that drives the audit-log reconciliation
/// pull loop. On a configurable timer (default 5 minutes) the actor walks every /// pull loop. On a configurable timer (default 5 minutes) the actor walks every
/// known site, asks the site for any <see cref="AuditEvent"/> rows with /// known site, asks the site for any <see cref="AuditEvent"/> rows with
/// <see cref="AuditEvent.OccurredAtUtc"/> &gt;= the site's last reconciled /// <see cref="AuditEvent.OccurredAtUtc"/> &gt;= the site's last reconciled
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// missed push (gRPC blip, central restart, site offline) is eventually /// missed push (gRPC blip, central restart, site offline) is eventually
/// repaired by central re-pulling whatever the site still has in /// repaired by central re-pulling whatever the site still has in
/// <c>Pending</c>/<c>Forwarded</c> state. Idempotency on /// <c>Pending</c>/<c>Forwarded</c> state. Idempotency on
/// <see cref="AuditEvent.EventId"/> (M2 Bundle A's race-fix) makes duplicate /// <see cref="AuditEvent.EventId"/> makes duplicate
/// arrivals from both paths a silent no-op. /// arrivals from both paths a silent no-op.
/// </para> /// </para>
/// <para> /// <para>
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// the cursors reset to <see cref="DateTime.MinValue"/>. That is conservative /// the cursors reset to <see cref="DateTime.MinValue"/>. That is conservative
/// but correct — the next tick simply asks for everything the site still has, /// but correct — the next tick simply asks for everything the site still has,
/// and idempotent ingest swallows the dupes. Persisting cursors to MS SQL was /// and idempotent ingest swallows the dupes. Persisting cursors to MS SQL was
/// considered and rejected for M6: the cost of a write per tick outweighs the /// considered and rejected: the cost of a write per tick outweighs the
/// rare benefit of avoiding one over-broad pull after a restart. /// rare benefit of avoiding one over-broad pull after a restart.
/// </para> /// </para>
/// <para> /// <para>
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// consecutive pull cycles BOTH return non-empty AND <c>MoreAvailable=true</c> /// consecutive pull cycles BOTH return non-empty AND <c>MoreAvailable=true</c>
/// — i.e. the backlog isn't draining. The actor publishes /// — i.e. the backlog isn't draining. The actor publishes
/// <see cref="SiteAuditTelemetryStalledChanged"/> on the actor system's /// <see cref="SiteAuditTelemetryStalledChanged"/> on the actor system's
/// EventStream so a future <c>ICentralHealthCollector</c> bridge (M6 Bundle E) /// EventStream so a future <c>ICentralHealthCollector</c> bridge
/// can flip the health metric without coupling this actor to the health /// can flip the health metric without coupling this actor to the health
/// collection surface today. /// collection surface today.
/// </para> /// </para>
@@ -97,7 +97,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
private readonly Dictionary<string, bool> _stalled = new(); private readonly Dictionary<string, bool> _stalled = new();
/// <summary> /// <summary>
/// AuditLog-004: per-EventId retry counter for rows whose central insert /// Per-EventId retry counter for rows whose central insert
/// threw. While a row keeps failing AND is below /// threw. While a row keeps failing AND is below
/// <see cref="MaxPermanentInsertAttempts"/>, the cursor is held back so the /// <see cref="MaxPermanentInsertAttempts"/>, the cursor is held back so the
/// next reconciliation tick re-pulls and retries the row. Crossing the /// next reconciliation tick re-pulls and retries the row. Crossing the
@@ -110,7 +110,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
private readonly Dictionary<Guid, int> _failedInsertAttempts = new(); private readonly Dictionary<Guid, int> _failedInsertAttempts = new();
/// <summary> /// <summary>
/// AuditLog-004: number of consecutive central-insert failures before a row /// Number of consecutive central-insert failures before a row
/// is permanently abandoned with a Critical log entry and the cursor is /// is permanently abandoned with a Critical log entry and the cursor is
/// allowed to advance past it. Five attempts at the 5-minute default tick /// allowed to advance past it. Five attempts at the 5-minute default tick
/// is ~25 min of retry budget before a stuck row stops blocking progress. /// is ~25 min of retry budget before a stuck row stops blocking progress.
@@ -199,7 +199,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
return; return;
} }
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core // Use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously // services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup. // without blocking on sync Dispose() of pending connection cleanup.
await using var scope = _services.CreateAsyncScope(); await using var scope = _services.CreateAsyncScope();
@@ -260,9 +260,8 @@ public class SiteAuditReconciliationActor : ReceiveActor
{ {
// Idempotent repository write: duplicate EventIds (from a // Idempotent repository write: duplicate EventIds (from a
// concurrent push, or a retry of this very pull) collapse to // concurrent push, or a retry of this very pull) collapse to
// a no-op courtesy of M2 Bundle A's race-fix on // a no-op on InsertIfNotExistsAsync.
// InsertIfNotExistsAsync. // IngestedAtUtc is a DetailsJson field on the canonical record —
// C3: IngestedAtUtc is a DetailsJson field on the canonical record —
// stamp it via the projection helper. // stamp it via the projection helper.
var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc); var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false); await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
@@ -271,7 +270,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
} }
catch (Exception ex) catch (Exception ex)
{ {
// AuditLog-004: per-row catch so one bad event does not abandon // Per-row catch so one bad event does not abandon
// the rest of the batch. Track the failure count per EventId — // the rest of the batch. Track the failure count per EventId —
// below MaxPermanentInsertAttempts the cursor is HELD BACK so // below MaxPermanentInsertAttempts the cursor is HELD BACK so
// the next tick re-pulls and retries; at the threshold the row // the next tick re-pulls and retries; at the threshold the row
@@ -305,7 +304,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
} }
} }
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime. // Canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
var occurredUtc = evt.OccurredAtUtc.UtcDateTime; var occurredUtc = evt.OccurredAtUtc.UtcDateTime;
if (advanceForThisRow && occurredUtc > maxOccurred) if (advanceForThisRow && occurredUtc > maxOccurred)
{ {
@@ -313,7 +312,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
} }
} }
// AuditLog-004: only advance the persisted cursor if no event in this // Only advance the persisted cursor if no event in this
// batch is still being retried. Leaving the cursor at `since` re-pulls // batch is still being retried. Leaving the cursor at `since` re-pulls
// the whole batch next tick — successful rows are no-ops thanks to // the whole batch next tick — successful rows are no-ops thanks to
// InsertIfNotExistsAsync's idempotency, and the failing row gets // InsertIfNotExistsAsync's idempotency, and the failing row gets
@@ -388,7 +387,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
/// <summary> /// <summary>
/// Published on the actor system EventStream when a site's reconciliation /// Published on the actor system EventStream when a site's reconciliation
/// puller transitions into or out of the "stalled" state (backlog not /// puller transitions into or out of the "stalled" state (backlog not
/// draining across multiple cycles). The M6 Bundle E central health collector /// draining across multiple cycles). The central health collector
/// will subscribe to this and surface /// will subscribe to this and surface
/// <c>SiteAuditTelemetryStalled</c> on the health-report payload. /// <c>SiteAuditTelemetryStalled</c> on the health-report payload.
/// </summary> /// </summary>
@@ -2,13 +2,13 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Tuning knobs for the central <see cref="SiteAuditReconciliationActor"/> singleton. /// Tuning knobs for the central <see cref="SiteAuditReconciliationActor"/> singleton.
/// Defaults mirror the M6 Bundle B brief: pull every 5 minutes per site, 256 rows per /// Defaults: pull every 5 minutes per site, 256 rows per
/// batch, declare a site "stalled" after two consecutive pull cycles return non-empty /// batch, declare a site "stalled" after two consecutive pull cycles return non-empty
/// AND <c>MoreAvailable=true</c> (the backlog is not draining). /// AND <c>MoreAvailable=true</c> (the backlog is not draining).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Per the M6 plan the reconciliation actor is the fallback when push telemetry is /// The reconciliation actor is the fallback when push telemetry is
/// lost; it is intentionally low-frequency. Lowering /// lost; it is intentionally low-frequency. Lowering
/// <see cref="ReconciliationIntervalSeconds"/> in production trades MS SQL load for /// <see cref="ReconciliationIntervalSeconds"/> in production trades MS SQL load for
/// fresher self-healing — keep the default unless a deployment can prove the extra /// fresher self-healing — keep the default unless a deployment can prove the extra
@@ -5,10 +5,10 @@ using Akka.Event;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Audit Log (#23) M6 Bundle E (T7) — central singleton that subscribes to the /// Central singleton that subscribes to the
/// actor system's EventStream for <see cref="SiteAuditTelemetryStalledChanged"/> /// actor system's EventStream for <see cref="SiteAuditTelemetryStalledChanged"/>
/// publications and maintains a per-site latched stalled-state map readable /// publications and maintains a per-site latched stalled-state map readable
/// via <see cref="Snapshot"/>. Consumed by the M6 Bundle E /// via <see cref="Snapshot"/>. Consumed by the
/// <see cref="AuditCentralHealthSnapshot"/> aggregator so the central health /// <see cref="AuditCentralHealthSnapshot"/> aggregator so the central health
/// surface can surface per-site "reconciliation isn't draining" without /// surface can surface per-site "reconciliation isn't draining" without
/// coupling the publisher (<see cref="SiteAuditReconciliationActor"/>) to the /// coupling the publisher (<see cref="SiteAuditReconciliationActor"/>) to the
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
/// <summary> /// <summary>
/// Configuration for Audit Log (#23). Bound from the <c>AuditLog</c> section of /// Configuration for Audit Log. Bound from the <c>AuditLog</c> section of
/// <c>appsettings.json</c>. Defaults reflect the design (alog.md §6, §10): an /// <c>appsettings.json</c>. Defaults reflect the design (alog.md §6, §10): an
/// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central /// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central
/// retention window with monthly partition-switch purge. The default /// retention window with monthly partition-switch purge. The default
@@ -38,7 +38,7 @@ public sealed class AuditLogOptions
public int RetentionDays { get; set; } = 365; public int RetentionDays { get; set; } = 365;
/// <summary> /// <summary>
/// M5.5 (T3) per-channel retention overrides, keyed by the canonical channel name /// Per-channel retention overrides, keyed by the canonical channel name
/// (the <see cref="AuditChannel"/> enum name — e.g. <c>ApiOutbound</c>, /// (the <see cref="AuditChannel"/> enum name — e.g. <c>ApiOutbound</c>,
/// <c>DbOutbound</c>, <c>Notification</c>, <c>ApiInbound</c>). The value is a /// <c>DbOutbound</c>, <c>Notification</c>, <c>ApiInbound</c>). The value is a
/// retention window in days that MUST be SHORTER than or equal to the global /// retention window in days that MUST be SHORTER than or equal to the global
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
/// <summary> /// <summary>
/// Validates <see cref="AuditLogOptions"/> on startup. The caps drive payload /// Validates <see cref="AuditLogOptions"/> on startup. The caps drive payload
/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily /// truncation in the writers, so an unset/zero cap would let arbitrarily
/// large blobs into the central <c>AuditLog</c> table. <see cref="AuditLogOptions.ErrorCapBytes"/> /// large blobs into the central <c>AuditLog</c> table. <see cref="AuditLogOptions.ErrorCapBytes"/>
/// must be at least as large as <see cref="AuditLogOptions.DefaultCapBytes"/> /// must be at least as large as <see cref="AuditLogOptions.DefaultCapBytes"/>
/// because the error cap is meant to capture <em>more</em> detail than the /// because the error cap is meant to capture <em>more</em> detail than the
@@ -54,7 +54,7 @@ public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOpti
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " + $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes."); $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
// M5.5 (T3): per-channel retention overrides. Each entry must be keyed by a // Per-channel retention overrides. Each entry must be keyed by a
// recognized AuditChannel name and carry a window in [MinRetentionDays, // recognized AuditChannel name and carry a window in [MinRetentionDays,
// RetentionDays] — i.e. SHORTER than or equal to the global window. A longer // RetentionDays] — i.e. SHORTER than or equal to the global window. A longer
// per-channel window is meaningless under month-partition switch-out (governed // per-channel window is meaningless under month-partition switch-out (governed
@@ -17,7 +17,7 @@ public sealed class PerTargetRedactionOverride
/// <summary> /// <summary>
/// Opt-in SQL parameter redaction: case-insensitive regex matched against /// Opt-in SQL parameter redaction: case-insensitive regex matched against
/// each SQL parameter NAME in the M4 <c>AuditingDbCommand</c> RequestSummary /// each SQL parameter NAME in the <c>AuditingDbCommand</c> RequestSummary
/// JSON (<c>{"sql":"...","parameters":{"@name":"value", ...}}</c>); values /// JSON (<c>{"sql":"...","parameters":{"@name":"value", ...}}</c>); values
/// whose name matches are replaced with <c>&lt;redacted&gt;</c>. Null (the /// whose name matches are replaced with <c>&lt;redacted&gt;</c>. Null (the
/// default) means parameter values are captured verbatim. Only applied to /// default) means parameter values are captured verbatim. Only applied to
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Kpi; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
/// <summary> /// <summary>
/// Audit Log (#23) M6 "KPI History &amp; Trends" (K8) — the /// "KPI History &amp; Trends" — the
/// <see cref="IKpiSampleSource"/> for the <see cref="KpiSources.AuditLog"/> source. /// <see cref="IKpiSampleSource"/> for the <see cref="KpiSources.AuditLog"/> source.
/// The central recorder singleton enumerates this provider once per sampling pass /// The central recorder singleton enumerates this provider once per sampling pass
/// and persists its samples into the central <c>KpiSample</c> history table. /// and persists its samples into the central <c>KpiSample</c> history table.
@@ -38,7 +38,7 @@ public sealed class AuditLogKpiSampleSource : IKpiSampleSource
// Metric catalog — the exact strings persisted in KpiSample.Metric. All three are // Metric catalog — the exact strings persisted in KpiSample.Metric. All three are
// charted, so they share the public Commons catalog: source + UI trend page key off // charted, so they share the public Commons catalog: source + UI trend page key off
// one symbol (#178). Stable identifiers; do not rename (values are persisted). // one symbol. Stable identifiers; do not rename (values are persisted).
private const string TotalEventsLastHourMetric = KpiMetrics.AuditLog.TotalEventsLastHour; private const string TotalEventsLastHourMetric = KpiMetrics.AuditLog.TotalEventsLastHour;
private const string ErrorEventsLastHourMetric = KpiMetrics.AuditLog.ErrorEventsLastHour; private const string ErrorEventsLastHourMetric = KpiMetrics.AuditLog.ErrorEventsLastHour;
private const string BacklogTotalMetric = KpiMetrics.AuditLog.BacklogTotal; private const string BacklogTotalMetric = KpiMetrics.AuditLog.BacklogTotal;
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// Pure, stateless redaction + truncation primitives used by /// Pure, stateless redaction + truncation primitives used by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// (which operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> + its <c>DetailsJson</c>). /// (which operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> + its <c>DetailsJson</c>).
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) so the /// Extracted in a ScadaBridge audit re-architecture so the
/// byte-exact redaction logic lives in ONE place. /// byte-exact redaction logic lives in ONE place.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -193,7 +193,7 @@ internal static class AuditRedactionPrimitives
} }
/// <summary> /// <summary>
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary /// Walk the <c>{"sql":"...","parameters":{...}}</c> RequestSummary
/// shape; for each parameter whose NAME matches /// shape; for each parameter whose NAME matches
/// <paramref name="paramNameRegex"/>, replace its value with /// <paramref name="paramNameRegex"/>, replace its value with
/// <see cref="RedactedMarker"/>. Re-serialise. No-op pass-through when the /// <see cref="RedactedMarker"/>. Re-serialise. No-op pass-through when the
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary> /// <summary>
/// Per-instance compiled-regex cache for audit body / SQL-parameter redactors /// Per-instance compiled-regex cache for audit body / SQL-parameter redactors
/// used by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>. /// used by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>.
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) to /// Extracted in ScadaBridge audit re-architecture to
/// centralize compile rules (50 ms per-match timeout, 100 ms compile budget, /// centralize compile rules (50 ms per-match timeout, 100 ms compile budget,
/// invalid-pattern sentinel). /// invalid-pattern sentinel).
/// </summary> /// </summary>
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// Counter sink invoked by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> /// Counter sink invoked by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// every time a redactor (header / body regex / SQL parameter) throws and the /// every time a redactor (header / body regex / SQL parameter) throws and the
/// redactor has to over-redact the offending field with the /// redactor has to over-redact the offending field with the
/// <c>&lt;redacted: redactor error&gt;</c> marker. Bundle C bridges this into /// <c>&lt;redacted: redactor error&gt;</c> marker. This is bridged into
/// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>. /// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary> /// <summary>
/// Default <see cref="IAuditRedactionFailureCounter"/> binding used when the /// Default <see cref="IAuditRedactionFailureCounter"/> binding used when the
/// Site Health Monitoring bridge has not been wired yet. Bundle C replaces /// Site Health Monitoring bridge has not been wired yet. Replaces
/// this registration with the real counter that surfaces in the site health /// this registration with the real counter that surfaces in the site health
/// report payload as <c>AuditRedactionFailure</c>. /// report payload as <c>AuditRedactionFailure</c>.
/// </summary> /// </summary>
@@ -11,7 +11,7 @@ public sealed class NoOpAuditRedactionFailureCounter : IAuditRedactionFailureCou
/// <inheritdoc/> /// <inheritdoc/>
public void Increment() public void Increment()
{ {
// Intentionally empty — Bundle C overrides this binding with the real // Intentionally empty — overrides this binding with the real
// health-metric counter. // health-metric counter.
} }
} }
@@ -19,12 +19,12 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
namespace ZB.MOM.WW.ScadaBridge.AuditLog; namespace ZB.MOM.WW.ScadaBridge.AuditLog;
/// <summary> /// <summary>
/// Composition root for the Audit Log (#23) component. /// Composition root for the Audit Log component.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// M1 registered <see cref="AuditLogOptions"/> + the validator. M2 Bundle E /// Registered <see cref="AuditLogOptions"/> + the validator. Extends the
/// extends the surface with the site-side writer chain /// surface with the site-side writer chain
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> + /// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators /// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>, /// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
@@ -32,8 +32,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog;
/// <see cref="SqliteAuditWriterOptions"/>). /// <see cref="SqliteAuditWriterOptions"/>).
/// </para> /// </para>
/// <para> /// <para>
/// Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call /// Audit Log sits alongside Notification Outbox and Site Call
/// Audit (#22). <c>IAuditLogRepository</c> is registered by /// Audit. <c>IAuditLogRepository</c> is registered by
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>, /// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so the caller (the Host on the central node) must also call that. /// so the caller (the Host on the central node) must also call that.
/// </para> /// </para>
@@ -59,7 +59,7 @@ public static class ServiceCollectionExtensions
public const string ReconciliationSectionName = "AuditLog:Reconciliation"; public const string ReconciliationSectionName = "AuditLog:Reconciliation";
/// <summary> /// <summary>
/// Registers the Audit Log (#23) component services: options, the site /// Registers the Audit Log component services: options, the site
/// SQLite writer chain (primary + ring fallback + failure-counter sink), /// SQLite writer chain (primary + ring fallback + failure-counter sink),
/// and the site-→central telemetry collaborators. Idempotent re-registration /// and the site-→central telemetry collaborators. Idempotent re-registration
/// is not supported; call this exactly once per <see cref="IServiceCollection"/>. /// is not supported; call this exactly once per <see cref="IServiceCollection"/>.
@@ -72,7 +72,7 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(config);
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.). // Top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
// Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the // Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
// "AuditLog" section, registers the validator, and enables ValidateOnStart in // "AuditLog" section, registers the validator, and enables ValidateOnStart in
// one call. Same section path as before; AddAuditLog is call-once per // one call. Same section path as before; AddAuditLog is call-once per
@@ -80,20 +80,19 @@ public static class ServiceCollectionExtensions
// validator (a strict improvement over the previous AddSingleton). // validator (a strict improvement over the previous AddSingleton).
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName); services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
// C3 (Task 2.5): the canonical IAuditRedactor is wired as // The canonical IAuditRedactor is wired as
// ScadaBridgeAuditRedactor — same truncation + header / body / // ScadaBridgeAuditRedactor — same truncation + header / body /
// SQL-parameter redaction as the original pipeline, applied between // SQL-parameter redaction as the original pipeline, applied between
// event construction and persistence. Singleton — stateless; the // event construction and persistence. Singleton — stateless; the
// IOptionsMonitor dependency picks up hot reloads on its own. // IOptionsMonitor dependency picks up hot reloads on its own.
services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>(); services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
// M5 Bundle B: per-stage redactor-failure counter. NoOp default; // Per-stage redactor-failure counter. NoOp default; this binding is
// Bundle C replaces this binding with the Site Health Monitoring // later replaced with the Site Health Monitoring bridge that surfaces
// bridge that surfaces failures as AuditRedactionFailure on the site // failures as AuditRedactionFailure on the site health report.
// health report.
services.TryAddSingleton<IAuditRedactionFailureCounter, NoOpAuditRedactionFailureCounter>(); services.TryAddSingleton<IAuditRedactionFailureCounter, NoOpAuditRedactionFailureCounter>();
// M2 Bundle E: site writer + telemetry options bindings. // Site writer + telemetry options bindings.
// BindConfiguration is not used because the configuration root supplied // BindConfiguration is not used because the configuration root supplied
// by the caller may not be the application root — we go through the // by the caller may not be the application root — we go through the
// section explicitly so a partial IConfiguration (e.g. a test stub // section explicitly so a partial IConfiguration (e.g. a test stub
@@ -113,18 +112,18 @@ public static class ServiceCollectionExtensions
// RingBufferFallback: drop-oldest in-memory ring used by // RingBufferFallback: drop-oldest in-memory ring used by
// FallbackAuditWriter when the primary SQLite writer throws. Default // FallbackAuditWriter when the primary SQLite writer throws. Default
// capacity is fine for M2 (1024). // capacity is fine (1024).
services.AddSingleton<RingBufferFallback>(); services.AddSingleton<RingBufferFallback>();
// IAuditWriteFailureCounter: NoOp default. Bundle G overrides this // IAuditWriteFailureCounter: NoOp default. This binding is later
// binding with the real Site Health Monitoring counter. Registered // overridden with the real Site Health Monitoring counter. Registered
// before FallbackAuditWriter so the factory can resolve it. // before FallbackAuditWriter so the factory can resolve it.
services.AddSingleton<IAuditWriteFailureCounter, NoOpAuditWriteFailureCounter>(); services.AddSingleton<IAuditWriteFailureCounter, NoOpAuditWriteFailureCounter>();
// The script-thread surface is FallbackAuditWriter (primary + ring + // The script-thread surface is FallbackAuditWriter (primary + ring +
// counter), not the raw SqliteAuditWriter — primary failures must NEVER // counter), not the raw SqliteAuditWriter — primary failures must NEVER
// abort the user-facing action. // abort the user-facing action.
// C3 (Task 2.5): the canonical IAuditRedactor singleton above is wired // The canonical IAuditRedactor singleton above is wired
// through the factory so every event written through this surface is // through the factory so every event written through this surface is
// truncated + redacted before it hits SQLite (and the ring on // truncated + redacted before it hits SQLite (and the ring on
// failure). // failure).
@@ -145,72 +144,71 @@ public static class ServiceCollectionExtensions
// created during Akka bootstrap, not at DI-composition time). // created during Akka bootstrap, not at DI-composition time).
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>(); services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
// M3 Bundle F: site-side dual emitter for cached-call lifecycle // Site-side dual emitter for cached-call lifecycle
// telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall / // telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
// Database.CachedWrite resolves this through DI and pushes one combined // Database.CachedWrite resolves this through DI and pushes one combined
// packet per lifecycle event; the forwarder writes the audit half // packet per lifecycle event; the forwarder writes the audit half
// through IAuditWriter and the operational half through the // through IAuditWriter and the operational half through the
// IOperationTrackingStore. The audit writer is always wired (the M2 // IOperationTrackingStore. The audit writer is always wired (the
// chain above); the operational tracking store is SITE-ONLY (registered // chain above); the operational tracking store is SITE-ONLY (registered
// by ZB.MOM.WW.ScadaBridge.SiteRuntime). On a Central composition root the tracking // by ZB.MOM.WW.ScadaBridge.SiteRuntime). On a Central composition root the tracking
// store has no registration, so the factory resolves it with GetService // store has no registration, so the factory resolves it with GetService
// (returning null) — the forwarder degrades to "audit-only" emission, // (returning null) — the forwarder degrades to "audit-only" emission,
// mirroring the lazy IAuditWriter chain established in M2. // mirroring the lazy IAuditWriter chain established above.
services.AddSingleton<ICachedCallTelemetryForwarder>(sp => services.AddSingleton<ICachedCallTelemetryForwarder>(sp =>
new CachedCallTelemetryForwarder( new CachedCallTelemetryForwarder(
sp.GetRequiredService<IAuditWriter>(), sp.GetRequiredService<IAuditWriter>(),
sp.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>(), sp.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>(),
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(), sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(),
// AuditLog-007: INodeIdentityProvider is now required across // INodeIdentityProvider is now required across
// every consumer in AddAuditLog. The Host's // every consumer in AddAuditLog. The Host's
// SiteServiceRegistration registers it as a singleton on both // SiteServiceRegistration registers it as a singleton on both
// site and central paths (InboundAPI-022 / Host registration // site and central paths, and the AddAuditLogTests fixture provides a
// sweep), and the AddAuditLogTests fixture provides a
// FakeNodeIdentityProvider; a silent GetService() that // FakeNodeIdentityProvider; a silent GetService() that
// returned null would mask a future composition root that // returned null would mask a future composition root that
// forgot to register the provider. // forgot to register the provider.
sp.GetRequiredService<INodeIdentityProvider>())); sp.GetRequiredService<INodeIdentityProvider>()));
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook // Bridge the store-and-forward retry-loop observer hook
// to the cached-call forwarder so per-attempt + terminal telemetry // to the cached-call forwarder so per-attempt + terminal telemetry
// emitted from the S&F retry sweep lands on the same SQLite hot-path // emitted from the S&F retry sweep lands on the same SQLite hot-path
// as the script-thread CachedSubmit row. Registered as a singleton // as the script-thread CachedSubmit row. Registered as a singleton
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward // and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
// can resolve it through DI (Bundle F StoreAndForward wiring change). // can resolve it through DI.
// SourceNode-stamping (Task 14): factory-resolved so the // SourceNode-stamping: factory-resolved so the
// INodeIdentityProvider singleton can be threaded through — the // INodeIdentityProvider singleton can be threaded through — the
// bridge stamps SiteCallOperational.SourceNode from // bridge stamps SiteCallOperational.SourceNode from
// INodeIdentityProvider.NodeName on every cached-call lifecycle row. // INodeIdentityProvider.NodeName on every cached-call lifecycle row.
// AuditLog-007: the provider is resolved with GetRequiredService — // The provider is resolved with GetRequiredService —
// SiteServiceRegistration.BindSharedOptions registers it on both // SiteServiceRegistration.BindSharedOptions registers it on both
// site and central paths, so a missing registration is a // site and central paths, so a missing registration is a
// composition-root bug, not a silent null-SourceNode degradation. // composition-root bug, not a silent null-SourceNode degradation.
services.AddSingleton<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge( services.AddSingleton<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge(
sp.GetRequiredService<ICachedCallTelemetryForwarder>(), sp.GetRequiredService<ICachedCallTelemetryForwarder>(),
sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(), sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(),
// AuditLog-007: required, matches the other consumers in this // Required, matches the other consumers in this
// composition root — the provider is always registered by // composition root — the provider is always registered by
// SiteServiceRegistration. // SiteServiceRegistration.
sp.GetRequiredService<INodeIdentityProvider>())); sp.GetRequiredService<INodeIdentityProvider>()));
services.AddSingleton<ICachedCallLifecycleObserver>( services.AddSingleton<ICachedCallLifecycleObserver>(
sp => sp.GetRequiredService<CachedCallLifecycleBridge>()); sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
// M6 Bundle E (T8): central audit-write failure counter — NoOp default // Central audit-write failure counter — NoOp default
// for site/test composition roots that don't wire the central health // for site/test composition roots that don't wire the central health
// snapshot. AddAuditLogCentralMaintenance below replaces this binding // snapshot. AddAuditLogCentralMaintenance below replaces this binding
// with the AuditCentralHealthSnapshot implementation so increments // with the AuditCentralHealthSnapshot implementation so increments
// surface on the central dashboard. // surface on the central dashboard.
services.TryAddSingleton<ICentralAuditWriteFailureCounter, NoOpCentralAuditWriteFailureCounter>(); services.TryAddSingleton<ICentralAuditWriteFailureCounter, NoOpCentralAuditWriteFailureCounter>();
// M5.3 (T7): inbound body-ceiling hit counter — NoOp default for // Inbound body-ceiling hit counter — NoOp default for
// site/test roots. AddAuditLogCentralMaintenance replaces this binding // site/test roots. AddAuditLogCentralMaintenance replaces this binding
// with the AuditCentralHealthSnapshot implementation so ceiling-hit // with the AuditCentralHealthSnapshot implementation so ceiling-hit
// counts surface on the central dashboard alongside write-failure and // counts surface on the central dashboard alongside write-failure and
// redaction-failure counters. // redaction-failure counters.
services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>(); services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>();
// M4 Bundle B: central direct-write audit writer used by // Central direct-write audit writer used by
// NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to // NotificationOutboxActor and Inbound API to
// emit AuditLog rows that originate ON central, not via site telemetry. // emit AuditLog rows that originate ON central, not via site telemetry.
// Singleton — the writer is stateless; its per-call scope opens a fresh // Singleton — the writer is stateless; its per-call scope opens a fresh
// IAuditLogRepository (a SCOPED EF Core service registered by // IAuditLogRepository (a SCOPED EF Core service registered by
@@ -218,17 +216,17 @@ public static class ServiceCollectionExtensions
// is intentionally distinct from IAuditWriter so site composition roots // is intentionally distinct from IAuditWriter so site composition roots
// do not accidentally bind it; central composition roots that include // do not accidentally bind it; central composition roots that include
// AddConfigurationDatabase get a working implementation transparently. // AddConfigurationDatabase get a working implementation transparently.
// C3 (Task 2.5): wire the canonical IAuditRedactor into the factory so // Wire the canonical IAuditRedactor into the factory so
// NotificationOutboxActor + Inbound API rows are truncated + redacted // NotificationOutboxActor + Inbound API rows are truncated + redacted
// before they hit MS SQL. // before they hit MS SQL.
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter // Also wire the ICentralAuditWriteFailureCounter
// so swallowed repo throws bump the central health counter. // so swallowed repo throws bump the central health counter.
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter( services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
sp, sp,
sp.GetRequiredService<ILogger<CentralAuditWriter>>(), sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
sp.GetRequiredService<IAuditRedactor>(), sp.GetRequiredService<IAuditRedactor>(),
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(), sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
// SourceNode-stamping (Task 12): wire the local node identity so // SourceNode-stamping: wire the local node identity so
// central-origin rows (Notification Outbox dispatch, Inbound API) // central-origin rows (Notification Outbox dispatch, Inbound API)
// carry the writing node's identifier when the caller hasn't // carry the writing node's identifier when the caller hasn't
// already supplied one. GetRequiredService — the production // already supplied one. GetRequiredService — the production
@@ -236,7 +234,7 @@ public static class ServiceCollectionExtensions
// provider as a singleton on both site and central paths. // provider as a singleton on both site and central paths.
sp.GetRequiredService<INodeIdentityProvider>())); sp.GetRequiredService<INodeIdentityProvider>()));
// M6 (K8) KPI History & Trends: the AuditLog source the central KPI // KPI History & Trends: the AuditLog source the central KPI
// recorder enumerates each sampling pass to snapshot Global-scope Audit // recorder enumerates each sampling pass to snapshot Global-scope Audit
// Log KPIs (volume / error rate / backlog) into the KpiSample history // Log KPIs (volume / error rate / backlog) into the KpiSample history
// table. Scoped to match its IAuditLogRepository dependency — a SCOPED // table. Scoped to match its IAuditLogRepository dependency — a SCOPED
@@ -252,7 +250,7 @@ public static class ServiceCollectionExtensions
} }
/// <summary> /// <summary>
/// Audit Log (#23) M2 Bundle G + M5 Bundle C — swap the default /// Swap the default
/// <see cref="NoOpAuditWriteFailureCounter"/> and /// <see cref="NoOpAuditWriteFailureCounter"/> and
/// <see cref="NoOpAuditRedactionFailureCounter"/> registrations for the /// <see cref="NoOpAuditRedactionFailureCounter"/> registrations for the
/// real <see cref="HealthMetricsAuditWriteFailureCounter"/> / /// real <see cref="HealthMetricsAuditWriteFailureCounter"/> /
@@ -282,10 +280,10 @@ public static class ServiceCollectionExtensions
/// double-registered (AddHostedService has no TryAdd variant). /// double-registered (AddHostedService has no TryAdd variant).
/// </para> /// </para>
/// <para> /// <para>
/// Site-side only for M5: the central composition root keeps the NoOp /// Site-side only: the central composition root keeps the NoOp
/// defaults; the central health-metric surface that would expose /// defaults; the central health-metric surface that would expose
/// <c>AuditRedactionFailure</c> next to the existing central counters /// <c>AuditRedactionFailure</c> next to the existing central counters
/// ships in M6. /// ships later.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="services">The service collection to register into.</param> /// <param name="services">The service collection to register into.</param>
@@ -294,7 +292,7 @@ public static class ServiceCollectionExtensions
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
// AuditLog-011: guard against double-registration. AddHostedService is // Guard against double-registration. AddHostedService is
// additive (no TryAdd variant) so a second call without this sentinel // additive (no TryAdd variant) so a second call without this sentinel
// would spin up a second SiteAuditBacklogReporter, doubling the 30 s // would spin up a second SiteAuditBacklogReporter, doubling the 30 s
// SQL probe rate and racing on the ISiteHealthCollector snapshot. The // SQL probe rate and racing on the ISiteHealthCollector snapshot. The
@@ -310,7 +308,7 @@ public static class ServiceCollectionExtensions
ServiceDescriptor.Singleton<IAuditWriteFailureCounter, HealthMetricsAuditWriteFailureCounter>()); ServiceDescriptor.Singleton<IAuditWriteFailureCounter, HealthMetricsAuditWriteFailureCounter>());
services.Replace( services.Replace(
ServiceDescriptor.Singleton<IAuditRedactionFailureCounter, HealthMetricsAuditRedactionFailureCounter>()); ServiceDescriptor.Singleton<IAuditRedactionFailureCounter, HealthMetricsAuditRedactionFailureCounter>());
// M6 Bundle E (T6): the site-side backlog reporter polls the // The site-side backlog reporter polls the
// SqliteAuditWriter every 30 s and pushes the snapshot into the // SqliteAuditWriter every 30 s and pushes the snapshot into the
// collector so the next SiteHealthReport carries a fresh // collector so the next SiteHealthReport carries a fresh
// SiteAuditBacklog field. Registered alongside the other site-only // SiteAuditBacklog field. Registered alongside the other site-only
@@ -322,7 +320,7 @@ public static class ServiceCollectionExtensions
} }
/// <summary> /// <summary>
/// Audit Log (#23) M6-T5 Bundle D — central-only registration for the /// Central-only registration for the
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service plus /// <see cref="AuditLogPartitionMaintenanceService"/> hosted service plus
/// its <see cref="AuditLogPartitionMaintenanceOptions"/> binding. Must be /// its <see cref="AuditLogPartitionMaintenanceOptions"/> binding. Must be
/// called from the Central role's composition root (not from a site /// called from the Central role's composition root (not from a site
@@ -372,7 +370,7 @@ public static class ServiceCollectionExtensions
services.AddOptions<SiteAuditReconciliationOptions>() services.AddOptions<SiteAuditReconciliationOptions>()
.Bind(config.GetSection(ReconciliationSectionName)); .Bind(config.GetSection(ReconciliationSectionName));
// M6 Bundle E (T8 + T9): central health snapshot — a single object // Central health snapshot — a single object
// that owns the CentralAuditWriteFailures + AuditRedactionFailure // that owns the CentralAuditWriteFailures + AuditRedactionFailure
// Interlocked counters AND surfaces them on // Interlocked counters AND surfaces them on
// IAuditCentralHealthSnapshot. The same instance is bound to BOTH // IAuditCentralHealthSnapshot. The same instance is bound to BOTH
@@ -391,7 +389,7 @@ public static class ServiceCollectionExtensions
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()); sp => sp.GetRequiredService<AuditCentralHealthSnapshot>());
services.Replace(ServiceDescriptor.Singleton<ICentralAuditWriteFailureCounter>( services.Replace(ServiceDescriptor.Singleton<ICentralAuditWriteFailureCounter>(
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>())); sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
// M6 Bundle E (T9): override the NoOp IAuditRedactionFailureCounter // Override the NoOp IAuditRedactionFailureCounter
// (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter // (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter
// bridge so payload-filter throws on CentralAuditWriter / // bridge so payload-filter throws on CentralAuditWriter /
// AuditLogIngestActor paths surface on the central dashboard. The // AuditLogIngestActor paths surface on the central dashboard. The
@@ -400,11 +398,11 @@ public static class ServiceCollectionExtensions
// counter as CentralAuditWriteFailures. The site composition root // counter as CentralAuditWriteFailures. The site composition root
// overrides this binding AGAIN via AddAuditLogHealthMetricsBridge — // overrides this binding AGAIN via AddAuditLogHealthMetricsBridge —
// central nodes do not call that bridge, so this is the final // central nodes do not call that bridge, so this is the final
// binding on a central host. Mirrors the M5 Bundle C // binding on a central host. Mirrors the
// HealthMetricsAuditRedactionFailureCounter shape one-for-one. // HealthMetricsAuditRedactionFailureCounter shape one-for-one.
services.Replace(ServiceDescriptor.Singleton<IAuditRedactionFailureCounter, services.Replace(ServiceDescriptor.Singleton<IAuditRedactionFailureCounter,
CentralAuditRedactionFailureCounter>()); CentralAuditRedactionFailureCounter>());
// M5.3 (T7): replace the NoOp IAuditInboundCeilingHitsCounter with the // Replace the NoOp IAuditInboundCeilingHitsCounter with the
// AuditCentralHealthSnapshot so ceiling-hit counts surface on the // AuditCentralHealthSnapshot so ceiling-hit counts surface on the
// central dashboard. Same singleton-forward pattern as // central dashboard. Same singleton-forward pattern as
// ICentralAuditWriteFailureCounter above. // ICentralAuditWriteFailureCounter above.
@@ -415,7 +413,7 @@ public static class ServiceCollectionExtensions
} }
/// <summary> /// <summary>
/// Audit Log (#23) M6 — central-only registration of the production /// Audit Log — central-only registration of the production
/// <see cref="IPullAuditEventsClient"/> (<see cref="GrpcPullAuditEventsClient"/>) /// <see cref="IPullAuditEventsClient"/> (<see cref="GrpcPullAuditEventsClient"/>)
/// and its unary-call invoker (<see cref="GrpcPullAuditEventsInvoker"/>) used /// and its unary-call invoker (<see cref="GrpcPullAuditEventsInvoker"/>) used
/// by <see cref="SiteAuditReconciliationActor"/> to pull reconciliation /// by <see cref="SiteAuditReconciliationActor"/> to pull reconciliation
@@ -500,7 +498,7 @@ public static class ServiceCollectionExtensions
sp.GetRequiredService<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(), sp.GetRequiredService<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(),
sp.GetRequiredService<ILogger<GrpcPullAuditEventsClient>>())); sp.GetRequiredService<ILogger<GrpcPullAuditEventsClient>>()));
// Site Call Audit (#22) reconciliation pull client — central-only, the // Site Call Audit reconciliation pull client — central-only, the
// sibling of the audit pull client above. Lives here (not in the // sibling of the audit pull client above. Lives here (not in the
// SiteCallAudit project) so it can reuse the central-only // SiteCallAudit project) so it can reuse the central-only
// ISiteEnumerator registered just above; SiteCallAudit does not // ISiteEnumerator registered just above; SiteCallAudit does not
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <para> /// <para>
/// Each primary failure increments <see cref="IAuditWriteFailureCounter"/> so /// Each primary failure increments <see cref="IAuditWriteFailureCounter"/> so
/// Site Health Monitoring can surface a sustained outage as /// Site Health Monitoring can surface a sustained outage as
/// <c>SiteAuditWriteFailures</c> (Bundle G). /// <c>SiteAuditWriteFailures</c>.
/// </para> /// </para>
/// <para> /// <para>
/// Errors raised by the ring drain on recovery are logged and silently dropped /// Errors raised by the ring drain on recovery are logged and silently dropped
@@ -36,7 +36,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly SemaphoreSlim _drainGate = new(1, 1); private readonly SemaphoreSlim _drainGate = new(1, 1);
/// <summary> /// <summary>
/// Bundle C (M5-T6) wires the singleton <see cref="IAuditRedactor"/> /// Wires the singleton <see cref="IAuditRedactor"/>
/// here so every event written via the site hot path is truncated + /// here so every event written via the site hot path is truncated +
/// header/body/SQL-param redacted before it hits both the primary SQLite /// header/body/SQL-param redacted before it hits both the primary SQLite
/// writer AND the ring fallback. The parameter is optional (defaults to /// writer AND the ring fallback. The parameter is optional (defaults to
@@ -62,8 +62,8 @@ public sealed class FallbackAuditWriter : IAuditWriter
_ring = ring ?? throw new ArgumentNullException(nameof(ring)); _ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter)); _failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to a null redactor — over-redact instead. // Never default to a null redactor — over-redact instead.
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam. // Wired via the canonical IAuditRedactor seam.
// SafeDefaultAuditRedactor performs HTTP header redaction with the // SafeDefaultAuditRedactor performs HTTP header redaction with the
// hard-coded sensitive defaults (Authorization, X-Api-Key, Cookie, // hard-coded sensitive defaults (Authorization, X-Api-Key, Cookie,
// Set-Cookie) on the DetailsJson summaries so a test composition root // Set-Cookie) on the DetailsJson summaries so a test composition root
@@ -82,7 +82,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
// and (on failure) to the ring buffer — so a primary outage that // and (on failure) to the ring buffer — so a primary outage that
// drains later still hands the SqliteAuditWriter a row that has // drains later still hands the SqliteAuditWriter a row that has
// already been truncated and redacted. The redactor contract is // already been truncated and redacted. The redactor contract is
// "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults // "MUST NOT throw". _redactor is now non-null (defaults
// to SafeDefaultAuditRedactor so header redaction is always applied // to SafeDefaultAuditRedactor so header redaction is always applied
// even in composition roots that don't wire the real redactor). // even in composition roots that don't wire the real redactor).
var filtered = _redactor.Apply(evt); var filtered = _redactor.Apply(evt);
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary> /// <summary>
/// Audit Log (#23) M5 Bundle C — bridges /// Bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by /// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// a header / body / SQL parameter redactor stage throws and the redactor has /// a header / body / SQL parameter redactor stage throws and the redactor has
@@ -22,16 +22,15 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// redaction failures must NEVER abort the user-facing action, alog.md §7). /// redaction failures must NEVER abort the user-facing action, alog.md §7).
/// </para> /// </para>
/// <para> /// <para>
/// Mirrors the M2 Bundle G <see cref="HealthMetricsAuditWriteFailureCounter"/> /// Mirrors the <see cref="HealthMetricsAuditWriteFailureCounter"/>
/// shape one-for-one so the two health-metric bridges age together. /// shape one-for-one so the two health-metric bridges age together.
/// </para> /// </para>
/// <para> /// <para>
/// Site-side only for M5: the redaction filter also runs on the central /// Site-side only: the redaction filter also runs on the central
/// writers (CentralAuditWriter + AuditLogIngestActor), but the central /// writers (CentralAuditWriter + AuditLogIngestActor); the central
/// health-metric surface that would expose <c>AuditRedactionFailure</c> /// health-metric surface exposing <c>AuditRedactionFailure</c> is bridged
/// alongside the existing central counters ships in M6. Until then, the /// separately by <c>CentralAuditRedactionFailureCounter</c>, which the
/// central composition root keeps the NoOp default — the redactions still /// central composition root registers in place of the NoOp default.
/// happen, they just don't get counted into a health report.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class HealthMetricsAuditRedactionFailureCounter : IAuditRedactionFailureCounter public sealed class HealthMetricsAuditRedactionFailureCounter : IAuditRedactionFailureCounter
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary> /// <summary>
/// Audit Log (#23) M2 Bundle G — bridges <see cref="IAuditWriteFailureCounter"/> /// Bridges <see cref="IAuditWriteFailureCounter"/>
/// (incremented by <see cref="FallbackAuditWriter"/> every time the primary /// (incremented by <see cref="FallbackAuditWriter"/> every time the primary
/// SQLite writer throws) into <see cref="ISiteHealthCollector"/> so the count /// SQLite writer throws) into <see cref="ISiteHealthCollector"/> so the count
/// surfaces in the site health report payload as /// surfaces in the site health report payload as
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary> /// <summary>
/// Lightweight counter sink invoked by <see cref="FallbackAuditWriter"/> every /// Lightweight counter sink invoked by <see cref="FallbackAuditWriter"/> every
/// time the primary <see cref="SqliteAuditWriter"/> throws on an audit write. /// time the primary <see cref="SqliteAuditWriter"/> throws on an audit write.
/// Bundle G (M2-T11) implements this as a thread-safe Interlocked counter /// Implements this as a thread-safe Interlocked counter
/// bridged into the Site Health Monitoring report payload as /// bridged into the Site Health Monitoring report payload as
/// <c>SiteAuditWriteFailures</c>. /// <c>SiteAuditWriteFailures</c>.
/// </summary> /// </summary>
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary> /// <summary>
/// Default <see cref="IAuditWriteFailureCounter"/> registered by /// Default <see cref="IAuditWriteFailureCounter"/> registered by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.ServiceCollectionExtensions.AddAuditLog"/> on /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.ServiceCollectionExtensions.AddAuditLog"/> on
/// every node. Bundle G replaces this binding with a real counter that bridges /// every node. This binding will be replaced with a real counter that bridges
/// into the Site Health Monitoring report payload as /// into the Site Health Monitoring report payload as
/// <c>SiteAuditWriteFailures</c> — until then, /// <c>SiteAuditWriteFailures</c> — until then,
/// <see cref="FallbackAuditWriter"/> emits to a silent sink rather than NRE-ing /// <see cref="FallbackAuditWriter"/> emits to a silent sink rather than NRE-ing
@@ -19,7 +19,7 @@ public sealed class NoOpAuditWriteFailureCounter : IAuditWriteFailureCounter
/// <inheritdoc/> /// <inheritdoc/>
public void Increment() public void Increment()
{ {
// Intentionally empty. Bundle G overrides this binding with the real // Intentionally empty. This binding is replaced with the real
// counter once Site Health Monitoring is wired. // counter once Site Health Monitoring is wired.
} }
} }
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// count at capacity, exactly one event has been dropped. /// count at capacity, exactly one event has been dropped.
/// </para> /// </para>
/// <para> /// <para>
/// Per the M2 plan: the ring is the absolute-last-resort buffer for the /// The ring is the absolute-last-resort buffer for the
/// hot-path; it is NOT a substitute for the bounded /// hot-path; it is NOT a substitute for the bounded
/// <see cref="SqliteAuditWriter"/> write queue. /// <see cref="SqliteAuditWriter"/> write queue.
/// </para> /// </para>
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary> /// <summary>
/// Audit Log (#23) M6 Bundle E (T6) — site-side hosted service that /// Site-side hosted service that
/// periodically pulls a backlog snapshot from <see cref="ISiteAuditQueue"/> /// periodically pulls a backlog snapshot from <see cref="ISiteAuditQueue"/>
/// and pushes it into <see cref="ISiteHealthCollector"/> so the next /// and pushes it into <see cref="ISiteHealthCollector"/> so the next
/// <see cref="ISiteHealthCollector.CollectReport"/> emits a fresh /// <see cref="ISiteHealthCollector.CollectReport"/> emits a fresh
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// across many reports, fine enough that the central dashboard never lags by /// across many reports, fine enough that the central dashboard never lags by
/// more than one health-report interval. Tunable via /// more than one health-report interval. Tunable via
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.SqliteAuditWriterOptions"/> in a follow-up /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.SqliteAuditWriterOptions"/> in a follow-up
/// if ops needs a different cadence; for M6 we hard-code the value because the /// if ops needs a different cadence; for now we hard-code the value because the
/// brief calls it out explicitly. /// brief calls it out explicitly.
/// </para> /// </para>
/// <para> /// <para>
@@ -12,7 +12,7 @@ using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary> /// <summary>
/// Site-side SQLite hot-path writer for Audit Log (#23) events. Mirrors the /// Site-side SQLite hot-path writer for Audit Log events. Mirrors the
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogger"/> design — a single /// <see cref="ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogger"/> design — a single
/// owned <see cref="SqliteConnection"/> serialised behind a write lock, fed by a /// owned <see cref="SqliteConnection"/> serialised behind a write lock, fed by a
/// bounded <see cref="Channel{T}"/> drained on a dedicated background writer /// bounded <see cref="Channel{T}"/> drained on a dedicated background writer
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>C4 (Task 2.5) — two-table schema.</b> The site store is now two tables: /// <b>Two-table schema.</b> The site store is now two tables:
/// the append-only canonical <c>audit_event</c> (the 10 canonical /// the append-only canonical <c>audit_event</c> (the 10 canonical
/// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and /// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
/// the mutable operational <c>audit_forward_state</c> sidecar that carries the /// the mutable operational <c>audit_forward_state</c> sidecar that carries the
@@ -31,10 +31,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </para> /// </para>
/// <para> /// <para>
/// <b>Ephemeral reset.</b> The site SQLite store is ephemeral (≈7-day retention, /// <b>Ephemeral reset.</b> The site SQLite store is ephemeral (≈7-day retention,
/// recreated per deployment), so C4's schema change is an in-place RESET: the new /// recreated per deployment), so this schema change is an in-place RESET: the new
/// tables are created and the old single 24-column <c>AuditLog</c> table is /// tables are created and the old single 24-column <c>AuditLog</c> table is
/// DROP-ped if present. No SQLite data migration is performed (and none is /// DROP-ped if present. No SQLite data migration is performed (and none is
/// needed) — any rows in a pre-C4 <c>AuditLog</c> table are within the retention /// needed) — any rows in the old <c>AuditLog</c> table are within the retention
/// window and are discarded by the drop. /// window and are discarded by the drop.
/// </para> /// </para>
/// <para> /// <para>
@@ -56,7 +56,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private const int SqliteErrorConstraint = 19; private const int SqliteErrorConstraint = 19;
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
// AuditLog-005: dedicated read-only connection used by GetBacklogStatsAsync, // Dedicated read-only connection used by GetBacklogStatsAsync,
// ReadPendingAsync, ReadPendingSinceAsync, and ReadForwardedAsync so a slow // ReadPendingAsync, ReadPendingSinceAsync, and ReadForwardedAsync so a slow
// backlog scan (COUNT(*) over hundreds of thousands of Pending rows under a // backlog scan (COUNT(*) over hundreds of thousands of Pending rows under a
// central outage) never parks the hot-path writer behind _writeLock. // central outage) never parks the hot-path writer behind _writeLock.
@@ -102,7 +102,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
InitializeSchema(); InitializeSchema();
// AuditLog-005: open a second connection for read-only callers // Open a second connection for read-only callers
// (GetBacklogStatsAsync, ReadPendingAsync, ReadPendingSinceAsync, // (GetBacklogStatsAsync, ReadPendingAsync, ReadPendingSinceAsync,
// ReadForwardedAsync). InitializeSchema set journal_mode=WAL on the // ReadForwardedAsync). InitializeSchema set journal_mode=WAL on the
// writer connection, which is a database-level setting that persists // writer connection, which is a database-level setting that persists
@@ -128,7 +128,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
new BoundedChannelOptions(_options.ChannelCapacity) new BoundedChannelOptions(_options.ChannelCapacity)
{ {
// The hot-path enqueue must back-pressure if the background // The hot-path enqueue must back-pressure if the background
// writer falls behind; a higher-level fallback (Bundle B-T4) // writer falls behind; a higher-level fallback
// handles truly catastrophic primary failure with a drop-oldest // handles truly catastrophic primary failure with a drop-oldest
// ring buffer. // ring buffer.
FullMode = BoundedChannelFullMode.Wait, FullMode = BoundedChannelFullMode.Wait,
@@ -150,7 +150,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery(); pragmaCmd.ExecuteNonQuery();
} }
// AuditLog-005: enable WAL so a second connection on the same file can // Enable WAL so a second connection on the same file can
// serve read-only callers (GetBacklogStatsAsync, ReadPendingAsync, // serve read-only callers (GetBacklogStatsAsync, ReadPendingAsync,
// ReadPendingSinceAsync, ReadForwardedAsync) concurrently with the // ReadPendingSinceAsync, ReadForwardedAsync) concurrently with the
// batched writer, decoupling those reads from _writeLock. WAL is a // batched writer, decoupling those reads from _writeLock. WAL is a
@@ -185,14 +185,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery(); pragmaCmd.ExecuteNonQuery();
} }
// C4 (Task 2.5) — in-place reset. The site store is EPHEMERAL (≈7-day // In-place reset. The site store is EPHEMERAL (≈7-day
// retention, recreated per deployment), so we do NOT migrate the old // retention, recreated per deployment), so we do NOT migrate the old
// single 24-column AuditLog table to the new two-table shape: any rows // single 24-column AuditLog table to the new two-table shape: any rows
// it holds are within the retention window and discarded. DROP it if a // it holds are within the retention window and discarded. DROP it if an
// pre-C4 deployment left it behind, then CREATE the two new tables. This // earlier deployment left it behind, then CREATE the two new tables. This
// is safe precisely BECAUSE the site store is ephemeral — never do this // is safe precisely BECAUSE the site store is ephemeral — never do this
// on a durable store (the central SQL Server side keeps its shim until // on a durable store (the central SQL Server side keeps its shim until
// C5 and is migrated, not reset). // it is migrated, not reset).
using (var dropCmd = _connection.CreateCommand()) using (var dropCmd = _connection.CreateCommand())
{ {
dropCmd.CommandText = "DROP TABLE IF EXISTS AuditLog;"; dropCmd.CommandText = "DROP TABLE IF EXISTS AuditLog;";
@@ -442,10 +442,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
} }
// AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry // Cached-lifecycle audit kinds that ride the combined-telemetry
// drain (joined with the operational tracking row + pushed via // drain (joined with the operational tracking row + pushed via
// IngestCachedTelemetryAsync into the central dual-write transaction). // IngestCachedTelemetryAsync into the central dual-write transaction).
// C4: this is the SAME set the pre-C4 ReadPendingCachedTelemetryAsync query // This is the SAME set the earlier ReadPendingCachedTelemetryAsync query
// filtered on (Kind IN (...)); it is now precomputed into the sidecar's // filtered on (Kind IN (...)); it is now precomputed into the sidecar's
// IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap // IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
// integer predicate, not a JSON parse. ReadPendingAsync drains everything // integer predicate, not a JSON parse. ReadPendingAsync drains everything
@@ -459,7 +459,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}; };
/// <summary> /// <summary>
/// C4: precomputes the sidecar's <c>IsCachedKind</c> flag from a canonical /// Precomputes the sidecar's <c>IsCachedKind</c> flag from a canonical
/// row's <c>DetailsJson</c>. Parses the <see cref="AuditDetails.Kind"/> /// row's <c>DetailsJson</c>. Parses the <see cref="AuditDetails.Kind"/>
/// discriminator via <see cref="AuditDetailsCodec"/> and returns <c>true</c> /// discriminator via <see cref="AuditDetailsCodec"/> and returns <c>true</c>
/// iff it is one of the cached-lifecycle kinds /// iff it is one of the cached-lifecycle kinds
@@ -489,14 +489,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0."); throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
} }
// AuditLog-005: read via the dedicated _readConnection so this scan // Read via the dedicated _readConnection so this scan
// (which can be expensive when the backlog grows under a central // (which can be expensive when the backlog grows under a central
// outage) does not block the batched writer on _writeLock. WAL mode // outage) does not block the batched writer on _writeLock. WAL mode
// gives us a stable snapshot of the tables while writes proceed on the // gives us a stable snapshot of the tables while writes proceed on the
// writer connection. _readLock serialises this connection across // writer connection. _readLock serialises this connection across
// multiple concurrent read callers since SqliteConnection itself is // multiple concurrent read callers since SqliteConnection itself is
// not thread-safe. // not thread-safe.
// C4: JOIN the sidecar and filter on IsCachedKind=0 — the cached- // JOIN the sidecar and filter on IsCachedKind=0 — the cached-
// lifecycle kinds (IsCachedKind=1) flow through // lifecycle kinds (IsCachedKind=1) flow through
// ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The // ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
// split is a precomputed integer predicate on the indexed sidecar, not // split is a precomputed integer predicate on the indexed sidecar, not
@@ -539,7 +539,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0."); throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
} }
// AuditLog-001 / C4: dedicated read surface for the cached-call lifecycle // Dedicated read surface for the cached-call lifecycle
// drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1. // drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
// Same _readConnection + _readLock pattern so the hot-path writer is not // Same _readConnection + _readLock pattern so the hot-path writer is not
// contended. // contended.
@@ -586,9 +586,9 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0."); throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
} }
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection / // Mirror ReadPendingAsync — read via _readConnection /
// _readLock so this query never contends with the batched writer on // _readLock so this query never contends with the batched writer on
// _writeLock. C4: JOIN the sidecar and filter on ForwardState='Forwarded' // _writeLock. JOIN the sidecar and filter on ForwardState='Forwarded'
// (no IsCachedKind split — both cached and non-cached Forwarded rows are // (no IsCachedKind split — both cached and non-cached Forwarded rows are
// returned, as before). // returned, as before).
lock (_readLock) lock (_readLock)
@@ -626,7 +626,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand(); using var cmd = _connection.CreateCommand();
// C4: flip the sidecar — UPDATE audit_forward_state, not the canonical // Flip the sidecar — UPDATE audit_forward_state, not the canonical
// audit_event (which is append-only / write-once). Bump AttemptCount + // audit_event (which is append-only / write-once). Bump AttemptCount +
// stamp LastAttemptUtc so operators can see how many drain passes a row // stamp LastAttemptUtc so operators can see how many drain passes a row
// took to forward. Build a single IN (...) parameter list so we issue // took to forward. Build a single IN (...) parameter list so we issue
@@ -667,7 +667,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary> /// <summary>
/// Returns up to <paramref name="batchSize"/> pending or forwarded audit events /// Returns up to <paramref name="batchSize"/> pending or forwarded audit events
/// with <see cref="AuditEvent.OccurredAtUtc"/> &gt;= <paramref name="sinceUtc"/>, oldest first. /// with <see cref="AuditEvent.OccurredAtUtc"/> &gt;= <paramref name="sinceUtc"/>, oldest first.
/// Used by the M6 reconciliation-pull handler. /// Used by the reconciliation-pull handler.
/// </summary> /// </summary>
/// <param name="sinceUtc">Lower bound timestamp (UTC) for event occurrence.</param> /// <param name="sinceUtc">Lower bound timestamp (UTC) for event occurrence.</param>
/// <param name="batchSize">Maximum number of rows to return.</param> /// <param name="batchSize">Maximum number of rows to return.</param>
@@ -681,8 +681,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(batchSize), "batchSize must be > 0."); throw new ArgumentOutOfRangeException(nameof(batchSize), "batchSize must be > 0.");
} }
// AuditLog-005: read via _readConnection / _readLock — same lock- // Read via _readConnection / _readLock — same lock-
// decoupling as ReadPendingAsync. C4: JOIN the sidecar; the range scan // decoupling as ReadPendingAsync. JOIN the sidecar; the range scan
// is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd. // is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd.
// Both Pending and Forwarded rows are returned (the central reconciliation // Both Pending and Forwarded rows are returned (the central reconciliation
// puller dedups on EventId; re-shipping a Forwarded-but-not-yet-ingested // puller dedups on EventId; re-shipping a Forwarded-but-not-yet-ingested
@@ -729,7 +729,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand(); using var cmd = _connection.CreateCommand();
// C4: flip the sidecar from Pending/Forwarded → Reconciled. Rows // Flip the sidecar from Pending/Forwarded → Reconciled. Rows
// already Reconciled are left untouched (idempotent re-call), and the // already Reconciled are left untouched (idempotent re-call), and the
// canonical audit_event row is never modified. // canonical audit_event row is never modified.
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
@@ -759,13 +759,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
int pendingCount; int pendingCount;
DateTime? oldestPending; DateTime? oldestPending;
// AuditLog-005: read via the dedicated _readConnection (under // Read via the dedicated _readConnection (under
// _readLock) so this probe — polled every 30 s by SiteAuditBacklogReporter // _readLock) so this probe — polled every 30 s by SiteAuditBacklogReporter
// — never blocks the batched hot-path writer on _writeLock. Under a // — never blocks the batched hot-path writer on _writeLock. Under a
// central outage the Pending backlog can grow to hundreds of thousands // central outage the Pending backlog can grow to hundreds of thousands
// of rows and the COUNT(*) scan correspondingly stretches; that no // of rows and the COUNT(*) scan correspondingly stretches; that no
// longer adds tail latency to user-facing audit writes. // longer adds tail latency to user-facing audit writes.
// C4: count over the sidecar (audit_forward_state) — the canonical // Count over the sidecar (audit_forward_state) — the canonical
// audit_event table carries no ForwardState. The IX_fwd index makes both // audit_event table carries no ForwardState. The IX_fwd index makes both
// aggregates cheap (count is a covering scan, min is the first key). // aggregates cheap (count is a covering scan, min is the first key).
lock (_readLock) lock (_readLock)
@@ -843,7 +843,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
/// <summary> /// <summary>
/// C4: builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10 /// Builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10
/// stored <c>audit_event</c> columns — no 24-column <c>Recompose</c>, because /// stored <c>audit_event</c> columns — no 24-column <c>Recompose</c>, because
/// <c>audit_event</c> already holds the canonical fields + <c>DetailsJson</c>. /// <c>audit_event</c> already holds the canonical fields + <c>DetailsJson</c>.
/// <c>Outcome</c> is stored as the enum's name; the safe /// <c>Outcome</c> is stored as the enum's name; the safe
@@ -875,7 +875,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// Disposes the audit writer and releases resources. /// Disposes the audit writer and releases resources.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// AuditLog-006: prefer <see cref="DisposeAsync"/> when possible (DI honours /// Prefer <see cref="DisposeAsync"/> when possible (DI honours
/// <see cref="IAsyncDisposable"/> on singletons). The sync path remains for /// <see cref="IAsyncDisposable"/> on singletons). The sync path remains for
/// callers that only know about <see cref="IDisposable"/> (e.g. legacy /// callers that only know about <see cref="IDisposable"/> (e.g. legacy
/// composition roots, <c>using</c> statements without <c>await</c>). To /// composition roots, <c>using</c> statements without <c>await</c>). To
@@ -938,7 +938,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
_connection.Dispose(); _connection.Dispose();
} }
// AuditLog-005: dispose the dedicated read connection after the writer // Dispose the dedicated read connection after the writer
// is fully drained and closed. _readLock is taken to fence out any // is fully drained and closed. _readLock is taken to fence out any
// in-flight read caller that grabbed the lock before _disposed flipped // in-flight read caller that grabbed the lock before _disposed flipped
// — they observe ObjectDisposedException on the next attempt. // — they observe ObjectDisposedException on the next attempt.
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// <summary> /// <summary>
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt /// Translates per-attempt
/// notifications from the store-and-forward retry loop into one (or two) /// notifications from the store-and-forward retry loop into one (or two)
/// <see cref="CachedCallTelemetry"/> packets and pushes them through /// <see cref="CachedCallTelemetry"/> packets and pushes them through
/// <see cref="ICachedCallTelemetryForwarder"/>. /// <see cref="ICachedCallTelemetryForwarder"/>.
@@ -40,7 +40,7 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
private readonly ILogger<CachedCallLifecycleBridge> _logger; private readonly ILogger<CachedCallLifecycleBridge> _logger;
/// <summary> /// <summary>
/// SourceNode-stamping (Task 14): the local node identity provider used to /// SourceNode-stamping: the local node identity provider used to
/// stamp <c>SiteCallOperational.SourceNode</c> on every cached-call /// stamp <c>SiteCallOperational.SourceNode</c> on every cached-call
/// lifecycle row this bridge emits. Optional — when null (legacy hosts / /// lifecycle row this bridge emits. Optional — when null (legacy hosts /
/// tests that don't register the provider) SourceNode stays null and /// tests that don't register the provider) SourceNode stays null and
@@ -93,8 +93,8 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
// Per-attempt row: kind discriminates channel; status is always // Per-attempt row: kind discriminates channel; status is always
// Attempted regardless of outcome (success vs. failure is captured // Attempted regardless of outcome (success vs. failure is captured
// by the companion HttpStatus / ErrorMessage fields, NOT by flipping // by the companion HttpStatus / ErrorMessage fields, NOT by flipping
// the status — CachedResolve carries the terminal Status). Per the // the status — CachedResolve carries the terminal Status). Per
// M3 brief and alog.md §4. // alog.md §4.
var kind = ChannelToAttemptKind(context.Channel); var kind = ChannelToAttemptKind(context.Channel);
var status = AuditStatus.Attempted; var status = AuditStatus.Attempted;
@@ -148,22 +148,22 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc), occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
target: context.Target, target: context.Target,
correlationId: context.TrackedOperationId.Value, correlationId: context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script // The originating script
// execution's per-run correlation id, threaded through the S&F // execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat). // buffer; null on rows buffered before ExecutionId support was added (back-compat).
executionId: context.ExecutionId, executionId: context.ExecutionId,
// Audit Log #23 (ParentExecutionId Task 6): the spawning // The spawning
// inbound-API request's ExecutionId, threaded through the S&F // inbound-API request's ExecutionId, threaded through the S&F
// buffer alongside ExecutionId so the retry-loop cached rows // buffer alongside ExecutionId so the retry-loop cached rows
// correlate back to the cross-execution chain. Null for a // correlate back to the cross-execution chain. Null for a
// non-routed run and on rows buffered before Task 6. // non-routed run and on rows buffered before ParentExecutionId support was added.
parentExecutionId: context.ParentExecutionId, parentExecutionId: context.ParentExecutionId,
sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite, sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
sourceInstanceId: context.SourceInstanceId, sourceInstanceId: context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now // SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the // threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the // retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows. // script-side cached rows do. Null on rows buffered before ExecutionId support was added.
sourceScript: context.SourceScript, sourceScript: context.SourceScript,
httpStatus: httpStatus, httpStatus: httpStatus,
durationMs: context.DurationMs, durationMs: context.DurationMs,
@@ -173,7 +173,7 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
Channel: context.Channel, Channel: context.Channel,
Target: context.Target, Target: context.Target,
SourceSite: context.SourceSite, SourceSite: context.SourceSite,
// SourceNode-stamping (Task 14): the local cluster node name // SourceNode-stamping: the local cluster node name
// (node-a/node-b on a site). Stamped from the injected // (node-a/node-b on a site). Stamped from the injected
// INodeIdentityProvider; null when no provider was wired so // INodeIdentityProvider; null when no provider was wired so
// central persists SiteCalls.SourceNode as NULL. // central persists SiteCalls.SourceNode as NULL.
@@ -9,15 +9,15 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// <summary> /// <summary>
/// Site-side dual emitter for cached-call lifecycle telemetry (Audit Log #23 / /// Site-side dual emitter for cached-call lifecycle telemetry. Sister to
/// M3). Sister to <see cref="SiteAuditTelemetryActor"/>: where the M2 actor /// <see cref="SiteAuditTelemetryActor"/>: where that actor
/// drains audit-only events, this forwarder takes a combined /// drains audit-only events, this forwarder takes a combined
/// <see cref="CachedCallTelemetry"/> packet and fans it out to the two /// <see cref="CachedCallTelemetry"/> packet and fans it out to the two
/// site-local stores in a single call: /// site-local stores in a single call:
/// <list type="bullet"> /// <list type="bullet">
/// <item><description>The <see cref="AuditEvent"/> row is written via /// <item><description>The <see cref="AuditEvent"/> row is written via
/// <see cref="IAuditWriter"/> (the site <c>FallbackAuditWriter</c> + /// <see cref="IAuditWriter"/> (the site <c>FallbackAuditWriter</c> +
/// <c>SqliteAuditWriter</c> chain established in M2).</description></item> /// <c>SqliteAuditWriter</c> chain).</description></item>
/// <item><description>The operational <see cref="SiteCallOperational"/> half /// <item><description>The operational <see cref="SiteCallOperational"/> half
/// updates the site-local <c>OperationTracking</c> SQLite store via /// updates the site-local <c>OperationTracking</c> SQLite store via
/// <see cref="IOperationTrackingStore"/>, with the per-lifecycle method /// <see cref="IOperationTrackingStore"/>, with the per-lifecycle method
@@ -54,7 +54,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
private readonly ILogger<CachedCallTelemetryForwarder> _logger; private readonly ILogger<CachedCallTelemetryForwarder> _logger;
/// <summary> /// <summary>
/// SourceNode-stamping (Task 14): local node identity provider used to /// SourceNode-stamping: local node identity provider used to
/// stamp the tracking-store row's <c>SourceNode</c> column on /// stamp the tracking-store row's <c>SourceNode</c> column on
/// <c>RecordEnqueueAsync</c>. Optional — when null (legacy / test hosts) /// <c>RecordEnqueueAsync</c>. Optional — when null (legacy / test hosts)
/// the column stays NULL on the tracking row. /// the column stays NULL on the tracking row.
@@ -64,11 +64,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
/// <summary> /// <summary>
/// Construct the forwarder. <paramref name="trackingStore"/> is optional — /// Construct the forwarder. <paramref name="trackingStore"/> is optional —
/// when null only the audit half of the packet is emitted, which matches /// when null only the audit half of the packet is emitted, which matches
/// the M3 Bundle F composition-root contract on Central nodes: the /// the composition-root contract on Central nodes: the
/// AuditLog DI surface registers the forwarder unconditionally (mirroring /// AuditLog DI surface registers the forwarder unconditionally (mirroring
/// the IAuditWriter chain) but the site-only tracking store has no central /// the IAuditWriter chain) but the site-only tracking store has no central
/// registration. Production site nodes wire both — the central lazy /// registration. Production site nodes wire both — the central lazy
/// resolution is a no-op path kept symmetric with the M2 writer chain. /// resolution is a no-op path kept symmetric with the writer chain.
/// </summary> /// </summary>
/// <param name="auditWriter">Writer used to persist audit events from the telemetry packet.</param> /// <param name="auditWriter">Writer used to persist audit events from the telemetry packet.</param>
/// <param name="trackingStore">Optional store for updating operation tracking state; null on central nodes.</param> /// <param name="trackingStore">Optional store for updating operation tracking state; null on central nodes.</param>
@@ -111,7 +111,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// FallbackAuditWriter) handles transient writer failures upstream; // FallbackAuditWriter) handles transient writer failures upstream;
// a throw bubbling up here means the writer's own swallow contract // a throw bubbling up here means the writer's own swallow contract
// failed, which is itself best-effort-handled. // failed, which is itself best-effort-handled.
// C3: Kind/Status are domain fields carried in DetailsJson — decompose to log them. // Kind/Status are domain fields carried in DetailsJson — decompose to log them.
var d = AuditRowProjection.Decompose(telemetry.Audit); var d = AuditRowProjection.Decompose(telemetry.Audit);
_logger.LogWarning(ex, _logger.LogWarning(ex,
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})", "CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
@@ -130,7 +130,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
return; return;
} }
// C3: the audit half's domain fields (Kind/SourceInstanceId/SourceScript) // The audit half's domain fields (Kind/SourceInstanceId/SourceScript)
// ride inside DetailsJson — decompose once for this packet. // ride inside DetailsJson — decompose once for this packet.
var audit = AuditRowProjection.Decompose(telemetry.Audit); var audit = AuditRowProjection.Decompose(telemetry.Audit);
try try
@@ -141,7 +141,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// Enqueue — insert-if-not-exists with the operational // Enqueue — insert-if-not-exists with the operational
// channel as the kind discriminator. RetryCount is fixed // channel as the kind discriminator. RetryCount is fixed
// at 0 by the tracking store's INSERT contract. // at 0 by the tracking store's INSERT contract.
// SourceNode-stamping (Task 14): stamp the local node // SourceNode-stamping: stamp the local node
// name (node-a/node-b) from the injected // name (node-a/node-b) from the injected
// INodeIdentityProvider; null when no provider was wired // INodeIdentityProvider; null when no provider was wired
// so the tracking row's SourceNode column stays NULL. // so the tracking row's SourceNode column stays NULL.
@@ -24,8 +24,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// The <see cref="SiteAuditTelemetryActor"/> drain loop treats a thrown /// The <see cref="SiteAuditTelemetryActor"/> drain loop treats a thrown
/// exception as transient and leaves the rows <c>Pending</c> for the next tick. /// exception as transient and leaves the rows <c>Pending</c> for the next tick.
/// Swallowing the fault into an empty ack would be indistinguishable from "zero /// Swallowing the fault into an empty ack would be indistinguishable from "zero
/// rows accepted" and would silently lose the retry signal. Task 1 confirmed /// rows accepted" and would silently lose the retry signal. It has been confirmed
/// the central receiving end does not collapse an ingest fault into an empty /// that the central receiving end does not collapse an ingest fault into an empty
/// ack either, so a site-side Ask through the whole path faults cleanly on a /// ack either, so a site-side Ask through the whole path faults cleanly on a
/// central-side timeout. /// central-side timeout.
/// </para> /// </para>
@@ -26,7 +26,7 @@ public interface ISiteStreamAuditClient
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct); Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
/// <summary> /// <summary>
/// Forwards the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23) /// Forwards the combined <see cref="CachedTelemetryBatch"/>
/// to the central cached-telemetry ingest path. Each packet carries both the /// to the central cached-telemetry ingest path. Each packet carries both the
/// audit row and the operational <c>SiteCalls</c> upsert; central writes both /// audit row and the operational <c>SiteCalls</c> upsert; central writes both
/// in a single MS SQL transaction. Returns the same <see cref="IngestAck"/> /// in a single MS SQL transaction. Returns the same <see cref="IngestAck"/>
@@ -45,7 +45,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// which case the cached-drain scheduler is never armed. /// which case the cached-drain scheduler is never armed.
/// </para> /// </para>
/// <para> /// <para>
/// Per Bundle D's brief, audit-write paths must be fail-safe — a thrown /// Audit-write paths must be fail-safe — a thrown
/// exception inside the actor MUST NOT crash it. Both Drain handlers wrap /// exception inside the actor MUST NOT crash it. Both Drain handlers wrap
/// their pipelines in a top-level try/catch that logs and re-schedules; the /// their pipelines in a top-level try/catch that logs and re-schedules; the
/// actor's <see cref="SupervisorStrategy"/> defaults to /// actor's <see cref="SupervisorStrategy"/> defaults to
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// matters. /// matters.
/// </para> /// </para>
/// <para> /// <para>
/// AuditLog-001: wires the previously-unreachable combined-telemetry transport. /// Wires the previously-unreachable combined-telemetry transport.
/// Prior to this the cached audit rows flowed through the audit-only drain via /// Prior to this the cached audit rows flowed through the audit-only drain via
/// <c>IngestAuditEventsAsync</c> and the central <c>OnCachedTelemetryAsync</c> /// <c>IngestAuditEventsAsync</c> and the central <c>OnCachedTelemetryAsync</c>
/// dual-write handler was dead production code; the operational <c>SiteCalls</c> /// dual-write handler was dead production code; the operational <c>SiteCalls</c>
@@ -70,7 +70,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
private readonly ILogger<SiteAuditTelemetryActor> _logger; private readonly ILogger<SiteAuditTelemetryActor> _logger;
private ICancelable? _pendingTick; private ICancelable? _pendingTick;
private ICancelable? _pendingCachedTick; private ICancelable? _pendingCachedTick;
// AuditLog-010: per-actor lifecycle CTS so an in-flight drain (queue read, // Per-actor lifecycle CTS so an in-flight drain (queue read,
// gRPC push, mark-forwarded write) is actually cancelled when the actor is // gRPC push, mark-forwarded write) is actually cancelled when the actor is
// stopped — without it, a stuck IngestAuditEventsAsync would hold the // stopped — without it, a stuck IngestAuditEventsAsync would hold the
// continuation through CoordinatedShutdown's actor-system terminate window. // continuation through CoordinatedShutdown's actor-system terminate window.
@@ -132,7 +132,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
{ {
_pendingTick?.Cancel(); _pendingTick?.Cancel();
_pendingCachedTick?.Cancel(); _pendingCachedTick?.Cancel();
// AuditLog-010: cancel any in-flight drain so a stuck queue read or // Cancel any in-flight drain so a stuck queue read or
// gRPC push does not hold the continuation past actor stop. // gRPC push does not hold the continuation past actor stop.
try try
{ {
@@ -149,7 +149,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
private async Task OnDrainAsync() private async Task OnDrainAsync()
{ {
var nextDelay = TimeSpan.FromSeconds(_options.BusyIntervalSeconds); var nextDelay = TimeSpan.FromSeconds(_options.BusyIntervalSeconds);
// AuditLog-010: route every async dependency call through the // Route every async dependency call through the
// per-actor lifecycle token so PostStop cancellation actually // per-actor lifecycle token so PostStop cancellation actually
// propagates into the queue read, the gRPC push, and the // propagates into the queue read, the gRPC push, and the
// mark-forwarded write. OperationCanceledException is swallowed by // mark-forwarded write. OperationCanceledException is swallowed by
@@ -178,7 +178,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
catch (Exception ex) catch (Exception ex)
{ {
// gRPC fault — leave the rows in Pending so the next drain // gRPC fault — leave the rows in Pending so the next drain
// retries. Bundle D's brief: "On gRPC exception (any), log // retries "On gRPC exception (any), log
// Warning, schedule next Drain in BusyIntervalSeconds." // Warning, schedule next Drain in BusyIntervalSeconds."
_logger.LogWarning(ex, _logger.LogWarning(ex,
"IngestAuditEvents push failed for {Count} pending events; will retry next drain.", "IngestAuditEvents push failed for {Count} pending events; will retry next drain.",
@@ -201,7 +201,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
} }
finally finally
{ {
// AuditLog-010: if the actor is already shutting down, do not // If the actor is already shutting down, do not
// arm another tick — the scheduler would fire after PostStop and // arm another tick — the scheduler would fire after PostStop and
// the message would land in dead letters. // the message would land in dead letters.
if (!_lifecycleCts.IsCancellationRequested) if (!_lifecycleCts.IsCancellationRequested)
@@ -212,7 +212,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
} }
/// <summary> /// <summary>
/// AuditLog-001: combined-telemetry drain. Reads cached-lifecycle audit /// Combined-telemetry drain. Reads cached-lifecycle audit
/// rows, joins each with the matching <see cref="IOperationTrackingStore"/> /// rows, joins each with the matching <see cref="IOperationTrackingStore"/>
/// snapshot, builds a <see cref="CachedTelemetryBatch"/>, and pushes via /// snapshot, builds a <see cref="CachedTelemetryBatch"/>, and pushes via
/// <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/>. Rows /// <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/>. Rows
@@ -354,7 +354,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
} }
/// <summary> /// <summary>
/// AuditLog-001: build the combined wire packet from one cached audit row /// Build the combined wire packet from one cached audit row
/// + its matching operational tracking snapshot. The operational state /// + its matching operational tracking snapshot. The operational state
/// reflects the latest tracking row at emission time (not the per-event /// reflects the latest tracking row at emission time (not the per-event
/// status the audit row implies) because central's <c>SiteCalls</c> /// status the audit row implies) because central's <c>SiteCalls</c>
@@ -364,7 +364,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
private static CachedTelemetryPacket BuildCachedPacket( private static CachedTelemetryPacket BuildCachedPacket(
AuditEvent auditRow, TrackingStatusSnapshot snapshot) AuditEvent auditRow, TrackingStatusSnapshot snapshot)
{ {
// C3: SourceSiteId + Channel ride inside the canonical record's // SourceSiteId + Channel ride inside the canonical record's
// DetailsJson — decompose to read them. // DetailsJson — decompose to read them.
var audit = AuditRowProjection.Decompose(auditRow); var audit = AuditRowProjection.Decompose(auditRow);
var sourceSite = audit.SourceSiteId ?? string.Empty; var sourceSite = audit.SourceSiteId ?? string.Empty;
@@ -457,7 +457,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
/// <summary> /// <summary>
/// Self-tick message that triggers a combined-telemetry drain cycle. /// Self-tick message that triggers a combined-telemetry drain cycle.
/// AuditLog-001: introduced alongside the cached-drain to keep the two /// Introduced alongside the cached-drain to keep the two
/// paths' cadences independent — a stall on one does not block the other. /// paths' cadences independent — a stall on one does not block the other.
/// </summary> /// </summary>
private sealed class CachedDrain private sealed class CachedDrain
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// <summary> /// <summary>
/// Tuning knobs for the site-side <see cref="SiteAuditTelemetryActor"/> drain /// Tuning knobs for the site-side <see cref="SiteAuditTelemetryActor"/> drain
/// loop. Defaults mirror Bundle D's plan: drain every 5 s while rows are /// loop. Defaults: drain every 5 s while rows are
/// flowing (busy), every 30 s when the queue is empty (idle). /// flowing (busy), every 30 s when the queue is empty (idle).
/// </summary> /// </summary>
public sealed class SiteAuditTelemetryOptions public sealed class SiteAuditTelemetryOptions
@@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- Bundle D D1: SiteAuditTelemetryActor + (D2) AuditLogIngestActor live <!-- SiteAuditTelemetryActor + AuditLogIngestActor live
in this project, so Akka is an explicit dependency. --> in this project, so Akka is an explicit dependency. -->
<PackageReference Include="Akka" /> <PackageReference Include="Akka" />
<PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Data.Sqlite" />
@@ -32,7 +32,7 @@ internal static class AlarmTriggerConfigJson
/// <param name="hiHi">HiLo: high-high setpoint.</param> /// <param name="hiHi">HiLo: high-high setpoint.</param>
/// <param name="expression">Expression: boolean trigger expression.</param> /// <param name="expression">Expression: boolean trigger expression.</param>
/// <param name="analysisKind"> /// <param name="analysisKind">
/// M9-T28b: optional analysis kind for Expression triggers ("strict" → emits /// Optional analysis kind for Expression triggers ("strict" → emits
/// <c>"analysisKind":"Strict"</c>; null/"advisory"/anything else → Advisory default, /// <c>"analysisKind":"Strict"</c>; null/"advisory"/anything else → Advisory default,
/// key omitted). Ignored for non-Expression trigger types. /// key omitted). Ignored for non-Expression trigger types.
/// </param> /// </param>
@@ -86,7 +86,7 @@ internal static class AlarmTriggerConfigJson
break; break;
case "expression": case "expression":
w.WriteString("expression", expression ?? ""); w.WriteString("expression", expression ?? "");
// M9-T28b: emit "analysisKind":"Strict" only when the caller passes // Emit "analysisKind":"Strict" only when the caller passes
// --trigger-kind strict (case-insensitive); Advisory (the default) is // --trigger-kind strict (case-insensitive); Advisory (the default) is
// expressed by omitting the key, matching ValidationService.IsStrictAnalysis. // expressed by omitting the key, matching ValidationService.IsStrictAnalysis.
if (string.Equals(analysisKind?.Trim(), "Strict", StringComparison.OrdinalIgnoreCase)) if (string.Equals(analysisKind?.Trim(), "Strict", StringComparison.OrdinalIgnoreCase))
+1 -1
View File
@@ -40,7 +40,7 @@ public class CliConfig
".scadabridge", "config.json"); ".scadabridge", "config.json");
if (File.Exists(configPath)) if (File.Exists(configPath))
{ {
// CLI-021: a malformed (`JsonException`), unreadable // A malformed (`JsonException`), unreadable
// (`UnauthorizedAccessException`), or otherwise faulted // (`UnauthorizedAccessException`), or otherwise faulted
// (`IOException`) config file must not crash the CLI before any // (`IOException`) config file must not crash the CLI before any
// command runs — even a command that supplies everything via // command runs — even a command that supplies everything via
@@ -27,8 +27,8 @@ public sealed class AuditBackfillSourceNodeArgs
} }
/// <summary> /// <summary>
/// Pure helpers for the <c>audit backfill-source-node</c> subcommand (Audit Log /// Pure helpers for the <c>audit backfill-source-node</c> subcommand. Builds
/// #23 M5.6 T5). Builds the request body, POSTs to /// the request body, POSTs to
/// <c>/api/audit/backfill-source-node</c>, and renders the result. Kept separate /// <c>/api/audit/backfill-source-node</c>, and renders the result. Kept separate
/// from the command wiring so each piece is unit-testable without standing up the /// from the command wiring so each piece is unit-testable without standing up the
/// command tree. /// command tree.
@@ -4,8 +4,8 @@ using System.CommandLine.Parsing;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// The <c>scadabridge audit</c> command group (Audit Log #23 M8). Provides read access to /// The <c>scadabridge audit</c> command group. Provides read access to
/// the centralized append-only Audit Log via the Bundle B REST endpoints /// the centralized append-only Audit Log via the REST endpoints
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>, /// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>,
/// <c>GET /api/audit/tree</c>), plus a v1 no-op <c>verify-chain</c> placeholder /// <c>GET /api/audit/tree</c>), plus a v1 no-op <c>verify-chain</c> placeholder
/// for the deferred hash-chain tamper-evidence feature. /// for the deferred hash-chain tamper-evidence feature.
@@ -291,7 +291,7 @@ public static class AuditCommands
} }
/// <summary> /// <summary>
/// Builds the <c>audit backfill-source-node</c> sub-command (Audit Log #23 M5.6 T5). /// Builds the <c>audit backfill-source-node</c> sub-command.
/// Sets <c>SourceNode</c> on historical pre-feature rows whose <c>SourceNode IS NULL</c> /// Sets <c>SourceNode</c> on historical pre-feature rows whose <c>SourceNode IS NULL</c>
/// and <c>OccurredAtUtc</c> is older than <c>--before</c>, in batches. Admin-only. /// and <c>OccurredAtUtc</c> is older than <c>--before</c>, in batches. Admin-only.
/// </summary> /// </summary>
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the /// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
/// Bundle B <c>GET /api/audit/export</c> parameters. /// <c>GET /api/audit/export</c> parameters.
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/> /// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so /// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like /// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like
@@ -150,7 +150,7 @@ public static class AuditExportHelpers
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var message = await response.Content.ReadAsStringAsync(); var message = await response.Content.ReadAsStringAsync();
// CLI-018: honour the documented "authorization failure → exit 2" // Honour the documented "authorization failure → exit 2"
// contract on the REST audit surface as well. HTTP 403 is the // contract on the REST audit surface as well. HTTP 403 is the
// primary signal; the server may also surface UNAUTHORIZED / // primary signal; the server may also surface UNAUTHORIZED /
// FORBIDDEN via the JSON error envelope on a non-403 status. // FORBIDDEN via the JSON error envelope on a non-403 status.
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Renders a page of audit-log events to a writer. The <c>audit query</c> command picks /// Renders a page of audit-log events to a writer. The <c>audit query</c> command picks
/// a formatter from the <c>--format</c> option. The default JSONL formatter is defined /// a formatter from the <c>--format</c> option. The default JSONL formatter is defined
/// here; the human-readable table formatter is supplied by Bundle C. /// here; the human-readable table formatter is supplied by <see cref="TableAuditFormatter"/>.
/// </summary> /// </summary>
public interface IAuditFormatter public interface IAuditFormatter
{ {
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// centralized append-only Audit Log served by <see cref="AuditCommands"/>). /// centralized append-only Audit Log served by <see cref="AuditCommands"/>).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Renamed from <c>audit-log</c> in #23 M8-T7 to avoid confusion with the new /// Renamed from <c>audit-log</c> to avoid confusion with the new
/// <c>scadabridge audit</c> group. The old <c>audit-log</c> name is retained as a /// <c>scadabridge audit</c> group. The old <c>audit-log</c> name is retained as a
/// deprecated alias; <see cref="DeprecatedAlias"/> still resolves the full subcommand /// deprecated alias; <see cref="DeprecatedAlias"/> still resolves the full subcommand
/// tree, and <c>Program.cs</c> prints a deprecation warning when it is used. /// tree, and <c>Program.cs</c> prints a deprecation warning when it is used.
@@ -54,7 +54,7 @@ public static class AuditLogCommands
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("audit-config") { Description = "Query the configuration-change audit log" }; var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
// Backward-compatible alias for the pre-M8 `audit-log` name. The alias keeps // Backward-compatible alias for the old `audit-log` name. The alias keeps
// full subcommand parity automatically; the deprecation warning is emitted by // full subcommand parity automatically; the deprecation warning is emitted by
// the args[0] check in Program.cs. // the args[0] check in Program.cs.
command.Aliases.Add(DeprecatedAlias); command.Aliases.Add(DeprecatedAlias);
@@ -6,7 +6,7 @@ using System.Text.RegularExpressions;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B /// Filter arguments for an <c>audit query</c> invocation. Mirrors the
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/> /// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601). /// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/> /// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
@@ -192,7 +192,7 @@ public static class AuditQueryHelpers
{ {
OutputFormatter.WriteError( OutputFormatter.WriteError(
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR"); response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
// CLI-018: surface the documented "authorization failure → exit 2" // Surface the documented "authorization failure → exit 2"
// contract for the audit REST surface too, not just /management. // contract for the audit REST surface too, not just /management.
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1; return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
} }
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Transport (#24) bundle export / preview / import. The bundle bytes travel /// Transport bundle export / preview / import. The bundle bytes travel
/// through the management endpoint as base64 inside the standard JSON envelope /// through the management endpoint as base64 inside the standard JSON envelope
/// so no transport plumbing diverges from the other commands; the CLI handles /// so no transport plumbing diverges from the other commands; the CLI handles
/// file I/O at the edges. /// file I/O at the edges.
@@ -61,14 +61,14 @@ public static class BundleCommands
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names"); var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names"); var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names"); var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
// SMS (S10b): SmsConfiguration is keyed by AccountSid (no Name column), so // SMS: SmsConfiguration is keyed by AccountSid (no Name column), so
// tokens are AccountSid values — mirrors --smtp-configs using Host. // tokens are AccountSid values — mirrors --smtp-configs using Host.
var smsConfigsOption = NameListOption("--sms-configs", "Comma-separated SMS provider account SIDs"); var smsConfigsOption = NameListOption("--sms-configs", "Comma-separated SMS provider account SIDs");
// Inbound API keys are not transported between environments (re-arch C4) — no // Inbound API keys are not transported between environments — no
// --api-keys option. Re-create keys and re-grant their method scopes on the // --api-keys option. Re-create keys and re-grant their method scopes on the
// destination via the admin UI/CLI. // destination via the admin UI/CLI.
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names"); var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
// M8 (B4): site/instance-scoped export. Sites accept a SiteIdentifier // Site/instance-scoped export. Sites accept a SiteIdentifier
// (preferred) or friendly Name per token; instances accept a UniqueName. // (preferred) or friendly Name per token; instances accept a UniqueName.
var sitesOption = NameListOption("--sites", "Comma-separated site identifiers or names"); var sitesOption = NameListOption("--sites", "Comma-separated site identifiers or names");
var instancesOption = NameListOption("--instances", "Comma-separated instance unique-names"); var instancesOption = NameListOption("--instances", "Comma-separated instance unique-names");
@@ -131,7 +131,7 @@ public static class BundleCommands
timeout: BundleCommandTimeout, timeout: BundleCommandTimeout,
onSuccess: jsonOk => onSuccess: jsonOk =>
{ {
// CLI-020: previously the JSON envelope parse + property extraction + // Previously the JSON envelope parse + property extraction +
// base64 decode all ran unguarded — a server-side bug that omits one of // base64 decode all ran unguarded — a server-side bug that omits one of
// the two expected properties, returns a null base64 value, sends invalid // the two expected properties, returns a null base64 value, sends invalid
// base64, or returns a malformed JSON envelope would surface as one of // base64, or returns a malformed JSON envelope would surface as one of
@@ -139,7 +139,7 @@ public static class BundleCommands
// JsonException, i.e. an unhandled stack trace rather than the // JsonException, i.e. an unhandled stack trace rather than the
// documented "exit 1 with a clean INVALID_RESPONSE error". Wrap the // documented "exit 1 with a clean INVALID_RESPONSE error". Wrap the
// envelope parse and the streamed write in a single try/catch matching // envelope parse and the streamed write in a single try/catch matching
// the graceful-degradation theme established by CLI-002 / CLI-003 / CLI-005. // the graceful-degradation theme established elsewhere in this command group.
string base64; string base64;
int byteCount; int byteCount;
try try
@@ -158,7 +158,7 @@ public static class BundleCommands
return 1; return 1;
} }
// CLI-019: stream the base64 → file write so a 100 MB bundle // Stream the base64 → file write so a 100 MB bundle
// doesn't double-buffer through Convert.FromBase64String's // doesn't double-buffer through Convert.FromBase64String's
// ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes. // ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes.
// The management envelope's body is still buffered into the // The management envelope's body is still buffered into the
@@ -314,7 +314,7 @@ public static class BundleCommands
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.", Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
DefaultValueFactory = _ => "overwrite", DefaultValueFactory = _ => "overwrite",
}; };
// M8 (D3): site/connection name mapping. Repeatable. A token with no // Site/connection name mapping. Repeatable. A token with no
// "=dst" part, or "=" with an empty right-hand side, means CreateNew (the // "=dst" part, or "=" with an empty right-hand side, means CreateNew (the
// destination is created from the bundle payload); otherwise the source // destination is created from the bundle payload); otherwise the source
// is mapped to the named existing destination. // is mapped to the named existing destination.
@@ -400,7 +400,7 @@ public static class BundleCommands
// Shared HTTP plumbing // Shared HTTP plumbing
// ==================================================================== // ====================================================================
// //
// CLI-017: bundle commands previously routed through a private // Bundle commands previously routed through a private
// RunBundleCommandAsync that re-implemented URL/credential resolution and // RunBundleCommandAsync that re-implemented URL/credential resolution and
// skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync // skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync
// enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error // enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error
@@ -409,7 +409,7 @@ public static class BundleCommands
// the longer BundleCommandTimeout and a per-command success handler, so the // the longer BundleCommandTimeout and a per-command success handler, so the
// exit-code contract is unified across every command group. // exit-code contract is unified across every command group.
// CLI-019: chunked base64 → file streaming. The management envelope's // Chunked base64 → file streaming. The management envelope's
// success body is a single buffered JSON string (the wire format does not // success body is a single buffered JSON string (the wire format does not
// currently support response-body streaming), so we cannot remove the // currently support response-body streaming), so we cannot remove the
// ~base64-string + ~envelope-string allocation. What we CAN — and do — // ~base64-string + ~envelope-string allocation. What we CAN — and do —
@@ -27,13 +27,12 @@ internal static class CommandHelpers
/// body instead of running the default <see cref="HandleResponse"/> rendering path — /// body instead of running the default <see cref="HandleResponse"/> rendering path —
/// useful when the caller needs to capture the response (e.g. write a file) rather /// useful when the caller needs to capture the response (e.g. write a file) rather
/// than print it. The authorization-failure exit-code contract /// than print it. The authorization-failure exit-code contract
/// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way, /// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way.
/// closing CLI-017's regression.
/// </param> /// </param>
/// <param name="tableProjector"> /// <param name="tableProjector">
/// Optional transform applied to the success JSON body <em>only</em> when the resolved /// Optional transform applied to the success JSON body <em>only</em> when the resolved
/// format is <c>table</c>. Lets a command render a compact table projection (e.g. /// format is <c>table</c>. Lets a command render a compact table projection (e.g.
/// <c>template list</c> dropping per-template attribute dumps, followup #6) while /// <c>template list</c> dropping per-template attribute dumps) while
/// leaving JSON output untouched for machine consumers. Ignored when /// leaving JSON output untouched for machine consumers. Ignored when
/// <paramref name="onSuccess"/> is supplied. /// <paramref name="onSuccess"/> is supplied.
/// </param> /// </param>
@@ -95,8 +94,7 @@ internal static class CommandHelpers
// Caller-supplied success handler short-circuits the default rendering — but // Caller-supplied success handler short-circuits the default rendering — but
// the error path still routes through IsAuthorizationFailure so the documented // the error path still routes through IsAuthorizationFailure so the documented
// exit-2 contract holds whether or not a custom handler is provided // exit-2 contract holds whether or not a custom handler is provided.
// (CLI-017 unification of the bundle path).
if (onSuccess is not null) if (onSuccess is not null)
{ {
if (response.JsonData is not null) if (response.JsonData is not null)
@@ -255,7 +253,7 @@ internal static class CommandHelpers
// Derive the header set as the union of property names across *every* // Derive the header set as the union of property names across *every*
// element, in first-seen order. Using only items[0] would silently drop // element, in first-seen order. Using only items[0] would silently drop
// columns for any later element with a different shape (CLI-016). // columns for any later element with a different shape.
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList(); var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
string[] headers; string[] headers;
if (objectItems.Count > 0) if (objectItems.Count > 0)
@@ -100,7 +100,7 @@ public static class DebugCommands
.WithAutomaticReconnect() .WithAutomaticReconnect()
.Build(); .Build();
// CLI-011: CancellationTokenSource owns a WaitHandle and must be disposed. // CancellationTokenSource owns a WaitHandle and must be disposed.
using var cts = new CancellationTokenSource(); using var cts = new CancellationTokenSource();
var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously); var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -199,7 +199,7 @@ public static class DebugCommands
} }
catch (Exception ex) catch (Exception ex)
{ {
// CLI-010: Ctrl+C during connect throws OperationCanceledException — that is // Ctrl+C during connect throws OperationCanceledException — that is
// a graceful user cancellation, not a connection failure. // a graceful user cancellation, not a connection failure.
var failure = DebugStreamHelpers.ClassifyConnectFailure(ex, cts.IsCancellationRequested); var failure = DebugStreamHelpers.ClassifyConnectFailure(ex, cts.IsCancellationRequested);
if (failure.IsCancellation) if (failure.IsCancellation)
@@ -250,7 +250,7 @@ public static class DebugCommands
await connection.DisposeAsync(); await connection.DisposeAsync();
// CLI-012: resolve the exit code from a single authoritative source. A result // Resolve the exit code from a single authoritative source. A result
// set by OnStreamTerminated/Closed always wins; a brief grace period covers a // set by OnStreamTerminated/Closed always wins; a brief grace period covers a
// termination racing with Ctrl+C. Pure Ctrl+C (no result) is a graceful exit 0. // termination racing with Ctrl+C. Pure Ctrl+C (no result) is a graceful exit 0.
return await DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTcs.Task); return await DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTcs.Task);
@@ -3,8 +3,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Pure, testable helpers for the <c>debug stream</c> command. The SignalR-driven /// Pure, testable helpers for the <c>debug stream</c> command. The SignalR-driven
/// <see cref="DebugCommands"/> body itself cannot be unit-tested without a live hub, so /// <see cref="DebugCommands"/> body itself cannot be unit-tested without a live hub, so
/// the decision logic — connect-failure classification (CLI-010) and exit-code /// the decision logic — connect-failure classification and exit-code
/// resolution after stream termination (CLI-012) — is extracted here. /// resolution after stream termination — is extracted here.
/// </summary> /// </summary>
internal static class DebugStreamHelpers internal static class DebugStreamHelpers
{ {
@@ -3,8 +3,8 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Human-readable table formatter for <c>audit query --format table</c> (Audit Log /// Human-readable table formatter for <c>audit query --format table</c>.
/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed /// Renders each fetched page as a column-aligned text table with a fixed
/// column set (<see cref="Columns"/>). Long free-text fields (Target, Actor) are /// column set (<see cref="Columns"/>). Long free-text fields (Target, Actor) are
/// truncated with an ellipsis so columns stay aligned regardless of payload size. /// truncated with an ellipsis so columns stay aligned regardless of payload size.
/// </summary> /// </summary>
@@ -446,7 +446,7 @@ public static class TemplateCommands
/// <summary> /// <summary>
/// Determines whether <c>--trigger-kind</c> was supplied without a <c>--trigger-config</c>. /// Determines whether <c>--trigger-kind</c> was supplied without a <c>--trigger-config</c>.
/// In that case <see cref="TriggerConfigJson.InjectAnalysisKind"/> has no JSON object to inject /// In that case <see cref="TriggerConfigJson.InjectAnalysisKind"/> has no JSON object to inject
/// into and silently drops the kind (#257) — the caller should warn the user. /// into and silently drops the kind — the caller should warn the user.
/// </summary> /// </summary>
/// <param name="triggerConfig">The raw <c>--trigger-config</c> JSON value, or null when absent.</param> /// <param name="triggerConfig">The raw <c>--trigger-config</c> JSON value, or null when absent.</param>
/// <param name="triggerKind">The raw <c>--trigger-kind</c> value, or null when absent.</param> /// <param name="triggerKind">The raw <c>--trigger-kind</c> value, or null when absent.</param>
@@ -502,7 +502,7 @@ public static class TemplateCommands
var hiOption = new Option<double?>("--hi") { Description = "HiLo: high setpoint" }; var hiOption = new Option<double?>("--hi") { Description = "HiLo: high setpoint" };
var hiHiOption = new Option<double?>("--hihi") { Description = "HiLo: high-high setpoint" }; var hiHiOption = new Option<double?>("--hihi") { Description = "HiLo: high-high setpoint" };
var expressionOption = new Option<string?>("--expression") { Description = "Expression: boolean trigger expression" }; var expressionOption = new Option<string?>("--expression") { Description = "Expression: boolean trigger expression" };
// M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory). // Analysis kind for Expression triggers (advisory|strict; default advisory).
// Writes "analysisKind":"Strict" into the trigger config when set to strict. // Writes "analysisKind":"Strict" into the trigger config when set to strict.
var triggerKindOption = new Option<string?>("--trigger-kind") var triggerKindOption = new Option<string?>("--trigger-kind")
{ {
@@ -567,7 +567,7 @@ public static class TemplateCommands
var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" }; var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" }; var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false; updateLockedOption.DefaultValueFactory = _ => false;
// M9-T28b: --trigger-kind for update (same semantics as add) // --trigger-kind for update (same semantics as add)
var updateTriggerKindOption = new Option<string?>("--trigger-kind") var updateTriggerKindOption = new Option<string?>("--trigger-kind")
{ {
Description = AlarmTriggerKindOptionDescription Description = AlarmTriggerKindOptionDescription
@@ -699,7 +699,7 @@ public static class TemplateCommands
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" }; var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var minTimeOption = new Option<string?>("--min-time-between-runs") { Description = MinTimeBetweenRunsOptionDescription }; var minTimeOption = new Option<string?>("--min-time-between-runs") { Description = MinTimeBetweenRunsOptionDescription };
var execTimeoutOption = new Option<int?>("--execution-timeout-seconds") { Description = ExecutionTimeoutOptionDescription }; var execTimeoutOption = new Option<int?>("--execution-timeout-seconds") { Description = ExecutionTimeoutOptionDescription };
// M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory). // Analysis kind for Expression triggers (advisory|strict; default advisory).
var scriptTriggerKindOption = new Option<string?>("--trigger-kind") var scriptTriggerKindOption = new Option<string?>("--trigger-kind")
{ {
Description = ScriptTriggerKindOptionDescription Description = ScriptTriggerKindOptionDescription
@@ -758,7 +758,7 @@ public static class TemplateCommands
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" }; var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var updateMinTimeOption = new Option<string?>("--min-time-between-runs") { Description = MinTimeBetweenRunsOptionDescription }; var updateMinTimeOption = new Option<string?>("--min-time-between-runs") { Description = MinTimeBetweenRunsOptionDescription };
var updateExecTimeoutOption = new Option<int?>("--execution-timeout-seconds") { Description = ExecutionTimeoutOptionDescription }; var updateExecTimeoutOption = new Option<int?>("--execution-timeout-seconds") { Description = ExecutionTimeoutOptionDescription };
// M9-T28b: --trigger-kind for update (same semantics as add) // --trigger-kind for update (same semantics as add)
var updateScriptTriggerKindOption = new Option<string?>("--trigger-kind") var updateScriptTriggerKindOption = new Option<string?>("--trigger-kind")
{ {
Description = ScriptTriggerKindOptionDescription Description = ScriptTriggerKindOptionDescription
@@ -4,7 +4,7 @@ using System.Text.Json.Nodes;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Compact table projection for <c>template list</c> / <c>template get</c> (followup #6). /// Compact table projection for <c>template list</c> / <c>template get</c>.
/// The management API returns full <c>Template</c> entities — every attribute, alarm, /// The management API returns full <c>Template</c> entities — every attribute, alarm,
/// script, and composition inline — which the generic table renderer dumps as one giant /// script, and composition inline — which the generic table renderer dumps as one giant
/// cell per template (~171 KB for a real catalogue, unusable in a terminal). This /// cell per template (~171 KB for a real catalogue, unusable in a terminal). This
@@ -93,7 +93,7 @@ public class ManagementHttpClient : IDisposable
/// <summary> /// <summary>
/// Issues a plain HTTP <c>GET</c> against a REST endpoint (e.g. the audit /// Issues a plain HTTP <c>GET</c> against a REST endpoint (e.g. the audit
/// <c>/api/audit/query</c> endpoint introduced by Audit Log #23 M8) and returns the /// <c>/api/audit/query</c> endpoint) and returns the
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call /// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain /// in the <c>POST /management</c> command envelope — the audit endpoints are plain
/// REST resources. Authentication (HTTP Basic) and the base address are shared. /// REST resources. Authentication (HTTP Basic) and the base address are shared.
+1 -1
View File
@@ -41,7 +41,7 @@ rootCommand.SetAction(_ =>
Console.WriteLine("Use --help to see available commands."); Console.WriteLine("Use --help to see available commands.");
}); });
// Deprecation notice for the pre-M8 `audit-log` command name. The command itself // Deprecation notice for the earlier `audit-log` command name. The command itself
// still works (it is an alias of `audit-config`), but using the old name emits a // still works (it is an alias of `audit-config`), but using the old name emits a
// warning to stderr so scripts can be migrated. // warning to stderr so scripts can be migrated.
AuditLogCommands.WriteDeprecationWarningIfNeeded(args, Console.Error); AuditLogCommands.WriteDeprecationWarningIfNeeded(args, Console.Error);
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
/// <summary> /// <summary>
/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F). /// Minimal-API endpoint hosting the Audit Log CSV export.
/// ///
/// <para> /// <para>
/// CentralUI ships no MVC controllers (see <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Auth.AuthEndpoints"/> /// CentralUI ships no MVC controllers (see <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Auth.AuthEndpoints"/>
@@ -24,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
/// ///
/// <para> /// <para>
/// The route is gated on the <see cref="AuthorizationPolicies.AuditExport"/> /// The route is gated on the <see cref="AuthorizationPolicies.AuditExport"/>
/// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export /// policy so only roles with the bulk-export
/// permission can pull a CSV — the page-level /// permission can pull a CSV — the page-level
/// <see cref="AuthorizationPolicies.OperationalAudit"/> gate is read-only /// <see cref="AuthorizationPolicies.OperationalAudit"/> gate is read-only
/// and intentionally narrower. The query-string parser silently drops /// and intentionally narrower. The query-string parser silently drops
@@ -45,7 +45,7 @@ public static class AuthEndpoints
} }
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam // Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
// (Task 1.1 ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper). // (ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
// The full RoleMappingResult — including PermittedSiteIds and the // The full RoleMappingResult — including PermittedSiteIds and the
// system-wide flag — is carried in the mapping's opaque Scope so the // system-wide flag — is carried in the mapping's opaque Scope so the
// site-scope→SiteId claims below are built exactly as before. // site-scope→SiteId claims below are built exactly as before.
@@ -63,7 +63,7 @@ public static class AuthEndpoints
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false); : new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
// Build claims from LDAP auth + role mapping. // Build claims from LDAP auth + role mapping.
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped // No fixed "expires_at" absolute-cap claim is stamped
// — session expiry is owned by the cookie middleware's sliding window // — session expiry is owned by the cookie middleware's sliding window
// (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout, // (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
// SlidingExpiration = true). A frozen absolute claim would contradict // SlidingExpiration = true). A frozen absolute claim would contradict
@@ -71,11 +71,11 @@ public static class AuthEndpoints
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName; var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username; var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
// M2.19 (#15): build the cookie principal through the shared // Build the cookie principal through the shared
// SessionClaimBuilder — the SINGLE source of truth that the mid-session // SessionClaimBuilder — the SINGLE source of truth that the mid-session
// OnValidatePrincipal role-refresh path ALSO uses, so login and refresh can // OnValidatePrincipal role-refresh path ALSO uses, so login and refresh can
// never drift. It stamps the canonical identity/role/scope claims (with // never drift. It stamps the canonical identity/role/scope claims (with
// roleType/nameType pinned for IsInRole), PLUS the M2.19 additions: one // roleType/nameType pinned for IsInRole), PLUS these additions: one
// zb:group claim per raw LDAP group (the durable input the mid-session // zb:group claim per raw LDAP group (the durable input the mid-session
// RoleMapper re-run consumes) and a zb:lastrolerefresh anchor (login time, // RoleMapper re-run consumes) and a zb:lastrolerefresh anchor (login time,
// UTC) that also seeds the LastActivity idle anchor. The refresh timestamp is // UTC) that also seeds the LastActivity idle anchor. The refresh timestamp is
@@ -147,7 +147,7 @@ public static class AuthEndpoints
}); });
}).DisableAntiforgery(); }).DisableAntiforgery();
// Logout is a state-changing authenticated action (CentralUI-017): it // Logout is a state-changing authenticated action: it
// keeps antiforgery validation enabled so it cannot be triggered // keeps antiforgery validation enabled so it cannot be triggered
// cross-site. The NavMenu sign-out form includes the antiforgery token // cross-site. The NavMenu sign-out form includes the antiforgery token
// (rendered by the <AntiforgeryToken /> component). There is deliberately // (rendered by the <AntiforgeryToken /> component). There is deliberately
@@ -159,9 +159,9 @@ public static class AuthEndpoints
context.Response.Redirect("/login"); context.Response.Redirect("/login");
}); });
// CentralUI-020: liveness probe for the client-side idle-logout check. // Liveness probe for the client-side idle-logout check.
// The Blazor circuit's CookieAuthenticationStateProvider serves a frozen // The Blazor circuit's CookieAuthenticationStateProvider serves a frozen
// constructor-time principal (CentralUI-004), so a circuit can never // constructor-time principal, so a circuit can never
// observe a server-side cookie expiry by polling the auth state. // observe a server-side cookie expiry by polling the auth state.
// SessionExpiry instead polls this endpoint via fetch(): being a normal // SessionExpiry instead polls this endpoint via fetch(): being a normal
// HTTP request, the cookie middleware re-validates (and slides) the // HTTP request, the cookie middleware re-validates (and slides) the
@@ -178,7 +178,7 @@ public static class AuthEndpoints
/// <summary> /// <summary>
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's /// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
/// cookie session is still valid and <c>401</c> once it has lapsed /// cookie session is still valid and <c>401</c> once it has lapsed
/// server-side. See CentralUI-020. /// server-side.
/// </summary> /// </summary>
/// <param name="context">The current HTTP context used to check authentication state and write the response.</param> /// <param name="context">The current HTTP context used to check authentication state and write the response.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
@@ -192,7 +192,7 @@ public static class AuthEndpoints
/// <summary> /// <summary>
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in. /// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>. /// Deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
/// Session expiry is owned by the cookie authentication middleware's sliding /// Session expiry is owned by the cookie authentication middleware's sliding
/// window (configured in <c>ZB.MOM.WW.ScadaBridge.Security</c>'s <c>AddCookie</c>: /// window (configured in <c>ZB.MOM.WW.ScadaBridge.Security</c>'s <c>AddCookie</c>:
/// <c>ExpireTimeSpan</c> = the idle timeout, <c>SlidingExpiration = true</c>). /// <c>ExpireTimeSpan</c> = the idle timeout, <c>SlidingExpiration = true</c>).
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary> /// <summary>
/// Claim-lookup helpers for the Central UI. CentralUI-024: claim types are owned /// Claim-lookup helpers for the Central UI. Claim types are owned
/// by <see cref="JwtTokenService"/> (the single source of truth). These helpers /// by <see cref="JwtTokenService"/> (the single source of truth). These helpers
/// resolve them through the <c>JwtTokenService</c> constants so a rename there /// resolve them through the <c>JwtTokenService</c> constants so a rename there
/// propagates here instead of silently breaking ten copy-pasted call sites. /// propagates here instead of silently breaking ten copy-pasted call sites.
@@ -36,7 +36,7 @@ public static class ClaimsPrincipalExtensions
/// <summary> /// <summary>
/// Resolves the current user's audit username from the auth state provider. /// Resolves the current user's audit username from the auth state provider.
/// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into /// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into
/// ten components (CentralUI-024). /// ten components.
/// </summary> /// </summary>
/// <param name="authStateProvider">The Blazor authentication state provider to read from.</param> /// <param name="authStateProvider">The Blazor authentication state provider to read from.</param>
/// <returns>A task that resolves to the current user's audit username, or <see cref="UnknownUser"/> if not authenticated.</returns> /// <returns>A task that resolves to the current user's audit username, or <see cref="UnknownUser"/> if not authenticated.</returns>
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// </para> /// </para>
/// <para> /// <para>
/// We must NOT read <see cref="IHttpContextAccessor"/> on every /// We must NOT read <see cref="IHttpContextAccessor"/> on every
/// <see cref="GetAuthenticationStateAsync"/> call (CentralUI-004): for the /// <see cref="GetAuthenticationStateAsync"/> call: for the
/// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c> /// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c>
/// (or, worse, a stale/foreign context), so a later re-evaluation — /// (or, worse, a stale/foreign context), so a later re-evaluation —
/// e.g. <c>&lt;AuthorizeView&gt;</c> re-rendering — would otherwise see an /// e.g. <c>&lt;AuthorizeView&gt;</c> re-rendering — would otherwise see an
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary> /// <summary>
/// Resolves the set of sites the current user is permitted to operate on, from /// Resolves the set of sites the current user is permitted to operate on, from
/// the <c>SiteId</c> claims attached at login (CentralUI-002). /// the <c>SiteId</c> claims attached at login.
/// <para> /// <para>
/// The design (Component-CentralUI, CLAUDE.md "Security &amp; Auth") makes the /// The design (Component-CentralUI, CLAUDE.md "Security &amp; Auth") makes the
/// Deployment role site-scoped: a Deployment user mapped through an LDAP group /// Deployment role site-scoped: a Deployment user mapped through an LDAP group
@@ -93,7 +93,7 @@ public sealed class SiteScopeService
} }
// No SiteId claims => system-wide. Absence of scope rules means an // No SiteId claims => system-wide. Absence of scope rules means an
// unrestricted deployer (Security-017 made this service the sole // unrestricted deployer (this service is the sole
// site-scoping mechanism — there is no separate handler to mirror). // site-scoping mechanism — there is no separate handler to mirror).
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids); var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
_cached = result; _cached = result;
@@ -1,7 +1,7 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8). @* Audit Log drilldown drawer.
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page. Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
The drawer owns only the offcanvas chrome (backdrop, header, Close buttons); The drawer owns only the offcanvas chrome (backdrop, header, Close buttons);
the single-AuditEvent detail body is delegated to <AuditEventDetail>, which the single-AuditEvent detail body is delegated to <AuditEventDetail>, which
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8). /// Child component for the central Audit Log page.
/// Renders one <see cref="AuditEventView"/> in a right-side off-canvas drawer. /// Renders one <see cref="AuditEventView"/> in a right-side off-canvas drawer.
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two /// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional /// Close buttons; the single-row detail body (read-only fields, conditional
@@ -1,7 +1,7 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8). @* Reusable single-AuditEvent detail body.
Extracted from AuditDrilldownDrawer so the drawer and the execution-tree Extracted from AuditDrilldownDrawer so the drawer and the execution-tree
node-detail modal share one rendering of a row's detail. node-detail modal share one rendering of a row's detail.
All form/field rendering follows the form-layout memory: All form/field rendering follows the form-layout memory:
@@ -9,8 +9,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Reusable single-<see cref="AuditEvent"/> detail body (#23 M7 Bundle C / /// Reusable single-<see cref="AuditEvent"/> detail body. Extracted verbatim
/// M7-T4..T8). Extracted verbatim from <see cref="AuditDrilldownDrawer"/> so /// from <see cref="AuditDrilldownDrawer"/> so
/// the drawer and the execution-tree node-detail modal render a row's detail /// the drawer and the execution-tree node-detail modal render a row's detail
/// identically. Renders the read-only field list, the conditional /// identically. Renders the read-only field list, the conditional
/// Error/Request/Response/Extra subsections, and the action buttons (Copy as /// Error/Request/Response/Extra subsections, and the action buttons (Copy as
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent /// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c> /// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
/// — the spawner's id used as the per-run drill-in target. All are deep /// — the spawner's id used as the per-run drill-in target. All are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads. /// links the Audit Log page deserializes on init and auto-loads.
/// </para> /// </para>
/// </summary> /// </summary>
public partial class AuditEventDetail public partial class AuditEventDetail
@@ -281,7 +281,7 @@ public partial class AuditEventDetail
/// <summary> /// <summary>
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId /// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
/// feature, Task 10). Navigates to /// feature). Navigates to
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page /// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
/// resolves the whole chain rooted at the topmost ancestor and renders it /// resolves the whole chain rooted at the topmost ancestor and renders it
/// expandably, with this row's execution highlighted. The button is only /// expandably, with this row's execution highlighted. The button is only
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the /// Filter bar for the central Audit Log page. Owns the
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls /// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// — Channel as a single-select (one channel at a time, so the Kind options /// — Channel as a single-select (one channel at a time, so the Kind options
/// narrow to it cleanly); Kind / Status / Site as compact /// narrow to it cleanly); Kind / Status / Site as compact
@@ -55,7 +55,7 @@ public partial class AuditFilterBar
[Parameter] public Func<DateTime>? NowUtcProvider { get; set; } [Parameter] public Func<DateTime>? NowUtcProvider { get; set; }
/// <summary> /// <summary>
/// Bundle D drill-in seam (#23 M7-T10..T12). When set on first render, /// When set on first render,
/// pre-populates the Instance free-text input. Instance is UI-only — the /// pre-populates the Instance free-text input. Instance is UI-only — the
/// repository filter contract has no instance column — so this flows in /// repository filter contract has no instance column — so this flows in
/// through a separate parameter rather than the <see cref="AuditLogQueryFilter"/> /// through a separate parameter rather than the <see cref="AuditLogQueryFilter"/>
@@ -167,7 +167,7 @@ public partial class AuditFilterBar
private async Task Apply() private async Task Apply()
{ {
// CentralUI-026: <input type="datetime-local"> binds with DateTimeKind.Unspecified // <input type="datetime-local"> binds with DateTimeKind.Unspecified
// — the value is the user's browser-local wall-clock. Tag it as Local then convert // — the value is the user's browser-local wall-clock. Tag it as Local then convert
// to UTC before the model emits the filter, otherwise a non-UTC operator's window // to UTC before the model emits the filter, otherwise a non-UTC operator's window
// is silently shifted by their UTC offset. Done on a swap-and-restore basis so the // is silently shifted by their UTC offset. Done on a swap-and-restore basis so the
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// UI-side binding model for <see cref="AuditFilterBar"/> (#23 M7-T2). /// UI-side binding model for <see cref="AuditFilterBar"/>.
/// ///
/// <para> /// <para>
/// The model mirrors <see cref="AuditLogQueryFilter"/> but allows multi-select chip /// The model mirrors <see cref="AuditLogQueryFilter"/> but allows multi-select chip
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). /// Keyset-paged results grid for the central Audit Log page.
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc, /// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus, /// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// ErrorMessage — plus the ExecutionId per-run correlation column and the /// ErrorMessage — plus the ExecutionId per-run correlation column and the
@@ -66,7 +66,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
private bool _loading; private bool _loading;
private string? _error; private string? _error;
// CentralUI-032: small in-component stack of prior-page cursors so the user // Small in-component stack of prior-page cursors so the user
// can step backwards through results. Each Next push captures the cursor // can step backwards through results. Each Next push captures the cursor
// that produced the current page (null for page 1) before advancing; each // that produced the current page (null for page 1) before advancing; each
// Previous pop reloads the page at the recovered cursor. Mirrors the // Previous pop reloads the page at the recovered cursor. Mirrors the
@@ -108,7 +108,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
[Parameter] public IReadOnlyList<string>? ColumnOrder { get; set; } [Parameter] public IReadOnlyList<string>? ColumnOrder { get; set; }
/// <summary> /// <summary>
/// Raised when the user clicks a row. Bundle C wires this to the drilldown /// Raised when the user clicks a row. Consumers wire this to the drilldown
/// drawer. The event payload is the full <see cref="AuditEventView"/>. /// drawer. The event payload is the full <see cref="AuditEventView"/>.
/// </summary> /// </summary>
[Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; } [Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; }
@@ -228,15 +228,15 @@ public partial class AuditResultsGrid : IAsyncDisposable
AfterOccurredAtUtc: last.OccurredAtUtc, AfterOccurredAtUtc: last.OccurredAtUtc,
AfterEventId: last.EventId); AfterEventId: last.EventId);
// CentralUI-032: remember the cursor that produced the current page so // Remember the cursor that produced the current page so
// a later Previous can navigate back to it. The page-1 entry is pushed // a later Previous can navigate back to it. The first-page entry is pushed
// as null — LoadAsync treats null as "first page" (PageSize-only). // as null — LoadAsync treats null as "first page" (PageSize-only).
_cursorStack.Push(_currentPaging); _cursorStack.Push(_currentPaging);
await LoadAsync(cursor); await LoadAsync(cursor);
_pageNumber++; _pageNumber++;
} }
// CentralUI-032: pops the previous-page cursor off the stack and reloads // Pops the previous-page cursor off the stack and reloads
// at that position. The pop only happens AFTER a successful reload — a // at that position. The pop only happens AFTER a successful reload — a
// failed page-fetch leaves the user on the current page with the error // failed page-fetch leaves the user on the current page with the error
// banner instead of stranding them between pages. // banner instead of stranding them between pages.
@@ -8,8 +8,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature, /// Execution-Tree Node Detail Modal. Opened from an execution-tree node
/// Task 3). Opened from an execution-tree node double-click: given an /// double-click: given an
/// <see cref="ExecutionId"/> it loads that execution's audit rows via /// <see cref="ExecutionId"/> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail. /// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
/// ///
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Recursive Blazor tree component for the execution-chain view (Audit Log /// Recursive Blazor tree component for the execution-chain view (Audit Log
/// ParentExecutionId feature, Task 10). /// ParentExecutionId feature).
/// ///
/// <para> /// <para>
/// <b>Flat list → tree.</b> The repository / query service returns the chain as /// <b>Flat list → tree.</b> The repository / query service returns the chain as
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components;
/// <para> /// <para>
/// CLAUDE.md mandates UTC throughout the system, but a <c>datetime-local</c> /// CLAUDE.md mandates UTC throughout the system, but a <c>datetime-local</c>
/// value carries no offset, so it must be <i>converted</i> to UTC, not relabelled /// value carries no offset, so it must be <i>converted</i> to UTC, not relabelled
/// as UTC. Relabelling (the CentralUI-008 bug) shifts every query window by the /// as UTC. Relabelling shifts every query window by the
/// user's offset for any non-UTC browser. /// user's offset for any non-UTC browser.
/// </para> /// </para>
/// </summary> /// </summary>
@@ -134,7 +134,7 @@
private string _failureMessage = ""; private string _failureMessage = "";
private List<TreeNode> _rootNodes = new(); private List<TreeNode> _rootNodes = new();
// Search state (T16): _searchActive distinguishes "ran a search that found // Search state: _searchActive distinguishes "ran a search that found
// nothing" from "never searched"; a blank query clears the panel entirely. // nothing" from "never searched"; a blank query clears the panel entirely.
private string _searchQuery = ""; private string _searchQuery = "";
private bool _searchActive; private bool _searchActive;
@@ -157,7 +157,7 @@
public bool HasChildren { get; } public bool HasChildren { get; }
/// <summary> /// <summary>
/// Friendly DataType name for Variable nodes (T15 type column); null when /// Friendly DataType name for Variable nodes; null when
/// not a Variable or the type read failed. Rendered as a muted badge. /// not a Variable or the type read failed. Rendered as a muted badge.
/// </summary> /// </summary>
public string? DataType { get; init; } public string? DataType { get; init; }
@@ -261,7 +261,7 @@
node.Expanded = true; node.Expanded = true;
} }
// Load-more / BrowseNext (T15): fetch the next page under a truncated node, // Load-more / BrowseNext: fetch the next page under a truncated node,
// APPEND the children (preserve what's already shown + expanded), and refresh // APPEND the children (preserve what's already shown + expanded), and refresh
// the stored token (null → exhausted → the button disappears). // the stored token (null → exhausted → the button disappears).
private async Task LoadMoreAsync(TreeNode node) private async Task LoadMoreAsync(TreeNode node)
@@ -297,7 +297,7 @@
private Task SearchOnEnter(KeyboardEventArgs e) => private Task SearchOnEnter(KeyboardEventArgs e) =>
e.Key == "Enter" ? SearchAsync() : Task.CompletedTask; e.Key == "Enter" ? SearchAsync() : Task.CompletedTask;
// Address-space search (T16): a blank query clears the panel; otherwise run // Address-space search: a blank query clears the panel; otherwise run
// the bounded recursive search and render matches as a flat selectable list. // the bounded recursive search and render matches as a flat selectable list.
private async Task SearchAsync() private async Task SearchAsync()
{ {
@@ -349,7 +349,7 @@
[Parameter] public bool IsLegacy { get; set; } [Parameter] public bool IsLegacy { get; set; }
[Parameter] public ValidationResult? Errors { get; set; } [Parameter] public ValidationResult? Errors { get; set; }
// Verify-endpoint context (M7 T17): the site + connection identity the verify // Verify-endpoint context (M7): the site + connection identity the verify
// probe targets. Supplied by DataConnectionForm (_formSiteId → SiteIdentifier, // probe targets. Supplied by DataConnectionForm (_formSiteId → SiteIdentifier,
// _formName, _protocol). When SiteIdentifier is blank the connection has not been // _formName, _protocol). When SiteIdentifier is blank the connection has not been
// assigned a site yet, so verification is unavailable. // assigned a site yet, so verification is unavailable.
@@ -394,7 +394,7 @@
} }
} }
// M7 T17: trust the captured untrusted server certificate at every node of the // M7: trust the captured untrusted server certificate at every node of the
// owning site, then re-run Verify (which should now succeed and clear the cert // owning site, then re-run Verify (which should now succeed and clear the cert
// panel). Administrator-gated by the AuthorizeView wrapping the button; the // panel). Administrator-gated by the AuthorizeView wrapping the button; the
// CertManagementService enforces the same role server-side-of-the-trust-boundary. // CertManagementService enforces the same role server-side-of-the-trust-boundary.
@@ -1,5 +1,5 @@
@* @*
Audit Log (#23) M7 Bundle E (T13) — three Health-dashboard KPI tiles for the Three Health-dashboard KPI tiles for the
Audit channel: Volume / Error rate / Backlog. Renders Bootstrap card tiles in Audit channel: Volume / Error rate / Backlog. Renders Bootstrap card tiles in
a single row, each acting as a navigation link to a pre-filtered Audit Log a single row, each acting as a navigation link to a pre-filtered Audit Log
view. The component is purely presentational — the parent page owns the view. The component is purely presentational — the parent page owns the
@@ -4,11 +4,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
/// <summary> /// <summary>
/// Audit Log (#23) M7 Bundle E (T13) code-behind for <see cref="AuditKpiTiles"/>. /// Code-behind for <see cref="AuditKpiTiles"/>.
/// Renders three KPI tiles — volume, error rate, backlog — from a /// Renders three KPI tiles — volume, error rate, backlog — from a
/// <see cref="AuditLogKpiSnapshot"/> the parent page supplies. Tiles act as /// <see cref="AuditLogKpiSnapshot"/> the parent page supplies. Tiles act as
/// drill-in links: clicking navigates to <c>/audit/log</c> with the relevant /// drill-in links: clicking navigates to <c>/audit/log</c> with the relevant
/// query-string filter pre-applied (Bundle D already parses these params). /// query-string filter pre-applied (the page already parses these params).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -94,7 +94,7 @@ public partial class AuditKpiTiles
{ {
return "—"; return "—";
} }
// Format to one decimal so a 1-error-in-2000 rate doesn't round to 0%. // Format to one decimal so a rate of 1 error in 2000 doesn't round to 0%.
return $"{ErrorRatePercent:0.0}%"; return $"{ErrorRatePercent:0.0}%";
} }
} }
@@ -58,7 +58,7 @@
{ {
<div class="text-muted small mb-3">Site Call KPIs unavailable: @ErrorMessage</div> <div class="text-muted small mb-3">Site Call KPIs unavailable: @ErrorMessage</div>
} }
@* ── Per-node stuck/parked sub-table (T6: M5.2 per-node stuck-count KPIs) ── *@ @* ── Per-node stuck/parked sub-table ── *@
@if (HasNodeBreakdown) @if (HasNodeBreakdown)
{ {
<div class="mb-3"> <div class="mb-3">
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
/// <summary> /// <summary>
/// Site Call Audit (#22) Task 7 code-behind for <see cref="SiteCallKpiTiles"/>. /// Site Call Audit code-behind for <see cref="SiteCallKpiTiles"/>.
/// Renders three KPI tiles — Buffered, Stuck, Parked — from a /// Renders three KPI tiles — Buffered, Stuck, Parked — from a
/// <see cref="SiteCallKpiResponse"/> the parent Health dashboard supplies. /// <see cref="SiteCallKpiResponse"/> the parent Health dashboard supplies.
/// Tiles act as drill-in links: clicking navigates to <c>/site-calls/report</c> /// Tiles act as drill-in links: clicking navigates to <c>/site-calls/report</c>
@@ -61,7 +61,7 @@ public partial class SiteCallKpiTiles
[Parameter] public string? ErrorMessage { get; set; } [Parameter] public string? ErrorMessage { get; set; }
/// <summary> /// <summary>
/// Optional per-node KPI breakdown (T6: M5.2 per-node stuck-count KPIs). /// Optional per-node KPI breakdown.
/// When non-null and non-empty, a compact node-level stuck/parked sub-table /// When non-null and non-empty, a compact node-level stuck/parked sub-table
/// is rendered below the main tiles. <c>null</c> means the parent has not /// is rendered below the main tiles. <c>null</c> means the parent has not
/// loaded it yet or has opted out — the sub-table is suppressed entirely. /// loaded it yet or has opted out — the sub-table is suppressed entirely.
@@ -8,7 +8,7 @@
<NavMenu /> <NavMenu />
</Nav> </Nav>
<RailFooter> <RailFooter>
@* T34b: dark-mode toggle sits in the rail footer alongside the session @* Dark-mode toggle sits in the rail footer alongside the session
block. It is auth-agnostic (pure client-side theme) so it renders even block. It is auth-agnostic (pure client-side theme) so it renders even
on the login page. *@ on the login page. *@
<DarkModeToggle /> <DarkModeToggle />
@@ -85,7 +85,7 @@
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Operations — Secured Writes (M7 T14b). Two-person MxGateway write workflow: @* Operations — Secured Writes. Two-person MxGateway write workflow:
Operator submits, a different Verifier approves/rejects. The section must show Operator submits, a different Verifier approves/rejects. The section must show
for Operator OR Verifier. There is no combined policy, so the OR is expressed for Operator OR Verifier. There is no combined policy, so the OR is expressed
as: Operator → render; otherwise (NotAuthorized) fall through to a Verifier as: Operator → render; otherwise (NotAuthorized) fall through to a Verifier
@@ -124,8 +124,7 @@
</AuthorizeView> </AuthorizeView>
</NavRailSection> </NavRailSection>
@* Audit — gated on the OperationalAudit policy (#23 M7-T15 @* Audit — gated on the OperationalAudit policy. Hosts the Audit Log page (#23 M7) and the
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
Configuration Audit Log (IAuditService config-change Configuration Audit Log (IAuditService config-change
viewer). The whole section sits inside the policy block: viewer). The whole section sits inside the policy block:
a non-audit user does not even see the heading. a non-audit user does not even see the heading.
@@ -29,7 +29,7 @@
@:Add API Key @:Add API Key
} }
</h4> </h4>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log @* Drill-in: deep-link into the central Audit Log
pre-filtered to this API key's inbound calls. Inbound audit rows record pre-filtered to this API key's inbound calls. Inbound audit rows record
the key Name as Actor and live on the ApiInbound channel. *@ the key Name as Actor and live on the ApiInbound channel. *@
@if (IsEditMode && !string.IsNullOrWhiteSpace(_formName)) @if (IsEditMode && !string.IsNullOrWhiteSpace(_formName))
@@ -112,7 +112,7 @@
</div> </div>
@code { @code {
// Inbound-API key re-arch (C3): this form drives the IInboundApiKeyAdmin seam. // Inbound-API key re-arch: this form drives the IInboundApiKeyAdmin seam.
// Keys are identified by an opaque string KeyId; method access is a set of method // Keys are identified by an opaque string KeyId; method access is a set of method
// NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV. // NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV.
// The list of all methods still comes from IInboundApiRepository (methods stay in SQL). // The list of all methods still comes from IInboundApiRepository (methods stay in SQL).
@@ -97,7 +97,7 @@
</div> </div>
@code { @code {
// Inbound-API key re-arch (C3): this page reads keys from the IInboundApiKeyAdmin seam // Inbound-API key re-arch: this page reads keys from the IInboundApiKeyAdmin seam
// (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no // (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no
// retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row. // retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row.
private List<InboundApiKeyInfo> _keys = new(); private List<InboundApiKeyInfo> _keys = new();
@@ -22,7 +22,7 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6> <h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit @* Drill-in: deep-link into the central Audit
Log pre-filtered to this site's events. AuditEvent.SourceSiteId Log pre-filtered to this site's events. AuditEvent.SourceSiteId
stores the SiteIdentifier (string), so we pass that through. *@ stores the SiteIdentifier (string), so we pass that through. *@
@if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier)) @if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier))
@@ -12,7 +12,7 @@
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<h1 class="h4 mb-3">Audit Log</h1> <h1 class="h4 mb-3">Audit Log</h1>
@* Trends panel (M6 / K15). A best-effort collapsible Bootstrap card sitting @* Trends panel. A best-effort collapsible Bootstrap card sitting
above the audit query UI: one KpiTrendChart per AuditLog global metric over above the audit query UI: one KpiTrendChart per AuditLog global metric over
a 24h (default) / 7d window. Series are fetched independently in the a 24h (default) / 7d window. Series are fetched independently in the
code-behind — a failed fetch degrades only that chart to the unavailable code-behind — a failed fetch degrades only that chart to the unavailable
@@ -62,22 +62,22 @@
} }
</div> </div>
@* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. @* Filter bar. Apply hands the collapsed filter to the grid.
Bundle D (M7-T10..T12) threads a query-string instance prefill through Threads a query-string instance prefill through
InitialInstanceSearch — UI-only because the filter contract has no instance column. *@ InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
<div class="mb-3"> <div class="mb-3">
<AuditFilterBar OnFilterChanged="HandleFilterChanged" <AuditFilterBar OnFilterChanged="HandleFilterChanged"
InitialInstanceSearch="@_initialInstanceSearch" /> InitialInstanceSearch="@_initialInstanceSearch" />
</div> </div>
@* Export button (Bundle F / M7-T14). A plain <a download> link triggers the @* Export button. A plain <a download> link triggers the
streaming CSV endpoint at /api/centralui/audit/export — chosen over a streaming CSV endpoint at /api/centralui/audit/export — chosen over a
SignalR-driven download because the request can stream 100k rows directly SignalR-driven download because the request can stream 100k rows directly
to the response body without buffering through the Blazor circuit. The to the response body without buffering through the Blazor circuit. The
href reflects the most recently applied filter; before Apply is clicked, href reflects the most recently applied filter; before Apply is clicked,
an unconstrained export is exposed. an unconstrained export is exposed.
Bundle G (#23 M7-T15) gates the button on the AuditExport policy so an Gates the button on the AuditExport policy so an
OperationalAudit-only operator (read access without bulk export) sees the OperationalAudit-only operator (read access without bulk export) sees the
page + filters but cannot trigger the CSV pull. The endpoint itself is page + filters but cannot trigger the CSV pull. The endpoint itself is
gated separately, so a hand-crafted URL still 403s — the AuthorizeView gated separately, so a hand-crafted URL still 403s — the AuthorizeView
@@ -96,7 +96,7 @@
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's @* Results grid. Row clicks emit OnRowSelected for the
drilldown drawer; the grid stays in "no events" mode until the user applies a drilldown drawer; the grid stays in "no events" mode until the user applies a
filter so the page does not auto-load the full audit table on first render. *@ filter so the page does not auto-load the full audit table on first render. *@
<div> <div>
@@ -104,7 +104,7 @@
</div> </div>
</div> </div>
@* Drilldown drawer (Bundle C / M7-T4..T8). Hosted at the page level so the @* Drilldown drawer. Hosted at the page level so the
off-canvas overlay sits above the grid / filter bar irrespective of scroll. *@ off-canvas overlay sits above the grid / filter bar irrespective of scroll. *@
<AuditDrilldownDrawer Event="@_selectedEvent" <AuditDrilldownDrawer Event="@_selectedEvent"
IsOpen="@_drawerOpen" IsOpen="@_drawerOpen"
@@ -11,19 +11,19 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
/// <summary> /// <summary>
/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3) /// Code-behind for the central Audit Log page. Wires up <c>AuditFilterBar</c>
/// wires up <c>AuditFilterBar</c> and <c>AuditResultsGrid</c>: the page owns the /// and <c>AuditResultsGrid</c>: the page owns the
/// active <see cref="AuditLogQueryFilter"/> and re-pushes a fresh instance to the /// active <see cref="AuditLogQueryFilter"/> and re-pushes a fresh instance to the
/// grid on every Apply (the grid uses reference identity as its "reload" /// grid on every Apply (the grid uses reference identity as its "reload"
/// trigger). Row clicks land in <see cref="HandleRowSelected"/>, which pins /// trigger). Row clicks land in <see cref="HandleRowSelected"/>, which pins
/// the selected row and opens the drilldown drawer. /// the selected row and opens the drilldown drawer.
/// ///
/// <para> /// <para>
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can /// Adds query-string drill-in parsing so other pages can
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>, /// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only /// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends /// <c>?instance=</c> are read on initialization. This extends
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can /// with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds /// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
/// <c>?executionId=</c> for the "View this execution" drill-in, and the /// <c>?executionId=</c> for the "View this execution" drill-in, and the
/// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the /// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the
@@ -51,7 +51,7 @@ public partial class AuditLogPage : IDisposable
[Inject] private NavigationManager Navigation { get; set; } = null!; [Inject] private NavigationManager Navigation { get; set; } = null!;
/// <summary> /// <summary>
/// KPI-history facade for the M6 (K15) Trends panel — fetched per metric, /// KPI-history facade for the Trends panel — fetched per metric,
/// per window. Best-effort: a failed series fetch degrades that one chart to /// per window. Best-effort: a failed series fetch degrades that one chart to
/// the unavailable placeholder and never breaks the audit query UI. /// the unavailable placeholder and never breaks the audit query UI.
/// </summary> /// </summary>
@@ -187,7 +187,7 @@ public partial class AuditLogPage : IDisposable
IReadOnlyList<AuditKind>? kinds = IReadOnlyList<AuditKind>? kinds =
AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind")); AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind"));
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in // The Health-dashboard Audit error-rate tile drills in
// with ?status=Failed (and operators may craft URLs with Parked/Discarded). // with ?status=Failed (and operators may craft URLs with Parked/Discarded).
// Unknown values are silently dropped — the page still renders without // Unknown values are silently dropped — the page still renders without
// the constraint. // the constraint.
@@ -248,7 +248,7 @@ public partial class AuditLogPage : IDisposable
private void HandleRowSelected(AuditEventView row) private void HandleRowSelected(AuditEventView row)
{ {
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as // A grid row click hands us the full AuditEvent. We pin it as
// the selected row and open the drilldown drawer — the drawer is fully // the selected row and open the drilldown drawer — the drawer is fully
// presentational so we do not need to refetch the row. // presentational so we do not need to refetch the row.
_selectedEvent = row; _selectedEvent = row;
@@ -263,7 +263,7 @@ public partial class AuditLogPage : IDisposable
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// M6 (K15) — Audit Log trend charts. // Audit Log trend charts.
// //
// A best-effort Trends panel that sits above the audit query UI: one // A best-effort Trends panel that sits above the audit query UI: one
// KpiTrendChart per AuditLog global metric, over a 24h (default) or 7d // KpiTrendChart per AuditLog global metric, over a 24h (default) or 7d
@@ -278,8 +278,7 @@ public partial class AuditLogPage : IDisposable
/// <summary> /// <summary>
/// True while a window's series are being (re)fetched — disables the 24h/7d /// True while a window's series are being (re)fetched — disables the 24h/7d
/// window toggle buttons so a mid-flight click cannot stack overlapping loads /// window toggle buttons so a mid-flight click cannot stack overlapping loads.
/// (mirrors the K13/K14 trend pages).
/// </summary> /// </summary>
private bool _trendsLoading; private bool _trendsLoading;
@@ -367,7 +366,7 @@ public partial class AuditLogPage : IDisposable
} }
/// <summary> /// <summary>
/// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most /// URL the Export-CSV link points at. Renders the most
/// recently applied filter as query-string params so the server-side /// recently applied filter as query-string params so the server-side
/// streaming endpoint reproduces the user's current view. With no filter /// streaming endpoint reproduces the user's current view. With no filter
/// applied yet, returns the bare endpoint — i.e. an unconstrained export. /// applied yet, returns the bare endpoint — i.e. an unconstrained export.
@@ -394,7 +393,7 @@ public partial class AuditLogPage : IDisposable
// No capacity hint: the dimensions are multi-value, so the part count is // No capacity hint: the dimensions are multi-value, so the part count is
// unbounded by the number of filter fields. // unbounded by the number of filter fields.
var parts = new List<KeyValuePair<string, string?>>(); var parts = new List<KeyValuePair<string, string?>>();
// Task 9: the filter dimensions are multi-value end-to-end. Emit ONE // The filter dimensions are multi-value end-to-end. Emit ONE
// repeated query-string key per selected value (channel=A&channel=B); the // repeated query-string key per selected value (channel=A&channel=B); the
// export endpoint's ParseFilter reads the full repeated set. // export endpoint's ParseFilter reads the full repeated set.
if (filter.Channels is { Count: > 0 } channels) if (filter.Channels is { Count: > 0 } channels)
@@ -13,7 +13,7 @@
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
@* Bundle Import filter chip (T24). Set via ?bundleImportId={guid} query @* Bundle Import filter chip. Set via ?bundleImportId={guid} query
string so drill-ins from the Import wizard / other pages can scope this string so drill-ins from the Import wizard / other pages can scope this
page to a single import run. Cleared via the × button, which navigates page to a single import run. Cleared via the × button, which navigates
back to the page without the query param so the user sees all rows. *@ back to the page without the query param so the user sees all rows. *@
@@ -192,7 +192,7 @@
@code { @code {
/// <summary> /// <summary>
/// T24 (Transport). When non-null, scopes the page to a single bundle /// When non-null, scopes the page to a single bundle
/// import run. Set via the <c>?bundleImportId=</c> query string from /// import run. Set via the <c>?bundleImportId=</c> query string from
/// drill-ins (Import wizard summary, future BundleImported row links). /// drill-ins (Import wizard summary, future BundleImported row links).
/// </summary> /// </summary>
@@ -256,7 +256,7 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// T24: when the BundleImportId query param is set (or cleared), refetch // When the BundleImportId query param is set (or cleared), refetch
// automatically so the user lands on a pre-filtered page from a drill-in // automatically so the user lands on a pre-filtered page from a drill-in
// link without having to click Search. // link without having to click Search.
if (BundleImportId != _lastFetchedBundleImportId) if (BundleImportId != _lastFetchedBundleImportId)
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
/// <summary> /// <summary>
/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId /// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId
/// feature, Task 10). Route <c>/audit/execution-tree</c>, reached via the Audit /// feature). Route <c>/audit/execution-tree</c>, reached via the Audit
/// Log drilldown drawer's "View execution chain" action with /// Log drilldown drawer's "View execution chain" action with
/// <c>?executionId={guid}</c>. /// <c>?executionId={guid}</c>.
/// ///
@@ -40,7 +40,7 @@ public partial class ExecutionTreePage
private bool _loading; private bool _loading;
private string? _error; private string? _error;
// Execution-Tree Node Detail Modal feature (Task 4) — state backing the // Execution-Tree Node Detail Modal feature — state backing the
// <ExecutionDetailModal>. A double-click on a tree node sets // <ExecutionDetailModal>. A double-click on a tree node sets
// _modalExecutionId + flips _modalOpen true; the modal loads that // _modalExecutionId + flips _modalOpen true; the modal loads that
// execution's audit rows on the closed → open transition. _modalOpen is the // execution's audit rows on the closed → open transition. _modalOpen is the
@@ -4,8 +4,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary> /// <summary>
/// Pure (no Blazor/DI) builder that turns a flat list of streamed attribute (and, /// Pure (no Blazor/DI) builder that turns a flat list of streamed attribute (and
/// in DV-4, alarm) events into a collapsible composition forest of /// alarm) events into a collapsible composition forest of
/// <see cref="DebugTreeNode"/>. Path-qualified canonical names are split on /// <see cref="DebugTreeNode"/>. Path-qualified canonical names are split on
/// <c>'.'</c> to derive branch nodes; the terminal segment carries the payload. /// <c>'.'</c> to derive branch nodes; the terminal segment carries the payload.
/// </summary> /// </summary>
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; // AlarmState
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary> /// <summary>
/// A node in a Debug View composition tree. Attribute (and, in DV-4, alarm) /// A node in a Debug View composition tree. Attribute (and alarm)
/// names are path-qualified canonical names (e.g. <c>Motor1.Compressor.Pump</c>); /// names are path-qualified canonical names (e.g. <c>Motor1.Compressor.Pump</c>);
/// the tree is derived by splitting those names on <c>'.'</c>. A node is either a /// the tree is derived by splitting those names on <c>'.'</c>. A node is either a
/// branch (composition member, no payload, has children) or a leaf (one attribute /// branch (composition member, no payload, has children) or a leaf (one attribute
@@ -22,9 +22,9 @@ public sealed class DebugTreeNode
// Leaf payloads — exactly one is set on a leaf; both null on a pure branch. // Leaf payloads — exactly one is set on a leaf; both null on a pure branch.
public AttributeValueChanged? Attribute { get; init; } public AttributeValueChanged? Attribute { get; init; }
public AlarmStateChanged? Alarm { get; init; } // computed leaf, native condition, or placeholder (DV-4) public AlarmStateChanged? Alarm { get; init; } // computed leaf, native condition, or placeholder
public bool IsNativeBinding { get; init; } // branch grouping native conditions (DV-4) public bool IsNativeBinding { get; init; } // branch grouping native conditions
// Roll-up (set by the builder for branch nodes). // Roll-up (set by the builder for branch nodes).
public AlarmState WorstState { get; set; } = AlarmState.Normal; public AlarmState WorstState { get; set; } = AlarmState.Normal;
@@ -25,7 +25,7 @@
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back to Topology</button> <button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back to Topology</button>
<h4 class="mb-0">Configure Instance</h4> <h4 class="mb-0">Configure Instance</h4>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log @* Drill-in: deep-link into the central Audit Log
pre-filtered to this instance. Instance is UI-only on the filter bar pre-filtered to this instance. Instance is UI-only on the filter bar
(AuditEvent has no Instance column), so we use the ?instance= UI-text (AuditEvent has no Instance column), so we use the ?instance= UI-text
seam — the filter bar's Instance free-text input is pre-populated. *@ seam — the filter bar's Instance free-text input is pre-populated. *@
@@ -178,7 +178,7 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header py-2 d-flex justify-content-between align-items-center"> <div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Attribute Overrides</strong> <strong>Attribute Overrides</strong>
@* M7-T16: bulk import of attribute overrides from a CSV @* Bulk import of attribute overrides from a CSV
(AttributeName,Value[,ElementType]). Selecting a file parses + (AttributeName,Value[,ElementType]). Selecting a file parses +
validates it against this instance's overridable attributes and — validates it against this instance's overridable attributes and —
all-or-nothing — applies every row through the SAME all-or-nothing — applies every row through the SAME
@@ -598,7 +598,7 @@
// un-parseable List element caught on the pre-submit round-trip). // un-parseable List element caught on the pre-submit round-trip).
private Dictionary<string, string> _overrideErrors = new(); private Dictionary<string, string> _overrideErrors = new();
// M7-T16: CSV bulk-import result summary. _csvImportResult is the headline // CSV bulk-import result summary. _csvImportResult is the headline
// ("Imported N overrides." or "Import rejected — N error(s)."); on failure the // ("Imported N overrides." or "Import rejected — N error(s)."); on failure the
// per-line messages are listed from _csvImportErrors. Null until an import runs. // per-line messages are listed from _csvImportErrors. Null until an import runs.
private string? _csvImportResult; private string? _csvImportResult;
@@ -1047,7 +1047,7 @@
_saving = false; _saving = false;
} }
// ── M7-T16: CSV bulk override import ──────────────────── // ── CSV bulk override import ────────────────────
/// <summary> /// <summary>
/// Handles a selected override CSV. Reads the file text (size-capped), parses it /// Handles a selected override CSV. Reads the file text (size-capped), parses it
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary> /// <summary>
/// Code-behind for <c>InstanceConfigure.razor</c> — hosts the pure, side-effect-free /// Code-behind for <c>InstanceConfigure.razor</c> — hosts the pure, side-effect-free
/// core of the CSV bulk-override import (M7-T16) so it can be unit-pinned without /// core of the CSV bulk-override import so it can be unit-pinned without
/// standing up the page's ≈7 injected services. The Razor side reads the uploaded /// standing up the page's ≈7 injected services. The Razor side reads the uploaded
/// file's text, calls <see cref="OverrideCsvParser.Parse"/>, then feeds the result /// file's text, calls <see cref="OverrideCsvParser.Parse"/>, then feeds the result
/// here; on success it applies the returned dict through the SAME /// here; on success it applies the returned dict through the SAME
@@ -198,7 +198,7 @@
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>(); = Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
// Inbound-API key re-arch (C3): the approved-keys list is driven by the IInboundApiKeyAdmin // Inbound-API key re-arch: the approved-keys list is driven by the IInboundApiKeyAdmin
// seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.) // seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.)
// still lives on IInboundApiRepository — only the key↔method approval relationship moved to // still lives on IInboundApiRepository — only the key↔method approval relationship moved to
// per-key method-scopes. Keys are identified by an opaque string KeyId. // per-key method-scopes. Keys are identified by an opaque string KeyId.
@@ -213,7 +213,7 @@
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
// M9-T25: live per-connection health, keyed by DataConnection.Id. Sourced from // Live per-connection health, keyed by DataConnection.Id. Sourced from
// the existing site→central health transport via IConnectionHealthQueryService // the existing site→central health transport via IConnectionHealthQueryService
// and refreshed on a ~10s poll, mirroring the Health dashboard's timer pattern. // and refreshed on a ~10s poll, mirroring the Health dashboard's timer pattern.
// A connection absent from the map renders an "Unknown" badge (tolerates a site // A connection absent from the map renders an "Unknown" badge (tolerates a site
@@ -419,7 +419,7 @@
} }
} }
// ── M9-T24b / T33b: Move connection to another site ── // ── Move connection to another site ──
// Opened via IDialogService.ShowAsync. The body dispatches MoveDataConnectionCommand // Opened via IDialogService.ShowAsync. The body dispatches MoveDataConnectionCommand
// through the guard-running ManagementActor path (IDataConnectionMoveService) — NOT // through the guard-running ManagementActor path (IDataConnectionMoveService) — NOT
// a direct repository write — so the server enforces the Designer gate and every // a direct repository write — so the server enforces the Designer gate and every
@@ -451,7 +451,7 @@
.Select(r => (r.SiteId!.Value, r.Label)) .Select(r => (r.SiteId!.Value, r.Label))
.ToList(); .ToList();
// M9-T25: enum → Bootstrap badge class. Mirrors the Health dashboard's // Enum → Bootstrap badge class. Mirrors the Health dashboard's
// GetConnectionHealthBadge (Components/Pages/Monitoring/Health.razor) so the // GetConnectionHealthBadge (Components/Pages/Monitoring/Health.razor) so the
// design page surfaces the same colour coding for the same status. Kept as a // design page surfaces the same colour coding for the same status. Kept as a
// small local mirror — the Health helper is a private page-local method, not a // small local mirror — the Health helper is a private page-local method, not a
@@ -12,7 +12,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(Id.HasValue ? "Edit External System" : "Add External System")</h4> <h4 class="mb-0">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log @* Drill-in: deep-link into the central Audit Log
pre-filtered to this external system's outbound API events. Audit rows pre-filtered to this external system's outbound API events. Audit rows
record the target by external-system name, so we filter on Target. *@ record the target by external-system name, so we filter on Target. *@
@if (Id.HasValue && !string.IsNullOrWhiteSpace(_name)) @if (Id.HasValue && !string.IsNullOrWhiteSpace(_name))
@@ -6,9 +6,9 @@
@inject ISchemaLibraryService SchemaLibraryService @inject ISchemaLibraryService SchemaLibraryService
@inject IDialogService Dialog @inject IDialogService Dialog
@* Schema Library (M9-T32c): list + create/edit (via SchemaBuilder) + delete the @* Schema Library: list + create/edit (via SchemaBuilder) + delete the
reusable named JSON-Schema library entries that the {"$ref":"lib:Name"} resolver reusable named JSON-Schema library entries that the {"$ref":"lib:Name"} resolver
(T32b) resolves against. Every mutation is dispatched through ISchemaLibraryService resolves against. Every mutation is dispatched through ISchemaLibraryService
— the guard-running ManagementActor path — never a direct repo write. *@ — the guard-running ManagementActor path — never a direct repo write. *@
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
@@ -64,7 +64,7 @@
private Template? _ownerTemplate; private Template? _ownerTemplate;
private TemplateComposition? _ownerComposition; private TemplateComposition? _ownerComposition;
// M9-T26b: the FULL transitively-resolved inherited member set (multi-level // M9: the FULL transitively-resolved inherited member set (multi-level
// chain + post-creation base additions) + staleness, fetched read-only via // chain + post-creation base additions) + staleness, fetched read-only via
// GetResolvedTemplateMembersCommand. Populated only for derived templates; // GetResolvedTemplateMembersCommand. Populated only for derived templates;
// null when the read failed (editor falls back to the stored-row view). // null when the read failed (editor falls back to the stored-row view).
@@ -228,7 +228,7 @@
} }
} }
// M9-T26b: resolve the FULL inherited member set (whole chain + // M9: resolve the FULL inherited member set (whole chain +
// post-creation base additions) read-only. The stored-row tables // post-creation base additions) read-only. The stored-row tables
// above only carry the IMMEDIATE base; this surfaces the rest with // above only carry the IMMEDIATE base; this surfaces the rest with
// origin annotation + a base-changed staleness banner. Read-only: // origin annotation + a base-changed staleness banner. Read-only:
@@ -314,7 +314,7 @@
</div> </div>
} }
@* M9-T26b: the FULL transitively-resolved inherited member set — the whole @* M9: the FULL transitively-resolved inherited member set — the whole
chain (grandparent + further ancestors) plus base members added after chain (grandparent + further ancestors) plus base members added after
this template was created, which the immediate-base tables below cannot this template was created, which the immediate-base tables below cannot
show. Read-only preview; each row carries its origin template + lock show. Read-only preview; each row carries its origin template + lock
@@ -473,7 +473,7 @@
} }
}; };
// ---- M9-T26b: full resolved inherited set (read-only preview) ---- // ---- M9: full resolved inherited set (read-only preview) ----
/// <summary> /// <summary>
/// Renders the FULL transitively-resolved effective member set (attributes, /// Renders the FULL transitively-resolved effective member set (attributes,
@@ -425,7 +425,7 @@
} }
} }
// ---- Sibling reorder (T23b) ---- // ---- Sibling reorder ----
// Dispatches the reorder the same way Move/Rename do — directly through // Dispatches the reorder the same way Move/Rename do — directly through
// TemplateFolderService (the backend swaps SortOrder with the adjacent // TemplateFolderService (the backend swaps SortOrder with the adjacent
// same-parent sibling, no-op at the ends) — then reloads so the new order // same-parent sibling, no-op at the ends) — then reloads so the new order
@@ -471,7 +471,7 @@
.ToList(); .ToList();
} }
// ---- Root-level context menu (T23b) ---- // ---- Root-level context menu ----
// Right-click on the tree zone (empty space or below the tree) offers // Right-click on the tree zone (empty space or below the tree) offers
// New Folder / New Template at root. Node right-clicks are handled by the // New Folder / New Template at root. Node right-clicks are handled by the
// TreeView's own context menu and never reach here. // TreeView's own context menu and never reach here.
@@ -9,7 +9,7 @@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@* @*
TransportExport wizard (Component #24, Task T21). TransportExport wizard (Component #24).
A 4-step linear wizard: A 4-step linear wizard:
Step 1 — Select : templates (tree, checkbox-mode) + flat artifact lists. Step 1 — Select : templates (tree, checkbox-mode) + flat artifact lists.
@@ -138,7 +138,7 @@
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs) @RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
</fieldset> </fieldset>
@* S10c: SMS provider configs, mirroring the SMTP section above. Labelled by @* SMS provider configs, mirroring the SMTP section above. Labelled by
AccountSid (the bundle key); the secret AuthToken is never rendered. *@ AccountSid (the bundle key); the secret AuthToken is never rendered. *@
<fieldset class="mb-4" data-testid="group-sms-configs"> <fieldset class="mb-4" data-testid="group-sms-configs">
<legend class="h6">SMS Configurations</legend> <legend class="h6">SMS Configurations</legend>
@@ -361,12 +361,12 @@
{ {
<li>SmtpConfig: @s.Host</li> <li>SmtpConfig: @s.Host</li>
} }
@* S10c: SMS configs in the closure, mirroring SmtpConfig above; AccountSid only, never AuthToken. *@ @* SMS configs in the closure, mirroring SmtpConfig above; AccountSid only, never AuthToken. *@
@foreach (var s in _resolved.SmsConfigs.OrderBy(s => s.AccountSid, StringComparer.OrdinalIgnoreCase)) @foreach (var s in _resolved.SmsConfigs.OrderBy(s => s.AccountSid, StringComparer.OrdinalIgnoreCase))
{ {
<li>SmsConfig: @s.AccountSid</li> <li>SmsConfig: @s.AccountSid</li>
} }
@* Inbound API keys are not transported (re-arch C4) — methods only. *@ @* Inbound API keys are not transported — methods only. *@
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) @foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
{ {
<li>ApiMethod: @m.Name</li> <li>ApiMethod: @m.Name</li>

Some files were not shown because too many files have changed in this diff Show More