From 8aa6bf2270e54f6128d4c317abee83be0b6412b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 11:21:29 -0400 Subject: [PATCH] fix(audit): populate ParentExecutionId on alarm-triggered script runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5.4 T4 threaded a `parentExecutionId` parameter through AlarmActor.SpawnAlarmExecution → AlarmExecutionActor → ScriptRuntimeContext, but every call site passed null — so alarm on-trigger runs were silently always execution-tree roots, contradicting the "tag-cascade coverage is complete" claim in CLAUDE.md and Component-AuditLog.md. Source the id where a spawner genuinely exists: a static attribute write issued by a site script (`Instance.SetAttribute`) or by an inbound API request (`Route.To(...).SetAttributes(...)`, whose ParentExecutionId was already carried to the site and then dropped). The id rides site-locally through three additive, nullable fields — no wire, proto or central schema change: ScriptRuntimeContext.SetAttribute / RouteToSetAttributesRequest.ParentExecutionId → SetStaticAttributeCommand.SourceExecutionId → AttributeValueChanged.SourceExecutionId (InstanceActor static-write path) → AlarmActor.SpawnAlarmExecution → AlarmExecutionActor → ScriptRuntimeContext All four computed trigger types participate. Expression triggers evaluate off the dispatcher, so the writer of the newest value folded into the snapshot is captured *with* the snapshot and echoed home on ExpressionEvalResult / ExpressionEvalFailed — a change arriving mid-flight cannot mis-attribute the raise. Deliberately still roots (documented, not deferred): alarms fired by Data Connection Layer values (external device data has no spawning execution — this includes the device echo of a script write to a *data-sourced* attribute, so only static writes cascade), and ScriptActor value-change/conditional/ expression/timer trigger runs (a timer tick has no spawner; a WhileTrue/interval run has no single identifiable write). Tests: new SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests pins all three hops — SetAttribute stamps the run's ExecutionId, InstanceActor publishes it on the change (and publishes null when absent), and ValueMatch/HiLo/ Expression alarms parent the on-trigger run to the writer while a DCL-originated change leaves it a root. Docs: CLAUDE.md and Component-AuditLog.md corrected from "complete" to the true behaviour; Component-SiteRuntime.md gains an "Audit correlation of an on-trigger run" section with the hop table and the by-design root cases. --- CLAUDE.md | 2 +- docs/requirements/Component-AuditLog.md | 41 ++- docs/requirements/Component-SiteRuntime.md | 35 ++ .../Instance/SetStaticAttributeCommand.cs | 18 +- .../Streaming/AttributeValueChanged.cs | 36 +- .../Actors/AlarmActor.cs | 98 ++++-- .../Actors/AlarmExecutionActor.cs | 18 +- .../Actors/DeploymentManagerActor.cs | 9 +- .../Actors/InstanceActor.cs | 7 +- .../Scripts/ScriptRuntimeContext.cs | 18 +- .../AlarmCascadeParentExecutionTests.cs | 320 ++++++++++++++++++ .../Scripts/ParentExecutionTreeTests.cs | 18 +- 12 files changed, 562 insertions(+), 58 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 78e076d8..381c1724 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,7 @@ spec for each is `docs/requirements/Component-.md`, and `README.md` carrie - Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded. - One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`). - `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots). -- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. Tag-cascade coverage is complete as of M5.4 (T4) — no further spawn points are deferred. +- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. **Tag-cascade (alarm leg) is populated, not just plumbed** — M5.4 T4 threaded the `parentExecutionId` parameter but every `AlarmActor.SpawnAlarmExecution` call site passed null, so alarm runs were silently always roots; the id now rides site-locally as `SetStaticAttributeCommand.SourceExecutionId` → `AttributeValueChanged.SourceExecutionId` → `SpawnAlarmExecution` (all additive/nullable, no wire/proto/schema change; `Expression` triggers capture the writer *with* the evaluated snapshot since the eval completes off-dispatcher). Sources are `ScriptRuntimeContext.SetAttribute` (the run's own `ExecutionId`) and `Route.To(...).SetAttributes(...)` (the inbound request's). **Still roots by design, not omission:** alarms fired by DCL data (external values have no spawning execution — including the device echo of a script write to a *data-sourced* attribute, so only **static** writes cascade), and `ScriptActor` value-change/conditional/expression/timer trigger runs (a timer tick has no spawner; a `WhileTrue`/interval run has no single identifiable write). - Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost. - Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction. - Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. Inbound API: full verbatim capture up to `InboundMaxBytes` (default 1 MiB); request headers stored in `Extra.requestHeaders` (post-redaction); per-method `SkipBodyCapture` flag suppresses bodies while still recording headers + metadata; `AuditInboundCeilingHits` counter surfaced on health snapshot. (M5.3 T7) diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index 175dfd08..7dcad870 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -91,7 +91,7 @@ row per lifecycle event across all channels. | `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). | | `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. | | `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. | -| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). | +| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`), a nested `CallScript`/`CallShared` run (= the caller's), and an alarm on-trigger run fired by a script- or inbound-API-initiated static attribute write (= the writer's). NULL for a top-level run: the inbound request itself, timer- and value-change-triggered scripts, and alarms fired by DCL (external device) data. | | `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. | | `SourceNode` | `varchar(64)` NULL | The cluster node on which the event was emitted — `node-a` / `node-b` for site rows (qualified by `SourceSiteId`), `central-a` / `central-b` for central-originated rows. Nullable so reconciled rows from a node that has since been retired don't block ingest. | | `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). | @@ -195,20 +195,41 @@ known spawn points: that calls `Route.Call`; the routed site script records the inbound request's `ExecutionId` as its `ParentExecutionId`, while the inbound `InboundRequest` row is top-level (`ParentExecutionId` NULL). -- **Alarm-triggered on-trigger script** — when an alarm fires and its on-trigger - script runs (via `AlarmActor → AlarmExecutionActor`), the alarm context's - `ExecutionId` is carried as the run's `ParentExecutionId`. Currently the alarm - subsystem has no Guid-typed firing id so on-trigger runs are roots (NULL) in - practice, but the wiring is in place for a future alarm `ExecutionId`. +- **Alarm-triggered on-trigger script** — when a write trips an alarm and its + on-trigger script runs (via `AlarmActor → AlarmExecutionActor`), the run + records the **writing execution's** `ExecutionId` as its `ParentExecutionId`. + The write's originating execution rides site-locally from + `ScriptRuntimeContext.SetAttribute` (or the inbound API's + `Route.To(...).SetAttributes(...)`, whose `ParentExecutionId` is reused) + → `SetStaticAttributeCommand.SourceExecutionId` → the Instance Actor's + published `AttributeValueChanged.SourceExecutionId` → the Alarm Actor's + `SpawnAlarmExecution`. All four computed trigger types are covered; for an + `Expression` trigger the writer captured with the evaluated snapshot is used, + since the evaluation completes off the dispatcher after the firing change has + left scope. - **Nested `CallScript` / `CallShared` invocations** — when a script calls `Instance.CallScript(...)` or a shared script via `CallShared`, the calling execution's `ExecutionId` threads into the spawned run as its `ParentExecutionId`, making deeply nested call chains visible as a tree. -Attribute-write-triggered cascades (one tag change triggering another script via a -tag subscription) are also wired: trigger-driven runs carry `ParentExecutionId = -NULL` (top-level roots), and any nested `CallScript`/`CallShared` they perform -chains as above. The schema is unchanged — no further tag-cascade work is deferred. +**Runs that remain roots — by design, not by omission.** `ParentExecutionId` is +NULL where no spawning execution exists: + +- **Alarms fired by Data Connection Layer data.** A value that arrives from a + device subscription has no originating execution, so it carries no + `SourceExecutionId` and the on-trigger run it fires is a root. This also covers + the *confirmed* value of a script-initiated write to a **data-sourced** + attribute: that write goes to the device and the echo returns on the + subscription, long after the writing execution ended. Only **static** + attribute writes (in-memory + persisted override) cascade. +- **Script value-change / conditional / expression triggers and timer-driven + runs** (`ScriptActor`). A timer tick has no spawner at all, and a + `WhileTrue`/interval script fires repeatedly from a timer rather than from one + identifiable write, so these runs stay roots; any nested `CallScript` / + `CallShared` they perform chains normally beneath them. + +The schema is unchanged throughout — the cascade is carried on site-local +message fields, not on the wire or in central tables. **Execution-tree traversal bound.** `GetExecutionTreeAsync` first walks up `ParentExecutionId` to the chain root, then walks down via a recursive CTE. The diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index 1d68ba7c..492a4a22 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -259,6 +259,41 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak - **Can** call instance scripts via `Instance.CallScript()` — sends an ask message to the appropriate sibling Script Actor. - Instance scripts **cannot** call alarm on-trigger scripts — the call direction is one-way. +### Audit correlation of an on-trigger run (`ParentExecutionId` tag-cascade) + +An alarm fired by a **script- or inbound-API-initiated static attribute write** +is a genuine execution spawn, and the on-trigger run records the writing +execution as its parent in the central Audit Log (#23). The originating id rides +site-locally through three additive, nullable fields — no wire, proto or schema +change: + +| Hop | Field | +|---|---| +| `ScriptRuntimeContext.SetAttribute` (this run's `ExecutionId`) / `RouteToSetAttributesRequest.ParentExecutionId` (the inbound request's) | → `SetStaticAttributeCommand.SourceExecutionId` | +| Instance Actor static-write path (`HandleSetStaticAttributeCore`) | → `AttributeValueChanged.SourceExecutionId` | +| Alarm Actor trigger evaluation → `SpawnAlarmExecution` | → `AlarmExecutionActor` → `ScriptRuntimeContext.ParentExecutionId` | + +All four computed trigger types participate. `Expression` triggers evaluate a +whole attribute snapshot **off the dispatcher**, so the firing change is no +longer in scope when the boolean returns: the writer of the newest value folded +into the snapshot is captured *with* the snapshot and echoed back on the +evaluation result, so a change arriving mid-flight cannot mis-attribute the +raise. + +The on-trigger run is correctly a **tree root** (`ParentExecutionId` NULL) when +the firing value has no originating execution: + +- Values from the **Data Connection Layer** — external device data has no + spawning execution. This includes the confirmed value of a write to a + **data-sourced** attribute: that write is forwarded to the device and the echo + arrives on the subscription long after the writing execution ended, so only + **static** attribute writes cascade. +- Deploy-time seeding and central Test Run / tooling writes. + +`AttributeValueChanged.SourceExecutionId` is deliberately **not** projected onto +the gRPC debug/stream wire shapes — the cascade is resolved inside the site node +that owns the Instance Actor, and central reads the linkage from audit rows. + --- ## Native Alarm Actor diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/SetStaticAttributeCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/SetStaticAttributeCommand.cs index 01cd7683..bda4d24d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/SetStaticAttributeCommand.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/SetStaticAttributeCommand.cs @@ -4,12 +4,28 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; /// Command to set a static attribute value on an Instance Actor. /// Updates in-memory state and persists the override to SQLite. /// +/// Per-operation correlation id. +/// Unique name of the target instance. +/// Canonical name of the attribute to write. +/// Canonical string form of the new value. +/// UTC timestamp of the command. +/// +/// Audit Log #23 (ParentExecutionId tag-cascade): the ExecutionId of the +/// execution issuing this write — a site script run +/// (ScriptRuntimeContext.SetAttribute) or the inbound API request behind a +/// Route.To(...).SetAttributes(...). Additive and nullable; null +/// when the write has no audited originating execution (central Test Run, +/// deployment tooling, tests). The Instance Actor stamps it onto the resulting +/// AttributeValueChanged so an alarm the write trips can record it as the +/// on-trigger script run's ParentExecutionId. +/// public record SetStaticAttributeCommand( string CorrelationId, string InstanceUniqueName, string AttributeName, string Value, - DateTimeOffset Timestamp); + DateTimeOffset Timestamp, + Guid? SourceExecutionId = null); /// /// Response confirming that a static attribute was set. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AttributeValueChanged.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AttributeValueChanged.cs index c7f27468..6bd73496 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AttributeValueChanged.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AttributeValueChanged.cs @@ -1,9 +1,43 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +/// +/// A single attribute value change on a deployed instance — published to the +/// site-wide stream and routed to the interested child actors (Script Actors, +/// Alarm Actors) of the owning Instance Actor. +/// +/// Unique name of the instance the attribute belongs to. +/// Path-qualified canonical name of the attribute. +/// Canonical name of the attribute. +/// The new value. +/// Quality of the new value (Good/Bad/Uncertain). +/// UTC timestamp of the change. +/// +/// Audit Log #23 (ParentExecutionId tag-cascade): the ExecutionId of the +/// script execution / inbound API request whose write produced this change, or +/// null when the change did not originate from an audited execution — +/// which is the case for every value that arrives from the Data Connection +/// Layer (external device data), for deployment-time seeding, and for the +/// confirmed value of a data-sourced write (the device echo arrives on the +/// subscription long after the writing execution ended). +/// +/// +/// The Alarm Actor records this id as the ParentExecutionId of the +/// on-trigger script run the change fires, so a script-initiated write that +/// trips an alarm chains under the writing execution in the audit tree. +/// +/// +/// +/// Deliberately site-local: the field is NOT projected onto the gRPC +/// DebugAttributeValueDto / SiteStreamEvent wire shapes — the +/// cascade is resolved entirely inside the site node that owns the Instance +/// Actor, and central reads the linkage from the audit rows instead. +/// +/// public record AttributeValueChanged( string InstanceUniqueName, string AttributePath, string AttributeName, object? Value, string Quality, - DateTimeOffset Timestamp) : ISiteStreamEvent; + DateTimeOffset Timestamp, + Guid? SourceExecutionId = null) : ISiteStreamEvent; diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs index 2abb5f24..dc25baeb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs @@ -102,6 +102,18 @@ public class AlarmActor : ReceiveActor private bool _evalInFlight; private bool _evalPending; + /// + /// Audit Log #23 (ParentExecutionId tag-cascade): the + /// of the most recent + /// change folded into . Expression triggers + /// evaluate a whole snapshot off the dispatcher, so there is no single + /// "triggering change" in scope when the boolean result comes back — this + /// field is the latest writer, captured into the in-flight evaluation by + /// and echoed home on + /// . Touched only on the actor thread. + /// + private Guid? _latestSourceExecutionId; + /// /// The exact dictionary instance this actor was seeded from /// at construction. The Instance Actor must pass a private snapshot here, not @@ -110,6 +122,18 @@ public class AlarmActor : ReceiveActor /// internal IReadOnlyDictionary? SeedAttributesReference { get; } + /// + /// Audit Log #23 (ParentExecutionId tag-cascade): the + /// parentExecutionId handed to the most recently spawned + /// — i.e. the execution whose attribute + /// write fired this alarm, or null when the firing change came from + /// the Data Connection Layer (external data has no spawning execution). + /// The spawned actor builds its own + /// internally, so this is exposed for regression coverage of the cascade + /// contract (mirrors ). + /// + internal Guid? LastOnTriggerParentExecutionId { get; private set; } + // Rate of change tracking private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new(); private readonly TimeSpan _rateOfChangeWindowDuration; @@ -241,6 +265,10 @@ public class AlarmActor : ReceiveActor if (_triggerType == AlarmTriggerType.Expression) { _attributeSnapshot[changed.AttributeName] = changed.Value; + // (ParentExecutionId tag-cascade): remember which + // execution (if any) wrote the newest value folded into the snapshot, + // so the off-dispatcher evaluation can attribute its raise to it. + _latestSourceExecutionId = changed.SourceExecutionId; } else if (!IsMonitoredAttribute(changed.AttributeName)) { @@ -252,7 +280,7 @@ public class AlarmActor : ReceiveActor { if (_triggerType == AlarmTriggerType.HiLo) { - HandleHiLoTransition(EvaluateHiLo(changed.Value)); + HandleHiLoTransition(EvaluateHiLo(changed.Value), changed.SourceExecutionId); return; } @@ -274,7 +302,7 @@ public class AlarmActor : ReceiveActor _ => false }; - ApplyTriggeredState(isTriggered); + ApplyTriggeredState(isTriggered, changed.SourceExecutionId); } catch (Exception ex) { @@ -292,7 +320,14 @@ public class AlarmActor : ReceiveActor /// Expression path (via ). Edge state /// () is touched only on the actor thread. /// - private void ApplyTriggeredState(bool isTriggered) + /// The evaluated truth value of the alarm condition. + /// + /// (ParentExecutionId tag-cascade): the execution that + /// wrote the value which produced this evaluation, recorded as the + /// on-trigger script run's ParentExecutionId. Null when the value + /// came from the DCL (external data has no spawning execution). + /// + private void ApplyTriggeredState(bool isTriggered, Guid? sourceExecutionId = null) { if (isTriggered && _currentState == AlarmState.Normal) { @@ -313,7 +348,7 @@ public class AlarmActor : ReceiveActor // Spawn AlarmExecutionActor if on-trigger script defined if (_onTriggerCompiledScript != null) { - SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty); + SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty, sourceExecutionId); } } else if (!isTriggered && _currentState == AlarmState.Active) @@ -339,7 +374,13 @@ public class AlarmActor : ReceiveActor /// edge (i.e., when entering an alarm band from the normal band) — not on /// level escalations like Hi→HiHi or Low→LowLow. /// - private void HandleHiLoTransition(AlarmLevel newLevel) + /// The newly evaluated HiLo band. + /// + /// (ParentExecutionId tag-cascade): the execution that + /// wrote the value driving this transition; recorded as the on-trigger + /// script run's ParentExecutionId. Null for DCL-originated values. + /// + private void HandleHiLoTransition(AlarmLevel newLevel, Guid? sourceExecutionId = null) { if (newLevel == _currentLevel) return; @@ -383,7 +424,7 @@ public class AlarmActor : ReceiveActor && newLevel != AlarmLevel.None && _onTriggerCompiledScript != null) { - SpawnAlarmExecution(newLevel, priority, message); + SpawnAlarmExecution(newLevel, priority, message, sourceExecutionId); } } @@ -543,6 +584,11 @@ public class AlarmActor : ReceiveActor var snapshot = new Dictionary(_attributeSnapshot); // point-in-time copy, actor thread var expression = _compiledTriggerExpression; var self = Self; + // (ParentExecutionId tag-cascade): capture the writer of + // the newest value in THIS snapshot alongside the snapshot itself, so a + // later change arriving while the evaluation is in flight cannot + // mis-attribute the raise this evaluation produces. + var sourceExecutionId = _latestSourceExecutionId; Task.Factory.StartNew(async () => { try @@ -568,8 +614,8 @@ public class AlarmActor : ReceiveActor } }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, _scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self, - success: r => new ExpressionEvalResult(r), - failure: ex => new ExpressionEvalFailed(ex)); + success: r => new ExpressionEvalResult(r, sourceExecutionId), + failure: ex => new ExpressionEvalFailed(ex, sourceExecutionId)); } /// @@ -582,7 +628,7 @@ public class AlarmActor : ReceiveActor _evalInFlight = false; try { - ApplyTriggeredState(msg.Result); + ApplyTriggeredState(msg.Result, msg.SourceExecutionId); } catch (Exception ex) { @@ -607,7 +653,7 @@ public class AlarmActor : ReceiveActor _logger.LogError(msg.Cause, "Alarm {Alarm} trigger-expression evaluation task faulted on {Instance}; treated as false", _alarmName, _instanceName); - HandleExpressionEvalResult(new ExpressionEvalResult(false)); + HandleExpressionEvalResult(new ExpressionEvalResult(false, msg.SourceExecutionId)); } /// @@ -667,13 +713,14 @@ public class AlarmActor : ReceiveActor /// The firing alarm priority. /// The firing alarm message. /// - /// 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. + /// (ParentExecutionId tag-cascade): the ExecutionId + /// of the execution whose attribute write fired this alarm — a site script + /// run (Instance.SetAttribute) or the inbound API request behind a + /// Route.To(...).SetAttributes(...) — recorded as the on-trigger + /// script run's ParentExecutionId so the alarm-triggered run chains + /// under its firing execution in the audit tree. null when the firing + /// value arrived from the Data Connection Layer: external device data has no + /// spawning execution, so that on-trigger run is correctly a tree ROOT. /// private void SpawnAlarmExecution( AlarmLevel level, int priority, string message, Guid? parentExecutionId = null) @@ -682,6 +729,10 @@ public class AlarmActor : ReceiveActor var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}"; + // Record what the on-trigger run was parented to (null = root); read by + // the tag-cascade regression tests, which cannot see inside the child. + LastOnTriggerParentExecutionId = parentExecutionId; + // The on-trigger script body runs on the dedicated // ScriptExecutionScheduler, not the shared .NET thread pool. var props = Props.Create(() => new AlarmExecutionActor( @@ -697,7 +748,7 @@ public class AlarmActor : ReceiveActor _logger, // Per-script timeout from the on-trigger script (null = global). _onTriggerExecutionTimeoutSeconds, - // The firing context's execution id (null today). + // The firing execution's id — null for DCL-originated changes. parentExecutionId, // Scheduler seam (#18): share this alarm's scheduler override with the // spawned on-trigger script body (null = process-wide shared). @@ -829,9 +880,12 @@ public class AlarmActor : ReceiveActor /// /// Piped back to self from an off-dispatcher trigger-expression evaluation (P1); /// carries the boolean truth value so the Normal↔Active transition runs on the - /// actor thread. + /// actor thread, plus the SourceExecutionId captured with the evaluated + /// snapshot (ParentExecutionId tag-cascade) so a raise is attributed + /// to the execution that actually wrote the value it evaluated — not to a + /// later change that arrived while the evaluation was in flight. /// - private sealed record ExpressionEvalResult(bool Result); + private sealed record ExpressionEvalResult(bool Result, Guid? SourceExecutionId = null); /// /// Piped back to self when the off-dispatcher evaluation TASK itself faults @@ -839,8 +893,10 @@ public class AlarmActor : ReceiveActor /// during shutdown) — the inner body's catch never sees that. Without this /// mapping the actor receives an unhandled Status.Failure and _evalInFlight /// stays true forever, permanently parking the expression trigger (N2). + /// The captured SourceExecutionId rides along so the synthesized + /// false result keeps the same attribution the successful path would have had. /// - internal sealed record ExpressionEvalFailed(Exception Cause); + internal sealed record ExpressionEvalFailed(Exception Cause, Guid? SourceExecutionId = null); } internal enum RateOfChangeDirection { Either, Rising, Falling } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs index 2ad7d07e..0a298131 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs @@ -30,12 +30,13 @@ public class AlarmExecutionActor : ReceiveActor /// Logger for execution diagnostics. /// The on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global . /// - /// 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. + /// ParentExecutionId tag-cascade: the ExecutionId of + /// the execution whose attribute write fired this alarm, threaded into the + /// on-trigger script's as its + /// ParentExecutionId so the alarm-triggered run chains under its + /// firing execution. Null when the firing value came from the Data + /// Connection Layer (external data has no spawning execution) — that + /// on-trigger run is a tree root. /// public AlarmExecutionActor( string alarmName, @@ -115,9 +116,8 @@ public class AlarmExecutionActor : ReceiveActor // 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. + // execution's id as its ParentExecutionId — null (a root) + // only when the firing value came from the DCL. parentExecutionId: parentExecutionId, // WaitForAttribute (spec §4.4): thread the alarm on-trigger // script's per-script execution-timeout token so a diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 2cd221fa..338818c6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -1826,7 +1826,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers var asks = request.AttributeValues .Select(kvp => instanceActor.Ask( new SetStaticAttributeCommand( - correlationId, request.InstanceUniqueName, kvp.Key, kvp.Value, DateTimeOffset.UtcNow), + correlationId, request.InstanceUniqueName, kvp.Key, kvp.Value, DateTimeOffset.UtcNow, + // (ParentExecutionId tag-cascade): the routed + // write's originating execution is the inbound API request + // that issued Route.To(...).SetAttributes(...) — already + // carried on the request as ParentExecutionId. Stamping it + // here lets an alarm this write trips chain its on-trigger + // script run under the inbound execution. + SourceExecutionId: request.ParentExecutionId), TimeSpan.FromSeconds(30))) .ToArray(); diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index ea42bb6e..f1e03047 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -462,7 +462,12 @@ public class InstanceActor : ReceiveActor command.AttributeName, command.Value, "Good", - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, + // (ParentExecutionId tag-cascade): carry the writing + // execution's id onto the change so a child Alarm Actor this write + // trips can chain its on-trigger script run under the writer. Null for + // writes with no audited origin (central Test Run, deployment tooling). + SourceExecutionId: command.SourceExecutionId); PublishAndNotifyChildren(changed); diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 115abf75..a7ac779e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -151,10 +151,13 @@ public class ScriptRuntimeContext /// /// (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 + /// execution — the inbound request's per-request execution id for an + /// inbound-API-routed call, the caller's id for a nested + /// CallScript/CallShared, and the writing execution's id for an + /// alarm on-trigger run fired by a script- or inbound-API-initiated attribute + /// write. null for genuinely top-level runs: timer-triggered scripts, + /// script value-change triggers, and alarms fired by DCL (external) data. The + /// spawned 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. /// @@ -514,7 +517,12 @@ public class ScriptRuntimeContext { var correlationId = Guid.NewGuid().ToString(); var command = new SetStaticAttributeCommand( - correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow); + correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow, + // (ParentExecutionId tag-cascade): stamp THIS run's own + // ExecutionId as the write's originating execution. The Instance Actor + // carries it onto the published AttributeValueChanged, so an alarm this + // write trips records this run as the on-trigger script's parent. + SourceExecutionId: _executionId); // Ask — mutation serialized through the Instance Actor mailbox; the reply // carries the device-write outcome for data-connected attributes. diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs new file mode 100644 index 00000000..29339fcb --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs @@ -0,0 +1,320 @@ +using Akka.Actor; +using Akka.TestKit; +using Akka.TestKit.Xunit2; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming; +using ZB.MOM.WW.ScadaBridge.TestSupport; +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// Audit Log #23 (M5.4 T4 — ParentExecutionId tag-cascade, alarm leg): +/// an alarm on-trigger script run must chain under the execution whose attribute +/// write fired the alarm, and must stay a tree ROOT when the firing value had no +/// audited origin. +/// +/// The cascade is three hops, each pinned here: +/// +/// +/// ScriptRuntimeContext.SetAttribute stamps the running script's +/// ExecutionId onto . +/// +/// +/// The Instance Actor carries that id onto the published +/// . +/// +/// +/// The Alarm Actor hands it to the spawned AlarmExecutionActor as the +/// on-trigger run's ParentExecutionId — observable via +/// AlarmActor.LastOnTriggerParentExecutionId. +/// +/// +/// +/// +/// A value arriving from the Data Connection Layer (external device data) carries +/// no SourceExecutionId, so the alarm it fires is correctly parentless. +/// +/// +public class AlarmCascadeParentExecutionTests : TestKit, IDisposable +{ + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedLibrary; + private readonly SiteRuntimeOptions _options; + private readonly TestLocalDb _localDb; + private readonly SiteStorageService _storage; + + public AlarmCascadeParentExecutionTests() + { + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + _options = new SiteRuntimeOptions(); + _localDb = TestLocalDb.CreateTemp("alarm-cascade-test"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + } + + void IDisposable.Dispose() + { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. + Shutdown(); + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + /// A trivially-succeeding on-trigger script, so the alarm actually spawns an execution. + private Script OnTriggerScript() + { + var compiled = _compilationService.Compile("OnTrigger", "return null;"); + Assert.NotNull(compiled.CompiledScript); + return compiled.CompiledScript!; + } + + /// + /// Compiles a trigger expression outside the trust validator (mirrors + /// AlarmActorTests.CompileRawTriggerExpression). + /// + private static Script CompileTriggerExpression(string expression) + { + var opts = ScriptOptions.Default + .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly) + .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); + var script = CSharpScript.Create(expression, opts, typeof(TriggerExpressionGlobals)); + script.Compile(); + return script; + } + + /// + /// Builds an Alarm Actor as a TestActorRef so the spawn-time + /// ParentExecutionId can be read off the underlying actor (the spawned + /// child builds its ScriptRuntimeContext internally and exposes nothing). + /// + private TestActorRef BuildAlarm( + ResolvedAlarm config, out Akka.TestKit.TestProbe instanceProbe, + Script? triggerExpression = null) + { + var probe = CreateTestProbe(); + instanceProbe = probe; + return ActorOfAsTestActorRef( + Props.Create(() => new AlarmActor( + config.CanonicalName, "Pump1", probe.Ref, config, + OnTriggerScript(), _sharedLibrary, _options, + NullLogger.Instance, triggerExpression)), + "alarm-" + Guid.NewGuid().ToString("N")); + } + + private static ResolvedAlarm ValueMatchAlarm() => new() + { + CanonicalName = "HighTemp", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}", + PriorityLevel = 1 + }; + + // ------------------------------------------------------------------------- + // Hop 3 — Alarm Actor → AlarmExecutionActor + // ------------------------------------------------------------------------- + + [Fact] + public void ScriptWrittenValue_FiringAlarm_ParentsOnTriggerRunToTheWritingExecution() + { + var writerExecutionId = Guid.NewGuid(); + var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe); + + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow, + SourceExecutionId: writerExecutionId)); + + instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId); + } + + [Fact] + public void DclOriginatedValue_FiringAlarm_LeavesOnTriggerRunAsATreeRoot() + { + // External device data has no spawning execution: null is the CORRECT + // answer here, not a gap. The on-trigger run is a tree root. + var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe); + + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); + + // The alarm really did raise (and therefore really did spawn the + // on-trigger run) — so the null below is a recorded root, not a no-op. + instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Null(alarm.UnderlyingActor.LastOnTriggerParentExecutionId); + } + + [Fact] + public void HiLoAlarm_EnteringABand_ParentsOnTriggerRunToTheWritingExecution() + { + var writerExecutionId = Guid.NewGuid(); + var alarm = BuildAlarm( + new ResolvedAlarm + { + CanonicalName = "TempBand", + TriggerType = "HiLo", + TriggerConfiguration = "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":95}", + PriorityLevel = 1 + }, + out var instanceProbe); + + alarm.Tell(new AttributeValueChanged( + "Pump1", "Temp", "Temp", 90.0, "Good", DateTimeOffset.UtcNow, + SourceExecutionId: writerExecutionId)); + + instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId); + } + + [Fact] + public void ExpressionAlarm_CarriesTheWriterCapturedWithTheEvaluatedSnapshot() + { + // Expression triggers evaluate a whole snapshot OFF the dispatcher, so the + // firing change is no longer in scope when the boolean comes back. The + // writer is captured alongside the snapshot and echoed home on the result. + var writerExecutionId = Guid.NewGuid(); + var alarm = BuildAlarm( + new ResolvedAlarm + { + CanonicalName = "ExprAlarm", + TriggerType = "Expression", + TriggerConfiguration = "{\"expression\":\"true\"}", + PriorityLevel = 1 + }, + out _, + CompileTriggerExpression("true")); + + alarm.Tell(new AttributeValueChanged( + "Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow, + SourceExecutionId: writerExecutionId)); + + AwaitAssert( + () => Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId), + TimeSpan.FromSeconds(10)); + } + + // ------------------------------------------------------------------------- + // Hop 1 — ScriptRuntimeContext.SetAttribute stamps the running execution + // ------------------------------------------------------------------------- + + [Fact] + public async Task SetAttribute_StampsTheRunningExecutionIdOnTheWriteCommand() + { + var probe = CreateTestProbe(); + var executionId = Guid.NewGuid(); + var context = new ScriptRuntimeContext( + probe.Ref, + ActorRefs.Nobody, + _sharedLibrary, + currentCallDepth: 0, + maxCallDepth: 10, + askTimeout: TimeSpan.FromSeconds(5), + instanceName: "Pump1", + logger: NullLogger.Instance, + executionId: executionId); + + var write = context.SetAttribute("Status", "Critical"); + + var command = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(executionId, command.SourceExecutionId); + + probe.Reply(new SetStaticAttributeResponse( + command.CorrelationId, "Pump1", "Status", true, null, DateTimeOffset.UtcNow)); + await write; + } + + // ------------------------------------------------------------------------- + // Hop 2 — Instance Actor carries the writer onto the published change + // ------------------------------------------------------------------------- + + [Fact] + public void InstanceActor_StaticWrite_PublishesTheWritingExecutionOnTheChange() + { + var streamManager = new SiteStreamManager( + new SiteRuntimeOptions { StreamBufferSize = 100 }, + NullLogger.Instance); + streamManager.Initialize(Sys); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" } + ] + }; + + var instance = ActorOf(Props.Create(() => new InstanceActor( + "Pump1", + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedLibrary, + streamManager, + _options, + NullLogger.Instance))); + + var subscriber = CreateTestProbe(); + streamManager.Subscribe("Pump1", subscriber.Ref); + + var writerExecutionId = Guid.NewGuid(); + instance.Tell(new SetStaticAttributeCommand( + "corr-1", "Pump1", "Status", "Critical", DateTimeOffset.UtcNow, + SourceExecutionId: writerExecutionId)); + + var published = subscriber.FishForMessage( + m => m.AttributeName == "Status", TimeSpan.FromSeconds(10)); + Assert.Equal(writerExecutionId, published.SourceExecutionId); + } + + [Fact] + public void InstanceActor_WriteWithNoAuditedOrigin_PublishesNoExecutionId() + { + var streamManager = new SiteStreamManager( + new SiteRuntimeOptions { StreamBufferSize = 100 }, + NullLogger.Instance); + streamManager.Initialize(Sys); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump2", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" } + ] + }; + + var instance = ActorOf(Props.Create(() => new InstanceActor( + "Pump2", + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedLibrary, + streamManager, + _options, + NullLogger.Instance))); + + var subscriber = CreateTestProbe(); + streamManager.Subscribe("Pump2", subscriber.Ref); + + instance.Tell(new SetStaticAttributeCommand( + "corr-2", "Pump2", "Status", "Critical", DateTimeOffset.UtcNow)); + + var published = subscriber.FishForMessage( + m => m.AttributeName == "Status", TimeSpan.FromSeconds(10)); + Assert.Null(published.SourceExecutionId); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs index 28345670..84222a27 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs @@ -35,8 +35,10 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// /// 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. +/// script context, and the alarm run is itself a proper execution node whose +/// own ExecutionId cascades onward. Which firing execution the alarm run +/// is parented TO is covered separately by +/// Actors.AlarmCascadeParentExecutionTests. /// /// /// @@ -240,11 +242,11 @@ public class ParentExecutionTreeTests : TestKit 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 — + // runs in a ScriptRuntimeContext built by AlarmExecutionActor. The change + // below carries no SourceExecutionId (it stands in for DCL data), so the + // alarm run is a ROOT — 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( @@ -280,7 +282,7 @@ public class ParentExecutionTreeTests : TestKit 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 + // This alarm run is a root (no writer on the firing change), 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);