diff --git a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json
index a7f18782..a13dbd93 100644
--- a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json
+++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json
@@ -17,7 +17,7 @@
{
"id": 2,
"subject": "Task 2: Commons \u2014 FlowGraphBuilder groups operations, async branches, spawn edges, Response origin",
- "status": "in_progress",
+ "status": "completed",
"blockedBy": [
0,
1
@@ -26,7 +26,7 @@
{
"id": 3,
"subject": "Task 3: Site runtime \u2014 outbound rows flow through the trace collector",
- "status": "pending",
+ "status": "in_progress",
"blockedBy": [
0
]
@@ -34,7 +34,7 @@
{
"id": 4,
"subject": "Task 4: Central \u2014 audit InboundDatabaseHelper (DbOutbound) and feed the trace sink",
- "status": "pending",
+ "status": "in_progress",
"blockedBy": [
0
]
@@ -42,7 +42,7 @@
{
"id": 5,
"subject": "Task 5: Central replay filter + site pull verification",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
0
]
@@ -96,5 +96,5 @@
]
}
],
- "lastUpdated": "2026-09-06T05:38:41.681788+00:00"
+ "lastUpdated": "2026-09-06T05:47:05.104042+00:00"
}
\ No newline at end of file
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 f5d24fde..46bb44f3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -299,8 +299,9 @@ public interface IAuditLogRepository
///
/// 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).
+ /// does) whose channel is ScriptTrace,
+ /// ApiOutbound, DbOutbound or Notification (outbound-calls plan 2026-09-06, D6),
+ /// 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.
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index ba29f608..113a4a44 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -1133,8 +1133,8 @@ VALUES
{
// 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
+ // execution ids, then the rows themselves filtered to the trace + outbound channels
+ // + 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;
@@ -1241,7 +1241,15 @@ VALUES
var rows = await _context.Set()
.AsNoTracking()
.Where(r => r.ExecutionId != null && chainIds.Contains(r.ExecutionId.Value))
+ // Outbound-calls plan (2026-09-06, D6): the replay set is the trace channel plus the
+ // three outbound channels (the script's API / DB / notification calls already sit in
+ // the tree under the run's execution id) plus the inbound row. SecuredWrite, Cluster
+ // and InboundAuthFailure rows sharing an execution id stay out — nothing the flow
+ // graph can draw.
.Where(r => r.Channel == AuditChannel.ScriptTrace
+ || r.Channel == AuditChannel.ApiOutbound
+ || r.Channel == AuditChannel.DbOutbound
+ || r.Channel == AuditChannel.Notification
|| (r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest))
.OrderBy(r => r.OccurredAtUtc)
.ThenBy(r => r.EventId)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs
index 3deb2b4f..7b41caac 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs
@@ -72,6 +72,37 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
}
}
+ ///
+ /// Outbound-calls plan (2026-09-06, D6): the site pull filters by execution id ONLY — an
+ /// ApiOutbound row (or any other channel) stored under the execution id is served alongside
+ /// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in.
+ ///
+ [Fact]
+ public async Task ServesOutboundRows_NoChannelPredicate()
+ {
+ var root = Guid.NewGuid();
+ var siteRun = Guid.NewGuid();
+ var run = Row(T0.AddSeconds(1), siteRun, root);
+ var apiCall = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
+ occurredAtUtc: T0.AddSeconds(2), target: "Delmia.AckRecipe", executionId: siteRun, parentExecutionId: root,
+ sourceSiteId: "site-a", correlationId: Guid.NewGuid());
+ var dbWrite = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddSeconds(3), target: "MesDb", executionId: siteRun, parentExecutionId: root, sourceSiteId: "site-a");
+ var notify = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
+ occurredAtUtc: T0.AddSeconds(4), target: "Planners", executionId: siteRun, parentExecutionId: root,
+ sourceSiteId: "site-a", correlationId: Guid.NewGuid());
+
+ await using var writer = CreateWriter();
+ foreach (var r in new[] { notify, apiCall, run, dbWrite }) await writer.WriteAsync(r);
+
+ var hits = await writer.ReadByExecutionIdsAsync([siteRun], T0, limit: 100);
+ Assert.Equal(
+ new[] { run.EventId, apiCall.EventId, dbWrite.EventId, notify.EventId },
+ hits.Select(h => h.EventId).ToArray());
+ Assert.Equal(apiCall, hits[1]);
+ Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel);
+ }
+
[Fact]
public async Task Guards_LimitAndIdCap()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs
index 4568e4e0..95b4c4f9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs
@@ -43,8 +43,9 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture
+ /// Outbound-calls plan (2026-09-06, D6): replay returns the chain's ScriptTrace, ApiOutbound,
+ /// DbOutbound and Notification rows plus the InboundRequest row, in (OccurredAtUtc, EventId)
+ /// order; SecuredWrite, Cluster and InboundAuthFailure rows in the same tree stay excluded.
+ ///
+ [SkippableFact]
+ public async Task IncludesOutboundApiDbAndNotificationRows_ExcludesOtherChannels()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+ var siteId = "test-trace-" + Guid.NewGuid().ToString("N")[..8];
+ var t0 = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
+ var root = Guid.NewGuid();
+ var siteRun = 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 apiSubmit = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
+ occurredAtUtc: t0.AddMilliseconds(30), target: "Delmia.AckRecipe", executionId: siteRun, parentExecutionId: root,
+ sourceSiteId: siteId, correlationId: Guid.NewGuid());
+ var dbWrite = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(40), target: "MesDb", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId);
+ var notify = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
+ occurredAtUtc: t0.AddMilliseconds(50), target: "Planners", executionId: siteRun, parentExecutionId: root,
+ sourceSiteId: siteId, correlationId: Guid.NewGuid());
+ // Same execution ids, but channels outside the replay set — all excluded.
+ var secured = ScadaBridgeAuditEventFactory.Create(AuditChannel.SecuredWrite, AuditKind.SecuredWriteSubmit, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(35), target: "Tag", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId);
+ var authFailure = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundAuthFailure, AuditStatus.Failed,
+ occurredAtUtc: t0.AddMilliseconds(1), actor: "key", target: "M", executionId: root, sourceSiteId: siteId);
+ var cluster = ScadaBridgeAuditEventFactory.Create(AuditChannel.Cluster, AuditKind.ManualFailover, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(45), actor: "admin", target: "central-a", executionId: root, sourceSiteId: siteId);
+
+ await using (var ctx = CreateContext())
+ {
+ var repo = new AuditLogRepository(ctx);
+ foreach (var e in new[] { notify, secured, dbWrite, cluster, run, authFailure, apiSubmit, inbound, route })
+ await repo.InsertIfNotExistsAsync(e);
+ }
+
+ await using (var ctx = CreateContext())
+ {
+ var repo = new AuditLogRepository(ctx);
+ foreach (var entry in new[] { root, siteRun })
+ {
+ var events = await repo.GetTraceEventsAsync(entry);
+ Assert.Equal(
+ new[] { inbound.EventId, route.EventId, run.EventId, apiSubmit.EventId, dbWrite.EventId, notify.EventId },
+ events.Select(e => e.EventId).ToArray());
+ Assert.Equal(apiSubmit, events[3]);
+ Assert.Equal(dbWrite, events[4]);
+ Assert.Equal(notify, events[5]);
+ var decomposed = events.Select(AuditRowProjection.Decompose).ToList();
+ Assert.DoesNotContain(decomposed, v => v.Channel is AuditChannel.SecuredWrite or AuditChannel.Cluster);
+ Assert.DoesNotContain(decomposed, v => v.Kind == AuditKind.InboundAuthFailure);
+ }
+ }
+ }
}