diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FlowTraceCollector.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FlowTraceCollector.cs
new file mode 100644
index 00000000..8dc855bc
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FlowTraceCollector.cs
@@ -0,0 +1,51 @@
+using ZB.MOM.WW.Audit;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+
+///
+/// Per-run sink for ScriptTrace audit events so a traced routed call can return them
+/// inline on ScriptCallResult.TraceEvents (Flow Debugger design 2026-09-05 §5.4).
+/// Lock-based: a run emits tens of events, not thousands. Beyond
+/// the collector keeps the first N, sets , and the audit rows written
+/// through IAuditWriter remain complete — Replay is never truncated, only the inline copy.
+/// An untraced run holds null rather than an inert collector; callers null-check.
+///
+public sealed class FlowTraceCollector
+{
+ /// Default inline cap per run (design §5.4 MaxTraceEventsPerRun).
+ public const int DefaultMaxEvents = 500;
+
+ private readonly List _events = new();
+ private readonly object _gate = new();
+ private readonly int _max;
+
+ /// True once an was dropped because the cap was reached.
+ public bool Truncated { get; private set; }
+
+ /// Creates a collector with the given inline cap (must be positive).
+ public FlowTraceCollector(int maxEvents = DefaultMaxEvents)
+ {
+ if (maxEvents <= 0) throw new ArgumentOutOfRangeException(nameof(maxEvents), "must be positive");
+ _max = maxEvents;
+ }
+
+ /// Appends an event, or marks the collector truncated once the cap is reached.
+ public void Add(AuditEvent evt)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ lock (_gate)
+ {
+ if (_events.Count >= _max) { Truncated = true; return; }
+ _events.Add(evt);
+ }
+ }
+
+ /// Current event count.
+ public int Count { get { lock (_gate) return _events.Count; } }
+
+ /// Copies the collected events in emission order.
+ public IReadOnlyList Snapshot()
+ {
+ lock (_gate) return _events.ToArray();
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/FlowTraceCollectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/FlowTraceCollectorTests.cs
new file mode 100644
index 00000000..c5a18319
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/FlowTraceCollectorTests.cs
@@ -0,0 +1,67 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
+
+/// Flow Debugger design 2026-09-05 §5.4: the per-run inline trace sink.
+public class FlowTraceCollectorTests
+{
+ private static AuditEvent Evt(int i) => new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "t",
+ Action = "ScriptTrace.AttributeWrite",
+ Outcome = AuditOutcome.Success,
+ Target = i.ToString(),
+ };
+
+ [Fact]
+ public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated()
+ {
+ var c = new FlowTraceCollector(maxEvents: 500);
+ Assert.False(c.Truncated);
+
+ for (var i = 0; i < 501; i++) c.Add(Evt(i));
+
+ var snap = c.Snapshot();
+ Assert.Equal(500, snap.Count);
+ Assert.True(c.Truncated);
+ Assert.Equal("0", snap[0].Target);
+ Assert.Equal("499", snap[499].Target);
+ }
+
+ [Fact]
+ public void Snapshot_IsACopy_InEmissionOrder()
+ {
+ var c = new FlowTraceCollector();
+ c.Add(Evt(1));
+ var first = c.Snapshot();
+ c.Add(Evt(2));
+
+ Assert.Single(first);
+ Assert.Equal(2, c.Count);
+ Assert.Equal(new[] { "1", "2" }, c.Snapshot().Select(e => e.Target));
+ }
+
+ [Fact]
+ public async Task ConcurrentAdds_NeverThrow_AndNeverExceedCap()
+ {
+ var c = new FlowTraceCollector(maxEvents: 100);
+ var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() =>
+ {
+ for (var i = 0; i < 50; i++) c.Add(Evt(t * 100 + i));
+ }));
+ await Task.WhenAll(tasks);
+
+ Assert.Equal(100, c.Snapshot().Count);
+ Assert.True(c.Truncated);
+ }
+
+ [Fact]
+ public void DefaultCap_Is500_AndNonPositiveCapIsRejected()
+ {
+ Assert.Equal(500, FlowTraceCollector.DefaultMaxEvents);
+ Assert.Throws(() => new FlowTraceCollector(0));
+ }
+}