From 30faee0d0eece04c72f7c6b64284ba121c33b424 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 01:47:05 -0400 Subject: [PATCH] feat(audit): replay/pull include outbound API, DB and notification rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetTraceEventsAsync widens its channel filter to ScriptTrace ∪ ApiOutbound ∪ DbOutbound ∪ Notification ∪ (ApiInbound & InboundRequest) (plan D6); SecuredWrite, Cluster and InboundAuthFailure rows in the same tree stay excluded. The site SqliteAuditWriter.ReadByExecutionIdsAsync already had no channel predicate — pinned by a test. Interface doc updated to match. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- ...flow-debugger-outbound-calls.md.tasks.json | 10 +-- .../Repositories/IAuditLogRepository.cs | 5 +- .../Repositories/AuditLogRepository.cs | 12 +++- .../SqliteAuditWriterReadByExecutionTests.cs | 31 +++++++++ .../AuditLogRepositoryTraceEventsTests.cs | 68 ++++++++++++++++++- 5 files changed, 115 insertions(+), 11 deletions(-) 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); + } + } + } }