docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)

Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 14:12:25 -04:00
parent 3a217b9484
commit 0ffd4bdbea
217 changed files with 1224 additions and 695 deletions
@@ -227,7 +227,7 @@ public class AuditLogIngestActor : ReceiveActor
projected.Add(AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc));
}
// ONE set-based statement for the whole packet (WP2.2) instead of one
// ONE set-based statement for the whole packet instead of one
// IF NOT EXISTS … INSERT round trip per event. A telemetry packet of 200
// rows used to cost 200 sequential round trips on the central singleton's
// dispatcher; it now costs two. Idempotency is unchanged — duplicates
@@ -356,7 +356,7 @@ public class AuditLogIngestActor : ReceiveActor
var strategy = dbContext.Database.CreateExecutionStrategy();
// Fast path (WP2.2): the WHOLE packet in ONE transaction — one
// Fast path: the WHOLE packet in ONE transaction — one
// set-based audit insert plus one single-statement upsert per entry.
// The predecessor opened a transaction PER entry and issued three
// statements inside it (IF NOT EXISTS insert, then the two-statement
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// than <c>DateTime.UtcNow - RetentionDays</c>.</item>
/// <item>For each eligible boundary, calls
/// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>, a
/// metadata-only staging-table switch (WP2.2 removed the index
/// drop/rebuild that used to bracket it).</item>
/// metadata-only staging-table switch (no index
/// drop/rebuild brackets it).</item>
/// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system
/// EventStream so the central health collector + ops surfaces
/// can subscribe without coupling to this actor.</item>
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <remarks>
/// <para>
/// The purge actor is a daily-cadence singleton, not a hot-loop. The
/// partition switch itself is metadata-only (since WP2.2's
/// <c>AlignAuditLogEventIdUniqueness</c> it no longer drops and rebuilds a
/// partition switch itself is metadata-only (since the
/// <c>AlignAuditLogEventIdUniqueness</c> migration it no longer drops and rebuilds a
/// non-aligned unique index around it), but it still takes schema-modification
/// locks and competes with ingest for the same table. Lower this only when an
/// operator can prove they need sub-daily purge granularity.
@@ -28,6 +28,9 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
private readonly ILogger<GrpcPullTraceEventsClient> _logger;
/// <summary>Creates the client over the given site enumerator and unary-call invoker.</summary>
/// <param name="sites">Enumerates the sites and their gRPC endpoints.</param>
/// <param name="invoker">Issues the unary <c>PullTraceEvents</c> call.</param>
/// <param name="logger">Logger for pull diagnostics.</param>
public GrpcPullTraceEventsClient(
ISiteEnumerator sites,
IPullTraceEventsInvoker invoker,
@@ -152,6 +155,11 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
public interface IPullTraceEventsInvoker
{
/// <summary>Issues the RPC against <paramref name="endpoint"/> with the site's preshared key.</summary>
/// <param name="siteId">The site the call targets.</param>
/// <param name="endpoint">The site node gRPC endpoint to call.</param>
/// <param name="request">The pull request.</param>
/// <param name="ct">Token to cancel the call.</param>
/// <returns>The site's trace-event response.</returns>
Task<PullTraceEventsResponse> InvokeAsync(
string siteId, string endpoint, PullTraceEventsRequest request, CancellationToken ct);
}
@@ -169,6 +177,8 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
private readonly ISitePskProvider? _pskProvider;
/// <summary>Creates the invoker with the given options and optional per-site PSK provider.</summary>
/// <param name="options">Communication options supplying keepalive settings.</param>
/// <param name="pskProvider">Optional provider of per-site preshared keys.</param>
public GrpcPullTraceEventsInvoker(CommunicationOptions options, ISitePskProvider? pskProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
@@ -212,7 +222,7 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
},
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
/// <inheritdoc />
/// <summary>Disposes every cached gRPC channel.</summary>
public void Dispose()
{
foreach (var channel in _channels.Values)
@@ -521,6 +521,8 @@ public class SiteAuditReconciliationActor : ReceiveActor
/// lost rows get distinct synthetic ids.
/// </para>
/// </remarks>
/// <param name="lostEventId">The event id of the lost row being abandoned.</param>
/// <returns>A deterministic synthetic GUID for the abandonment row.</returns>
internal static Guid DeriveAbandonmentEventId(Guid lostEventId)
{
Span<byte> input = stackalloc byte[32];
@@ -150,7 +150,7 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor, IDisposable
}
}
/// <inheritdoc />
/// <summary>Disposes the options-change registration, if any.</summary>
public void Dispose() => _optionsReload?.Dispose();
/// <summary>
@@ -174,7 +174,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery();
}
// synchronous=NORMAL (arch-review remediation WP1.2): SQLite's FULL default
// synchronous=NORMAL: SQLite's FULL default
// fsyncs on every transaction commit, which is the dominant cost of the
// per-event write path this same change coalesces via FlushIntervalMs — FULL
// would still fsync once per FLUSH even after that fix. NORMAL fsyncs at
@@ -346,8 +346,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// burst of concurrent enqueues. Once the immediately-available
// burst is drained, keep the transaction open for up to
// FlushIntervalMs waiting for MORE events to trickle in — this is
// the soft-flush coalescing arch-review remediation WP1.2 adds:
// without it, a low-rate trickle of writes (one script call every
// the soft-flush coalescing
// this loop provides: without it, a low-rate trickle of writes (one script call every
// few ms, well under BatchSize) fsyncs once PER EVENT, because the
// writer loop reaches an empty channel and flushes immediately.
// FlushIntervalMs bounds the worst-case added latency any single
@@ -392,8 +392,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary>
/// Total number of <see cref="FlushBatch"/> transactions committed since
/// construction. Test-only observability seam (arch-review remediation
/// WP1.2) for asserting that FlushIntervalMs coalesces a trickle of
/// construction. Test-only observability seam for asserting that FlushIntervalMs coalesces a trickle of
/// near-simultaneous writes into a single transaction rather than one
/// commit per event; production code never reads this.
/// </summary>
@@ -499,8 +498,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
{
eventCmd.ExecuteNonQuery();
fwdCmd.ExecuteNonQuery();
// Do NOT complete pending.Completion here (arch-review 2026-08-19
// P4-2). A row's INSERTs succeeding is not durable until the
// Do NOT complete pending.Completion here. A row's INSERTs succeeding is not durable until the
// transaction actually commits below — completing the caller's Task
// this early means a later commit failure (SQLITE_FULL, IOERR, or a
// later row in this same batch throwing) reports false success: the
@@ -866,7 +864,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary>Flow Debugger PullTraceEvents: hard cap on ids per read (design §5.4).</summary>
public const int MaxExecutionIdsPerRead = 64;
/// <inheritdoc />
/// <summary>
/// Reads audit events belonging to any of the given execution ids, newer than <paramref name="sinceUtc"/>, in keyset-paged order.
/// </summary>
/// <param name="executionIds">Execution ids to match (capped at <see cref="MaxExecutionIdsPerRead"/>).</param>
/// <param name="sinceUtc">Lower bound on the event's occurrence time.</param>
/// <param name="limit">Maximum number of events to return; must be positive.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="afterEventId">Optional keyset cursor; only events after this id are returned.</param>
/// <returns>The matching audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
{
@@ -925,6 +931,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// UNION deduplicates rows matching both IDs. Keeping payloads outside the union bounds sorting
/// to IDs/timestamps and avoids the optimizer preferring a whole-store timestamp scan.
/// </summary>
/// <param name="inList">The pre-built SQL parameter list for the <c>IN (...)</c> clauses.</param>
/// <returns>The trace-read SQL text.</returns>
internal static string BuildTraceReadSql(string inList) => $"""
SELECT e.EventId, e.OccurredAtUtc, e.Actor, e.Action, e.Outcome,
e.Category, e.Target, e.SourceNode, e.CorrelationId, e.DetailsJson
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </summary>
/// <remarks>
/// <see cref="SqliteAuditWriterOptions.DatabasePath"/> is deliberately NOT
/// validated here (arch-review remediation WP1.2). <c>AddAuditLog</c> runs on
/// validated here. <c>AddAuditLog</c> runs on
/// BOTH Central and Site composition roots and binds this options type with
/// <c>ValidateOnStart</c> either way, but only Site nodes ever resolve
/// <see cref="SqliteAuditWriter"/> — requiring a non-empty path in this
@@ -17,6 +17,8 @@ internal static class SqliteErrorCodes
/// "primary/extended" (e.g. "10/522") for a <see cref="SqliteException"/>
/// anywhere in the exception chain; "n/a" for non-SQLite failures.
/// </summary>
/// <param name="ex">The exception to inspect, including its inner exceptions.</param>
/// <returns>The "primary/extended" SQLite error code string, or "n/a" for non-SQLite failures.</returns>
public static string Describe(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
@@ -220,8 +220,8 @@ public static class AuditCommands
// Ctrl-C is the one way to abort an in-flight export other than the
// headers/inactivity deadlines below — mirrors the CancellationTokenSource
// owned by DebugCommands' streaming debug-view command (arch-review
// 2026-08-19 P6-8: this was the one CLI HTTP path with NO deadline at all).
// owned by DebugCommands' streaming debug-view command (this was the
// one CLI HTTP path with NO deadline at all).
using var cts = new CancellationTokenSource();
ConsoleCancelEventHandler onCancel = (_, e) =>
{
@@ -115,7 +115,7 @@ public static class AuditExportHelpers
/// and <see cref="SocketsHttpHandler.ConnectTimeout"/> bounds only the TCP/TLS
/// connect phase, not a connected-but-silent server. Reuses
/// <see cref="ManagementHttpClient.DefaultConnectTimeout"/> as the standard CLI
/// per-call default (arch-review 2026-08-19 P6-8).
/// per-call default.
/// </summary>
public static readonly TimeSpan DefaultHeadersTimeout = ManagementHttpClient.DefaultConnectTimeout;
@@ -126,7 +126,7 @@ public static class AuditExportHelpers
/// hung/crashed server holding the connection open) is bounded rather than
/// hanging the CLI forever — the residual gap the timeout-honesty fix
/// (<c>ManagementHttpClient.DefaultConnectTimeout</c> doc comment) left in this
/// one path (arch-review 2026-08-19 P6-8).
/// one path.
/// </summary>
public static readonly TimeSpan DefaultInactivityTimeout = ManagementHttpClient.DefaultConnectTimeout;
@@ -204,7 +204,7 @@ public static class AuditExportHelpers
// Honour the documented "authorization failure → exit 2"
// contract on the REST audit surface as well. HTTP 403 is the
// 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 status other than 403.
var errorCode = TryExtractErrorCode(message);
var isAuthFailure = (int)response.StatusCode == 403
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
@@ -404,7 +404,7 @@ public static class BundleCommands
// RunBundleCommandAsync that re-implemented URL/credential resolution and
// skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync
// enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error
// code on a non-403 status would exit 1 instead of the documented exit 2.
// code on a status other than 403 would exit 1 instead of the documented exit 2.
// The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with
// the longer BundleCommandTimeout and a per-command success handler, so the
// exit-code contract is unified across every command group.
@@ -262,7 +262,7 @@ internal static class CommandHelpers
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
// A "note" property that appears ONLY on single-property rows (a trailing
// run-level note, or — Task 14 review, 2026-09-09 — a step's folded log/exception
// run-level note, or a step's folded log/exception
// line) is not a real column: every other row's cell there would render blank,
// and column padding would swallow the raw line's own leading indentation. Such
// rows print as a raw unpadded line instead, at their position in the sequence,
@@ -96,6 +96,10 @@ public static class DeployCommands
/// whose summaries all carry <c>failureCount</c> — a per-site failure is a successful HTTP
/// call, so without this the process exited 0 and scripts could not detect it.
/// </summary>
/// <param name="json">The JSON response body from the management API.</param>
/// <param name="format">The output format (e.g. json or table).</param>
/// <param name="summaryName">Display name for the summary, used in the invalid-response error.</param>
/// <returns>0 when nothing failed; 1 when any failure was reported or the summary was invalid.</returns>
internal static int HandleSiteDeploymentResponse(string json, string format, string summaryName = "site deployment")
{
CommandHelpers.HandleResponse(new ManagementResponse(200, json, null, null), format);
@@ -215,6 +215,8 @@ public static class FlowDebugCommands
/// means "no parameters". Throws <see cref="JsonException"/> on malformed input or a
/// non-object root (the inbound API body is always a flat JSON object).
/// </summary>
/// <param name="json">The raw <c>--params</c> JSON text, or null/blank for none.</param>
/// <returns>The parsed root object, or null when no parameters were supplied.</returns>
internal static JsonElement? ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -45,7 +45,7 @@ internal static class FlowDebugTableProjection
/// <param name="showLogs">
/// When true, prints each step's <c>ScriptLog</c> lines and (on a failed step) the first
/// three lines of its exception detail as additional indented <c>note</c> rows immediately
/// following that step's row (step-logs design 2026-09-09, CLI Task 14). The <c>logs</c>
/// following that step's row. The <c>logs</c>
/// column (a count of attached log lines) is always present, independent of this flag.
/// </param>
/// <returns>Compact JSON suitable for table rendering.</returns>
@@ -103,7 +103,7 @@ internal static class FlowDebugTableProjection
if (truncated)
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
// Final review M8: the page shows this; the CLI silently dropped it.
// The page shows this; the CLI silently dropped it.
if (logsTruncated)
notes.Add("inline log lines capped — replay pulls the rest");
@@ -97,7 +97,7 @@ public class ManagementHttpClient : IDisposable
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
{
_httpClient = httpClient;
// Test seam (WP2.6e): exposes the constructed HttpClient's effective Timeout
// Test seam: exposes the constructed HttpClient's effective Timeout
// without requiring reflection.
EffectiveTimeout = httpClient.Timeout;
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
@@ -36,8 +36,7 @@ public static class OutputFormatter
/// <summary>
/// One row for the <see cref="TableRow"/> overload of <c>WriteTable</c>: either padded
/// column cells like the plain <see cref="string[]"/> overload, or an already-formatted
/// line printed verbatim with no column padding/alignment at all (Task 14 review,
/// 2026-09-09) — used for a note/log line folded onto a step, which must render as a raw
/// line printed verbatim with no column padding/alignment at all — used for a note/log line folded onto a step, which must render as a raw
/// indented line under that step's row rather than as a cell in a table column (every
/// other row's cell there would otherwise render blank, and the line's own leading
/// whitespace would be swallowed by column padding).
@@ -50,14 +49,20 @@ public static class OutputFormatter
RawLine = rawLine;
}
/// <summary>The column cells of a normal row, or null for a raw line.</summary>
internal string[]? Cells { get; }
/// <summary>The verbatim line of a raw row, or null for a column row.</summary>
internal string? RawLine { get; }
/// <summary>A normal row, rendered as padded columns aligned with the header set.</summary>
/// <param name="cells">The column cell values.</param>
/// <returns>A column row.</returns>
internal static TableRow Columns(string[] cells) => new(cells, null);
/// <summary>An already-formatted line, printed exactly as given with no column alignment.</summary>
/// <param name="line">The preformatted line.</param>
/// <returns>A raw row.</returns>
internal static TableRow Raw(string line) => new(null, line);
}
@@ -2,5 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker;
internal static class WorkerProgram
{
/// <summary>Runs the sandbox worker protocol loop until the host closes it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public static Task Main() => ScriptAnalysis.SandboxWorkerProtocol.RunWorkerAsync();
}
@@ -228,6 +228,11 @@ public static class AuthEndpoints
}
/// <summary>Records real browser input only after authenticating and validating antiforgery.</summary>
/// <param name="context">The current HTTP context.</param>
/// <param name="antiforgery">Antiforgery service used to validate the request.</param>
/// <param name="tracker">Tracker that records the session activity.</param>
/// <param name="clock">Time source for the activity timestamp.</param>
/// <returns>An HTTP result indicating whether the activity was recorded.</returns>
public static async Task<IResult> HandleActivity(HttpContext context, IAntiforgery antiforgery,
SessionActivityTracker tracker, TimeProvider clock)
{
@@ -13,11 +13,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
public static class ClaimsPrincipalExtensions
{
/// <summary>Gets fresh mutation authority, failing closed on a mapping error.</summary>
/// <param name="provider">The authentication state provider.</param>
/// <returns>The authoritative authentication state.</returns>
public static Task<AuthenticationState> GetAuthoritativeStateAsync(this AuthenticationStateProvider provider)
=> provider is CookieAuthenticationStateProvider cookie
? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync();
/// <summary>Checks the current grant before invoking a directly audited application service.</summary>
/// <param name="provider">The authentication state provider.</param>
/// <param name="role">The role the current session must hold.</param>
/// <returns>The username of the authorized principal.</returns>
public static async Task<string> RequireRoleAsync(this AuthenticationStateProvider provider, string role)
{
var principal = (await provider.GetAuthoritativeStateAsync()).User;
@@ -23,6 +23,9 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
/// <param name="httpContextAccessor">Supplies the user captured at construction.</param>
/// <param name="scopes">Factory for fresh scopes used during revalidation.</param>
/// <param name="autoLogin">Options indicating whether login is disabled (auto-login).</param>
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null)
{
_state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()));
@@ -36,6 +39,7 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false);
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
/// <returns>The authoritative authentication state.</returns>
public Task<AuthenticationState> GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true);
private async Task<AuthenticationState> RefreshAsync(bool failClosed)
@@ -23,7 +23,7 @@ public static class BrowserTime
/// <param name="browserUtcOffsetMinutes">
/// The value of JavaScript <c>new Date().getTimezoneOffset()</c>: the number
/// of minutes that, <b>added</b> to local time, yields UTC. It is positive
/// for time zones behind UTC (e.g. +300 for UTC-5) and negative for zones
/// for time zones behind UTC (e.g. +300 for five hours behind UTC) and negative for zones
/// ahead (e.g. -120 for UTC+2).
/// </param>
/// <returns>The equivalent instant in UTC, or <c>null</c> when the input is null.</returns>
@@ -79,7 +79,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
/// <summary>
/// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON
/// escape hatch validates against the same resolved shape the form renders (#37).
/// escape hatch validates against the same resolved shape the form renders.
/// </summary>
[Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!;
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
@@ -154,7 +154,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool _scriptParamsValid;
/// <summary>
/// Method entry (#37): the chosen method's resolved root schema (null when it declares no
/// Method entry: the chosen method's resolved root schema (null when it declares no
/// parameters or the definitions do not parse) and its top-level fields — the card renders
/// only when there is at least one. The values live in <see cref="_methodParams"/>, the same
/// coerced value tree <c>ParameterValueForm</c> emits for the script pane, so the request
@@ -166,7 +166,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool _methodParamsValid;
/// <summary>
/// Raw-JSON escape hatch for the whole method payload (#37): paste the exact body an
/// Raw-JSON escape hatch for the whole method payload: paste the exact body an
/// external caller would send. The text is parsed and schema-validated on every edit;
/// only a valid document reaches <see cref="_methodParams"/>, which stays the single source
/// of truth in both modes.
@@ -214,6 +214,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// on a step are NOT here — they are reported separately as
/// <see cref="FlowGraph.UnattachedLogLines"/>.
/// </summary>
/// <param name="graph">The flow graph to count log lines in, or null.</param>
/// <returns>The error, warning, info and total log-line counts.</returns>
internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph)
{
if (graph is null)
@@ -706,7 +708,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
// row the run did not already have, the page is no longer short of lines and the flag
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
// 5-30 s later, so a refresh that adds nothing proves nothing.
// Final review M7: read the OUTCOME's own flag — FlowDebugService copies the raw result's
// Read the OUTCOME's own flag — FlowDebugService copies the raw result's
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
// member with no consumer.
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
@@ -721,6 +723,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
/// <param name="known">Events already held by the page, or null.</param>
/// <param name="fresh">Newly read Replay events, or null.</param>
/// <returns>The merged event list.</returns>
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
{
var byId = new Dictionary<Guid, AuditEvent>();
@@ -852,7 +857,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
}
/// <inheritdoc/>
/// <summary>Cancels any pending outcome refresh when the component is disposed.</summary>
public void Dispose() => CancelPendingRefresh();
private async Task LoadReplayAsync()
@@ -972,7 +977,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
/// <summary>
/// Form ⇄ Raw JSON (#37). Entering raw mode seeds the editor from the current value tree so
/// Form ⇄ Raw JSON. Entering raw mode seeds the editor from the current value tree so
/// the operator edits what the form would have sent; leaving it re-mounts the form over the
/// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is
/// on, so it mounts fresh and re-reports validity from the tree).
@@ -1054,6 +1059,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <c>ParameterValueForm</c> emits and what the raw editor parsed), or null when the method
/// declares no parameters. Run is gated on validity, so nothing here re-checks the tree.
/// </summary>
/// <returns>The serialized parameters, or null when the method declares none.</returns>
internal JsonElement? BuildParameters()
=> _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams);
@@ -1062,6 +1068,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
/// </summary>
/// <returns>The serialized parameters, or null when the form produced nothing.</returns>
internal JsonElement? BuildScriptParameters()
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
@@ -1092,7 +1099,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <summary>
/// Restores a persisted value tree only when it still validates against the method's current
/// schema — a stale tree (the schema changed, or the pre-#37 flat string-per-field entry) is
/// schema — a stale tree (the schema changed, or the older flat string-per-field entry) is
/// dropped rather than painted into the form as errors.
/// </summary>
private async Task RestoreInputsAsync(int methodId)
@@ -123,6 +123,10 @@ public partial class InstanceConfigure
/// counts: it pins a reportable template alarm off for this instance, which is a
/// real override and not the same as inheriting.
/// </summary>
/// <param name="overrideJson">The override JSON entered in the modal, if any.</param>
/// <param name="priority">The priority override, if one was chosen.</param>
/// <param name="mesReportable">The MES-reportable override, if one was chosen.</param>
/// <returns><c>true</c> when the modal carries any override worth persisting; otherwise <c>false</c>.</returns>
internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable)
=> !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue;
@@ -112,7 +112,7 @@ public partial class TransportImport : ComponentBase, IDisposable
/// <summary>
/// Guards <see cref="_session"/> together with <see cref="_disposed"/> (register row
/// 103, the P6-6 lesser instance — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
/// 103 — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
/// <see cref="TryLoadAsync"/> awaits <see cref="IBundleImporter.LoadAsync"/>, and the
/// circuit can be torn down inside that window; publishing the freshly-opened session
/// and observing disposal must be one atomic step so exactly one side (this method or
@@ -258,7 +258,7 @@ public partial class TransportImport : ComponentBase, IDisposable
var loaded = await BundleImporter.LoadAsync(
stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
// Register row 103 (P6-6 lesser instance): the component may have been
// Register row 103: the component may have been
// disposed while LoadAsync was in flight (navigation away mid-upload).
// Publish and re-check disposal atomically under _sessionLock so exactly
// one side tears the freshly-opened session down.
@@ -278,9 +278,9 @@ public partial class TransportImport : ComponentBase, IDisposable
}
catch (ArgumentException)
{
// Encrypted bundle, no passphrase supplied — caller advances to Step 2.
// Encrypted bundle, no passphrase supplied — caller advances to the passphrase step.
// We deliberately do NOT set _errorMessage here; the page surfaces
// an empty Step-2 prompt instead.
// an empty passphrase prompt instead.
_session = null;
throw;
}
@@ -977,7 +977,7 @@ public partial class TransportImport : ComponentBase, IDisposable
/// Register row 103: the flag is set and the session taken under the same lock
/// <see cref="TryLoadAsync"/> publishes through, so a <see cref="TryLoadAsync"/> still
/// awaiting <see cref="IBundleImporter.LoadAsync"/> either hands the session over here
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s P6-6 fix).
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s fix).
/// </para>
/// </summary>
public void Dispose()
@@ -138,6 +138,8 @@ public partial class FlowGraphView : ComponentBase
}
/// <summary>Formats a coordinate culture-invariantly.</summary>
/// <param name="v">The value to format.</param>
/// <returns>The value formatted with up to two decimals using the invariant culture.</returns>
public static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
private string RenderedWidth => F(_layout.Width * Math.Max(0.1, Zoom));
@@ -199,6 +201,8 @@ public partial class FlowGraphView : ComponentBase
=> n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
/// <summary>Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).</summary>
/// <param name="outcome">The folded outcome of the outbound step.</param>
/// <returns>The short pill label for the outcome.</returns>
public static string PillText(FlowStepOutcome outcome) => outcome switch
{
FlowStepOutcome.Ok => "Ok",
@@ -224,6 +228,9 @@ public partial class FlowGraphView : ComponentBase
/// sync database call's operation detail (<c>write · 3 rows</c>) kept in front of it. The projector's
/// subtitle already leads with the audit status, which the pill now carries, so that head is dropped.
/// </summary>
/// <param name="kind">The kind of the outbound step.</param>
/// <param name="subtitle">The step's projected subtitle, which may lead with the audit status.</param>
/// <returns>The mode text shown after the status pill.</returns>
public static string OutboundMode(FlowStepKind kind, string? subtitle)
{
if (kind == FlowStepKind.Notify) return "notify";
@@ -243,6 +250,8 @@ public partial class FlowGraphView : ComponentBase
/// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge
/// never silently drops a line.
/// </summary>
/// <param name="s">The step whose log lines are counted; may be null.</param>
/// <returns>The error, warning and info line counts.</returns>
internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s)
{
if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0);
@@ -260,6 +269,8 @@ public partial class FlowGraphView : ComponentBase
}
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
/// <param name="counts">The per-severity log line counts.</param>
/// <returns>The hover text listing the non-zero levels.</returns>
internal static string LogTitle((int Errors, int Warnings, int Info) counts)
{
var (errors, warnings, info) = counts;
@@ -273,12 +284,14 @@ public partial class FlowGraphView : ComponentBase
/// <summary>
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
/// the type is the exception's name without its namespace. Parsing is
/// <see cref="ExceptionHeader.Parse"/> (final review M1 — the same parser the step detail panel
/// <see cref="ExceptionHeader.Parse"/> (the same parser the step detail panel
/// uses), which takes the first line of the captured <see cref="FlowStep.ErrorDetail"/> and follows
/// its <c>" ---&gt; "</c> chain to the innermost exception. Falls back to the line itself when it
/// carries no <c>": "</c>, and to <see cref="FlowStep.ErrorMessage"/> when there is no usable detail.
/// Null for every non-failed step.
/// </summary>
/// <param name="s">The step to build the subtitle for.</param>
/// <returns>The exception subtitle, or null for a step that did not fail.</returns>
internal static string? ExceptionSubtitle(FlowStep s)
{
if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null;
@@ -305,6 +318,8 @@ public partial class FlowGraphView : ComponentBase
/// than one row — the submit→terminal wall time with a leading <c>+</c> (<c>+4.1 s</c>), since that
/// span is elapsed time downstream of the script, not time the script spent (node anatomy).
/// </summary>
/// <param name="n">The layout node to describe.</param>
/// <returns>The formatted duration text for the node's head row.</returns>
public static string Duration(FlowLayoutNode n)
{
if (n.Step?.DurationMs is not int ms)
@@ -317,6 +332,9 @@ public partial class FlowGraphView : ComponentBase
}
/// <summary>Clips a gutter label to what fits, keeping the full text in a <c>title</c>.</summary>
/// <param name="text">The label text; may be null.</param>
/// <param name="max">The maximum number of characters to keep.</param>
/// <returns>The text, clipped to fit when it is longer than the maximum.</returns>
public static string Clip(string? text, int max)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
@@ -329,6 +347,8 @@ public partial class FlowGraphView : ComponentBase
/// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the
/// shared head is shown once in the lane gutter and each node shows the tail that tells it apart.
/// </summary>
/// <param name="titles">The node titles on the lane.</param>
/// <returns>The shared dotted prefix, or null when there is none worth showing.</returns>
public static string? CommonDottedPrefix(IReadOnlyList<string> titles)
{
if (titles.Count < 2) return null;
@@ -352,6 +372,8 @@ public partial class FlowGraphView : ComponentBase
/// camel-case boundaries so a wrapped title breaks at <c>DelmiaRecipe|Download</c> rather than
/// mid-word; the browser still falls back to breaking anywhere for a single overlong segment.
/// </summary>
/// <param name="text">The title text; may be null.</param>
/// <returns>The text with zero-width spaces inserted at preferred break points.</returns>
public static string WrapHints(string? text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
@@ -373,6 +395,9 @@ public partial class FlowGraphView : ComponentBase
/// Removes <paramref name="prefix"/> where it starts <paramref name="text"/> or a comma-separated
/// item within it (the write subtitle is <c>a.X = 1, a.Y = 2</c>); null/empty text passes through.
/// </summary>
/// <param name="text">The text to strip; may be null.</param>
/// <param name="prefix">The prefix to remove; may be null.</param>
/// <returns>The text with the prefix removed.</returns>
public static string? StripPrefix(string? text, string? prefix)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text;
@@ -385,6 +410,14 @@ public partial class FlowGraphView : ComponentBase
/// <c>&lt;text&gt;</c> tag for its own markup-mode switch and refuses attributes on it,
/// so the SVG element of the same name cannot be written literally inside a code block.
/// </summary>
/// <param name="x">The x coordinate.</param>
/// <param name="y">The y coordinate.</param>
/// <param name="cssClass">The CSS class for the element.</param>
/// <param name="content">The text content, encoded by this method.</param>
/// <param name="anchor">The optional <c>text-anchor</c> value.</param>
/// <param name="title">The optional tooltip title.</param>
/// <param name="dataTest">The optional <c>data-test</c> attribute value.</param>
/// <returns>The encoded SVG text element markup.</returns>
public static MarkupString SvgText(string x, string y, string cssClass, string content, string? anchor = null, string? title = null, string? dataTest = null)
{
var sb = new StringBuilder("<text x=\"").Append(x).Append("\" y=\"").Append(y).Append("\" class=\"").Append(cssClass).Append('"');
@@ -427,6 +460,10 @@ public partial class FlowGraphView : ComponentBase
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
/// Kept in step with those two path builders.
/// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <param name="isAsync">True when the connector is an async branch.</param>
/// <returns>The midpoint of the connector's vertical run.</returns>
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
{
var x1 = a.X + NodeWidth;
@@ -460,6 +497,7 @@ public partial class FlowGraphView : ComponentBase
/// </summary>
/// <param name="graph">The real graph, or null.</param>
/// <param name="predicted">The prediction, or null.</param>
/// <returns>The computed layout.</returns>
public static FlowLayout ComputeLayout(FlowGraph? graph, PredictedFlow? predicted)
{
var hasReal = graph is { Steps.Count: > 0 };
@@ -652,6 +690,8 @@ public partial class FlowGraphView : ComponentBase
/// up to <see cref="ExternalSublabelTargets"/> named, the rest folded into <c>+n</c>
/// (<c>Delmia.AckRecipe · MesDb · Planners +1</c>). Empty when the lane is unused.
/// </summary>
/// <param name="distinctTargets">The distinct external targets in first-seen order.</param>
/// <returns>The gutter sublabel text.</returns>
public static string ExternalSublabel(IReadOnlyList<string> distinctTargets)
{
if (distinctTargets.Count == 0) return string.Empty;
@@ -666,6 +706,9 @@ public partial class FlowGraphView : ComponentBase
/// crosses a node sitting between the two lanes. Falls back to <see cref="OrthogonalPath"/> when
/// the target is not below the source.
/// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <returns>The SVG path data for the connector.</returns>
public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b)
{
if (b.Y <= a.Y)
@@ -682,6 +725,9 @@ public partial class FlowGraphView : ComponentBase
/// of <paramref name="b"/>: straight when in the same lane, otherwise right-down/up-right
/// through the inter-column gap. A backwards edge (target left of source) loops under.
/// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <returns>The SVG path data for the connector.</returns>
public static string OrthogonalPath(FlowLayoutNode a, FlowLayoutNode b)
{
var x1 = a.X + NodeWidth;
@@ -58,8 +58,8 @@ public interface IDialogService
/// button's styling.</param>
/// <param name="confirmLabel">Verb rendered on the confirm button. Only
/// <see cref="DialogSeverity.Destructive"/> falls back to "Delete" when this is
/// blank; the other severities fall back to "Confirm". The row-28 rule still
/// applies — name the action.</param>
/// blank; the other severities fall back to "Confirm". The rule to name the
/// action still applies.</param>
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null);
@@ -8,11 +8,17 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
private readonly StringBuilder _retained = new(Math.Max(0, capacity));
private readonly object _gate = new();
private long _dropped;
/// <summary>Number of characters currently retained.</summary>
public int RetainedCharacters { get { lock (_gate) return _retained.Length; } }
/// <summary>Number of characters discarded because the budget was reached.</summary>
public long DroppedCharacters { get { lock (_gate) return _dropped; } }
/// <inheritdoc />
public override void Write(char value) { Span<char> one = stackalloc char[1]; one[0] = value; Write(one); }
/// <inheritdoc />
public override void Write(string? value) { if (value is not null) Write(value.AsSpan()); }
/// <inheritdoc />
public override void Write(char[] buffer, int index, int count) => Write(buffer.AsSpan(index, count));
/// <inheritdoc />
public override void Write(ReadOnlySpan<char> buffer)
{
lock (_gate)
@@ -22,24 +28,32 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
_dropped = Math.Min(long.MaxValue - (buffer.Length - take), _dropped) + buffer.Length - take;
}
}
/// <inheritdoc />
public override void WriteLine(string? value) { lock (_gate) { Write(value); Write(NewLine); } }
/// <inheritdoc />
public override void WriteLine() => Write(NewLine);
/// <inheritdoc />
public override string ToString() { lock (_gate) return _retained.ToString() + (_dropped > 0 ? "\n… (truncated)" : ""); }
}
/// <summary>Rejects serialized data once its byte budget is reached, without retaining the excess.</summary>
public sealed class BoundedSandboxStream(int capacity) : MemoryStream
{
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
/// <inheritdoc />
public override void Write(ReadOnlySpan<byte> buffer)
{
if (Length + buffer.Length > capacity) throw new InvalidDataException("Sandbox output exceeds its byte budget.");
var bounded = buffer.ToArray();
base.Write(bounded, 0, bounded.Length);
}
/// <inheritdoc />
public override void WriteByte(byte value) { Span<byte> one = stackalloc byte[1]; one[0] = value; Write(one); }
/// <inheritdoc />
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; }
/// <inheritdoc />
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer, offset, count); return Task.CompletedTask; }
}
@@ -521,7 +521,7 @@ public class SandboxAttributeAccessor
/// <summary>
/// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c>
/// overload (Gitea #36): present for editor/compile parity, throws
/// overload: present for editor/compile parity, throws
/// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport).
/// </summary>
/// <param name="values">The attribute values to write as a batch.</param>
@@ -21,10 +21,22 @@ public static class SandboxWorkerProtocol
private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open);
/// <summary>Runs one child under a deadline; a slot remains charged until confirmed process exit.</summary>
/// <param name="request">The sandbox run request to execute in the child process.</param>
/// <param name="services">The service provider supplying host services for the run.</param>
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
/// <param name="caller">Cancellation token from the caller.</param>
/// <returns>The sandbox run result.</returns>
public static async Task<SandboxRunResult> RunAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller)
=> await RunCoreAsync(request, services, catalog, caller);
/// <summary>Core run implementation; exposes a callback that reports the child process id once started.</summary>
/// <param name="request">The sandbox run request to execute in the child process.</param>
/// <param name="services">The service provider supplying host services for the run.</param>
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
/// <param name="caller">Cancellation token from the caller.</param>
/// <param name="started">Optional callback invoked with the child process id after launch.</param>
/// <returns>The sandbox run result.</returns>
internal static async Task<SandboxRunResult> RunCoreAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller, Action<int>? started = null)
{
@@ -163,6 +175,7 @@ public static class SandboxWorkerProtocol
}
/// <summary>Worker executable entry. Protocol streams are captured before script console routing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public static async Task RunWorkerAsync()
{
var input = Console.OpenStandardInput(); var output = Console.OpenStandardOutput();
@@ -195,15 +208,21 @@ public static class SandboxWorkerProtocol
}
finally { _gate.Release(); }
}
/// <inheritdoc />
public Task<IReadOnlyList<ScriptShape>> GetShapesAsync() => Task.FromResult<IReadOnlyList<ScriptShape>>([]);
/// <inheritdoc />
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
=> (await Call("shared", [name], cancellationToken)).Deserialize<SharedScriptSource>(Json);
/// <inheritdoc />
public async Task<ExternalCallResult> CallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, CancellationToken cancellationToken = default)
=> (await Call("external", [systemName, methodName, parameters], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<ExternalCallResult> CachedCallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedExternal", [systemName, methodName, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<ExternalCallResult> CachedWriteAsync(string connectionName, string sql, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedDb", [connectionName, sql, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<DbConnection> GetConnectionAsync(string connectionName, CancellationToken cancellationToken = default)
{
var info = (await Call("connection", [connectionName], cancellationToken)).Deserialize<ConnectionInfo>(Json)!;
@@ -213,11 +232,15 @@ public static class SandboxWorkerProtocol
if (info.Open) await connection.OpenAsync(cancellationToken);
return connection;
}
/// <inheritdoc />
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("get", [canonicalName], ct));
/// <inheritdoc />
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) => await Call("set", [canonicalName, value], ct);
/// <inheritdoc />
public async Task<object?> CallScriptAsync(string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("script", [canonicalScriptName, parameters], ct));
/// <inheritdoc />
public async Task<WaitResult> WaitForAttributeAsync(string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
=> (await Call("wait", [canonicalName, targetValueEncoded, timeout.Ticks, requireGoodQuality], ct)).Deserialize<WaitResult>(Json)!;
}
@@ -181,7 +181,7 @@ public class ScriptAnalysisService
/// <summary>
/// Shared options for the per-Test-Run return-value JSON serialize in
/// <see cref="SerializeReturn"/> (perf remediation, arch-review WP1.5) — avoids
/// <see cref="SerializeReturn"/> — avoids
/// allocating a new <see cref="JsonSerializerOptions"/> per run. Settings preserved
/// exactly.
/// </summary>
@@ -237,6 +237,9 @@ public class ScriptAnalysisService
=> SandboxWorkerProtocol.RunAsync(request, _services, _sharedScripts, ct);
/// <summary>Execution core invoked only by the disposable worker executable.</summary>
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
/// <param name="ct">Cancellation token for the run.</param>
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
public async Task<SandboxRunResult> RunInWorkerAsync(SandboxRunRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
@@ -537,6 +540,9 @@ public class ScriptAnalysisService
return result;
}
/// <summary>Converts a <see cref="JsonElement"/> into the equivalent CLR object (string, number, bool, list, dictionary or null).</summary>
/// <param name="element">The JSON element to convert.</param>
/// <returns>The converted CLR value.</returns>
internal static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
@@ -111,12 +111,11 @@ public static class ServiceCollectionExtensions
// The fan-out itself stays scoped (it holds scoped repositories + the snapshot
// client), but the page resolves it through SharedAlarmSummaryService — a process
// singleton that memoizes the fan-out per site with single-flight, so N circuits
// watching one site cost ONE fan-out per window instead of N (arch-review WP2.4).
// watching one site cost ONE fan-out per window instead of N.
services.AddScoped<AlarmSummaryService>();
services.AddSingleton<IAlarmSummaryService, SharedAlarmSummaryService>();
// Process-level memoized point-in-time KPI snapshots (arch-review WP2.4, finding
// #8's per-circuit half). Singleton on purpose: the memo has to outlive any one
// Process-level memoized point-in-time KPI snapshots. Singleton on purpose: the memo has to outlive any one
// circuit for N dashboards to share a single SQL round per refresh window.
services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>();
@@ -21,8 +21,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
/// exports.</c> footer line so an operator can tell a truncated download from
/// a complete one. The header row contains the same 24 columns, in the same
/// order, as the ManagementService CLI's CSV export (arch-review 2026-08-19
/// P6-7 — the two CSV writers were found to have drifted and are kept aligned).
/// order, as the ManagementService CLI's CSV export (the two CSV writers
/// had drifted and are kept aligned).
/// </para>
/// </summary>
public interface IAuditLogExportService
@@ -162,7 +162,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
// ─────────────────────────────────────────────────────────────────────
// CSV helpers — 24 columns, kept aligned with the ManagementService CLI's
// CSV export (arch-review 2026-08-19 P6-7)
// CSV export
// ─────────────────────────────────────────────────────────────────────
/// <summary>The 24 column names, in <see cref="AuditEventView"/> declaration order.</summary>
@@ -179,8 +179,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
/// cleanly on another. Free-text columns are run through
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as
/// a formula or DDE trigger by a spreadsheet application (CWE-1236;
/// arch-review 2026-08-19 P6-7). GUID/enum/numeric/date/bool columns this
/// a formula or DDE trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns this
/// formatter generates itself are never neutralized.
/// </summary>
/// <param name="evt">The audit event view to format as a CSV row.</param>
@@ -70,6 +70,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// </summary>
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
/// <param name="auth">Optional authentication state provider used to resolve the current user.</param>
public AuditLogQueryService(
IServiceScopeFactory scopeFactory,
ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null)
@@ -8,10 +8,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
public sealed class AuthorizedManualFailoverService(AuthenticationStateProvider auth, IManualFailoverService backend)
{
/// <summary>Fails over central only for a currently authorized administrator.</summary>
/// <returns>The failover outcome message, or <c>null</c> when none was reported.</returns>
public async Task<string?> FailOverCentralAsync()
=> await backend.FailOverCentralAsync(await auth.RequireRoleAsync(Roles.Administrator));
/// <summary>Fails over a site only for a currently authorized administrator.</summary>
/// <param name="siteId">The site identifier to fail over.</param>
/// <returns>The outcome of the site failover.</returns>
public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId)
=> await backend.FailOverSiteAsync(siteId, await auth.RequireRoleAsync(Roles.Administrator));
}
@@ -12,6 +12,7 @@ public enum BindingConnectionState { None, Single, Mixed }
/// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary>
public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
{
/// <summary>True when the row has no bound (or a non-positive) connection id.</summary>
public bool IsUnbound => Row.ConnectionId is null or <= 0;
/// <summary>Effective reference: override if set, else the default.</summary>
public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
@@ -32,6 +33,7 @@ public sealed record BindingGroup(
int? CommonConnectionId,
int UnboundCount)
{
/// <summary>True when this group holds the instance's direct (non-module) attributes.</summary>
public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
}
@@ -44,12 +46,14 @@ public static class BindingGroupBuilder
{
public const string InstanceGroupKey = "(instance)";
/// <summary>Groups the data-sourced rows by module and infers each group's shared connection and object prefix.</summary>
/// <param name="rows">The data-sourced flattened attributes with their current binding state.</param>
/// <param name="knownConnectionIds">
/// The site's data connection ids. When supplied, a row whose connection id is not
/// in the set is treated as UNBOUND — a binding to a deleted connection renders as
/// "— none —" and the deploy gate rejects it, so the pills must agree.
/// </param>
/// <returns>One <see cref="BindingGroup"/> per module (or the instance group).</returns>
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows, IReadOnlyCollection<int>? knownConnectionIds = null)
{
if (knownConnectionIds is not null)
@@ -153,6 +157,10 @@ public static class BindingGroupBuilder
/// Save path is an upsert and a removal could not persist anyway. Surrounding
/// whitespace and a trailing '.' are tolerated.
/// </summary>
/// <param name="group">The group whose members are rewritten.</param>
/// <param name="connectionId">The connection to bind, or null to keep each member's current connection.</param>
/// <param name="objectPath">The object path prefix, or blank to clear the overrides.</param>
/// <returns>One change per group member.</returns>
public static IReadOnlyList<BindingChange> ApplyObject(BindingGroup group, int? connectionId, string? objectPath)
{
var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
@@ -12,6 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
public sealed class ConfigurationCommandService(IServiceProvider services, AuthenticationStateProvider auth)
{
/// <summary>Runs an existing audited instance operation with fresh scope and an atomic audit commit.</summary>
/// <typeparam name="T">The result type of the operation.</typeparam>
/// <param name="instanceId">The existing instance id, or null when creating.</param>
/// <param name="createSiteId">The target site id when creating an instance.</param>
/// <param name="operation">The operation to run, given the instance service and the acting username.</param>
/// <returns>The operation's result.</returns>
public async Task<T> ExecuteInstanceAsync<T>(int? instanceId, int? createSiteId,
Func<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService, string, Task<T>> operation)
{
@@ -36,6 +41,8 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
}
/// <summary>Executes one command through the same guarded boundary as the management actor.</summary>
/// <param name="command">The configuration command to execute.</param>
/// <returns>The command's result, if any.</returns>
public async Task<object?> ExecuteAsync(object command)
{
var principal = (await auth.GetAuthoritativeStateAsync()).User;
@@ -52,6 +59,9 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
public static class ConfigurationCommandExtensions
{
/// <summary>Submits a database-only configuration mutation with current server-side authority.</summary>
/// <param name="services">The service provider used to resolve the command service.</param>
/// <param name="command">The configuration command to execute.</param>
/// <returns>The command's result, if any.</returns>
public static Task<object?> ExecuteConfigurationAsync(this IServiceProvider services, object command)
=> services.GetRequiredService<ConfigurationCommandService>().ExecuteAsync(command);
}
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// NAME keys back to <c>DataConnection.Id</c> using the same site repository the
/// design page loads from. Health reports key connections by <c>Name</c>, the
/// page renders nodes by id, so the join is per-site Name → Id; doing it per-site
/// avoids cross-site name collisions (two sites can each own a "PLC-1").
/// avoids cross-site name collisions (two sites can each own a "MainPlc").
/// </remarks>
public sealed class ConnectionHealthQueryService : IConnectionHealthQueryService
{
@@ -59,6 +59,8 @@ public sealed class FlowDebugService : IFlowDebugService
/// the run itself at <c>timeoutSeconds + RunGrace</c> (see <c>FlowDebugRunner.RunMethodAsync</c>),
/// so the client must wait at least that long or it cuts the run off before the server does.
/// </summary>
/// <param name="timeoutSeconds">The method's own timeout in seconds.</param>
/// <returns>The Ask timeout to use for the run.</returns>
public static TimeSpan RunAskTimeoutFor(int timeoutSeconds)
{
var t = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)) + FlowDebugRunner.RunGrace + ClientSlack;
@@ -69,6 +71,8 @@ public sealed class FlowDebugService : IFlowDebugService
/// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the
/// trigger-wait window plus <see cref="ClientSlack"/>.
/// </summary>
/// <param name="waitSeconds">The trigger-wait window in seconds.</param>
/// <returns>The Ask timeout to use for the run.</returns>
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
=> TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack;
@@ -110,6 +114,14 @@ public sealed class FlowDebugService : IFlowDebugService
IReadOnlyList<FlowScriptOption> Options);
/// <summary>Initializes the service.</summary>
/// <param name="holder">Holder for the management actor reference.</param>
/// <param name="auth">Provides the caller's authentication state.</param>
/// <param name="inboundApi">Repository for inbound API methods.</param>
/// <param name="templates">Repository for templates and instances.</param>
/// <param name="pipeline">Flattening pipeline used to resolve instance scripts.</param>
/// <param name="comms">Communication service for central-to-site requests.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="sites">Repository for sites.</param>
public FlowDebugService(
ManagementActorHolder holder,
AuthenticationStateProvider auth,
@@ -44,7 +44,7 @@ public interface IAlarmSummaryService
/// <summary>
/// Builds an <see cref="AlarmSummaryResult"/> directly from an in-memory live
/// snapshot (plan #10, Task 5) — the current alarms served by
/// snapshot — the current alarms served by
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.ISiteAlarmLiveCache.GetCurrentAlarms"/>
/// — instead of fanning out per-instance snapshot Asks. Each
/// <see cref="AlarmStateChanged"/> is flattened to one
@@ -28,6 +28,9 @@ public sealed record FlowDebugOutcome(
public bool Success => Graph != null && Error == null;
/// <summary>Creates a failed outcome.</summary>
/// <param name="error">The error message.</param>
/// <param name="errorCode">The machine-readable error code.</param>
/// <returns>A failed outcome carrying the error.</returns>
public static FlowDebugOutcome Fail(string error, string errorCode) => new(null, null, error, errorCode);
}
@@ -83,18 +86,21 @@ public interface IFlowDebugService
/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The instance's scripts ordered by canonical name.</returns>
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The predicted flow for the script.</returns>
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default);
/// <summary>Runs an inbound API method with tracing on the active central node and returns the flow.</summary>
/// <param name="apiMethodId">The method id.</param>
/// <param name="parameters">Flat JSON object of parameters, or null.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the run, including the built flow graph.</returns>
Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default);
/// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary>
@@ -103,11 +109,13 @@ public interface IFlowDebugService
/// <param name="parameters">Call kinds: flat JSON object of parameters, or null.</param>
/// <param name="value">Write kinds: the new value for the trigger's attribute, in canonical string form.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the run, including the built flow graph.</returns>
Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default);
/// <summary>Rebuilds a past flow from the central audit store.</summary>
/// <param name="executionId">Any execution id in the chain.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the replay, including the rebuilt flow graph.</returns>
Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default);
/// <summary>
@@ -120,6 +128,7 @@ public interface IFlowDebugService
/// </summary>
/// <param name="step">The outbound step to retry.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the retry request.</returns>
Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
/// <summary>
@@ -128,5 +137,6 @@ public interface IFlowDebugService
/// </summary>
/// <param name="step">The outbound step to discard.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the discard request.</returns>
Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Process-level memoized point-in-time KPI snapshots shared by every Blazor circuit on
/// this node (arch-review WP2.4, finding #8 — the per-circuit polling half).
/// this node (the per-circuit polling half).
/// <para>
/// Every KPI here is a <em>global, point-in-time aggregate</em>: it does not vary by user,
/// site scope, or role, so N circuits asking for it inside one refresh window are asking
@@ -13,6 +13,9 @@ public sealed class PollGate
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
/// <summary>Runs and observes a posted refresh, releasing the gate even if dispatch is rejected.</summary>
/// <param name="dispatch">Delegate that posts the refresh.</param>
/// <param name="onError">Callback invoked with any exception thrown by <paramref name="dispatch"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RunAsync(Func<Task> dispatch, Action<Exception> onError)
{
if (!TryEnter()) return;
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/> (arch-review WP2.4).
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/>.
/// <para>
/// The Alarm Summary page polls every 15s <em>per circuit</em>, and each poll fans one debug
/// snapshot Ask out to every Enabled instance on the site. Ten operators watching one site
@@ -17,6 +17,9 @@ public static class TemplatePathFormatter
/// Builds the display path for <paramref name="template"/> by walking its
/// folder chain upward through <paramref name="foldersById"/>.
/// </summary>
/// <param name="template">The template whose path is built.</param>
/// <param name="foldersById">Folders keyed by id, used to walk the parent chain.</param>
/// <returns>The folder-qualified display path.</returns>
public static string Format(Template template, IReadOnlyDictionary<int, TemplateFolder> foldersById)
{
var parts = new List<string> { template.Name };
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
/// <summary>
/// Pure decision logic for the simultaneous-cold-start split-brain guard (Gitea #33).
/// Pure decision logic for the simultaneous-cold-start split-brain guard.
/// </summary>
/// <remarks>
/// <para>
@@ -119,7 +119,7 @@ public class ClusterOptions
/// The simultaneous-cold-start split-brain guard (<c>ScadaBridge:Cluster:BootstrapGuard</c>).
/// Default OFF — a dark switch, so existing deployments and tests keep Akka's config-driven
/// self-first auto-join byte-identical. See <see cref="ClusterBootstrapGuard"/> for the
/// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime (Gitea #33).
/// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime.
/// </summary>
public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new();
}
@@ -82,7 +82,7 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
}
/// <summary>
/// When the bootstrap guard is enabled (Gitea #33), its timing knobs must be positive. A
/// When the bootstrap guard is enabled, its timing knobs must be positive. A
/// zero/negative <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/> in particular
/// silently degrades the guard to "never wait, always conclude the partner is dead" — the
/// higher node would form alone immediately and re-open the very split the guard exists to
@@ -292,6 +292,7 @@ public interface IAuditLogRepository
/// </remarks>
/// <param name="executionId">Any execution id in the chain; the implementation walks to the root and back down.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="permittedSiteIds">Optional site scope restricting which sites' rows are visible; <c>null</c> means unrestricted.</param>
/// <returns>A task that resolves to the full execution tree rooted at the topmost ancestor, one node per distinct execution.</returns>
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
@@ -307,6 +308,7 @@ public interface IAuditLogRepository
/// </summary>
/// <param name="executionId">Any execution id in the chain.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="permittedSiteIds">Optional site scope restricting which sites' rows are visible; <c>null</c> means unrestricted.</param>
/// <returns>The canonical audit events that make up the flow, oldest first.</returns>
Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(
Guid executionId,
@@ -93,7 +93,7 @@ public interface INotificationOutboxRepository
/// is its own transaction.
/// </summary>
/// <remarks>
/// <b>Why this exists (arch-review 2026-08-19 P5-4).</b> The operator retry/
/// <b>Why this exists.</b> The operator retry/
/// discard handlers are read-check-write across two round trips:
/// <see cref="GetByIdAsync"/> reads the row, the handler checks its
/// in-memory <c>Status</c>, then a write persists the transition. A detached
@@ -93,7 +93,7 @@ public interface ISecuredWriteRepository
/// the compare-and-swap guard for the approve-vs-reject race: a stale-read reject
/// used to land a blind full-row update while a concurrent approve's multi-second
/// site relay was still in flight, marking the row <c>Rejected</c> while the plant
/// write physically executed (arch-review 2026-08-19 P6-1). The conditional
/// write physically executed. The conditional
/// <c>WHERE Status='Pending'</c> makes exactly one of approve/reject/expiry win.
/// The loser observes <c>false</c> and must record nothing — no status change and no
/// reject audit row.
@@ -252,6 +252,12 @@ public interface ITemplateEngineRepository
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Reads one bounded, untracked page of topology instance rows for a site/area.</summary>
/// <param name="siteId">The site ID.</param>
/// <param name="areaId">The area ID to filter by, or null for every area.</param>
/// <param name="skip">The number of rows to skip.</param>
/// <param name="take">The maximum number of rows to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to a read-only page of instances.</returns>
Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default);
/// <summary>Retrieves an instance by unique name.</summary>
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
public interface IConfigurationCommandTransaction
{
/// <summary>Each database retry supplies a fresh scope; the operation must not perform external side effects.</summary>
/// <param name="operation">The database-only operation to run within the transaction.</param>
/// <returns>The operation's result, if any.</returns>
Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation);
}
@@ -11,6 +13,7 @@ public interface IConfigurationCommandTransaction
public interface IConfigurationCommitActions
{
/// <summary>Queues an idempotent local maintenance action.</summary>
/// <param name="action">The action to run after a successful commit.</param>
void Add(Func<Task> action);
}
@@ -2,5 +2,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>Private worker handoff for an existing configured SQL connection. Never log or expose the returned secret.</summary>
public interface ISandboxDatabaseConnectionSource
{
/// <summary>Resolves the connection string for a configured connection.</summary>
/// <param name="connectionName">The configured database connection name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The connection string; a secret that must never be logged or exposed.</returns>
Task<string> GetSandboxConnectionStringAsync(string connectionName, CancellationToken cancellationToken);
}
@@ -110,7 +110,7 @@ public interface ISiteAuditQueue
/// <param name="sinceUtc">Lower bound timestamp (UTC).</param>
/// <param name="batchSize">Maximum number of rows to return.</param>
/// <param name="afterId">
/// Composite-keyset tiebreak cursor (WP2.3), mirroring
/// Composite-keyset tiebreak cursor, mirroring
/// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/>: when non-null it is the
/// <see cref="AuditEvent.EventId"/> ("D" GUID form) of the last row already consumed at
/// <paramref name="sinceUtc"/>, and only rows strictly after the composite
@@ -125,7 +125,7 @@ public interface ISiteAuditQueue
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default);
/// <summary>
/// Reconciliation-pull commit surface, cursor form (WP2.3): flips every row at or before
/// Reconciliation-pull commit surface, cursor form: flips every row at or before
/// the composite cursor <c>(<paramref name="sinceUtc"/>, <paramref name="afterId"/>)</c>
/// from <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>/
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Forwarded"/> to
@@ -231,6 +231,12 @@ public interface ISiteAuditQueue
/// <c>(OccurredAtUtc, EventId)</c>, at most <paramref name="limit"/> rows. Independent of
/// forwarding state — moves no cursor and flips nothing. At most 64 ids per call.
/// </summary>
/// <param name="executionIds">Execution ids to match against <c>ExecutionId</c> or <c>ParentExecutionId</c>.</param>
/// <param name="sinceUtc">Lower bound on <c>OccurredAtUtc</c> (UTC).</param>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="afterEventId">Optional <c>EventId</c> keyset cursor for paging past rows already consumed.</param>
/// <returns>A task that resolves to the matching audit events ordered by <c>(OccurredAtUtc, EventId)</c>.</returns>
Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null);
}
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
/// <param name="CorrelationId">Correlation id echoed on the reply.</param>
/// <param name="AlarmsOnly">
/// When <c>true</c> the site builds ONLY the alarm half of the snapshot and returns an
/// empty <see cref="DebugViewSnapshot.AttributeValues"/> list (wire efficiency, WP2.3).
/// empty <see cref="DebugViewSnapshot.AttributeValues"/> list (wire efficiency).
/// Set by the central per-site live alarm cache, whose seed/reconcile fan-out discards
/// every attribute row anyway. Defaults to <c>false</c> so the Debug View and every other
/// caller keep the full snapshot; additive on the wire
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
/// <summary>
/// One site node's latest deployed-secrets mirror outcome, as carried on
/// <see cref="SiteHealthReport.SecretMirrorStatuses"/> (review finding M1).
/// <see cref="SiteHealthReport.SecretMirrorStatuses"/>.
/// </summary>
/// <remarks>
/// Every site node runs its own <c>DeployedSecretsMirror</c> and records its current
@@ -75,7 +75,7 @@ public record SiteHealthReport(
public double? ScriptOldestBusyAgeSeconds { get; init; }
/// <summary>
/// WP3.1: script-execution worker threads the stuck-script watchdog has DETACHED and
/// Script-execution worker threads the stuck-script watchdog has DETACHED and
/// replaced — a script wedged past its deadline plus grace in uninterruptible blocking
/// I/O, so the pool started a fresh thread and the wedged one will exit only when its
/// body finally returns. Point-in-time, refreshed by <c>ScriptSchedulerStatsReporter</c>.
@@ -85,7 +85,7 @@ public record SiteHealthReport(
public int DetachedScriptThreads { get; init; }
/// <summary>
/// WP3.1: per-interval count of script and alarm on-trigger runs SHED because
/// Per-interval count of script and alarm on-trigger runs SHED because
/// <c>MaxConcurrentRunsPerScript</c> runs were already in flight for that script. Raw
/// per-interval count (drained on collect) like <see cref="ScriptErrorCount"/>. A
/// sustained non-zero value means a trigger is firing faster than its script completes;
@@ -136,8 +136,7 @@ public record SiteHealthReport(
public long? LocalDbOplogBacklog { get; init; }
/// <summary>
/// WP2.6d (arch-review misc — site stream alarm-vs-attribute shared buffer):
/// cumulative count of alarm state changes dropped from
/// Cumulative count of alarm state changes dropped from
/// <c>SiteStreamManager</c>'s dedicated alarm publish queue because it was at
/// capacity — i.e. the site-wide alarm hand-off itself fell behind, not a
/// per-subscriber buffer. Point-in-time (not reset on collect), refreshed by
@@ -154,9 +153,9 @@ public record SiteHealthReport(
/// </summary>
/// <remarks>
/// <para>
/// Tri-state, and the distinction is the whole point (deferred-work register row 73).
/// Tri-state, and the distinction is the whole point.
/// <b>Null = not reported</b>: a central self-report, where the notion does not apply,
/// or a site node running a pre-row-73 build. <b><see langword="true"/></b> = the
/// or a site node running an older build. <b><see langword="true"/></b> = the
/// reconciler is running. <b><see langword="false"/></b> = the node booted WITHOUT it,
/// because no <c>IDeploymentConfigFetcher</c> was resolvable.
/// </para>
@@ -175,12 +174,12 @@ public record SiteHealthReport(
public bool? StartupReconcilerActive { get; init; }
/// <summary>
/// Every site node's latest deployed-secrets mirror outcome (review finding M1) — one
/// Every site node's latest deployed-secrets mirror outcome — one
/// entry per node that has recorded a row in the replicated <c>secret_mirror_status</c>
/// table, including the STANDBY node, whose own health report is never sent.
/// </summary>
/// <remarks>
/// <b>Null = no data</b>: a central self-report, a pre-M1 site build, a site whose
/// <b>Null = no data</b>: a central self-report, an older site build, a site whose
/// reporter has not run yet, or a failed read. An EMPTY list means the feature is wired
/// but no node has recorded a status yet. A node entry with an empty
/// <see cref="SecretMirrorNodeStatus.Failures"/> list is a healthy mirror. Names and
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
/// <param name="Value">Canonical string form of the new value.</param>
/// <param name="Timestamp">UTC timestamp of the command.</param>
/// <param name="SourceExecutionId">
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
/// ParentExecutionId tag-cascade: the <c>ExecutionId</c> of the
/// execution issuing this write — a site script run
/// (<c>ScriptRuntimeContext.SetAttribute</c>) or the inbound API request behind a
/// <c>Route.To(...).SetAttributes(...)</c>. Additive and nullable; <c>null</c>
@@ -59,7 +59,7 @@ public static class ScadaBridgeTelemetry
/// <summary>
/// Incremented each time a per-site live-alarm aggregator re-establishes its site-wide
/// gRPC stream — a NodeA↔NodeB failover flip or a reconcile-driven reopen after the
/// stream was given up (plan #10, Task 6). A sustained climb signals a flapping site link.
/// stream was given up. A sustained climb signals a flapping site link.
/// </summary>
private static readonly Counter<long> _liveAlarmStreamReconnects =
Meter.CreateCounter<long>("scadabridge.site.alarm_cache.reconnects", unit: "1",
@@ -67,7 +67,7 @@ public static class ScadaBridgeTelemetry
/// <summary>
/// Incremented for each live delta evicted from a per-site live-alarm aggregator's
/// bounded pre-seed buffer (drop-oldest, WP2.3). Non-zero means a seed/reconcile
/// bounded pre-seed buffer (drop-oldest). Non-zero means a seed/reconcile
/// fan-out ran long enough for the delta storm behind it to exceed the cap — the
/// dropped transitions are recovered by the fan-out's authoritative snapshot, but a
/// sustained climb points at a slow site.
@@ -78,7 +78,7 @@ public static class ScadaBridgeTelemetry
/// <summary>
/// Incremented for each debug event evicted from a central debug session's bounded
/// pre-snapshot buffer (drop-oldest, WP2.3). The Debug View is lossy-under-backpressure
/// pre-snapshot buffer (drop-oldest). The Debug View is lossy-under-backpressure
/// by design; this makes the loss measurable instead of unbounded memory growth.
/// </summary>
private static readonly Counter<long> _debugPreSnapshotDrops =
@@ -79,7 +79,7 @@ public static class AuditQueryParamParsers
/// the filter dimension stays unconstrained.
/// </summary>
/// <remarks>
/// Register row 99 (closure defect fixed by arch-review 2026-08-19 P6-3). Three
/// Register row 99. Three
/// traps this closes that a bare
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> does not:
/// <list type="number">
@@ -4,8 +4,15 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>Bounded replay data with independently retained chain-root and truncation metadata.</summary>
public sealed record TraceEventBatch(Guid RootExecutionId, IReadOnlyList<AuditEvent> Events, bool Truncated) : IReadOnlyList<AuditEvent>
{
/// <summary>Gets the trace event at the given position.</summary>
/// <param name="index">Zero-based position in <see cref="Events"/>.</param>
/// <returns>The event at <paramref name="index"/>.</returns>
public AuditEvent this[int index] => Events[index];
/// <summary>Gets the number of events in the batch.</summary>
public int Count => Events.Count;
/// <summary>Returns an enumerator over the batch's events.</summary>
/// <returns>An enumerator over <see cref="Events"/>.</returns>
public IEnumerator<AuditEvent> GetEnumerator() => Events.GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
@@ -25,6 +25,8 @@ public static class OpcUaReferenceForm
/// prefix, or empty/whitespace (nothing to warn about). False only for a bare
/// server-namespace index (<c>ns=&lt;n&gt;;…</c> with n ≥ 1).
/// </summary>
/// <param name="reference">The OPC UA node reference string to classify.</param>
/// <returns><see langword="true"/> when the reference is index-durable; otherwise <see langword="false"/>.</returns>
public static bool IsDurable(string? reference)
{
if (string.IsNullOrWhiteSpace(reference))
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>
/// Flow Debugger (final review M1): the ONE parser for the header line of a captured
/// Flow Debugger: the ONE parser for the header line of a captured
/// exception detail. A .NET <c>Exception.ToString()</c> opens with
/// <c>Namespace.Type: message</c>, and a wrapper (an <c>AggregateException</c>, a rethrow)
/// chains its inner exceptions on that same first line with <c>" ---&gt; "</c>.
@@ -59,6 +59,10 @@ public static class FlowGraphBuilder
/// Builds the graph. <paramref name="truncated"/> and <paramref name="logsTruncated"/> are passed
/// through to <see cref="FlowGraph.Truncated"/> / <see cref="FlowGraph.LogsTruncated"/>.
/// </summary>
/// <param name="events">The audit events to project into the graph.</param>
/// <param name="truncated">Whether the event set was truncated.</param>
/// <param name="logsTruncated">Whether the log rows were truncated.</param>
/// <returns>The built flow graph.</returns>
public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false, bool logsTruncated = false)
{
ArgumentNullException.ThrowIfNull(events);
@@ -338,8 +342,10 @@ public static class FlowGraphBuilder
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the
/// projector uses for the <c>sync</c> / <c>cached</c> subtitle. Such steps hang off the chain on an
/// <see cref="FlowEdge.Async"/> edge (D4).
/// <see cref="FlowEdge.Async"/> edge.
/// </summary>
/// <param name="step">The step to classify.</param>
/// <returns>True when the step is not awaited by the script.</returns>
public static bool IsAsync(FlowStep step)
{
if (step.Kind == FlowStepKind.Notify) return true;
@@ -352,9 +358,12 @@ public static class FlowGraphBuilder
/// <summary>
/// Orders the rows of one outbound operation when timestamps tie: submit before attempts before the
/// resolve, then non-terminal before terminal statuses — so "first" and "latest" (D3) never fall back
/// resolve, then non-terminal before terminal statuses — so "first" and "latest" never fall back
/// to GUID order for rows written in the same tick.
/// </summary>
/// <param name="kind">The audit row kind.</param>
/// <param name="status">The audit row status.</param>
/// <returns>The rank used to order the rows.</returns>
public static int LifecycleRank(string? kind, string? status)
{
var k = kind switch
@@ -444,6 +453,8 @@ public static class FlowGraphBuilder
}
/// <summary>Display rank used to break timestamp ties: spawners before what they spawn.</summary>
/// <param name="kind">The step kind.</param>
/// <returns>The display rank for the kind.</returns>
public static int KindRank(FlowStepKind kind) => kind switch
{
FlowStepKind.InboundRequest => 0,
@@ -14,12 +14,18 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
public static class FlowStepProjector
{
/// <summary>Projects an audit event onto a flow step.</summary>
/// <param name="evt">The audit event.</param>
/// <returns>The projected flow step.</returns>
public static FlowStep Project(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
return Project(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
}
/// <summary>Projects an audit event and its already-deserialized details onto a flow step.</summary>
/// <param name="evt">The audit event.</param>
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
/// <returns>The projected flow step.</returns>
internal static FlowStep Project(AuditEvent evt, AuditDetails d)
{
var extra = ScriptTraceExtra.TryParse(d.Extra);
@@ -52,6 +58,8 @@ public static class FlowStepProjector
}
/// <summary>True for a <c>ScriptTrace.ScriptLog</c> row — a log line, never a step.</summary>
/// <param name="evt">The audit event.</param>
/// <returns><see langword="true"/> when the event is a script log line.</returns>
public static bool IsLogLine(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
@@ -59,6 +67,9 @@ public static class FlowStepProjector
return IsLogLine(d);
}
/// <summary>True when the details describe a <c>ScriptTrace.ScriptLog</c> row.</summary>
/// <param name="d">The deserialized audit details of the event.</param>
/// <returns><see langword="true"/> when the details describe a script log line.</returns>
internal static bool IsLogLine(AuditDetails d)
{
return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal)
@@ -66,12 +77,18 @@ public static class FlowStepProjector
}
/// <summary>Projects a <c>ScriptLog</c> row onto its line. Level/source default to <c>info</c>/<c>script</c> when the extra is missing.</summary>
/// <param name="evt">The audit event.</param>
/// <returns>The projected log line.</returns>
public static FlowLogLine ToLogLine(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
return ToLogLine(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
}
/// <summary>Projects a <c>ScriptLog</c> row and its already-deserialized details onto its line.</summary>
/// <param name="evt">The audit event.</param>
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
/// <returns>The projected log line.</returns>
internal static FlowLogLine ToLogLine(AuditEvent evt, AuditDetails d)
{
var x = ScriptTraceExtra.TryParse(d.Extra);
@@ -89,12 +106,18 @@ public static class FlowStepProjector
}
/// <summary>Projects one outbound audit row onto its lifecycle entry (<see cref="FlowStep.Lifecycle"/>).</summary>
/// <param name="evt">The audit event.</param>
/// <returns>The projected lifecycle row.</returns>
public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
return ToLifecycleRow(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
}
/// <summary>Projects one outbound audit row and its already-deserialized details onto its lifecycle entry.</summary>
/// <param name="evt">The audit event.</param>
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
/// <returns>The projected lifecycle row.</returns>
internal static FlowLifecycleRow ToLifecycleRow(AuditEvent evt, AuditDetails d)
{
return new FlowLifecycleRow(
@@ -109,6 +132,9 @@ public static class FlowStepProjector
}
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
/// <param name="channel">The audit channel name.</param>
/// <param name="kind">The audit kind name.</param>
/// <returns>The matching step kind, or <see cref="FlowStepKind.Unknown"/>.</returns>
public static FlowStepKind ResolveKind(string? channel, string? kind)
{
if (string.Equals(channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal))
@@ -138,6 +164,9 @@ public static class FlowStepProjector
}
/// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags; outbound API / DB / notify → External.</summary>
/// <param name="kind">The step kind.</param>
/// <param name="sourceSiteId">The originating site id, used to place unknown kinds.</param>
/// <returns>The lane the step is drawn on.</returns>
public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch
{
FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central,
@@ -148,6 +177,8 @@ public static class FlowStepProjector
};
/// <summary>True for the three outbound kinds that live on the External lane.</summary>
/// <param name="kind">The step kind.</param>
/// <returns><see langword="true"/> for an API call, DB call or notification.</returns>
public static bool IsOutbound(FlowStepKind kind)
=> kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
@@ -92,6 +92,9 @@ public sealed class FlowTraceCollector
/// the inline copy is filtered through. A null <paramref name="redactor"/> — a host that
/// never called <c>AddAuditLog</c>, or a test — collects raw but is still capped.
/// </summary>
/// <param name="maxEvents">The maximum number of events kept inline.</param>
/// <param name="maxBytes">The maximum summed size of the events' details, in bytes.</param>
/// <param name="redactor">The redactor applied to the inline copy; null collects raw.</param>
public FlowTraceCollector(
int maxEvents = DefaultMaxEvents,
int maxBytes = DefaultMaxBytes,
@@ -142,6 +145,7 @@ public sealed class FlowTraceCollector
/// where it builds <c>ScriptCallResult.TraceEvents</c>. Idempotent: a second call returns
/// the same contents.
/// </summary>
/// <returns>The collected events in emission order.</returns>
public IReadOnlyList<AuditEvent> Complete()
{
lock (_gate)
@@ -159,6 +163,7 @@ public sealed class FlowTraceCollector
/// test-only peek. A reply path must use <see cref="Complete"/>, otherwise an un-awaited call
/// finishing after the reply could mutate the list the reply was built from.
/// </summary>
/// <returns>A copy of the events collected so far.</returns>
public IReadOnlyList<AuditEvent> Snapshot()
{
lock (_gate) return _events.ToArray();
@@ -68,9 +68,12 @@ public sealed record ScriptTraceExtra
};
/// <summary>Serializes to the compact camelCase JSON stored in <c>AuditDetails.Extra</c>.</summary>
/// <returns>The serialized JSON string.</returns>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>Parses <c>AuditDetails.Extra</c>; returns <c>null</c> for null/blank/invalid JSON — never throws.</summary>
/// <param name="json">The raw <c>AuditDetails.Extra</c> JSON text.</param>
/// <returns>The parsed value, or <c>null</c> when the input is null, blank or invalid.</returns>
public static ScriptTraceExtra? TryParse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// <summary>
/// Which leg of a reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> handshake a
/// <see cref="HandshakeResult"/> describes (Gitea #36, design 2026-09-10 §4).
/// <see cref="HandshakeResult"/> describes.
/// </summary>
public enum HandshakePhase
{
@@ -14,7 +14,7 @@ public enum HandshakePhase
}
/// <summary>
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload (Gitea #36):
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload:
/// like <see cref="WaitResult"/>, plus the <see cref="Phase"/> the outcome belongs to so a
/// script can report "timed out waiting for the reset echo" separately from "timed out
/// waiting for the device to complete". A reset that is never echoed means the batch and
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// How a KPI metric must be folded when an hour of one-minute
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample"/> rows is
/// aggregated into a single <c>KpiRollupHourly</c> row ("KPI History &amp; Trends"
/// hourly rollups, deferred #22). The correct fold differs by metric semantics, and
/// hourly rollups). The correct fold differs by metric semantics, and
/// choosing the wrong one silently corrupts long-range trend charts.
/// </summary>
public enum KpiRollupAggregation
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
public static class ScriptTriggerAttribute
{
/// <summary>True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute.</summary>
/// <param name="triggerType">The trigger type name.</param>
/// <returns>True when the trigger type is a write kind.</returns>
public static bool IsWriteKind(string? triggerType) =>
string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)
|| string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase);
@@ -20,6 +22,10 @@ public static class ScriptTriggerAttribute
/// false (and null) for every other kind, and for a write kind whose config is missing,
/// malformed, not an object, or names no attribute.
/// </summary>
/// <param name="triggerType">The trigger type name.</param>
/// <param name="triggerConfigurationJson">The trigger configuration JSON.</param>
/// <param name="attribute">Receives the monitored attribute name, or null.</param>
/// <returns>True when a monitored attribute was read.</returns>
public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute)
{
attribute = null;
@@ -20,7 +20,7 @@ public static class SecretReferenceScanner
/// <summary>
/// The single parser for a <c>secret:&lt;name&gt;</c> reference value, shared by this scanner
/// (what central ships) and every adapter that resolves the reference at connect time (what
/// the site looks up), so both sides always agree on the name (final review M3). The value is
/// the site looks up), so both sides always agree on the name. The value is
/// trimmed, the <c>secret:</c> prefix matches ordinal-ignore-case, and the remainder is
/// trimmed; an empty remainder is not a reference. The config-expander form
/// <c>${secret:…}</c> is a different syntax and never matches.
@@ -45,6 +45,8 @@ public static class SecretReferenceScanner
/// string values and returns the distinct names found, ordinal-sorted, each with the ordinal-sorted,
/// de-duplicated list of connection names that referenced it.
/// </summary>
/// <param name="connections">The data connection artifacts to scan.</param>
/// <returns>The distinct referenced secrets, each with the connections that reference it.</returns>
public static IReadOnlyList<SecretReference> Scan(IEnumerable<DataConnectionArtifact> connections)
{
var found = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
@@ -52,7 +52,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
internal static TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Hard deadline on the initial <see cref="DebugViewSnapshot"/> (WP2.3). The site builds
/// Hard deadline on the initial <see cref="DebugViewSnapshot"/> The site builds
/// it in milliseconds; if none arrives inside this window the site never answered (the
/// Ask was lost, the singleton moved mid-request, the instance actor is wedged) and the
/// session must FAIL rather than sit in the buffering phase accumulating live events
@@ -125,7 +125,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
/// <see cref="AlarmStateChanged"/>) that arrived before the snapshot was delivered.
/// Flushed (with per-entity dedup against the snapshot) when the snapshot arrives,
/// then never used again. Bounded by <see cref="MaxPreSnapshotBuffer"/> with drop-oldest
/// eviction (WP2.3): a snapshot that never arrives used to buffer without limit on the
/// eviction: a snapshot that never arrives used to buffer without limit on the
/// central node. Mutated only on the actor thread.
/// </summary>
private readonly Queue<object> _preSnapshotBuffer = new();
@@ -243,7 +243,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
_snapshotDelivered = true;
});
// Hard snapshot deadline (WP2.3). Nothing else ends a session stuck in the
// Hard snapshot deadline. Nothing else ends a session stuck in the
// buffering phase: the site's reply was lost, so no gRPC error fires, the stream
// keeps delivering events, and stream traffic does not renew the orphan net (which
// measures the consumer). Fail the session so the consumer is told and can reopen.
@@ -351,7 +351,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
_consumerLastSeenUtc = DateTime.UtcNow;
});
// Orphan safety net, CONSUMER-measured (WP2.3 follow-up). A periodic self-tick
// Orphan safety net, CONSUMER-measured. A periodic self-tick
// compares the consumer-last-seen stamp against ConsumerIdleTimeout; the previous
// SetReceiveTimeout(5 min) measured the mailbox instead, and once stream events were
// (correctly) marked INotInfluenceReceiveTimeout nothing recurring reset it — the
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <summary>
/// One-per-site aggregator on the active central node backing the operator Alarm
/// Summary live cache (plan #10, Task 4). Holds a transient in-memory
/// Summary live cache. Holds a transient in-memory
/// <c>Dictionary&lt;AlarmKey, AlarmStateChanged&gt;</c> of the whole site's current
/// alarm state — NO persistence (locked <c>[PERM]</c>: no central alarm store). Created
/// and torn down by <see cref="SiteAlarmLiveCacheService"/> under viewer reference-count.
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <b>Failover + drift:</b> a gRPC error flips NodeA↔NodeB with the same retry budget +
/// stability window as <see cref="DebugStreamBridgeActor"/>. A re-seed runs <b>once per
/// successful (re)connect</b> — driven by the stream's connected callback, not by each
/// reconnect ATTEMPT (WP2.3: a site outage used to fan a full snapshot out per retry, all
/// reconnect ATTEMPT (a site outage used to fan a full snapshot out per retry, all
/// of them against the very site that is unreachable). A periodic reconcile snapshot
/// (<see cref="_reconcileInterval"/>, default 60s, jittered per site so N aggregators do
/// not fan out in lockstep) remains the drift/backstop: it corrects instance-set drift and
@@ -68,7 +68,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
private const string OpenDeadlineTimerKey = "alarm-grpc-open-deadline";
/// <summary>
/// Production default for the stream open-attempt deadline (arch-review 2026-08-19 P3-4).
/// Production default for the stream open-attempt deadline.
/// Comfortably above <c>SiteStreamGrpcClient.ConnectedHeaderTimeout</c> (10 s) so a
/// normally-slow connect is never pre-empted, and comfortably below the 60 s reconcile
/// interval so the tick that follows always finds the flag released.
@@ -125,7 +125,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// <summary>
/// Ordered buffer of live deltas that arrived while a fan-out was in flight, capped at
/// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (WP2.3 — a stuck fan-out
/// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (a stuck fan-out
/// used to buffer without bound). Dropping the OLDEST is the right eviction here: the
/// fan-out that follows rebuilds the cache authoritatively, so an evicted delta is
/// superseded rather than lost, while the newest transitions — the ones the snapshot may
@@ -195,7 +195,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// <summary>
/// Deadline on ONE stream open attempt, after which <see cref="_openInFlight"/> is
/// released so <see cref="OnReconcileTick"/> can reopen (arch-review 2026-08-19 P3-4).
/// released so <see cref="OnReconcileTick"/> can reopen.
/// <para>
/// The three outcomes that clear the flag — connected, error, graceful completion — are
/// all events the peer has to produce. A stream that produces NONE of them pins the flag:
@@ -244,7 +244,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
private const int BufferWarnThreshold = 10_000;
private bool _bufferWarned;
/// <inheritdoc />
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>
@@ -263,7 +263,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// The second argument is the live-stream liveness flag: <c>false</c> means the site-wide
/// gRPC stream is currently down (fault-exhausted or gracefully completed), so the cache
/// is only as fresh as the last reconcile and the page must fall back to polling
/// (WP2.3 carried residual — <c>IsLive</c> used to stay true across a dead stream).
/// (<c>IsLive</c> used to stay true across a dead stream).
/// </param>
/// <param name="grpcFactory">Factory caching one gRPC client per (site, endpoint).</param>
/// <param name="grpcNodeAAddress">gRPC address of the site's node A.</param>
@@ -284,7 +284,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// up to +20%). Zero disables jitter, which is what tests want for determinism.
/// </param>
/// <param name="openAttemptTimeout">
/// Deadline on one gRPC stream open attempt (P3-4); <c>null</c> takes
/// Deadline on one gRPC stream open attempt; <c>null</c> takes
/// <see cref="DefaultOpenAttemptTimeout"/> (30 s) and <see cref="TimeSpan.Zero"/> or less
/// disables the deadline. See <see cref="_openAttemptTimeout"/>.
/// </param>
@@ -350,7 +350,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
OnStreamConnected();
});
// The open attempt produced none of its three outcomes inside the deadline (P3-4):
// The open attempt produced none of its three outcomes inside the deadline:
// release the in-flight flag so the reconcile tick's reopen guard can run again.
// Generation-fenced like every other stream callback, and a no-op once the attempt
// has already resolved (a late timer racing its own stream's connect).
@@ -486,7 +486,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// Periodic reconcile backstop: re-run the snapshot fan-out (corrects instance-set drift
/// + any missed delta) UNLESS a CONNECT- OR FAILOVER-DRIVEN seed already completed inside
/// this window — that seed makes the tick redundant, and running both was the "full
/// unconditional snapshot every 60s" waste (WP2.3). A tick's OWN fan-out never arms that
/// unconditional snapshot every 60s" waste. A tick's OWN fan-out never arms that
/// skip: it used to, which made steady state alternate fan-out/skip and halved the
/// effective reconcile rate. If the live stream was previously given up, self-heal it by
/// resetting the retry budget and reopening, so a sustained outage never permanently kills
@@ -700,7 +700,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
return;
}
// Backoff retry (WP2.3): the seed leg used to have none — a site returning errors
// Backoff retry: the seed leg used to have none — a site returning errors
// was re-fanned at full reconcile cadence forever. Retry at reconnectDelay doubling
// up to MaxSeedRetryBackoffMultiplier × the reconcile interval; the periodic
// reconcile remains the floor once the backoff saturates.
@@ -848,7 +848,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
if (_stopped) return;
// The stream is not live again until the server actually accepts it — liveness flips
// on GrpcAlarmStreamConnected, not on the attempt (WP2.3 carried residual).
// on GrpcAlarmStreamConnected, not on the attempt.
var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
_log.Info("Opening site-alarm gRPC stream for {0} to {1}", _siteIdentifier, endpoint);
@@ -861,7 +861,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
_openInFlight = true;
var generation = ++_streamGeneration;
// Bound this attempt (P3-4). Single-shot on a shared key, so a re-open replaces the
// Bound this attempt. Single-shot on a shared key, so a re-open replaces the
// previous attempt's deadline rather than stacking one.
if (_openAttemptTimeout > TimeSpan.Zero)
{
@@ -1005,7 +1005,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
CleanupGrpc();
// A completed stream is NOT live: without this the cache kept reporting live for up
// to a full reconcile interval after the site closed the feed (WP2.3 residual).
// to a full reconcile interval after the site closed the feed.
if (wasLive && _seeded)
Publish();
}
@@ -1080,6 +1080,6 @@ internal sealed record ReconnectAlarmStream;
internal sealed record GrpcAlarmStreamStable;
/// <summary>Internal: a stream OPEN attempt produced none of connected/error/completed inside its
/// deadline (P3-4), stamped with the generation it was armed for so a late timer from a superseded
/// deadline, stamped with the generation it was armed for so a late timer from a superseded
/// attempt is ignored. Releases the in-flight guard only — it is not a fault.</summary>
internal sealed record GrpcAlarmStreamOpenTimedOut(int Generation);
@@ -76,6 +76,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
/// pass a stub so they do not need to load Akka.Cluster into the <c>TestKit</c>
/// ActorSystem.
/// </param>
/// <param name="failOverRole">
/// Optional delegate mapping a site identifier to the cluster role used for failover/singleton
/// lookups; <c>null</c> uses the default role resolution.
/// </param>
/// <param name="transport">
/// The site→central transport. Production always passes the Host-built gRPC
/// <see cref="Grpc.GrpcCentralTransport"/>. <c>null</c> (used by TestKit suites that only
@@ -149,7 +149,7 @@ public class CommunicationOptions
/// <summary>
/// Send-channel capacity for the site-wide alarm stream (<c>SubscribeSite</c>). Larger
/// than the Debug View's (WP2.3): that feed backs the operator Alarm Summary, where a
/// than the Debug View's: that feed backs the operator Alarm Summary, where a
/// silently dropped transition is a missed alarm rather than a missed diagnostic frame,
/// and an alarm burst arriving during a WAN stall must survive the stall.
/// </summary>
@@ -211,9 +211,9 @@ public class CommunicationOptions
/// </summary>
public TimeSpan PendingDeploymentPurgeInterval { get; set; } = TimeSpan.FromHours(1);
// ── Aggregated live alarm cache (plan #10, Task 4) ───────────────────────────
// Introduced by Task 4 with sane defaults; Task 6 formalizes eager validation
// (positive interval/linger, positive concurrency/subscriber cap) and telemetry.
// ── Aggregated live alarm cache ───────────────────────────────────────────────
// Sane defaults, with eager validation (positive interval/linger, positive
// concurrency/subscriber cap) and telemetry.
/// <summary>
/// How long the shared per-site live alarm aggregator keeps its gRPC stream +
@@ -256,7 +256,7 @@ public class CommunicationOptions
/// <summary>
/// Random jitter added to each per-site reconcile tick, as a fraction of
/// <see cref="LiveAlarmCacheReconcileInterval"/> (WP2.3). Without it every aggregator
/// <see cref="LiveAlarmCacheReconcileInterval"/>. Without it every aggregator
/// started by one central failover fans its whole-site snapshot out on the same 60s
/// boundary forever. Zero disables jitter.
/// </summary>
@@ -113,7 +113,7 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
+ "entries (a site node must list at least one central gRPC endpoint; central nodes "
+ "leave it empty).");
// ── Aggregated live alarm cache (plan #10, Task 6) ───────────────────────
// ── Aggregated live alarm cache ──────────────────────────────────────────
// Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator
// immediately when the last viewer leaves), only a negative value is invalid.
builder.RequireThat(options.LiveAlarmCacheLinger >= TimeSpan.Zero,
@@ -230,7 +230,7 @@ public class CommunicationService
// RouteToSetAttributes / RouteToWaitForAttribute), driven from
// InboundAPI/CommunicationServiceInstanceRouter and sharing IntegrationTimeout.
// The earlier generic IntegrationCallRequest primitive was never wired to a
// producer or a site handler and was removed as dead code (Gitea #32).
// producer or a site handler and was removed as dead code.
// ── Pattern 5: Debug View ──
@@ -803,7 +803,7 @@ public class CommunicationService
}
/// <summary>
/// Asks a site to gracefully fail over its own two-node pair (Task 10). Central and each
/// Asks a site to gracefully fail over its own two-node pair. Central and each
/// site are SEPARATE Akka clusters, so central cannot act on site membership directly —
/// the site's own <c>SiteCommunicationActor</c> performs the <c>Cluster.Leave</c> against
/// its site-specific role and acks the outcome.
@@ -82,7 +82,7 @@ public class DebugStreamService : IDisposable
/// <param name="ct">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A debug stream session with the initial snapshot.</returns>
/// <remarks>
/// Virtual purely as a test seam: the consumers' dispose-vs-start race (P6-6) can only
/// Virtual purely as a test seam: the consumers' dispose-vs-start race can only
/// be driven deterministically by a stub that holds the start open. Behaviour and the
/// keepalive/orphan contract are unchanged.
/// </remarks>
@@ -114,7 +114,7 @@ public class DebugStreamService : IDisposable
instanceUniqueName = instance.UniqueName;
siteIdentifier = site.SiteIdentifier;
// Single-endpoint site (arch-review 2026-08-19 P3-3, applying the same N9
// Single-endpoint site (applying the same
// degeneration SiteAlarmLiveCacheService.ResolveSiteAsync already uses): reuse the
// sole configured endpoint for both slots, so the bridge actor's NodeA↔NodeB
// failover flip degenerates to a reconnect against the same node. Requiring BOTH
@@ -11,10 +11,16 @@ public static class AuditWireBudget
public const int InitialBytes = 7;
/// <summary>
/// Appends one row to a field-1 repeated-message collection if it fits. A row larger than the
/// Appends one row to a repeated-message collection if it fits. A row larger than the
/// normal budget travels alone, up to the hard channel ceiling. Oversize rows are retained
/// locally and fail explicitly with their identity; they are never acknowledged or truncated.
/// </summary>
/// <typeparam name="T">The protobuf message type of the rows.</typeparam>
/// <param name="rows">The repeated field to append to.</param>
/// <param name="row">The row to append.</param>
/// <param name="wireBytes">The running wire size of the batch; increased by the row's size when appended.</param>
/// <param name="rowIdentity">Identity of the row, used in the error raised when it exceeds the hard ceiling.</param>
/// <returns><c>true</c> when the row was appended; <c>false</c> when it does not fit in the current batch.</returns>
public static bool TryAppend<T>(RepeatedField<T> rows, T row, ref int wireBytes, string rowIdentity)
where T : IMessage<T>
{
@@ -268,7 +268,7 @@ public sealed class CentralChannelProvider : IDisposable
return TimeSpan.FromTicks(scaled >= cap || scaled < 0 ? cap : scaled);
}
/// <inheritdoc />
/// <summary>Stops the failback timer and disposes all cached gRPC channels.</summary>
public void Dispose()
{
lock (_gate)
@@ -74,7 +74,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
public static class CentralControlDtoMapper
{
// -----------------------------------------------------------------------
// Notification Outbox (#21)
// Notification Outbox
// -----------------------------------------------------------------------
/// <summary>Projects a <see cref="NotificationSubmit"/> onto its wire DTO.</summary>
@@ -216,7 +216,7 @@ public static class CentralControlDtoMapper
}
// -----------------------------------------------------------------------
// Audit Log (#23) ingest — envelopes only; rows go through the existing mappers
// Audit Log ingest — envelopes only; rows go through the existing mappers
// -----------------------------------------------------------------------
/// <summary>
@@ -437,7 +437,7 @@ public static class CentralControlDtoMapper
}
// -----------------------------------------------------------------------
// Health Monitoring (#11)
// Health Monitoring
// -----------------------------------------------------------------------
/// <summary>Projects a <see cref="SiteHealthReport"/> onto its wire DTO.</summary>
@@ -559,7 +559,7 @@ public static class CentralControlDtoMapper
dto.SiteAuditBacklog = snapshot;
}
// Review finding M1: wrapper presence keeps null ("no data") distinct from empty.
// Wrapper presence keeps null ("no data") distinct from empty.
if (msg.SecretMirrorStatuses is { } mirrorStatuses)
{
var wrapper = new SecretMirrorStatusListDto();
@@ -277,21 +277,15 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
return CentralControlDtoMapper.ToDto(ack);
}
/// <summary>
/// Application heartbeat — <b>always answers OK</b>, even when this node is not ready.
/// </summary>
/// <remarks>
/// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply,
/// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised
/// an error would be a self-inflicted outage on a purely informational signal). So this is
/// the one RPC that does not go through <see cref="RequireReady"/>: a heartbeat arriving
/// before the actor exists is logged and dropped, exactly as the actor itself drops one
/// that arrives before <c>ICentralHealthAggregator</c> is resolvable. Liveness is still
/// detected — the aggregator's offline timeout fires when the heartbeats stop landing.
/// </remarks>
/// <param name="request">The heartbeat.</param>
/// <param name="context">The gRPC call context.</param>
/// <returns>An empty reply, always.</returns>
// Application heartbeat — always answers OK, even when this node is not ready.
// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply,
// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised
// an error would be a self-inflicted outage on a purely informational signal). So this is
// the one RPC that does not go through RequireReady: a heartbeat arriving before the actor
// exists is logged and dropped, exactly as the actor itself drops one that arrives before
// ICentralHealthAggregator is resolvable. Liveness is still detected — the aggregator's
// offline timeout fires when the heartbeats stop landing.
/// <inheritdoc />
public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context)
{
var central = _central;
@@ -8,14 +8,20 @@ public static class CentralControlSiteIdentity
private static readonly object IdentityKey = new();
/// <summary>Stores an identity only after the site's credential has been verified.</summary>
/// <param name="context">The call context to record the identity on.</param>
/// <param name="siteId">The verified site identifier.</param>
public static void SetVerified(ServerCallContext context, string siteId) => context.UserState[IdentityKey] = siteId;
/// <summary>Requires a credential-verified identity on the current call.</summary>
/// <param name="context">The call context carrying the verified identity.</param>
/// <returns>The verified site identifier.</returns>
public static string RequireVerified(ServerCallContext context) =>
context.UserState.TryGetValue(IdentityKey, out var value) && value is string site && !string.IsNullOrWhiteSpace(site)
? site : throw Denied();
/// <summary>Checks every ownership field before dispatching any part of a central control request.</summary>
/// <param name="request">The inbound request whose ownership fields are checked.</param>
/// <param name="context">The call context carrying the verified site identity.</param>
public static void ValidateRequest(object request, ServerCallContext context)
{
var site = RequireVerified(context);
@@ -9,14 +9,20 @@ public static class GrpcRetrySafety
public const string DispatchTrailer = "scadabridge-dispatch";
/// <summary>Creates a readiness rejection that proves no work was dispatched.</summary>
/// <param name="detail">Human-readable rejection detail placed on the status.</param>
/// <returns>An <see cref="RpcException"/> with <c>Unavailable</c> status and the not-dispatched trailer.</returns>
public static RpcException NotDispatched(string detail) => new(
new Status(StatusCode.Unavailable, detail), new Metadata { { DispatchTrailer, "not-started" } });
/// <summary>True only for an explicit rejection from the server before it dispatched work.</summary>
/// <param name="error">The RPC failure to inspect.</param>
/// <returns><c>true</c> when the trailer proves the server never dispatched the command.</returns>
public static bool WasNotDispatched(RpcException error) => error.StatusCode == StatusCode.Unavailable
&& error.Trailers.Any(e => e.Key == DispatchTrailer && e.Value == "not-started");
/// <summary>Reports an ambiguous mutation without inviting a blind replay.</summary>
/// <param name="error">The original RPC failure whose trailers are preserved.</param>
/// <returns>An <see cref="RpcException"/> with <c>Aborted</c> status marking the outcome as unknown.</returns>
public static RpcException OutcomeUnknown(RpcException error) => new(
new Status(StatusCode.Aborted, "OUTCOME_UNKNOWN: connection failed after the command may have been applied. Verify its state before retrying."),
error.Trailers);
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <para>
/// <b>Per-call deadlines match today's Ask timeouts exactly</b> — see <see cref="ResolveDeadline"/>.
/// An unknown/unconfigured site now fails fast: it surfaces to the caller as a
/// <see cref="Status.Failure"/> immediately (WP1.6) instead of being warned-and-dropped to let the
/// <see cref="Status.Failure"/> immediately instead of being warned-and-dropped to let the
/// caller's Ask burn its full timeout. A transport fault takes the same <see cref="Status.Failure"/>
/// path, which the S&amp;F/audit layers already treat as transient.
/// </para>
@@ -83,7 +83,7 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
}
catch (SiteChannelUnavailableException ex)
{
// WP1.6: a known-dead send (no configured channel for this site) used to warn-and-drop,
// A known-dead send (no configured channel for this site) used to warn-and-drop,
// leaving the caller's Ask to burn its full timeout for a failure we already know about.
// Fail fast instead — same Status.Failure completion path a transport fault takes below —
// so S&F/audit treat it as transient immediately rather than tens of seconds later.
@@ -130,6 +130,8 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
}
/// <summary>Only read-only commands can replay after a response may have been lost.</summary>
/// <param name="message">The command message about to be sent.</param>
/// <returns><c>true</c> when the message is a read-only command safe to replay; otherwise <c>false</c>.</returns>
internal static bool IsRetrySafe(object message) => message is
DeploymentStateQueryRequest or BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand
or ListServerCertsCommand or EventLogQueryRequest or DebugSnapshotRequest or ParkedMessageQueryRequest
@@ -56,7 +56,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// Nullable <b>collections</b> are wrapped in a per-collection message, so
/// the null/empty distinction becomes a message-presence question. proto3
/// <c>repeated</c>/<c>map</c> alone collapses the two — precisely the silent
/// data-loss class the transport round-trip guard caught in PLAN-05 T8.
/// data-loss class the transport round-trip guard caught in the transport round-trip tests.
/// </item>
/// <item>
/// <c>Guid?</c> members ride as a <c>string</c> holding the "D" form, with
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// Raised when a site has no usable gRPC channel — an unknown site, or a site with neither
/// <c>GrpcNodeAAddress</c> nor <c>GrpcNodeBAddress</c> configured. <see cref="GrpcSiteTransport"/>
/// treats this as a known-dead send: it fails the caller's <c>Ask</c> fast with an
/// <c>Akka.Actor.Status.Failure</c> wrapping this exception (WP1.6) instead of warning and dropping
/// <c>Akka.Actor.Status.Failure</c> wrapping this exception instead of warning and dropping
/// the message, so the caller does not burn its full Ask timeout on a failure already known at send
/// time (central still never buffers).
/// </summary>
@@ -51,8 +51,7 @@ public sealed class SitePairChannelProvider : IDisposable
private static readonly TimeSpan FailbackSweepInterval = TimeSpan.FromSeconds(1);
/// <summary>
/// How long a replaced/removed channel is PARKED before it is disposed (arch-review
/// 2026-08-19 P3-2). Active calls retain the channel beyond this minimum until completion.
/// How long a replaced/removed channel is PARKED before it is disposed. Active calls retain the channel beyond this minimum until completion.
/// </summary>
internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5);
@@ -366,7 +365,7 @@ public sealed class SitePairChannelProvider : IDisposable
/// <summary>
/// Parks a channel that is no longer reachable through <see cref="_sites"/> instead of
/// disposing it inline (arch-review 2026-08-19 P3-2).
/// disposing it inline.
/// <para>
/// <b>Why deferred.</b> <see cref="ExecuteAsync{T}"/> reads the channel under
/// <c>pair.Gate</c> but invokes the RPC OUTSIDE it, so an inline
@@ -472,7 +471,7 @@ public sealed class SitePairChannelProvider : IDisposable
}
}
/// <inheritdoc />
/// <summary>Disposes every pooled channel and stops the failback sweep and parked-channel disposal.</summary>
public void Dispose()
{
lock (_lifecycleGate)
@@ -500,6 +499,7 @@ public sealed class SitePairChannelProvider : IDisposable
private sealed class SitePair(string siteId)
{
/// <summary>The site identifier this pair of node channels belongs to.</summary>
public string SiteId { get; } = siteId;
public readonly object Gate = new();
public string? NodeAEndpoint;
@@ -515,12 +515,15 @@ public sealed class SitePairChannelProvider : IDisposable
private TimeSpan _backoff = InitialBackoff;
/// <summary>Sets the failback backoff to <paramref name="delay"/> and schedules the next probe that far ahead.</summary>
/// <param name="delay">The delay before the next failback probe.</param>
public void ScheduleFailback(TimeSpan delay)
{
_backoff = delay;
NextProbeUtc = DateTime.UtcNow + _backoff;
}
/// <summary>Doubles the failback backoff (capped at the maximum) and schedules the next probe.</summary>
public void BumpFailbackBackoff()
{
var next = TimeSpan.FromTicks(Math.Min(_backoff.Ticks * 2, MaxBackoff.Ticks));
@@ -528,6 +531,7 @@ public sealed class SitePairChannelProvider : IDisposable
NextProbeUtc = DateTime.UtcNow + _backoff;
}
/// <summary>Resets the backoff to its initial value and clears the scheduled failback probe.</summary>
public void ResetFailback()
{
_backoff = InitialBackoff;
@@ -122,6 +122,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// Creates a test-only instance that has no gRPC channel. Used to test
/// Unsubscribe and Dispose behavior without needing a real endpoint.
/// </summary>
/// <param name="options">Optional communication options; defaults are used when null.</param>
/// <returns>A <see cref="SiteStreamGrpcClient"/> with no channel or client, for testing only.</returns>
internal static SiteStreamGrpcClient CreateForTesting(CommunicationOptions? options = null) => new(options ?? new CommunicationOptions());
@@ -146,8 +147,8 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <see cref="SubscribeAsync"/> calls briefly share an ID), the prior CTS is
/// <b>cancelled only</b> — never disposed from here. Internal for testability.
/// <para>
/// <b>Cancel-only is the contract</b> (arch-review 2026-08-19 P3-1, the client half of the
/// row-47 class): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
/// <b>Cancel-only is the contract</b> (the client half of the
/// shared-token-source disposal race): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
/// still reads <c>cts.Token</c> (opening the call, <c>ReadAllAsync</c>, the
/// <c>IsCancellationRequested</c> checks), so disposing it from the REPLACING path throws
/// <see cref="ObjectDisposedException"/> inside the prior stream — and, worse, out of the
@@ -172,7 +173,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// Cancels a subscription token source, swallowing <see cref="ObjectDisposedException"/>.
/// A CTS whose owning stream loop has already unwound and disposed it is simply a
/// subscription that no longer needs cancelling; the caller must never see that race as a
/// failure (arch-review 2026-08-19 P3-1).
/// failure.
/// </summary>
/// <param name="cts">The token source to cancel.</param>
private static void CancelQuietly(CancellationTokenSource cts)
@@ -249,7 +250,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
}
// This call OWNS `cts` — it created it, so it disposes it once the stream loop has
// fully unwound (P3-1). No other path may dispose it while this one is still reading
// fully unwound. No other path may dispose it while this one is still reading
// cts.Token; a replacing subscription cancels only.
try
{
@@ -270,15 +271,15 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <summary>
/// Opens the site-wide, <b>alarm-only</b> server-streaming subscription
/// (<c>SubscribeSite</c>) for a whole site rather than a single instance. This is
/// the central per-site feed that backs the aggregated Alarm Summary live cache
/// (plan #10): the site runtime's <c>SubscribeSiteAlarms</c> hub drops the
/// the central per-site feed that backs the aggregated Alarm Summary live cache:
/// the site runtime's <c>SubscribeSiteAlarms</c> hub drops the
/// per-instance filter and carries only <see cref="AlarmStateChanged"/> events for
/// <em>all</em> instances on the site (attributes are deliberately excluded — the
/// summary never shows them and they are far higher-volume).
/// <para>
/// The callback is deliberately <b>typed</b> as <see cref="AlarmStateChanged"/>
/// rather than the generic <c>Action&lt;object&gt;</c> used by
/// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so Task 4's
/// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so the
/// per-site cache consumes an alarm delta directly with no downstream type test.
/// The mapping is still shared via <see cref="ConvertToDomainEvent"/>; a non-alarm
/// event (which should never appear on this stream) is defensively ignored rather
@@ -504,7 +505,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <summary>
/// Cancels an active subscription by correlation ID. Cancel-only: the subscription's own
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> call disposes the token
/// source once its stream loop unwinds (P3-1). Cancelling an entry whose owner already
/// source once its stream loop unwinds. Cancelling an entry whose owner already
/// disposed it — or unsubscribing after the client was disposed — is a silent no-op.
/// </summary>
/// <param name="correlationId">Unique identifier of the subscription to cancel.</param>
@@ -664,7 +665,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// here is synchronous (CTS cancellation and <see cref="GrpcChannel.Dispose"/>), so a
/// synchronous <see cref="Dispose"/> can release everything without sync-over-async blocking.
/// <para>
/// Cancel-only, per-entry guarded (P3-1). The token sources are owned by their
/// Cancel-only, per-entry guarded. The token sources are owned by their
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> calls, which dispose them
/// as their loops unwind in response to this cancellation; disposing them here raced those
/// loops, and one <see cref="ObjectDisposedException"/> aborted the whole foreach — which in
@@ -131,7 +131,7 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
/// <summary>
/// Asynchronously disposes all cached clients and clears the cache. Each client's disposal
/// is fault-isolated: one throwing client must not abort the loop and strand every later
/// client's channel undisposed (arch-review 2026-08-19 P3-1).
/// client's channel undisposed.
/// </summary>
/// <returns>A value task that completes when all clients have been disposed.</returns>
public async ValueTask DisposeAsync()
@@ -39,7 +39,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
private volatile bool _shuttingDown;
private long _actorCounter;
// Ask timeout is 30 s. The ingest actor's repo
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow
// calls take under 100 ms in steady state; a generous timeout absorbs a slow
// MSSQL connection without surfacing as a gRPC failure on a healthy site.
// Shared with CentralCommunicationActor (same assembly) so the two audit-ingest
// transports use one source of truth for the timeout.
@@ -275,7 +275,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// enriched AlarmStateUpdate, so it is reused unchanged.
_streamSubscriber.SubscribeSiteAlarms,
"site-wide alarms",
// Its OWN, much larger channel (WP2.3): sharing the Debug View's 1000-slot
// Its OWN, much larger channel: sharing the Debug View's 1000-slot
// DropOldest meant an alarm burst during a WAN stall silently evicted operator-
// visible transitions to make room for diagnostics traffic.
_siteAlarmChannelCapacity,
@@ -338,7 +338,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
//
// The removal is claimed FIRST, but nothing is cancelled until the concurrency cap
// has had its say, and a REPLACEMENT never consults the cap at all (arch-review
// 2026-08-19 P3-5). Both halves matter:
// 2026-08-19). Both halves matter:
//
// * A replacement is net-neutral on the stream count (one entry out, one back in),
// so refusing it can only ever destroy capacity, never protect it. The old code
@@ -402,7 +402,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
{
// Lossy-under-backpressure is the debug-view spec; make the real
// loss visible: first eviction + every 500th thereafter, plus a
// per-kind counter on the node (WP2.3).
// per-kind counter on the node.
var n = Interlocked.Increment(ref dropped);
Interlocked.Increment(ref _droppedStreamEvents);
ScadaBridgeTelemetry.RecordSiteStreamEventDropped(streamKind);
@@ -447,8 +447,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
"Stream {CorrelationId} started for {Description} (subscription {SubscriptionId})",
correlationId, description, subscriptionId);
// Flush response headers NOW, while the relay is attached and before the first event
// (WP2.3). This is the client's "the site accepted my subscription" signal — the
// Flush response headers NOW, while the relay is attached and before the first event.
// This is the client's "the site accepted my subscription" signal — the
// per-site aggregator hangs its once-per-successful-reconnect re-seed off it. Without
// an explicit flush, ASP.NET Core defers headers to the first written message, so a
// quiet site would never report connected. Best-effort: a client that vanished between
@@ -516,12 +516,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
}
}
/// <summary>Retained wire method; ingest is served only by CentralControlService.</summary>
/// <inheritdoc />
public override Task<IngestAck> IngestAuditEvents(AuditEventBatch request, ServerCallContext context)
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
StatusCode.Unimplemented, "Use CentralControlService.IngestAuditEvents on central.")));
/// <summary>Retained wire method; combined ingest is served only by CentralControlService.</summary>
/// <inheritdoc />
public override Task<IngestAck> IngestCachedTelemetry(CachedTelemetryBatch request, ServerCallContext context)
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
StatusCode.Unimplemented, "Use CentralControlService.IngestCachedTelemetry on central.")));
@@ -535,12 +535,11 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
/// <summary>Flow Debugger PullTraceEvents: server-side id cap, matching the site queue's.</summary>
public const int MaxTraceExecutionIdsPerPull = 64;
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
/// <c>ExecutionId</c> or <c>ParentExecutionId</c> is one of the requested ids. NOT a
/// reconciliation path — moves no cursor, flips no ForwardState. Gated by the same
/// <c>ControlPlaneAuthInterceptor</c> prefix as <see cref="PullAuditEvents"/>.
/// </summary>
// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
// ExecutionId or ParentExecutionId is one of the requested ids. NOT a reconciliation
// path — moves no cursor, flips no ForwardState. Gated by the same
// ControlPlaneAuthInterceptor prefix as PullAuditEvents.
/// <inheritdoc />
public override async Task<PullTraceEventsResponse> PullTraceEvents(
PullTraceEventsRequest request,
ServerCallContext context)
@@ -630,12 +629,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
? DateTime.SpecifyKind(request.SinceUtc.ToDateTime(), DateTimeKind.Utc)
: DateTime.MinValue;
// Composite-keyset cursor (WP2.3), mirroring PullSiteCalls: proto3
// Composite-keyset cursor, mirroring PullSiteCalls: proto3
// defaults an unset string to "", which the queue treats as "no cursor"
// (legacy inclusive >= behaviour), so an older central is unaffected.
var afterId = string.IsNullOrEmpty(request.AfterId) ? null : request.AfterId;
// AT-LEAST-ONCE (WP2.3). The incoming cursor is central's receipt for
// AT-LEAST-ONCE. The incoming cursor is central's receipt for
// everything at or before it — that is the ONLY proof the site accepts.
// Rows are flipped to Reconciled here, at the START of the NEXT pull,
// instead of right after the previous response was projected: a fault
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.Communication;
/// <summary>
/// Central, transient, in-memory per-site live alarm cache (plan #10, Task 4).
/// Central, transient, in-memory per-site live alarm cache.
/// A DI singleton shared by every Alarm Summary Blazor circuit on the active central
/// node. For each site with at least one active viewer it runs ONE shared per-site
/// aggregator that seeds from the existing debug-snapshot fan-out and stays warm on a
@@ -55,8 +55,8 @@ public interface ISiteAlarmLiveCache
/// <summary>
/// Whether the site's aggregator has completed its initial seed and is serving live
/// state. The page can use this to decide whether to keep its 15s poll fallback
/// (Task 5): <c>false</c> means "not live yet — keep polling"; <c>true</c> means the
/// state. The page can use this to decide whether to keep its 15s poll fallback:
/// <c>false</c> means "not live yet — keep polling"; <c>true</c> means the
/// cache is authoritative.
/// </summary>
/// <param name="siteId">The numeric site id.</param>
@@ -35,7 +35,7 @@ public static class ServiceCollectionExtensions
sp.GetService<ISitePskProvider>()));
services.AddSingleton<DebugStreamService>();
// Aggregated live alarm cache (plan #10, Task 4): transient, in-memory, shared
// Aggregated live alarm cache transient, in-memory, shared
// per-site aggregator backing the operator Alarm Summary page. Singleton so every
// Blazor circuit shares one aggregator per site; started/stopped by viewer
// reference-count. No persistence (locked [PERM]: no central alarm store).
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
/// <summary>
/// Default <see cref="ISiteAlarmLiveCache"/> — the DI singleton that owns the shared,
/// reference-counted, transient per-site live alarm cache (plan #10, Task 4). For each
/// reference-counted, transient per-site live alarm cache. For each
/// site with active Alarm Summary viewers it runs ONE
/// <see cref="SiteAlarmAggregatorActor"/>, seeded by the existing debug-snapshot fan-out
/// (<see cref="CommunicationService.RequestDebugSnapshotAsync"/>) and kept warm on a
@@ -246,7 +246,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
TimeSpan.FromSeconds(5), // reconnect delay — former ReconnectDelay static default
TimeSpan.FromSeconds(60), // stability window — former StabilityWindow static default
_options.LiveAlarmCacheReconcileJitterFraction,
// Open-attempt deadline (P3-4). Passed explicitly rather than defaulted:
// Open-attempt deadline. Passed explicitly rather than defaulted:
// Props.Create takes an expression tree, which may not omit an optional
// argument (CS0854).
SiteAlarmAggregatorActor.DefaultOpenAttemptTimeout));
@@ -426,9 +426,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
await gate.WaitAsync(ct);
try
{
// Alarms-only (WP2.3): this fan-out discards snapshot.AttributeValues, so
// Alarms-only: this fan-out discards snapshot.AttributeValues, so
// asking the site to build and ship them is pure wire waste. The flag is
// additive on the wire; a pre-WP2.3 site ignores it and returns the full
// additive on the wire; an older site ignores it and returns the full
// snapshot, which this loop reads exactly as before.
var request = new DebugSnapshotRequest(
instanceUniqueName, Guid.NewGuid().ToString("N"), AlarmsOnly: true);
@@ -470,7 +470,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
entry.HasPublished = true;
// Liveness is the STREAM's, not the cache's: a completed or given-up stream must
// stop reporting live between reopen ticks, or the page grafts a freezing snapshot
// over fresh poll data (WP2.3 carried residual).
// over fresh poll data.
entry.StreamLive = streamLive;
subscribers = entry.Subscribers.ToArray();
}
@@ -496,6 +496,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary>
private sealed class AggregatorTerminationWatcher : ReceiveActor
{
/// <summary>Creates the watcher and begins watching the aggregator.</summary>
/// <param name="watched">The aggregator actor to watch.</param>
/// <param name="onTerminated">Callback invoked once when the watched actor terminates.</param>
public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated)
{
Context.Watch(watched);
@@ -551,11 +554,20 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <summary>Per-site bookkeeping. All mutable fields are guarded by the service's <c>_lock</c>.</summary>
private sealed class SiteEntry
{
/// <summary>Creates the bookkeeping entry for a site.</summary>
/// <param name="siteId">The numeric site id.</param>
public SiteEntry(int siteId) => SiteId = siteId;
/// <summary>The numeric site id this entry tracks.</summary>
public int SiteId { get; }
/// <summary>Active viewer subscriptions for the site.</summary>
public List<Subscription> Subscribers { get; } = new();
/// <summary>The site's shared aggregator actor, or <c>null</c> when none is running.</summary>
public IActorRef? Actor { get; set; }
/// <summary>True while an aggregator start attempt is in progress.</summary>
public bool Starting { get; set; }
/// <summary>Current published snapshot the page renders. Swapped as a whole immutable list.</summary>
@@ -578,7 +590,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// </summary>
public bool StreamLive { get; set; }
/// <summary>Timer that stops the aggregator after the last viewer leaves.</summary>
public Timer? LingerTimer { get; set; }
/// <summary>Version counter that invalidates a stale linger-timer callback.</summary>
public int LingerVersion { get; set; }
/// <summary>Bounded retry timer armed when a start attempt failed to create the actor.</summary>
@@ -611,6 +626,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
{
public static readonly NoOpSubscription Instance = new();
private NoOpSubscription() { }
/// <summary>Does nothing; the rejected subscription was never registered.</summary>
public void Dispose() { }
}
@@ -620,6 +636,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
private readonly SiteAlarmLiveCacheService _owner;
private int _disposed;
/// <summary>Creates a subscription handle for a viewer.</summary>
/// <param name="owner">The owning cache service.</param>
/// <param name="siteId">The numeric site id subscribed to.</param>
/// <param name="onChanged">Callback invoked when the site's cache changes.</param>
public Subscription(SiteAlarmLiveCacheService owner, int siteId, Action onChanged)
{
_owner = owner;
@@ -627,9 +647,13 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
OnChanged = onChanged;
}
/// <summary>The numeric site id subscribed to.</summary>
public int SiteId { get; }
/// <summary>Callback invoked when the site's cache changes.</summary>
public Action OnChanged { get; }
/// <summary>Unregisters this viewer from the owning cache; safe to call more than once.</summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
@@ -191,7 +191,7 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
// INCLUDE(Status) makes the audit KPI window query (count/aggregate by Status
// over a recent OccurredAtUtc range) a covering index seek — the persisted
// computed Status column rides along in the leaf without a key/RID lookup back
// into the clustered index per matching row (arch-review WP1.4).
// into the clustered index per matching row.
builder.HasIndex(e => e.OccurredAtUtc)
.IsDescending(true)
.HasDatabaseName("IX_AuditLog_OccurredAtUtc")
@@ -74,7 +74,7 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
// Covers ComputeKpisAsync's "DeliveredLastInterval" count
// (Status == Delivered && DeliveredAt >= deliveredSince): filtered to just the
// Delivered rows so the index stays small relative to the full table and the KPI
// query can seek it directly instead of scanning every status (arch-review WP1.4).
// query can seek it directly instead of scanning every status.
// Status is stored as its enum name (HasConversion<string>() above), so the filter
// predicate matches on the literal 'Delivered', not the underlying int value.
// No N'' prefix: the value is ASCII-only (fine on SQL Server's nvarchar column)
@@ -93,11 +93,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
// SourceSiteId first, and IX_Notifications_Delivered is filtered to
// Status='Delivered' only. So an unfiltered (or lightly filtered) page
// fell back to a full clustered-index scan + sort on every COUNT + page
// request (arch-review 2026-08-19 P5-3, register row-35 class). With both
// request. With both
// key columns descending, a forward scan of this index already delivers
// rows in the requested order. Exact mirror of
// SiteCallEntityTypeConfiguration's IX_SiteCalls_Created_TrackedOperationId
// (2026-08-19, arch-review P5-2).
// SiteCallEntityTypeConfiguration's IX_SiteCalls_Created_TrackedOperationId.
builder.HasIndex(n => new { n.CreatedAt, n.NotificationId })
.IsDescending(true, true)
.HasDatabaseName("IX_Notifications_Created_NotificationId");
@@ -99,7 +99,7 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall
.IncludeProperties(nameof(SiteCall.SourceSite), nameof(SiteCall.SourceNode), nameof(SiteCall.Status));
// Terminal backs the daily retention purge's predicate (TerminalAtUtc IS NOT NULL
// AND TerminalAtUtc < cutoff) and Task 11's MIN() slice anchor: filtered to the
// AND TerminalAtUtc < cutoff) and the MIN() slice anchor: filtered to the
// terminal population so the purge seeks the expired tail instead of full-scanning
// the 365-day table (arch-review 04 round 2, R6). Complements IX_SiteCalls_NonTerminal,
// which is filtered to IS NULL and unusable for this predicate.
@@ -114,7 +114,7 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall
// (Source_Created requires an equality on SourceSite first;
// NonTerminal/Terminal are filtered to one side of TerminalAtUtc), so an
// unfiltered page previously fell back to a full clustered-index scan +
// sort (arch-review 2026-08-19 P5-2, register row-35 class).
// sort.
builder.HasIndex(s => new { s.CreatedAtUtc, s.TrackedOperationId })
.IsDescending(true, true)
.HasDatabaseName("IX_SiteCalls_Created_TrackedOperationId");
@@ -10,7 +10,15 @@ public sealed class DataConnectionSecretConverter(IDataProtector protector) : Va
{
internal const string Prefix = "dp:protocol:v1:";
internal const string Purpose = EncryptedStringConverter.ProtectorPurpose + ".DataConnection";
/// <summary>Encrypts plaintext protocol JSON and tags it with the versioned prefix.</summary>
/// <param name="protector">The data protector to encrypt with.</param>
/// <param name="plaintext">The plaintext to protect.</param>
/// <returns>The prefixed protected string.</returns>
internal static string Protect(IDataProtector protector, string plaintext) => Prefix + protector.Protect(plaintext);
/// <summary>Decrypts a stored, versioned protected value.</summary>
/// <param name="protector">The data protector to decrypt with.</param>
/// <param name="stored">The stored value, which must carry the versioned prefix.</param>
/// <returns>The decrypted plaintext.</returns>
internal static string Unprotect(IDataProtector protector, string stored)
=> stored.StartsWith(Prefix, StringComparison.Ordinal)
? protector.Unprotect(stored[Prefix.Length..])
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
public static class DataConnectionSecretUpgrade
{
/// <summary>Protects both configuration columns before repositories may materialize them.</summary>
/// <param name="db">The configuration database context.</param>
/// <param name="cancellationToken">Token to cancel the upgrade.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public static async Task UpgradeAsync(ScadaBridgeDbContext db, CancellationToken cancellationToken = default)
{
var connection = db.Database.GetDbConnection();

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