diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index 72e31a66..f5d24fde 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -296,6 +296,20 @@ public interface IAuditLogRepository
Guid executionId,
CancellationToken ct = default);
+ ///
+ /// Flow Debugger Replay (design 2026-09-05 §7.1): every row of the execution tree that
+ /// belongs to (walked to the root exactly as
+ /// does) whose channel is ScriptTrace, plus the
+ /// tree's ApiInbound.InboundRequest rows, ordered (OccurredAtUtc, EventId).
+ /// Empty when nothing in the store carries the id.
+ ///
+ /// Any execution id in the chain.
+ /// Cancellation token.
+ /// The canonical audit events that make up the flow, oldest first.
+ Task> GetTraceEventsAsync(
+ Guid executionId,
+ CancellationToken ct = default);
+
///
/// Returns the distinct, non-null SourceNode values present in the
/// AuditLog table, in ascending order. Backs the Audit Log page's
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index 6bfba4ae..ba29f608 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -9,6 +9,7 @@ using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
@@ -1125,6 +1126,131 @@ VALUES
}
}
+ ///
+ public async Task> GetTraceEventsAsync(
+ Guid executionId,
+ CancellationToken ct = default)
+ {
+ // Flow Debugger Replay (design 2026-09-05 §7.1): same walk as GetExecutionTreeAsync
+ // — up to the root, then the Edges/Chain CTE down — but projecting the chain's
+ // execution ids, then the rows themselves filtered to the trace channel + the
+ // inbound row. Two statements keep the row read on EF's canonical mapper
+ // (RowToCanonical) instead of a second hand-mapped reader.
+ var conn = _context.Database.GetDbConnection();
+ var openedHere = false;
+ if (conn.State != System.Data.ConnectionState.Open)
+ {
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+ openedHere = true;
+ }
+
+ var chainIds = new List();
+ try
+ {
+ var rootExecutionId = executionId;
+ for (var depth = 0; depth < ExecutionChainMaxDepth; depth++)
+ {
+ Guid? parent;
+ await using (var upCmd = conn.CreateCommand())
+ {
+ upCmd.CommandText =
+ "SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " +
+ "WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
+ var pCur = upCmd.CreateParameter();
+ pCur.ParameterName = "@cur";
+ pCur.Value = rootExecutionId;
+ upCmd.Parameters.Add(pCur);
+ var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ parent = result is null or DBNull ? null : (Guid)result;
+ }
+ if (parent is null) break;
+ rootExecutionId = parent.Value;
+ }
+
+ DateTime? rootFirstOccurred;
+ await using (var winCmd = conn.CreateCommand())
+ {
+ winCmd.CommandText = "SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
+ var pWinRoot = winCmd.CreateParameter();
+ pWinRoot.ParameterName = "@root";
+ pWinRoot.Value = rootExecutionId;
+ winCmd.Parameters.Add(pWinRoot);
+ var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
+ }
+ var edgesWindowPredicate = rootFirstOccurred is null
+ ? string.Empty
+ : "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
+
+ await using (var idsCmd = conn.CreateCommand())
+ {
+ idsCmd.CommandText = $@"
+ WITH Edges AS (
+ SELECT DISTINCT ExecutionId, ParentExecutionId
+ FROM dbo.AuditLog
+ WHERE ExecutionId IS NOT NULL
+ {edgesWindowPredicate}
+ ),
+ Chain AS (
+ SELECT CAST(@root AS uniqueidentifier) AS ExecutionId,
+ CAST(NULL AS uniqueidentifier) AS ParentExecutionId
+ UNION ALL
+ SELECT e.ExecutionId, e.ParentExecutionId
+ FROM Edges e
+ INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
+ )
+ SELECT ExecutionId FROM Chain
+ UNION
+ SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL
+ OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
+ var pRoot = idsCmd.CreateParameter();
+ pRoot.ParameterName = "@root";
+ pRoot.Value = rootExecutionId;
+ idsCmd.Parameters.Add(pRoot);
+ if (rootFirstOccurred is { } rootFirst)
+ {
+ var pWinStart = idsCmd.CreateParameter();
+ pWinStart.ParameterName = "@winStart";
+ pWinStart.Value = rootFirst - ExecutionTreeWindowSlack;
+ idsCmd.Parameters.Add(pWinStart);
+ var pWinEnd = idsCmd.CreateParameter();
+ pWinEnd.ParameterName = "@winEnd";
+ pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan;
+ idsCmd.Parameters.Add(pWinEnd);
+ }
+ await using var reader = await idsCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ if (!reader.IsDBNull(0)) chainIds.Add(reader.GetGuid(0));
+ }
+ }
+ }
+ finally
+ {
+ if (openedHere)
+ {
+ await conn.CloseAsync().ConfigureAwait(false);
+ }
+ }
+
+ if (chainIds.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var rows = await _context.Set()
+ .AsNoTracking()
+ .Where(r => r.ExecutionId != null && chainIds.Contains(r.ExecutionId.Value))
+ .Where(r => r.Channel == AuditChannel.ScriptTrace
+ || (r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest))
+ .OrderBy(r => r.OccurredAtUtc)
+ .ThenBy(r => r.EventId)
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+
+ return rows.Select(RowToCanonical).ToList();
+ }
+
///
public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs
index 45c2248e..fd01ce04 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs
@@ -430,6 +430,9 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
///
private sealed class BatchFailingAuditRepo : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
private readonly IAuditLogRepository _inner;
public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; }
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
index aa6f70e5..2703d0db 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
@@ -382,6 +382,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture
private sealed class TokenRecordingRepository : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
public CancellationToken? BatchToken { get; private set; }
public List RowTokens { get; } = new();
@@ -438,6 +441,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture
private sealed class ThrowingRepository : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
private readonly IAuditLogRepository _inner;
private readonly Guid _poisonId;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
index 457cba2f..98cc73cd 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
@@ -45,6 +45,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture
private sealed class RecordingRepo : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
public List ThresholdQueries { get; } = new();
public List SwitchedBoundaries { get; } = new();
public Func RowsPerBoundary { get; set; } = _ => 0L;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
index 40e8832a..e377fe8c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
@@ -37,6 +37,9 @@ public class CentralAuditWriteFailuresTests : TestKit
///
private sealed class ThrowingRepo : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated repo failure");
public Task> QueryAsync(
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
index 54634777..6d21925b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
@@ -68,6 +68,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture
private sealed class RecordingRepo : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
public List Inserted { get; } = new();
private readonly HashSet _seen = new();
public int InsertCallCount { get; private set; }
@@ -121,6 +124,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture
private sealed class PoisonInsertRepo : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
private readonly Guid _poisonId;
private readonly Exception _fault;
public List Inserted { get; } = new();
@@ -192,6 +198,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture
private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
public List Inserted { get; } = new();
private readonly HashSet _seen = new();
@@ -603,6 +612,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureRepository whose every insert throws, so the retry hold-back path is taken.
private sealed class AlwaysThrowingRepo : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
throw new InvalidOperationException("central insert failed");
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs
new file mode 100644
index 00000000..4568e4e0
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs
@@ -0,0 +1,76 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
+
+///
+/// Flow Debugger Replay (design 2026-09-05 §7.1):
+/// returns the whole chain's ScriptTrace rows plus the inbound row, from ANY entry id, ordered,
+/// and excludes unrelated and non-trace rows. Runs against the shared MSSQL fixture.
+///
+public class AuditLogRepositoryTraceEventsTests : IClassFixture
+{
+ private readonly MsSqlMigrationFixture _fixture;
+ public AuditLogRepositoryTraceEventsTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
+
+ private ScadaBridgeDbContext CreateContext() => new(
+ new DbContextOptionsBuilder().UseSqlServer(_fixture.ConnectionString).Options);
+
+ [SkippableFact]
+ public async Task ReturnsInboundPlusChainTraceRows_Ordered_FromAnyEntry()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+ var siteId = "test-trace-" + Guid.NewGuid().ToString("N")[..8];
+ var t0 = new DateTime(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
+ var root = Guid.NewGuid();
+ var siteRun = Guid.NewGuid();
+ var cascade = Guid.NewGuid();
+
+ var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
+ occurredAtUtc: t0, actor: "key", target: "M", executionId: root, sourceSiteId: siteId, responseSummary: "{}");
+ var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(10), target: "I.S", executionId: root, sourceSiteId: siteId,
+ extra: new ScriptTraceExtra { ChildExecutionId = siteRun }.ToJson());
+ var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(20), target: "S", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId);
+ var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(30), target: "A", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId);
+ var cascadeRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(40), target: "C", executionId: cascade, parentExecutionId: siteRun, sourceSiteId: siteId);
+ // Same chain but NOT a trace row: an outbound API call the site script made — excluded.
+ var outbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(25), target: "ERP.x", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId);
+ // Unrelated chain.
+ var other = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(5), target: "Z", executionId: Guid.NewGuid(), sourceSiteId: siteId);
+
+ await using (var ctx = CreateContext())
+ {
+ var repo = new AuditLogRepository(ctx);
+ foreach (var e in new[] { cascadeRun, other, write, outbound, inbound, run, route })
+ await repo.InsertIfNotExistsAsync(e);
+ }
+
+ await using (var ctx = CreateContext())
+ {
+ var repo = new AuditLogRepository(ctx);
+ foreach (var entry in new[] { root, siteRun, cascade })
+ {
+ var events = await repo.GetTraceEventsAsync(entry);
+ Assert.Equal(
+ new[] { inbound.EventId, route.EventId, run.EventId, write.EventId, cascadeRun.EventId },
+ events.Select(e => e.EventId).ToArray());
+ Assert.Equal(inbound, events[0]);
+ Assert.Equal(cascadeRun, events[4]);
+ }
+
+ Assert.Empty(await repo.GetTraceEventsAsync(Guid.NewGuid()));
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
index 8b9cc57b..7096ff65 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
@@ -89,6 +89,9 @@ public class SiteAuditPushFlowTests : TestKit
///
private sealed class InMemoryAuditLogRepository : IAuditLogRepository
{
+ public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
+
private readonly ConcurrentDictionary _rows = new();
public IReadOnlyCollection Rows => _rows.Values.ToList();