diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs
new file mode 100644
index 00000000..b4a7bbf7
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs
@@ -0,0 +1,23 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+
+/// A directed edge between two flow steps.
+/// Source step.
+/// Target step.
+/// True when the edge crosses the central ↔ site boundary (drawn in the accent colour).
+/// True for an inferred relationship the system did not observe directly — a device echo satisfying a wait after a write (drawn dashed).
+public sealed record FlowEdge(Guid FromEventId, Guid ToEventId, bool CrossesBoundary, bool External);
+
+/// The projected flow of one execution tree (Flow Debugger design 2026-09-05 §7.2).
+/// Steps in display order (time, then kind rank, then event id).
+/// Edges between steps.
+/// True when the graph came from static analysis rather than real events.
+/// True when the inline event list was capped (the audit rows remain complete — Replay shows everything).
+public sealed record FlowGraph(
+ IReadOnlyList Steps,
+ IReadOnlyList Edges,
+ bool Predicted,
+ bool Truncated)
+{
+ /// An empty, non-predicted graph.
+ public static FlowGraph Empty { get; } = new(Array.Empty(), Array.Empty(), false, false);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs
new file mode 100644
index 00000000..152e1c7f
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs
@@ -0,0 +1,149 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+
+///
+/// Builds a from the audit events of one execution tree
+/// (Flow Debugger design 2026-09-05 §7.2). Pure: same events in, same graph out.
+///
+///
+/// Rules:
+///
+/// - Steps are ordered by OccurredAtUtc, then a kind rank (inbound before route call
+/// before script run before writes/waits), then EventId.
+/// - Within one ExecutionId consecutive steps are chained in that order.
+/// - The first step of an execution is linked from its spawner: a RouteCall whose
+/// childExecutionId matches, else the latest step of the parent execution that occurred
+/// no later than it. The edge CrossesBoundary when exactly one end is in the Central lane.
+/// - An AttributeWait that was satisfied gets an External (dashed) edge from the
+/// preceding AttributeWrite in the same execution — the device echo the system inferred.
+/// - When an InboundRequest row carries a response summary, a Response step is
+/// synthesized as the final Central node, linked from the last real step.
+///
+///
+public static class FlowGraphBuilder
+{
+ /// Builds the graph. is passed through to .
+ public static FlowGraph Build(IReadOnlyList events, bool truncated = false)
+ {
+ ArgumentNullException.ThrowIfNull(events);
+ if (events.Count == 0)
+ return new FlowGraph(Array.Empty(), Array.Empty(), Predicted: false, Truncated: truncated);
+
+ var steps = events
+ .Select(FlowStepProjector.Project)
+ .OrderBy(s => s.OccurredAtUtc)
+ .ThenBy(s => KindRank(s.Kind))
+ .ThenBy(s => s.EventId)
+ .ToList();
+
+ var edges = new List();
+ var byExecution = steps
+ .Where(s => s.ExecutionId is not null)
+ .GroupBy(s => s.ExecutionId!.Value)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ // 1. Chain consecutive steps inside each execution.
+ foreach (var chain in byExecution.Values)
+ {
+ for (var i = 1; i < chain.Count; i++)
+ edges.Add(new FlowEdge(chain[i - 1].EventId, chain[i].EventId, CrossesBoundary(chain[i - 1], chain[i]), External: false));
+ }
+
+ // 2. Link each child execution's first step from its spawner.
+ var routeCallsByChild = steps
+ .Where(s => s.Kind == FlowStepKind.RouteCall && s.Extra?.ChildExecutionId is not null)
+ .GroupBy(s => s.Extra!.ChildExecutionId!.Value)
+ .ToDictionary(g => g.Key, g => g.First());
+
+ foreach (var (executionId, chain) in byExecution)
+ {
+ var first = chain[0];
+ if (first.ParentExecutionId is not { } parentId) continue;
+
+ FlowStep? spawner = null;
+ if (routeCallsByChild.TryGetValue(executionId, out var routeCall))
+ spawner = routeCall;
+ else if (byExecution.TryGetValue(parentId, out var parentChain))
+ spawner = parentChain.LastOrDefault(p => p.OccurredAtUtc <= first.OccurredAtUtc && p.Kind != FlowStepKind.Response)
+ ?? parentChain[0];
+
+ if (spawner is not null && spawner.EventId != first.EventId)
+ edges.Add(new FlowEdge(spawner.EventId, first.EventId, CrossesBoundary(spawner, first), External: false));
+ }
+
+ // 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
+ foreach (var chain in byExecution.Values)
+ {
+ for (var i = 0; i < chain.Count; i++)
+ {
+ var wait = chain[i];
+ if (wait.Kind != FlowStepKind.AttributeWait) continue;
+ if (!string.Equals(wait.Extra?.WaitOutcome, "satisfied", StringComparison.OrdinalIgnoreCase)) continue;
+ for (var j = i - 1; j >= 0; j--)
+ {
+ if (chain[j].Kind != FlowStepKind.AttributeWrite) continue;
+ edges.Add(new FlowEdge(chain[j].EventId, wait.EventId, CrossesBoundary: false, External: true));
+ break;
+ }
+ }
+ }
+
+ // 4. Synthesize the Response node from the inbound row's response summary.
+ var inbound = steps.FirstOrDefault(s => s.Kind == FlowStepKind.InboundRequest);
+ if (inbound is not null && inbound.OutputJson is not null)
+ {
+ var last = steps[^1];
+ var response = new FlowStep(
+ EventId: DeriveResponseId(inbound.EventId),
+ ExecutionId: inbound.ExecutionId,
+ ParentExecutionId: null,
+ Kind: FlowStepKind.Response,
+ Lane: FlowLane.Central,
+ Title: "Response",
+ Subtitle: inbound.Outcome == FlowStepOutcome.Ok ? "200" : "error",
+ OccurredAtUtc: inbound.DurationMs is { } ms ? inbound.OccurredAtUtc.AddMilliseconds(ms) : last.OccurredAtUtc,
+ DurationMs: null,
+ Outcome: inbound.Outcome,
+ InputJson: null,
+ OutputJson: inbound.OutputJson,
+ ErrorMessage: inbound.ErrorMessage,
+ SiteId: null,
+ Node: inbound.Node,
+ Instance: null,
+ Target: inbound.Target,
+ Extra: null);
+ steps.Add(response);
+ edges.Add(new FlowEdge(last.EventId, response.EventId, CrossesBoundary(last, response), External: false));
+ }
+
+ return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated);
+ }
+
+ /// Display rank used to break timestamp ties: spawners before what they spawn.
+ public static int KindRank(FlowStepKind kind) => kind switch
+ {
+ FlowStepKind.InboundRequest => 0,
+ FlowStepKind.RouteCall => 1,
+ FlowStepKind.ScriptRun => 2,
+ FlowStepKind.AttributeWrite => 3,
+ FlowStepKind.AttributeWait => 4,
+ FlowStepKind.External => 5,
+ FlowStepKind.Unknown => 6,
+ FlowStepKind.Response => 9,
+ _ => 7,
+ };
+
+ private static bool CrossesBoundary(FlowStep a, FlowStep b)
+ => (a.Lane == FlowLane.Central) != (b.Lane == FlowLane.Central);
+
+ /// A stable id for the synthesized Response node so re-builds of the same events select the same node.
+ private static Guid DeriveResponseId(Guid inboundEventId)
+ {
+ var bytes = inboundEventId.ToByteArray();
+ bytes[15] ^= 0x5A;
+ bytes[0] ^= 0xA5;
+ return new Guid(bytes);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs
new file mode 100644
index 00000000..53be7d1e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs
@@ -0,0 +1,73 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+
+/// Swimlane a flow step is drawn in (Flow Debugger design 2026-09-05 §4/§7.2).
+public enum FlowLane
+{
+ /// Central node: inbound request, method script, route call, response.
+ Central,
+ /// Site node: script runs and attribute waits.
+ Site,
+ /// Attribute / tag layer: attribute writes and their device echoes.
+ Tags,
+}
+
+/// What a flow step represents — the projection of an audit channel/kind pair.
+public enum FlowStepKind
+{
+ /// ApiInbound.InboundRequest.
+ InboundRequest,
+ /// ScriptTrace.RouteCall.
+ RouteCall,
+ /// ScriptTrace.ScriptRun.
+ ScriptRun,
+ /// ScriptTrace.AttributeWrite.
+ AttributeWrite,
+ /// ScriptTrace.AttributeWait.
+ AttributeWait,
+ /// Synthesized final node — the inbound response (from the inbound row's response summary).
+ Response,
+ /// An outbound API / DB / notification row emitted by a traced run (shown, not chained across the boundary).
+ External,
+ /// A channel/kind this build does not recognise — rendered, never dropped.
+ Unknown,
+}
+
+/// Per-step outcome.
+public enum FlowStepOutcome
+{
+ /// Completed successfully.
+ Ok,
+ /// Failed (the step's error message says why).
+ Failed,
+ /// A script run or wait that hit its deadline.
+ TimedOut,
+ /// Predicted only — no real event yet.
+ Predicted,
+ /// No trace event exists for this position (e.g. site on an older build).
+ NotTraced,
+}
+
+///
+/// One node of the flow graph — a central-side projection of a ScriptTrace /
+/// ApiInbound.InboundRequest . Never serialized
+/// to the wire; the wire carries the audit events themselves (design §5.4).
+///
+public sealed record FlowStep(
+ Guid EventId,
+ Guid? ExecutionId,
+ Guid? ParentExecutionId,
+ FlowStepKind Kind,
+ FlowLane Lane,
+ string Title,
+ string? Subtitle,
+ DateTimeOffset OccurredAtUtc,
+ int? DurationMs,
+ FlowStepOutcome Outcome,
+ string? InputJson,
+ string? OutputJson,
+ string? ErrorMessage,
+ string? SiteId,
+ string? Node,
+ string? Instance,
+ string? Target,
+ ScriptTraceExtra? Extra);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs
new file mode 100644
index 00000000..723e0d4a
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs
@@ -0,0 +1,110 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+
+///
+/// Projects one canonical onto a
+/// (Flow Debugger design 2026-09-05 §5.4: trace steps ARE audit events; this is the read side).
+/// Pure and total — an unrecognised channel/kind projects to ,
+/// never throws.
+///
+public static class FlowStepProjector
+{
+ /// Projects an audit event onto a flow step.
+ public static FlowStep Project(AuditEvent evt)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
+ var extra = ScriptTraceExtra.TryParse(d.Extra);
+ var kind = ResolveKind(d.Channel, d.Kind);
+ var lane = ResolveLane(kind, d.SourceSiteId);
+ var outcome = ResolveOutcome(kind, d.Status, d.ErrorMessage);
+ var title = string.IsNullOrWhiteSpace(evt.Target) ? kind.ToString() : evt.Target!;
+
+ return new FlowStep(
+ EventId: evt.EventId,
+ ExecutionId: d.ExecutionId,
+ ParentExecutionId: d.ParentExecutionId,
+ Kind: kind,
+ Lane: lane,
+ Title: title,
+ Subtitle: BuildSubtitle(kind, evt, d, extra),
+ OccurredAtUtc: evt.OccurredAtUtc,
+ DurationMs: d.DurationMs,
+ Outcome: outcome,
+ InputJson: d.RequestSummary,
+ OutputJson: d.ResponseSummary,
+ ErrorMessage: d.ErrorMessage,
+ SiteId: d.SourceSiteId,
+ Node: evt.SourceNode ?? extra?.Node,
+ Instance: d.SourceInstanceId,
+ Target: evt.Target,
+ Extra: extra);
+ }
+
+ /// Maps a channel/kind string pair onto a step kind.
+ public static FlowStepKind ResolveKind(string? channel, string? kind)
+ {
+ if (string.Equals(channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal))
+ {
+ return kind switch
+ {
+ nameof(AuditKind.RouteCall) => FlowStepKind.RouteCall,
+ nameof(AuditKind.ScriptRun) => FlowStepKind.ScriptRun,
+ nameof(AuditKind.AttributeWrite) => FlowStepKind.AttributeWrite,
+ nameof(AuditKind.AttributeWait) => FlowStepKind.AttributeWait,
+ _ => FlowStepKind.Unknown,
+ };
+ }
+
+ if (string.Equals(channel, nameof(AuditChannel.ApiInbound), StringComparison.Ordinal)
+ && string.Equals(kind, nameof(AuditKind.InboundRequest), StringComparison.Ordinal))
+ return FlowStepKind.InboundRequest;
+
+ if (string.Equals(channel, nameof(AuditChannel.ApiOutbound), StringComparison.Ordinal)
+ || string.Equals(channel, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal)
+ || string.Equals(channel, nameof(AuditChannel.Notification), StringComparison.Ordinal))
+ return FlowStepKind.External;
+
+ return FlowStepKind.Unknown;
+ }
+
+ /// Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags.
+ public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch
+ {
+ FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central,
+ FlowStepKind.ScriptRun or FlowStepKind.AttributeWait => FlowLane.Site,
+ FlowStepKind.AttributeWrite => FlowLane.Tags,
+ _ => string.IsNullOrEmpty(sourceSiteId) ? FlowLane.Central : FlowLane.Site,
+ };
+
+ private static FlowStepOutcome ResolveOutcome(FlowStepKind kind, string? status, string? error)
+ {
+ if (!string.Equals(status, nameof(AuditStatus.Failed), StringComparison.Ordinal))
+ return FlowStepOutcome.Ok;
+ if ((kind == FlowStepKind.ScriptRun || kind == FlowStepKind.AttributeWait)
+ && error is not null && error.Contains("timed out", StringComparison.OrdinalIgnoreCase))
+ return FlowStepOutcome.TimedOut;
+ return FlowStepOutcome.Failed;
+ }
+
+ private static string? BuildSubtitle(FlowStepKind kind, AuditEvent evt, AuditDetails d, ScriptTraceExtra? extra) => kind switch
+ {
+ FlowStepKind.InboundRequest => string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
+ FlowStepKind.RouteCall => extra?.ChildExecutionId is { } child ? $"→ {child.ToString("D")[..8]}" : null,
+ FlowStepKind.ScriptRun => d.SourceInstanceId is null
+ ? extra?.TriggerKind
+ : extra?.TriggerKind is null ? d.SourceInstanceId : $"{d.SourceInstanceId} · {extra.TriggerKind}",
+ FlowStepKind.AttributeWrite => extra?.Writes is { Count: > 0 } w
+ ? string.Join(", ", w.Select(x => x.Written is null ? x.Attribute : $"{x.Attribute} = {x.Written}"))
+ : null,
+ FlowStepKind.AttributeWait => extra is null
+ ? null
+ : extra.WaitOutcome is null
+ ? extra.ObservedValue
+ : extra.ObservedValue is null ? extra.WaitOutcome : $"{extra.WaitOutcome} · {extra.ObservedValue}",
+ _ => d.Kind,
+ };
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs
new file mode 100644
index 00000000..09045637
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs
@@ -0,0 +1,81 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+
+///
+/// Kind-specific detail carried in AuditDetails.Extra as camelCase JSON on
+/// ScriptTrace rows (and on the Flow Debugger's synthesized inbound row). Every member is
+/// optional; unknown members are ignored on read so older/newer emitters interoperate.
+///
+public sealed record ScriptTraceExtra
+{
+ /// Emitting node (node-a / central-a) when not already on the event.
+ public string? Node { get; init; }
+
+ /// Trigger kind of a ScriptRun: call | valuechange | …
+ public string? TriggerKind { get; init; }
+
+ /// Attribute whose change fired a value-change run.
+ public string? TriggerAttribute { get; init; }
+
+ /// Encoded value that fired a value-change run.
+ public string? TriggerValue { get; init; }
+
+ /// Where the triggering change came from: value-change (the actor cannot tell a static write from a device echo).
+ public string? TriggerSource { get; init; }
+
+ /// On a RouteCall row: the routed site run's ExecutionId.
+ public Guid? ChildExecutionId { get; init; }
+
+ /// On an AttributeWrite row: each write's outcome.
+ public IReadOnlyList? Writes { get; init; }
+
+ /// On an AttributeWait row: the awaited attribute.
+ public string? WaitAttribute { get; init; }
+
+ /// On an AttributeWait row: the target value / predicate description.
+ public string? WaitTarget { get; init; }
+
+ /// On an AttributeWait row: the timeout that applied.
+ public int? WaitTimeoutMs { get; init; }
+
+ /// On an AttributeWait row: satisfied | timeout | cancelled | error.
+ public string? WaitOutcome { get; init; }
+
+ /// On an AttributeWait row: the encoded value observed when the wait resolved.
+ public string? ObservedValue { get; init; }
+
+ /// flow-debugger on rows the debugger synthesizes (the Live-run inbound row, the value-change root).
+ public string? Source { get; init; }
+
+ /// Serializer options shared by every emitter and reader: camelCase, nulls omitted.
+ public static JsonSerializerOptions JsonOptions { get; } = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ /// Serializes to the compact camelCase JSON stored in AuditDetails.Extra.
+ public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
+
+ /// Parses AuditDetails.Extra; returns null for null/blank/invalid JSON — never throws.
+ public static ScriptTraceExtra? TryParse(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return null;
+ try
+ {
+ return JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
+
+/// One attribute write inside an AttributeWrite trace row.
+/// Canonical attribute name.
+/// Encoded value written.
+/// Whether the instance actor accepted the write.
+/// Error message when it did not.
+public sealed record AttributeWriteDetail(string Attribute, string? Written, bool Success, string? Error);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs
new file mode 100644
index 00000000..c4dced97
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs
@@ -0,0 +1,182 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
+
+///
+/// Flow Debugger design 2026-09-05 §7.2: lane/edge/boundary rules over a Delmia-shaped
+/// event list, truncation pass-through, and total-ness on unknown kinds.
+///
+public class FlowGraphBuilderTests
+{
+ private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
+ private static readonly Guid Root = Guid.NewGuid();
+ private static readonly Guid SiteRun = Guid.NewGuid();
+ private static readonly Guid Cascade = Guid.NewGuid();
+
+ /// Inbound → RouteCall → site ScriptRun → write → wait → write, plus a value-change ScriptRun spawned by the second write.
+ private static List DelmiaShaped()
+ {
+ var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
+ occurredAtUtc: T0, actor: "delmia-key", target: "DelmiaRecipeDownload", sourceNode: "central-a",
+ executionId: Root, durationMs: 900, requestSummary: "{\"MachineCode\":\"M1\"}", responseSummary: "{\"ok\":true}");
+ var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(10), actor: "inbound-api", target: "M1.ProcessRecipeDownload", sourceNode: "central-a",
+ executionId: Root, durationMs: 850, extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson());
+ var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(20), actor: "ProcessRecipeDownload", target: "ProcessRecipeDownload", sourceNode: "node-a",
+ executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800,
+ extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
+ var write1 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(30), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.RecipePath", sourceNode: "node-a",
+ executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 5,
+ extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.RecipePath", "C:\\r.nc", true, null)] }.ToJson());
+ var wait = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(40), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Processed", sourceNode: "node-a",
+ executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 700,
+ extra: new ScriptTraceExtra { WaitAttribute = "DelmiaReceiver.Processed", WaitOutcome = "satisfied", ObservedValue = "true" }.ToJson());
+ var write2 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(750), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Trigger", sourceNode: "node-a",
+ executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
+ extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.Trigger", "false", true, null)] }.ToJson());
+ var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(760), actor: "OnTriggerReset", target: "OnTriggerReset", sourceNode: "node-a",
+ executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
+ extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.Trigger", TriggerValue = "false" }.ToJson());
+
+ // Deliberately shuffled: the builder must order by time.
+ return [wait, cascade, inbound, write2, run, route, write1];
+ }
+
+ [Fact]
+ public void DelmiaShape_ProjectsLanes_Edges_AndSynthesizedResponse()
+ {
+ var graph = FlowGraphBuilder.Build(DelmiaShaped());
+
+ Assert.False(graph.Predicted);
+ Assert.False(graph.Truncated);
+ Assert.Equal(8, graph.Steps.Count); // 7 events + synthesized Response
+
+ var kinds = graph.Steps.Select(s => s.Kind).ToArray();
+ Assert.Equal(
+ new[]
+ {
+ FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite,
+ FlowStepKind.AttributeWait, FlowStepKind.AttributeWrite, FlowStepKind.ScriptRun, FlowStepKind.Response,
+ },
+ kinds);
+
+ Assert.Equal(FlowLane.Central, graph.Steps[0].Lane);
+ Assert.Equal(FlowLane.Central, graph.Steps[1].Lane);
+ Assert.Equal(FlowLane.Site, graph.Steps[2].Lane);
+ Assert.Equal(FlowLane.Tags, graph.Steps[3].Lane);
+ Assert.Equal(FlowLane.Site, graph.Steps[4].Lane);
+ Assert.Equal(FlowLane.Tags, graph.Steps[5].Lane);
+ Assert.Equal(FlowLane.Site, graph.Steps[6].Lane);
+ Assert.Equal(FlowLane.Central, graph.Steps[7].Lane);
+
+ // Exactly two boundary crossings: route → site run, and last site step → response.
+ var crossing = graph.Edges.Where(e => e.CrossesBoundary).ToList();
+ Assert.Equal(2, crossing.Count);
+ Assert.Contains(crossing, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId);
+ Assert.Contains(crossing, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId);
+
+ // One inferred echo edge: first write → satisfied wait.
+ var external = Assert.Single(graph.Edges, e => e.External);
+ Assert.Equal(graph.Steps[3].EventId, external.FromEventId);
+ Assert.Equal(graph.Steps[4].EventId, external.ToEventId);
+
+ // The cascade run hangs off the write that spawned it (same site, no boundary).
+ Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[5].EventId && e.ToEventId == graph.Steps[6].EventId && !e.CrossesBoundary);
+
+ // Inbound → route call chained inside the root execution.
+ Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[0].EventId && e.ToEventId == graph.Steps[1].EventId);
+
+ // Projection detail.
+ Assert.Equal("→ " + SiteRun.ToString("D")[..8], graph.Steps[1].Subtitle);
+ Assert.Equal("M1 · call", graph.Steps[2].Subtitle);
+ Assert.Equal("DelmiaReceiver.RecipePath = C:\\r.nc", graph.Steps[3].Subtitle);
+ Assert.Equal("satisfied · true", graph.Steps[4].Subtitle);
+ Assert.Equal("{\"ok\":true}", graph.Steps[7].OutputJson);
+ Assert.All(graph.Steps, s => Assert.Equal(FlowStepOutcome.Ok, s.Outcome));
+ }
+
+ [Fact]
+ public void Build_IsDeterministic_ResponseIdIsStable()
+ {
+ var events = DelmiaShaped();
+ var a = FlowGraphBuilder.Build(events);
+ var b = FlowGraphBuilder.Build(events);
+ Assert.Equal(a.Steps.Select(s => s.EventId), b.Steps.Select(s => s.EventId));
+ Assert.Equal(a.Edges, b.Edges);
+ }
+
+ [Fact]
+ public void Truncated_PassesThrough()
+ {
+ Assert.True(FlowGraphBuilder.Build(DelmiaShaped(), truncated: true).Truncated);
+ Assert.True(FlowGraphBuilder.Build(Array.Empty(), truncated: true).Truncated);
+ Assert.Empty(FlowGraphBuilder.Build(Array.Empty()).Steps);
+ }
+
+ [Fact]
+ public void UnknownKind_ProjectsToUnknown_NeverThrows()
+ {
+ var weird = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = T0,
+ Actor = "x",
+ Action = "Future.Thing",
+ Outcome = AuditOutcome.Success,
+ DetailsJson = "{\"channel\":\"Future\",\"kind\":\"Thing\",\"extra\":\"not json at all\"}",
+ };
+ var garbage = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = T0,
+ Actor = "x",
+ Action = "?",
+ Outcome = AuditOutcome.Failure,
+ DetailsJson = "{{{",
+ };
+
+ var graph = FlowGraphBuilder.Build([weird, garbage]);
+
+ Assert.Equal(2, graph.Steps.Count);
+ Assert.All(graph.Steps, s => Assert.Equal(FlowStepKind.Unknown, s.Kind));
+ Assert.All(graph.Steps, s => Assert.Null(s.Extra));
+ Assert.Empty(graph.Edges);
+ }
+
+ [Fact]
+ public void FailedScriptRun_WithTimeoutMessage_IsTimedOut()
+ {
+ var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed,
+ occurredAtUtc: T0, target: "S", executionId: SiteRun, errorMessage: "Script timed out after 30s");
+ var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Failed,
+ occurredAtUtc: T0.AddMilliseconds(1), target: "A", executionId: SiteRun, errorMessage: "rejected");
+
+ var graph = FlowGraphBuilder.Build([run, write]);
+
+ Assert.Equal(FlowStepOutcome.TimedOut, graph.Steps[0].Outcome);
+ Assert.Equal(FlowStepOutcome.Failed, graph.Steps[1].Outcome);
+ Assert.Single(graph.Edges);
+ }
+
+ [Fact]
+ public void ScriptTraceExtra_RoundTrips_CamelCase_AndIgnoresUnknownMembers()
+ {
+ var extra = new ScriptTraceExtra { ChildExecutionId = SiteRun, WaitTimeoutMs = 1500, Source = "flow-debugger" };
+ var json = extra.ToJson();
+ Assert.Contains("\"childExecutionId\"", json);
+ Assert.DoesNotContain("triggerKind", json);
+
+ var back = ScriptTraceExtra.TryParse(json.Insert(1, "\"futureField\":1,"));
+ Assert.Equal(extra, back);
+ Assert.Null(ScriptTraceExtra.TryParse(null));
+ Assert.Null(ScriptTraceExtra.TryParse("nope"));
+ }
+}