diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
index 041681a5..41447dc5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
@@ -571,7 +571,20 @@ public class AlarmActor : ReceiveActor
/// Passes the firing alarm's level/priority/message so the script can
/// branch on severity via the Alarm global.
///
- private void SpawnAlarmExecution(AlarmLevel level, int priority, string message)
+ /// The firing alarm severity level.
+ /// The firing alarm priority.
+ /// The firing alarm message.
+ ///
+ /// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the execution id of
+ /// the context that fired this alarm, recorded as the on-trigger script run's
+ /// ParentExecutionId so the alarm-triggered run chains under its firing
+ /// context in the audit tree. The alarm subsystem currently has no Guid-typed
+ /// firing id, so the only call sites pass null (the on-trigger run is a
+ /// root). The parameter exists so a future firing-id can flow without
+ /// touching the actor wiring.
+ ///
+ private void SpawnAlarmExecution(
+ AlarmLevel level, int priority, string message, Guid? parentExecutionId = null)
{
if (_onTriggerCompiledScript == null) return;
@@ -591,7 +604,9 @@ public class AlarmActor : ReceiveActor
_options,
_logger,
// M2.5 (#9): per-script timeout from the on-trigger script (null = global).
- _onTriggerExecutionTimeoutSeconds));
+ _onTriggerExecutionTimeoutSeconds,
+ // Audit Log #23 (M5.4): the firing context's execution id (null today).
+ parentExecutionId));
Context.ActorOf(props, executionId);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs
index 623457e3..e8fa34b6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs
@@ -29,6 +29,14 @@ public class AlarmExecutionActor : ReceiveActor
/// Site runtime configuration options, including the execution timeout.
/// Logger for execution diagnostics.
/// M2.5 (#9): the on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global .
+ ///
+ /// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the execution id of
+ /// the context that fired this alarm, threaded into the on-trigger script's
+ /// as its ParentExecutionId so the
+ /// alarm-triggered run chains under its firing context. Null today (no
+ /// Guid-typed firing id exists yet) — the run is a root, but the plumbing
+ /// is in place for a future firing id.
+ ///
public AlarmExecutionActor(
string alarmName,
string instanceName,
@@ -42,7 +50,9 @@ public class AlarmExecutionActor : ReceiveActor
ILogger logger,
// M2.5 (#9): per-script execution timeout override (seconds) for the
// alarm on-trigger script. Null or non-positive falls back to the global.
- int? executionTimeoutSeconds = null)
+ int? executionTimeoutSeconds = null,
+ // Audit Log #23 (M5.4): the firing context's execution id (null today).
+ Guid? parentExecutionId = null)
{
var self = Self;
var parent = Context.Parent;
@@ -51,7 +61,7 @@ public class AlarmExecutionActor : ReceiveActor
alarmName, instanceName, level, priority, message,
compiledScript, instanceActor,
sharedScriptLibrary, options, self, parent, logger,
- executionTimeoutSeconds);
+ executionTimeoutSeconds, parentExecutionId);
}
private static void ExecuteAlarmScript(
@@ -67,7 +77,8 @@ public class AlarmExecutionActor : ReceiveActor
IActorRef self,
IActorRef parent,
ILogger logger,
- int? executionTimeoutSeconds)
+ int? executionTimeoutSeconds,
+ Guid? parentExecutionId)
{
// M2.5 (#9): per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
@@ -95,7 +106,14 @@ public class AlarmExecutionActor : ReceiveActor
options.MaxScriptCallDepth,
timeout,
instanceName,
- logger);
+ logger,
+ // Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the
+ // alarm on-trigger run mints its own fresh ExecutionId (the
+ // ctor's `?? NewGuid()` fallback) and records the firing
+ // context's id as its ParentExecutionId — null today, so the
+ // run is a root, but the plumbing exists for a future
+ // firing id.
+ parentExecutionId: parentExecutionId);
var globals = new ScriptGlobals
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
index f8b2d4ab..6e214e86 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
@@ -247,6 +247,59 @@ public class ScriptRuntimeContext
_siteEventLogger = siteEventLogger;
}
+ ///
+ /// Audit Log #23 (M5.4): this run's own per-execution id. Exposed so a
+ /// nested Scripts.CallShared can record it as the spawned shared
+ /// script's ParentExecutionId, forming a true execution tree.
+ ///
+ internal Guid ExecutionId => _executionId;
+
+ ///
+ /// Audit Log #23 (M5.4): the spawning execution's id for this run (null for
+ /// a root run). Exposed for test assertions on the execution tree.
+ ///
+ internal Guid? ParentExecutionId => _parentExecutionId;
+
+ ///
+ /// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): builds a child
+ /// for an inline Scripts.CallShared
+ /// invocation. The shared script runs inline (no actor hop) but is modelled
+ /// as its OWN execution node in the audit tree: it mints a fresh
+ /// and records THIS run's
+ /// as its ParentExecutionId, so B → CallShared(C) yields
+ /// C.ParentExecutionId == B.ExecutionId. Every other dependency
+ /// (actors, gateways, audit writer, site id, source node, call-depth) is
+ /// carried over verbatim from this context.
+ ///
+ /// The recursion depth of the shared-script call.
+ internal ScriptRuntimeContext CreateChildContextForSharedScript(int childCallDepth)
+ {
+ return new ScriptRuntimeContext(
+ _instanceActor,
+ _self,
+ _sharedScriptLibrary,
+ childCallDepth,
+ _maxCallDepth,
+ _askTimeout,
+ _instanceName,
+ _logger,
+ _externalSystemClient,
+ _databaseGateway,
+ _storeAndForward,
+ _siteCommunicationActor,
+ _siteId,
+ _sourceScript,
+ _auditWriter,
+ _operationTrackingStore,
+ _cachedForwarder,
+ // Fresh execution id for the shared-script run (omit so the ctor mints one)…
+ executionId: null,
+ // …parented to THIS run's execution id (the spawner).
+ parentExecutionId: _executionId,
+ sourceNode: _sourceNode,
+ siteEventLogger: _siteEventLogger);
+ }
+
///
/// M2.12 (#25): fire-and-forget emission of a script Error site event
/// for a recursion-limit violation. Mirrors the call shape used by
@@ -366,7 +419,14 @@ public class ScriptRuntimeContext
scriptName,
ScriptArgs.Normalize(parameters),
nextDepth,
- correlationId);
+ correlationId,
+ // Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the child
+ // script run is a NEW execution spawned BY this run. Its parent is
+ // THIS run's own ExecutionId — NOT the inherited _parentExecutionId.
+ // So A → CallScript(B) yields B.ParentExecutionId == A.ExecutionId,
+ // building a true multi-level execution tree rather than flattening
+ // every nested call under the original inbound spawner.
+ ParentExecutionId: _executionId);
// Ask the Instance Actor, which routes to the appropriate Script Actor
var result = await _instanceActor.Ask(request, _askTimeout);
@@ -526,8 +586,14 @@ public class ScriptRuntimeContext
throw new InvalidOperationException(msg);
}
+ // Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the shared
+ // script runs inline, but is modelled as its OWN execution node — a
+ // child context mints a fresh ExecutionId parented to the caller's
+ // ExecutionId, so its audit rows chain under the calling run.
+ var childContext = _context.CreateChildContextForSharedScript(nextDepth);
+
return await _library.ExecuteAsync(
- scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken);
+ scriptName, childContext, ScriptArgs.Normalize(parameters), cancellationToken);
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs
new file mode 100644
index 00000000..28345670
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs
@@ -0,0 +1,291 @@
+using Akka.Actor;
+using Akka.TestKit.Xunit2;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
+
+///
+/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): nested
+/// CallScript / CallShared invocations and alarm on-trigger runs
+/// must form a true execution tree, where each spawned run records its
+/// immediate spawner's ExecutionId as its ParentExecutionId.
+///
+///
+/// -
+/// A nested CallScript (actor-routed) emits a
+/// whose ParentExecutionId is the
+/// CALLING run's OWN ExecutionId — NOT the inherited grandparent — so
+/// A → CallScript(B) yields B.Parent == A.ExecutionId.
+///
+/// -
+/// A nested CallShared (inline) runs in a child context that mints a
+/// fresh ExecutionId and records the caller's ExecutionId as its
+/// parent — so B → CallShared(C) yields C.Parent == B.ExecutionId
+/// (and NOT B's inherited parent A), proving a multi-level tree.
+///
+/// -
+/// The alarm on-trigger plumbing carries a parentExecutionId into the
+/// script context — null today (the run is a root) but threaded so a future
+/// firing id can flow.
+///
+///
+///
+public class ParentExecutionTreeTests : TestKit
+{
+ private const string InstanceName = "Plant.Pump42";
+
+ ///
+ /// In-memory capturing every emitted event
+ /// (mirrors ExecutionCorrelationContextTests.CapturingAuditWriter).
+ ///
+ private sealed class CapturingAuditWriter : IAuditWriter
+ {
+ public List Events { get; } = new();
+
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ Events.Add(evt.AsRow());
+ return Task.CompletedTask;
+ }
+ }
+
+ private static SharedScriptLibrary NewLibrary()
+ {
+ var compilationService = new ScriptCompilationService(
+ NullLogger.Instance);
+ return new SharedScriptLibrary(
+ compilationService, NullLogger.Instance);
+ }
+
+ ///
+ /// Builds a context whose CallScript Ask targets
+ /// (a probe), so the forwarded can be captured.
+ ///
+ private static ScriptRuntimeContext CreateContext(
+ IActorRef instanceActor,
+ SharedScriptLibrary library,
+ IExternalSystemClient? externalSystemClient = null,
+ IAuditWriter? auditWriter = null,
+ Guid? executionId = null,
+ Guid? parentExecutionId = null)
+ {
+ return new ScriptRuntimeContext(
+ instanceActor,
+ ActorRefs.Nobody,
+ library,
+ currentCallDepth: 0,
+ maxCallDepth: 10,
+ askTimeout: TimeSpan.FromSeconds(5),
+ instanceName: InstanceName,
+ logger: NullLogger.Instance,
+ externalSystemClient: externalSystemClient,
+ siteId: "site-77",
+ sourceScript: "ScriptActor:A",
+ auditWriter: auditWriter,
+ executionId: executionId,
+ parentExecutionId: parentExecutionId);
+ }
+
+ // -------------------------------------------------------------------------
+ // Nested CallScript (actor-routed) — A → CallScript(B)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task CallScript_StampsCallingRunsOwnExecutionId_AsChildParent()
+ {
+ // A → CallScript(B): the child request's ParentExecutionId must be A's
+ // OWN ExecutionId, forming the A→B tree edge.
+ var probe = CreateTestProbe();
+ var aExecutionId = Guid.NewGuid();
+ var context = CreateContext(probe.Ref, NewLibrary(), executionId: aExecutionId);
+
+ var call = context.CallScript("B");
+
+ var request = probe.ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal("B", request.ScriptName);
+ // B's parent is A's own execution id — the A→B tree edge.
+ Assert.Equal(aExecutionId, request.ParentExecutionId);
+
+ // Unblock the Ask so the test completes cleanly.
+ probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null));
+ await call;
+ }
+
+ [Fact]
+ public async Task CallScript_FromRoutedRun_UsesOwnExecutionId_NotInheritedParent()
+ {
+ // A 2-level tree edge: B was itself spawned (it carries a parent = A).
+ // When B does CallScript(C), C.Parent must be B's OWN ExecutionId — NOT
+ // the inherited A. This is the regression that distinguishes a true tree
+ // from a flattened "everything under the original spawner" model.
+ var probe = CreateTestProbe();
+ var bExecutionId = Guid.NewGuid();
+ var aExecutionId = Guid.NewGuid(); // B's inherited parent
+ var context = CreateContext(
+ probe.Ref, NewLibrary(),
+ executionId: bExecutionId,
+ parentExecutionId: aExecutionId);
+
+ var call = context.CallScript("C");
+
+ var request = probe.ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal(bExecutionId, request.ParentExecutionId);
+ Assert.NotEqual(aExecutionId, request.ParentExecutionId);
+
+ probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null));
+ await call;
+ }
+
+ // -------------------------------------------------------------------------
+ // Nested CallShared (inline) — B → CallShared(C)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task CallShared_ChildRun_ParentIsCallersExecutionId_FreshOwnExecutionId()
+ {
+ // B → CallShared(C): the shared script C runs inline but is modelled as
+ // its OWN execution node — a fresh ExecutionId parented to B's
+ // ExecutionId. Asserted via the audit row C emits through
+ // Instance.ExternalSystem.Call.
+ var client = new Mock();
+ client
+ .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{}", null));
+ var writer = new CapturingAuditWriter();
+
+ var library = NewLibrary();
+ Assert.True(library.CompileAndRegister(
+ "C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;"));
+
+ var bExecutionId = Guid.NewGuid();
+ var context = CreateContext(
+ ActorRefs.Nobody, library,
+ externalSystemClient: client.Object,
+ auditWriter: writer,
+ executionId: bExecutionId);
+
+ await context.Scripts.CallShared("C");
+
+ var evt = Assert.Single(writer.Events);
+ // C's parent is B's execution id — the B→C tree edge.
+ Assert.Equal(bExecutionId, evt.ParentExecutionId);
+ // C minted its OWN fresh, non-empty execution id, distinct from B.
+ Assert.NotNull(evt.ExecutionId);
+ Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
+ Assert.NotEqual(bExecutionId, evt.ExecutionId!.Value);
+ }
+
+ [Fact]
+ public async Task CallShared_FromRoutedRun_ChildParentIsCaller_NotInheritedGrandparent()
+ {
+ // Regression / multi-level: B itself carries a parent A. When B does
+ // CallShared(C), C.Parent must be B's OWN ExecutionId — NOT A. This is
+ // the A→B→C chain proving each level points at its immediate spawner.
+ var client = new Mock();
+ client
+ .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{}", null));
+ var writer = new CapturingAuditWriter();
+
+ var library = NewLibrary();
+ Assert.True(library.CompileAndRegister(
+ "C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;"));
+
+ var bExecutionId = Guid.NewGuid();
+ var aExecutionId = Guid.NewGuid(); // B's inherited parent
+ var context = CreateContext(
+ ActorRefs.Nobody, library,
+ externalSystemClient: client.Object,
+ auditWriter: writer,
+ executionId: bExecutionId,
+ parentExecutionId: aExecutionId);
+
+ await context.Scripts.CallShared("C");
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Equal(bExecutionId, evt.ParentExecutionId);
+ Assert.NotEqual(aExecutionId, evt.ParentExecutionId);
+ }
+
+ // -------------------------------------------------------------------------
+ // Alarm on-trigger plumbing
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void CreateChildContextForSharedScript_ParentIsCallerExecution_FreshOwnId()
+ {
+ // Unit-level proof of the child-context contract the CallShared path uses.
+ var bExecutionId = Guid.NewGuid();
+ var context = CreateContext(
+ ActorRefs.Nobody, NewLibrary(), executionId: bExecutionId);
+
+ var child = context.CreateChildContextForSharedScript(childCallDepth: 1);
+
+ Assert.Equal(bExecutionId, child.ParentExecutionId);
+ Assert.NotEqual(Guid.Empty, child.ExecutionId);
+ Assert.NotEqual(bExecutionId, child.ExecutionId);
+ }
+
+ [Fact]
+ public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent()
+ {
+ // End-to-end alarm plumbing: when an alarm fires, its on-trigger script
+ // runs in a ScriptRuntimeContext built by AlarmExecutionActor. With no
+ // Guid firing id today the alarm run is a ROOT (its own ParentExecutionId
+ // is null), but it still mints its OWN fresh ExecutionId. A nested
+ // CallScript from that on-trigger script must therefore carry the alarm
+ // run's OWN (non-null) ExecutionId as the child's ParentExecutionId —
+ // proving the alarm context is a proper execution node feeding the
+ // cascade and the parentExecutionId parameter is plumbed end-to-end.
+ var compilationService = new ScriptCompilationService(
+ NullLogger.Instance);
+ var sharedLibrary = new SharedScriptLibrary(
+ compilationService, NullLogger.Instance);
+ var options = new SiteRuntimeOptions();
+
+ var onTrigger = compilationService.Compile(
+ "OnTrigger", "await Instance.CallScript(\"Child\"); return null;");
+ Assert.NotNull(onTrigger.CompiledScript);
+
+ var alarmConfig = new ResolvedAlarm
+ {
+ CanonicalName = "HighTemp",
+ TriggerType = "ValueMatch",
+ TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
+ PriorityLevel = 1
+ };
+
+ var instanceProbe = CreateTestProbe();
+ var alarm = ActorOf(Props.Create(() => new AlarmActor(
+ "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
+ onTrigger.CompiledScript, sharedLibrary, options,
+ NullLogger.Instance)));
+
+ alarm.Tell(new AttributeValueChanged(
+ "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
+
+ // The alarm raises (instance gets AlarmStateChanged) AND the on-trigger
+ // script fires its nested CallScript at the instance.
+ instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5));
+ var request = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5));
+
+ Assert.Equal("Child", request.ScriptName);
+ // The alarm run is a root today (its own parent is null), but its OWN
+ // freshly-minted ExecutionId cascades to the child — so the child's
+ // ParentExecutionId is a real, non-empty value, NOT null.
+ Assert.NotNull(request.ParentExecutionId);
+ Assert.NotEqual(Guid.Empty, request.ParentExecutionId!.Value);
+
+ instanceProbe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null));
+ }
+}