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
@@ -102,6 +102,18 @@ public class AlarmActor : ReceiveActor
private bool _evalInFlight;
private bool _evalPending;
/// <summary>
/// Audit Log #23 (ParentExecutionId tag-cascade): the
/// <see cref="AttributeValueChanged.SourceExecutionId"/> of the most recent
/// change folded into <see cref="_attributeSnapshot"/>. 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
/// <see cref="StartExpressionEvaluation"/> and echoed home on
/// <see cref="ExpressionEvalResult"/>. Touched only on the actor thread.
/// </summary>
private Guid? _latestSourceExecutionId;
/// <summary>
/// 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
/// </summary>
internal IReadOnlyDictionary<string, object?>? SeedAttributesReference { get; }
/// <summary>
/// Audit Log #23 (ParentExecutionId tag-cascade): the
/// <c>parentExecutionId</c> handed to the most recently spawned
/// <see cref="AlarmExecutionActor"/> — i.e. the execution whose attribute
/// write fired this alarm, or <c>null</c> when the firing change came from
/// the Data Connection Layer (external data has no spawning execution).
/// The spawned actor builds its own <see cref="ScriptRuntimeContext"/>
/// internally, so this is exposed for regression coverage of the cascade
/// contract (mirrors <see cref="SeedAttributesReference"/>).
/// </summary>
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 <see cref="HandleExpressionEvalResult"/>). Edge state
/// (<see cref="_currentState"/>) is touched only on the actor thread.
/// </summary>
private void ApplyTriggeredState(bool isTriggered)
/// <param name="isTriggered">The evaluated truth value of the alarm condition.</param>
/// <param name="sourceExecutionId">
/// (ParentExecutionId tag-cascade): the execution that
/// wrote the value which produced this evaluation, recorded as the
/// on-trigger script run's <c>ParentExecutionId</c>. Null when the value
/// came from the DCL (external data has no spawning execution).
/// </param>
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.
/// </summary>
private void HandleHiLoTransition(AlarmLevel newLevel)
/// <param name="newLevel">The newly evaluated HiLo band.</param>
/// <param name="sourceExecutionId">
/// (ParentExecutionId tag-cascade): the execution that
/// wrote the value driving this transition; recorded as the on-trigger
/// script run's <c>ParentExecutionId</c>. Null for DCL-originated values.
/// </param>
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<string, object?>(_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));
}
/// <summary>
@@ -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));
}
/// <summary>
@@ -667,13 +713,14 @@ public class AlarmActor : ReceiveActor
/// <param name="priority">The firing alarm priority.</param>
/// <param name="message">The firing alarm message.</param>
/// <param name="parentExecutionId">
/// The execution id of
/// the context that fired this alarm, recorded as the on-trigger script run's
/// <c>ParentExecutionId</c> 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 <c>null</c> (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 <c>ExecutionId</c>
/// of the execution whose attribute write fired this alarm — a site script
/// run (<c>Instance.SetAttribute</c>) or the inbound API request behind a
/// <c>Route.To(...).SetAttributes(...)</c> — recorded as the on-trigger
/// script run's <c>ParentExecutionId</c> so the alarm-triggered run chains
/// under its firing execution in the audit tree. <c>null</c> 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.
/// </param>
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
/// <summary>
/// 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 <c>SourceExecutionId</c> 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.
/// </summary>
private sealed record ExpressionEvalResult(bool Result);
private sealed record ExpressionEvalResult(bool Result, Guid? SourceExecutionId = null);
/// <summary>
/// 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 <c>SourceExecutionId</c> rides along so the synthesized
/// false result keeps the same attribution the successful path would have had.
/// </summary>
internal sealed record ExpressionEvalFailed(Exception Cause);
internal sealed record ExpressionEvalFailed(Exception Cause, Guid? SourceExecutionId = null);
}
internal enum RateOfChangeDirection { Either, Rising, Falling }