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); + } }