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
@@ -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.
/// </summary>
/// <param name="CorrelationId">Per-operation correlation id.</param>
/// <param name="InstanceUniqueName">Unique name of the target instance.</param>
/// <param name="AttributeName">Canonical name of the attribute to write.</param>
/// <param name="Value">Canonical string form of the new value.</param>
/// <param name="Timestamp">UTC timestamp of the command.</param>
/// <param name="SourceExecutionId">
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
/// execution issuing this write — a site script run
/// (<c>ScriptRuntimeContext.SetAttribute</c>) or the inbound API request behind a
/// <c>Route.To(...).SetAttributes(...)</c>. Additive and nullable; <c>null</c>
/// when the write has no audited originating execution (central Test Run,
/// deployment tooling, tests). The Instance Actor stamps it onto the resulting
/// <c>AttributeValueChanged</c> so an alarm the write trips can record it as the
/// on-trigger script run's <c>ParentExecutionId</c>.
/// </param>
public record SetStaticAttributeCommand(
string CorrelationId,
string InstanceUniqueName,
string AttributeName,
string Value,
DateTimeOffset Timestamp);
DateTimeOffset Timestamp,
Guid? SourceExecutionId = null);
/// <summary>
/// Response confirming that a static attribute was set.
@@ -1,9 +1,43 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
/// <summary>
/// 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.
/// </summary>
/// <param name="InstanceUniqueName">Unique name of the instance the attribute belongs to.</param>
/// <param name="AttributePath">Path-qualified canonical name of the attribute.</param>
/// <param name="AttributeName">Canonical name of the attribute.</param>
/// <param name="Value">The new value.</param>
/// <param name="Quality">Quality of the new value (<c>Good</c>/<c>Bad</c>/<c>Uncertain</c>).</param>
/// <param name="Timestamp">UTC timestamp of the change.</param>
/// <param name="SourceExecutionId">
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
/// script execution / inbound API request whose write produced this change, or
/// <c>null</c> 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).
///
/// <para>
/// The Alarm Actor records this id as the <c>ParentExecutionId</c> 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.
/// </para>
///
/// <para>
/// Deliberately site-local: the field is NOT projected onto the gRPC
/// <c>DebugAttributeValueDto</c> / <c>SiteStreamEvent</c> 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.
/// </para>
/// </param>
public record AttributeValueChanged(
string InstanceUniqueName,
string AttributePath,
string AttributeName,
object? Value,
string Quality,
DateTimeOffset Timestamp) : ISiteStreamEvent;
DateTimeOffset Timestamp,
Guid? SourceExecutionId = null) : ISiteStreamEvent;