diff --git a/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs b/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs
index 5d06582..b8a7f41 100644
--- a/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs
+++ b/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs
@@ -1,7 +1,17 @@
namespace ScadaLink.Commons.Messages.ScriptExecution;
+///
+/// Audit Log #23 (ParentExecutionId): the spawning execution's ExecutionId.
+/// For an inbound-API-routed call this is the inbound request's per-request
+/// execution id (carried in from RouteToCallRequest.ParentExecutionId);
+/// the routed script execution records it as its ParentExecutionId so a
+/// spawned execution points back at its spawner. Additive trailing member —
+/// null for normal (tag-change / timer-triggered) runs, nested Script.Call
+/// invocations, and any request built before the field existed.
+///
public record ScriptCallRequest(
string ScriptName,
IReadOnlyDictionary? Parameters,
int CurrentCallDepth,
- string CorrelationId);
+ string CorrelationId,
+ Guid? ParentExecutionId = null);
diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs
index e28a32a..9eaf607 100644
--- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs
+++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs
@@ -735,9 +735,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
{
- // Convert to ScriptCallRequest and Ask the Instance Actor
+ // Convert to ScriptCallRequest and Ask the Instance Actor.
+ // Audit Log #23 (ParentExecutionId): carry the inbound request's
+ // ExecutionId down as ParentExecutionId so the routed script
+ // execution can record its spawner.
var scriptCall = new ScriptCallRequest(
- request.ScriptName, request.Parameters, 0, request.CorrelationId);
+ request.ScriptName, request.Parameters, 0, request.CorrelationId,
+ ParentExecutionId: request.ParentExecutionId);
var sender = Sender;
instanceActor.Ask(scriptCall, TimeSpan.FromSeconds(30))
.ContinueWith(t =>
diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs
index 1b3c80b..9aeef36 100644
--- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs
@@ -320,7 +320,10 @@ public class InstanceActor : ReceiveActor
{
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
{
- // Forward the request to the Script Actor, preserving the original sender
+ // Forward the request to the Script Actor, preserving the original
+ // sender. The whole record is forwarded unchanged, so any
+ // ParentExecutionId (Audit Log #23) set by an inbound-API-routed
+ // call is carried through to the Script Actor verbatim.
scriptActor.Forward(request);
}
else
diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
index 9cf6928..0fd5efa 100644
--- a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
+++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
@@ -184,7 +184,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
return;
}
- SpawnExecution(request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId);
+ // Audit Log #23 (ParentExecutionId): carry any inbound-routed
+ // ParentExecutionId through to the ScriptExecutionActor so the routed
+ // script's ScriptRuntimeContext can record its spawner. Null for normal
+ // (tag-change / timer) runs and nested Script.Call invocations.
+ SpawnExecution(
+ request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
+ request.ParentExecutionId);
}
///
@@ -379,7 +385,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
IReadOnlyDictionary? parameters,
int callDepth,
IActorRef replyTo,
- string correlationId)
+ string correlationId,
+ Guid? parentExecutionId = null)
{
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
@@ -401,7 +408,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
_logger,
_scope,
_healthCollector,
- _serviceProvider));
+ _serviceProvider,
+ // Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
+ // an inbound-API-routed call supplies the inbound request's id.
+ parentExecutionId));
Context.ActorOf(props, executionId);
}
diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs
index e5b84ae..c0f517c 100644
--- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs
+++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs
@@ -43,7 +43,11 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null,
- IServiceProvider? serviceProvider = null)
+ IServiceProvider? serviceProvider = null,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's
+ // ExecutionId for an inbound-API-routed call. Null for normal
+ // (tag-change / timer) runs and nested Script.Call invocations.
+ Guid? parentExecutionId = null)
{
// Immediately begin execution
var self = Self;
@@ -52,7 +56,8 @@ public class ScriptExecutionActor : ReceiveActor
ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
- self, parent, logger, scope, healthCollector, serviceProvider);
+ self, parent, logger, scope, healthCollector, serviceProvider,
+ parentExecutionId);
}
private static void ExecuteScript(
@@ -71,7 +76,8 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector,
- IServiceProvider? serviceProvider)
+ IServiceProvider? serviceProvider,
+ Guid? parentExecutionId)
{
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
@@ -164,7 +170,12 @@ public class ScriptExecutionActor : ReceiveActor
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
- cachedForwarder: cachedForwarder);
+ cachedForwarder: cachedForwarder,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's
+ // id for an inbound-API-routed call. The routed script still
+ // mints its own fresh ExecutionId — this records the spawner.
+ // Null for normal (tag-change / timer) runs.
+ parentExecutionId: parentExecutionId);
var globals = new ScriptGlobals
{
diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
index 2b5df67..a112928 100644
--- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
+++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
@@ -116,6 +116,19 @@ public class ScriptRuntimeContext
///
private readonly Guid _executionId;
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's
+ /// when this script run was spawned by another
+ /// execution — for an inbound-API-routed call this is the inbound request's
+ /// per-request execution id. null for normal (tag-change /
+ /// timer-triggered) runs and nested CallScript invocations. The
+ /// routed script still mints its OWN fresh ; this
+ /// field records the spawner so a spawned execution's audit rows can point
+ /// back at the execution that spawned it. (Task 5 wires the emitter that
+ /// stamps this onto AuditEvent.ParentExecutionId.)
+ ///
+ private readonly Guid? _parentExecutionId;
+
///
/// Audit Log #23: the per-execution id for this script run. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an
@@ -123,6 +136,13 @@ public class ScriptRuntimeContext
/// request. Stamped into AuditEvent.ExecutionId on every
/// trust-boundary audit row this execution emits.
///
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's
+ /// ExecutionId — supplied for an inbound-API-routed call (the
+ /// inbound request's per-request id), null for normal (tag-change /
+ /// timer-triggered) runs. The routed script still generates its own fresh
+ /// ; this only records the spawner.
+ ///
public ScriptRuntimeContext(
IActorRef instanceActor,
IActorRef self,
@@ -141,7 +161,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
- Guid? executionId = null)
+ Guid? executionId = null,
+ Guid? parentExecutionId = null)
{
_instanceActor = instanceActor;
_self = self;
@@ -161,6 +182,9 @@ public class ScriptRuntimeContext
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
_executionId = executionId ?? Guid.NewGuid();
+ // Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()`
+ // fallback. A non-routed run legitimately has no parent and stays null.
+ _parentExecutionId = parentExecutionId;
}
///
@@ -264,7 +288,10 @@ public class ScriptRuntimeContext
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue.
- _cachedForwarder);
+ _cachedForwarder,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's id,
+ // threaded alongside _executionId. Null for non-routed runs.
+ _parentExecutionId);
///
/// WP-13: Provides access to database operations.
@@ -285,7 +312,10 @@ public class ScriptRuntimeContext
_sourceScript,
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
// every Database.CachedWrite enqueue.
- _cachedForwarder);
+ _cachedForwarder,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's id,
+ // threaded alongside _executionId. Null for non-routed runs.
+ _parentExecutionId);
///
/// Provides access to the Notification Outbox API.
@@ -302,7 +332,10 @@ public class ScriptRuntimeContext
///
public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
- _executionId, _auditWriter);
+ _executionId, _auditWriter,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's id,
+ // threaded alongside _executionId. Null for non-routed runs.
+ _parentExecutionId);
///
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
@@ -384,6 +417,15 @@ public class ScriptRuntimeContext
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _executionId;
+
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's id when
+ /// this run was inbound-API-routed; null for non-routed runs.
+ /// Threaded alongside ready for the Task 5
+ /// emitter — no audit row carries it yet.
+ ///
+ private readonly Guid? _parentExecutionId;
+
private readonly IAuditWriter? _auditWriter;
private readonly string _siteId;
private readonly string? _sourceScript;
@@ -398,7 +440,9 @@ public class ScriptRuntimeContext
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a
// required-after-optional compile error, so the post-logger slot is the
- // one consistent position that compiles cleanly everywhere.
+ // one consistent position that compiles cleanly everywhere. The nullable
+ // parentExecutionId is a trailing optional param so existing positional
+ // callers stay source-compatible.
internal ExternalSystemHelper(
IExternalSystemClient? client,
string instanceName,
@@ -407,7 +451,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
- ICachedCallTelemetryForwarder? cachedForwarder = null)
+ ICachedCallTelemetryForwarder? cachedForwarder = null,
+ Guid? parentExecutionId = null)
{
_client = client;
_instanceName = instanceName;
@@ -417,6 +462,7 @@ public class ScriptRuntimeContext
_siteId = siteId;
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
+ _parentExecutionId = parentExecutionId;
}
public async Task Call(
@@ -1001,6 +1047,15 @@ public class ScriptRuntimeContext
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _executionId;
+
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's id when
+ /// this run was inbound-API-routed; null for non-routed runs.
+ /// Threaded alongside ready for the Task 5
+ /// emitter — no audit row carries it yet.
+ ///
+ private readonly Guid? _parentExecutionId;
+
private readonly string _siteId;
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -1020,7 +1075,7 @@ public class ScriptRuntimeContext
// Parameter ordering: executionId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
- // audit-threaded ctors.
+ // audit-threaded ctors. parentExecutionId is a trailing optional param.
internal DatabaseHelper(
IDatabaseGateway? gateway,
string instanceName,
@@ -1029,7 +1084,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
- ICachedCallTelemetryForwarder? cachedForwarder = null)
+ ICachedCallTelemetryForwarder? cachedForwarder = null,
+ Guid? parentExecutionId = null)
{
_gateway = gateway;
_instanceName = instanceName;
@@ -1039,6 +1095,7 @@ public class ScriptRuntimeContext
_siteId = siteId;
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
+ _parentExecutionId = parentExecutionId;
}
public async Task Connection(
@@ -1213,6 +1270,14 @@ public class ScriptRuntimeContext
///
private readonly Guid _executionId;
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's id when
+ /// this run was inbound-API-routed; null for non-routed runs.
+ /// Threaded alongside ready for the Task 5
+ /// emitter — no audit row carries it yet.
+ ///
+ private readonly Guid? _parentExecutionId;
+
///
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// Notification/NotifySend row produced when the script
@@ -1224,7 +1289,8 @@ public class ScriptRuntimeContext
private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger,
- // consistent with the other audit-threaded ctors.
+ // consistent with the other audit-threaded ctors. parentExecutionId is
+ // a trailing optional param.
internal NotifyHelper(
StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor,
@@ -1234,7 +1300,8 @@ public class ScriptRuntimeContext
TimeSpan askTimeout,
ILogger logger,
Guid executionId,
- IAuditWriter? auditWriter = null)
+ IAuditWriter? auditWriter = null,
+ Guid? parentExecutionId = null)
{
_storeAndForward = storeAndForward;
_siteCommunicationActor = siteCommunicationActor;
@@ -1245,6 +1312,7 @@ public class ScriptRuntimeContext
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
+ _parentExecutionId = parentExecutionId;
}
///
@@ -1259,7 +1327,10 @@ public class ScriptRuntimeContext
_executionId,
// Audit Log #23 (M4 Bundle C): forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission.
- _auditWriter);
+ _auditWriter,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's
+ // id, threaded alongside _executionId. Null for non-routed runs.
+ _parentExecutionId);
}
///
@@ -1340,6 +1411,14 @@ public class ScriptRuntimeContext
///
private readonly Guid _executionId;
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's id when
+ /// this run was inbound-API-routed; null for non-routed runs.
+ /// Threaded alongside ready for the Task 5
+ /// emitter — no audit row carries it yet.
+ ///
+ private readonly Guid? _parentExecutionId;
+
///
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// Notification/NotifySend row written immediately after
@@ -1356,7 +1435,8 @@ public class ScriptRuntimeContext
string? sourceScript,
ILogger logger,
Guid executionId,
- IAuditWriter? auditWriter = null)
+ IAuditWriter? auditWriter = null,
+ Guid? parentExecutionId = null)
{
_listName = listName;
_storeAndForward = storeAndForward;
@@ -1366,6 +1446,7 @@ public class ScriptRuntimeContext
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
+ _parentExecutionId = parentExecutionId;
}
///
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs
index f9c8513..fdfd952 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs
@@ -3,6 +3,7 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Deployment;
+using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
@@ -68,6 +69,28 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
return JsonSerializer.Serialize(config);
}
+ ///
+ /// Builds a config carrying a single callable (no-trigger) script that
+ /// returns a constant — enough for an inbound
+ /// to be routed end-to-end through the Instance/Script/ScriptExecution actors.
+ ///
+ private static string MakeConfigWithScriptJson(string instanceName, string scriptName)
+ {
+ var config = new FlattenedConfiguration
+ {
+ InstanceUniqueName = instanceName,
+ Attributes =
+ [
+ new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }
+ ],
+ Scripts =
+ [
+ new ResolvedScript { CanonicalName = scriptName, Code = "return 7;" }
+ ]
+ };
+ return JsonSerializer.Serialize(config);
+ }
+
[Fact]
public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs()
{
@@ -240,4 +263,57 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, response.Status);
}
+
+ // ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ──
+
+ [Fact]
+ public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully()
+ {
+ // A RouteToCallRequest carrying a ParentExecutionId (the inbound
+ // request's ExecutionId) must be mapped to a ScriptCallRequest and
+ // routed end-to-end through the Instance/Script/ScriptExecution actors.
+ // The additive ParentExecutionId field must not break that routing.
+ var actor = CreateDeploymentManager();
+ await Task.Delay(500); // empty startup
+
+ actor.Tell(new DeployInstanceCommand(
+ "dep-route", "RoutedPump", "sha256:route",
+ MakeConfigWithScriptJson("RoutedPump", "DoWork"), "admin", DateTimeOffset.UtcNow));
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ await Task.Delay(1000); // let the InstanceActor + ScriptActor spin up
+
+ var parentExecutionId = Guid.NewGuid();
+ actor.Tell(new RouteToCallRequest(
+ "route-corr-1", "RoutedPump", "DoWork",
+ Parameters: null, DateTimeOffset.UtcNow, ParentExecutionId: parentExecutionId));
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(10));
+ Assert.Equal("route-corr-1", response.CorrelationId);
+ Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
+ Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
+ }
+
+ [Fact]
+ public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes()
+ {
+ // A routed call with no ParentExecutionId (e.g. the Central UI sandbox)
+ // is the additive-default path — it must route exactly as before.
+ var actor = CreateDeploymentManager();
+ await Task.Delay(500);
+
+ actor.Tell(new DeployInstanceCommand(
+ "dep-route2", "RoutedPump2", "sha256:route2",
+ MakeConfigWithScriptJson("RoutedPump2", "DoWork"), "admin", DateTimeOffset.UtcNow));
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ await Task.Delay(1000);
+
+ // No ParentExecutionId argument — exercises the additive `= null` default.
+ actor.Tell(new RouteToCallRequest(
+ "route-corr-2", "RoutedPump2", "DoWork",
+ Parameters: null, DateTimeOffset.UtcNow));
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(10));
+ Assert.Equal("route-corr-2", response.CorrelationId);
+ Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
+ }
}
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
index 5e85efb..280830d 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
@@ -62,7 +62,8 @@ public class ExecutionCorrelationContextTests
IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter,
- Guid? executionId = null)
+ Guid? executionId = null,
+ Guid? parentExecutionId = null)
{
var compilationService = new ScriptCompilationService(
NullLogger.Instance);
@@ -87,7 +88,24 @@ public class ExecutionCorrelationContextTests
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
- executionId: executionId);
+ executionId: executionId,
+ parentExecutionId: parentExecutionId);
+ }
+
+ ///
+ /// Reads a private / field off a
+ /// . The ParentExecutionId plumbing (Audit
+ /// Log #23, Task 4) only stores the value on the context — no emitter stamps
+ /// it onto an audit row yet (that is Task 5) — so the field is inspected
+ /// directly rather than through an emitted row.
+ ///
+ private static object? ReadPrivateField(ScriptRuntimeContext context, string fieldName)
+ {
+ var field = typeof(ScriptRuntimeContext).GetField(
+ fieldName,
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ Assert.NotNull(field);
+ return field!.GetValue(context);
}
///
@@ -183,4 +201,54 @@ public class ExecutionCorrelationContextTests
Assert.Null(apiRow.CorrelationId);
Assert.Null(dbRow.CorrelationId);
}
+
+ [Fact]
+ public void ParentExecutionIdSupplied_StoredVerbatim_AndOwnExecutionIdIsFreshAndDistinct()
+ {
+ // Audit Log #23 (ParentExecutionId, Task 4): an inbound-API-routed call
+ // supplies the spawning execution's ExecutionId as the routed script's
+ // ParentExecutionId. The context must store that value verbatim AND
+ // still mint its OWN fresh ExecutionId — the routed script is a new
+ // execution, it does not inherit the parent's id.
+ var parentExecutionId = Guid.NewGuid();
+
+ var context = CreateContext(
+ externalSystemClient: null,
+ databaseGateway: null,
+ auditWriter: null,
+ // executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs.
+ parentExecutionId: parentExecutionId);
+
+ var storedParent = ReadPrivateField(context, "_parentExecutionId");
+ var ownExecutionId = ReadPrivateField(context, "_executionId");
+
+ // The parent id is carried through untouched.
+ Assert.Equal(parentExecutionId, storedParent);
+
+ // The routed script's own ExecutionId is freshly generated, non-empty,
+ // and NOT the parent id — they are separate correlation values.
+ Assert.NotNull(ownExecutionId);
+ var ownId = Assert.IsType(ownExecutionId);
+ Assert.NotEqual(Guid.Empty, ownId);
+ Assert.NotEqual(parentExecutionId, ownId);
+ }
+
+ [Fact]
+ public void NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNull()
+ {
+ // A normal (tag-change / timer) script run is not inbound-API-routed —
+ // no ParentExecutionId is supplied, so _parentExecutionId stays null
+ // while the run still gets its own fresh ExecutionId.
+ var context = CreateContext(
+ externalSystemClient: null,
+ databaseGateway: null,
+ auditWriter: null);
+
+ var storedParent = ReadPrivateField(context, "_parentExecutionId");
+ var ownExecutionId = ReadPrivateField(context, "_executionId");
+
+ Assert.Null(storedParent);
+ var ownId = Assert.IsType(ownExecutionId);
+ Assert.NotEqual(Guid.Empty, ownId);
+ }
}