fix(audit): populate ParentExecutionId on alarm-triggered script runs

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.
This commit is contained in:
Joseph Doherty
2026-08-01 11:21:29 -04:00
parent 88638d774a
commit 8aa6bf2270
12 changed files with 562 additions and 58 deletions
@@ -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;
/// <summary>
/// Audit Log #23 (M5.4 T4 — <c>ParentExecutionId</c> 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.
///
/// <para>The cascade is three hops, each pinned here:</para>
/// <list type="number">
/// <item><description>
/// <c>ScriptRuntimeContext.SetAttribute</c> stamps the running script's
/// <c>ExecutionId</c> onto <see cref="SetStaticAttributeCommand.SourceExecutionId"/>.
/// </description></item>
/// <item><description>
/// The Instance Actor carries that id onto the published
/// <see cref="AttributeValueChanged.SourceExecutionId"/>.
/// </description></item>
/// <item><description>
/// The Alarm Actor hands it to the spawned <c>AlarmExecutionActor</c> as the
/// on-trigger run's <c>ParentExecutionId</c> — observable via
/// <c>AlarmActor.LastOnTriggerParentExecutionId</c>.
/// </description></item>
/// </list>
///
/// <para>
/// A value arriving from the Data Connection Layer (external device data) carries
/// no <c>SourceExecutionId</c>, so the alarm it fires is correctly parentless.
/// </para>
/// </summary>
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<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions();
_localDb = TestLocalDb.CreateTemp("alarm-cascade-test");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.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);
}
/// <summary>A trivially-succeeding on-trigger script, so the alarm actually spawns an execution.</summary>
private Script<object?> OnTriggerScript()
{
var compiled = _compilationService.Compile("OnTrigger", "return null;");
Assert.NotNull(compiled.CompiledScript);
return compiled.CompiledScript!;
}
/// <summary>
/// Compiles a trigger expression outside the trust validator (mirrors
/// <c>AlarmActorTests.CompileRawTriggerExpression</c>).
/// </summary>
private static Script<object?> 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<object?>(expression, opts, typeof(TriggerExpressionGlobals));
script.Compile();
return script;
}
/// <summary>
/// Builds an Alarm Actor as a <c>TestActorRef</c> so the spawn-time
/// <c>ParentExecutionId</c> can be read off the underlying actor (the spawned
/// child builds its <c>ScriptRuntimeContext</c> internally and exposes nothing).
/// </summary>
private TestActorRef<AlarmActor> BuildAlarm(
ResolvedAlarm config, out Akka.TestKit.TestProbe instanceProbe,
Script<object?>? triggerExpression = null)
{
var probe = CreateTestProbe();
instanceProbe = probe;
return ActorOfAsTestActorRef<AlarmActor>(
Props.Create(() => new AlarmActor(
config.CanonicalName, "Pump1", probe.Ref, config,
OnTriggerScript(), _sharedLibrary, _options,
NullLogger<AlarmActor>.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<AlarmStateChanged>(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<AlarmStateChanged>(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<AlarmStateChanged>(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<SetStaticAttributeCommand>(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<SiteStreamManager>.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<InstanceActor>.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<AttributeValueChanged>(
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<SiteStreamManager>.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<InstanceActor>.Instance)));
var subscriber = CreateTestProbe();
streamManager.Subscribe("Pump2", subscriber.Ref);
instance.Tell(new SetStaticAttributeCommand(
"corr-2", "Pump2", "Status", "Critical", DateTimeOffset.UtcNow));
var published = subscriber.FishForMessage<AttributeValueChanged>(
m => m.AttributeName == "Status", TimeSpan.FromSeconds(10));
Assert.Null(published.SourceExecutionId);
}
}
@@ -35,8 +35,10 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// </description></item>
/// <item><description>
/// The alarm on-trigger plumbing carries a <c>parentExecutionId</c> 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 <c>ExecutionId</c> cascades onward. Which firing execution the alarm run
/// is parented TO is covered separately by
/// <c>Actors.AlarmCascadeParentExecutionTests</c>.
/// </description></item>
/// </list>
/// </summary>
@@ -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<ScriptCallRequest>(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);