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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user