feat(site-runtime): outbound API/DB/notification audit rows ride the flow-trace collector

ScriptRuntimeContext caches a trace sink (null on an untraced run, so the
production path is byte-identical) and threads it into the ExternalSystem /
Database / Notify helpers and AuditingDbConnection→AuditingDbCommand (onAudit).
All eight outbound emit sites hand the SAME AuditEvent instance to the sink
first, then to the writer/forwarder. FlowTraceCollector gains Completed +
Complete(): ScriptRunLauncher closes the collector where it builds the reply
snapshot, so un-awaited calls finishing after the run returned never mutate a
list the reply was built from. Cap (500) unchanged. Plan 2026-09-06 D5.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 01:51:19 -04:00
parent 865c4be651
commit f394f58405
6 changed files with 464 additions and 19 deletions
@@ -179,6 +179,16 @@ public class ScriptRuntimeContext
/// </summary>
private readonly FlowTraceCollector? _traceCollector;
/// <summary>
/// Outbound-calls addendum (plan 2026-09-06 D5): <see cref="Collect"/> as a delegate the
/// <c>ExternalSystem</c> / <c>Database</c> / <c>Notify</c> helpers hand every
/// <c>ApiOutbound</c> / <c>DbOutbound</c> / <c>Notification</c> row to — the same
/// <see cref="AuditEvent"/> instance the audit writer / telemetry forwarder receives, so
/// a Live run's inline reply carries them. Null when untraced so the helpers' emit paths
/// stay byte-identical to the pre-feature behaviour.
/// </summary>
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Initializes a new script runtime context with all dependencies for script
/// execution, external system calls, database access, and notification delivery.
@@ -287,8 +297,17 @@ public class ScriptRuntimeContext
_scriptTimeoutToken = scriptTimeoutToken;
_isTraced = isTraced;
_traceCollector = traceCollector;
_traceSink = traceCollector is null ? null : Collect;
}
/// <summary>
/// Flow Debugger: the single feeder of the inline collector. Every row this run's
/// reply should carry — the <c>ScriptTrace</c> rows from <see cref="EmitTrace"/> and the
/// outbound rows the helpers emit — passes through here; a no-op when untraced or once
/// the launcher has <see cref="FlowTraceCollector.Complete"/>d the run.
/// </summary>
private void Collect(AuditEvent evt) => _traceCollector?.Add(evt);
/// <summary>Flow Debugger (design 2026-09-05 §5.2): whether this run emits <c>ScriptTrace</c> rows.</summary>
public bool IsTraced => _isTraced;
@@ -385,7 +404,7 @@ public class ScriptRuntimeContext
return;
}
_traceCollector?.Add(evt);
Collect(evt);
if (_auditWriter is null) return;
try
{
@@ -818,7 +837,9 @@ public class ScriptRuntimeContext
// SourceNode-stamping: the local node name (node-a/node-b),
// threaded so the cached-call telemetry construction sites can stamp
// it onto SiteCallOperational.SourceNode.
_sourceNode);
_sourceNode,
// Flow Debugger: the outbound rows ride the inline collector (null when untraced).
traceSink: _traceSink);
/// <summary>
/// Provides access to database operations.
@@ -846,7 +867,9 @@ public class ScriptRuntimeContext
// SourceNode-stamping: the local node name (node-a/node-b),
// threaded so Database.CachedWrite's CachedSubmit telemetry can
// stamp it onto SiteCallOperational.SourceNode.
_sourceNode);
_sourceNode,
// Flow Debugger: the outbound rows ride the inline collector (null when untraced).
traceSink: _traceSink);
/// <summary>
/// Provides access to the Notification Outbox API.
@@ -869,7 +892,9 @@ public class ScriptRuntimeContext
_parentExecutionId,
// SourceNode-stamping: the local node name (node-a/node-b),
// threaded so NotifyTarget.Send can stamp it onto NotificationSubmit.
_sourceNode);
_sourceNode,
// Flow Debugger: the NotifySend row rides the inline collector (null when untraced).
traceSink: _traceSink);
/// <summary>
/// Read-only access to the instance's CURRENT alarm conditions (MES alarm-status API
@@ -1065,6 +1090,13 @@ public class ScriptRuntimeContext
/// </summary>
private readonly string? _sourceNode;
/// <summary>
/// Flow Debugger (plan 2026-09-06 D5): the run's inline collector. Each emit
/// site hands it the SAME <see cref="AuditEvent"/> it gives the writer /
/// forwarder, collector first. Null when untraced — the production default.
/// </summary>
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Initializes a new external system helper for script API calls.
/// </summary>
@@ -1078,6 +1110,7 @@ public class ScriptRuntimeContext
/// <param name="cachedForwarder">Optional forwarder for cached call telemetry.</param>
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit stamping.</param>
/// <param name="traceSink">Flow Debugger inline collector — receives the same <see cref="AuditEvent"/> each emit hands to the writer/forwarder; null when untraced.</param>
// Internal constructor for tests living in ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem.
@@ -1100,7 +1133,8 @@ public class ScriptRuntimeContext
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null,
string? sourceNode = null)
string? sourceNode = null,
Action<AuditEvent>? traceSink = null)
{
_client = client;
_instanceName = instanceName;
@@ -1112,6 +1146,7 @@ public class ScriptRuntimeContext
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
}
/// <summary>
@@ -1329,6 +1364,8 @@ public class ScriptRuntimeContext
try
{
// Flow Debugger: collector first, then the forwarder.
_traceSink?.Invoke(telemetry.Audit);
await _cachedForwarder.ForwardAsync(telemetry, cancellationToken)
.ConfigureAwait(false);
}
@@ -1452,6 +1489,9 @@ public class ScriptRuntimeContext
{
try
{
// Flow Debugger: the immediate-completion rows are emitted inline while
// the run is still open, so they legitimately ride the collector.
_traceSink?.Invoke(attempted.Audit);
await _cachedForwarder.ForwardAsync(attempted, cancellationToken)
.ConfigureAwait(false);
}
@@ -1519,6 +1559,7 @@ public class ScriptRuntimeContext
try
{
_traceSink?.Invoke(resolve.Audit);
await _cachedForwarder.ForwardAsync(resolve, cancellationToken)
.ConfigureAwait(false);
}
@@ -1569,6 +1610,8 @@ public class ScriptRuntimeContext
try
{
// Flow Debugger: collector first (same instance), then the writer.
_traceSink?.Invoke(evt);
// Fire-and-forget so we never block the script on the audit
// writer; the writer itself is responsible for fast, durable
// enqueue (site SQLite hot-path). We DO observe failures via
@@ -1758,6 +1801,14 @@ public class ScriptRuntimeContext
/// </summary>
private readonly string? _sourceNode;
/// <summary>
/// Flow Debugger (plan 2026-09-06 D5): the run's inline collector, handed the
/// same <see cref="AuditEvent"/> each emit gives the writer / forwarder and
/// threaded into <see cref="AuditingDbConnection"/> for the sync <c>DbWrite</c>
/// rows. Null when untraced.
/// </summary>
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Initializes a new database helper for script database access.
/// </summary>
@@ -1771,6 +1822,7 @@ public class ScriptRuntimeContext
/// <param name="cachedForwarder">Optional forwarder for cached call telemetry.</param>
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit stamping.</param>
/// <param name="traceSink">Flow Debugger inline collector — receives the same <see cref="AuditEvent"/> each emit hands to the writer/forwarder; null when untraced.</param>
// Parameter ordering: executionId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
@@ -1785,7 +1837,8 @@ public class ScriptRuntimeContext
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null,
string? sourceNode = null)
string? sourceNode = null,
Action<AuditEvent>? traceSink = null)
{
_gateway = gateway;
_instanceName = instanceName;
@@ -1797,6 +1850,7 @@ public class ScriptRuntimeContext
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
}
/// <summary>
@@ -1837,7 +1891,9 @@ public class ScriptRuntimeContext
executionId: _executionId,
// (ParentExecutionId): the spawning execution's
// id, threaded alongside _executionId. Null for non-routed runs.
parentExecutionId: _parentExecutionId);
parentExecutionId: _parentExecutionId,
// Flow Debugger: the sync DbWrite rows ride the inline collector.
onAudit: _traceSink);
}
/// <summary>
@@ -1959,6 +2015,8 @@ public class ScriptRuntimeContext
{
try
{
// Flow Debugger: emitted inline while the run is open — rides the collector.
_traceSink?.Invoke(attempted.Audit);
await _cachedForwarder.ForwardAsync(attempted, cancellationToken)
.ConfigureAwait(false);
}
@@ -1980,6 +2038,7 @@ public class ScriptRuntimeContext
{
try
{
_traceSink?.Invoke(resolve.Audit);
await _cachedForwarder.ForwardAsync(resolve, cancellationToken)
.ConfigureAwait(false);
}
@@ -2107,6 +2166,8 @@ public class ScriptRuntimeContext
try
{
// Flow Debugger: collector first, then the forwarder.
_traceSink?.Invoke(telemetry.Audit);
await _cachedForwarder.ForwardAsync(telemetry, cancellationToken)
.ConfigureAwait(false);
}
@@ -2169,6 +2230,12 @@ public class ScriptRuntimeContext
/// </summary>
private readonly string? _sourceNode;
/// <summary>
/// Flow Debugger (plan 2026-09-06 D5): the run's inline collector, forwarded to
/// each <see cref="NotifyTarget"/>. Null when untraced.
/// </summary>
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Initializes a new notification helper for script notification delivery.
/// </summary>
@@ -2183,6 +2250,7 @@ public class ScriptRuntimeContext
/// <param name="auditWriter">Optional writer for audit log entries.</param>
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit stamping.</param>
/// <param name="traceSink">Flow Debugger inline collector — receives the same <see cref="AuditEvent"/> <c>Send</c> hands to the writer; null when untraced.</param>
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors. parentExecutionId is
// a trailing optional param.
@@ -2197,7 +2265,8 @@ public class ScriptRuntimeContext
Guid executionId,
IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null,
string? sourceNode = null)
string? sourceNode = null,
Action<AuditEvent>? traceSink = null)
{
_storeAndForward = storeAndForward;
_siteCommunicationActor = siteCommunicationActor;
@@ -2210,6 +2279,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
}
/// <summary>
@@ -2232,7 +2302,9 @@ public class ScriptRuntimeContext
_parentExecutionId,
// SourceNode-stamping: the local node name, stamped
// onto NotificationSubmit.SourceNode in Send().
_sourceNode);
_sourceNode,
// Flow Debugger: the NotifySend row rides the inline collector.
traceSink: _traceSink);
}
/// <summary>
@@ -2339,6 +2411,13 @@ public class ScriptRuntimeContext
/// </summary>
private readonly string? _sourceNode;
/// <summary>
/// Flow Debugger (plan 2026-09-06 D5): the run's inline collector — handed the
/// same <c>NotifySend</c> <see cref="AuditEvent"/> the writer receives. Null when
/// untraced.
/// </summary>
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Initializes a new notification target for a specific notification list.
/// </summary>
@@ -2352,6 +2431,7 @@ public class ScriptRuntimeContext
/// <param name="auditWriter">Optional writer for audit log entries.</param>
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit stamping.</param>
/// <param name="traceSink">Flow Debugger inline collector; null when untraced.</param>
internal NotifyTarget(
string listName,
StoreAndForwardService? storeAndForward,
@@ -2362,7 +2442,8 @@ public class ScriptRuntimeContext
Guid executionId,
IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null,
string? sourceNode = null)
string? sourceNode = null,
Action<AuditEvent>? traceSink = null)
{
_listName = listName;
_storeAndForward = storeAndForward;
@@ -2374,6 +2455,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
}
/// <summary>
@@ -2558,6 +2640,8 @@ public class ScriptRuntimeContext
try
{
// Flow Debugger: collector first (same instance), then the writer.
_traceSink?.Invoke(evt);
// Fire-and-forget (mirrors ExternalSystemHelper.EmitCallAudit)
// so the script is never blocked on the audit writer; we observe
// failures via ContinueWith so a thrown writer is logged rather