Files
scadalink-design/docs/plans/2026-05-16-expression-trigger-design.md
Joseph Doherty c94d3b7570 docs(plans): design for expression-based script & alarm triggers
Captures the brainstormed design for a new Expression trigger: a read-only
boolean C# expression evaluated on attribute updates, edge-triggered for
scripts and level-based for alarms, compiled against a restricted read-only
globals type.
2026-05-16 05:21:57 -04:00

115 lines
5.9 KiB
Markdown

# Expression Trigger for Template Scripts and Alarms — Design
**Date:** 2026-05-16
**Status:** Approved (brainstorming) — implementation plan to follow
## Context
Template scripts and template alarms can only be triggered by single-attribute
conditions. Scripts support `Interval`, `ValueChange`, `Conditional`
(`{attributeName, operator, threshold}` — one attribute, numeric compare),
and `Call`. Alarms support `ValueMatch`, `RangeViolation`, `RateOfChange`, and
`HiLo` — all single-attribute. There is no way to trigger on a relationship
between *multiple* attributes (e.g. "speed is high *and* mode is Run").
This design adds an **Expression trigger**: a user-supplied read-only boolean
C# expression, evaluated whenever an instance attribute updates, that fires the
script / activates the alarm when it returns true. It generalizes the existing
single-attribute `Conditional` trigger.
### Decisions taken during brainstorming
- The trigger is a **read-only boolean expression** — no `External`/`Database`/
`Notify`/`CallScript` side effects. It must be cheap and safe to run on every
attribute update.
- **Scripts fire edge-triggered** — once per `false→true` transition.
- **Alarms are level-based** — active while the expression is true, clear when
false (consistent with all existing alarm trigger types).
- **Evaluation approach B** — compile against a *restricted read-only globals
type*, so read-only is enforced, not merely conventional. Reuses the existing
Roslyn compilation pipeline.
## Design
### 1. Trigger model & storage
- **Scripts:** `TemplateScript.TriggerType` (`string?`) gains the value
`"Expression"`. `TriggerConfiguration` JSON is `{ "expression": "<C#>" }`.
- **Alarms:** `AlarmTriggerType` enum gains a member `Expression`.
`TriggerConfiguration` JSON is the same `{ "expression": "<C#>" }`.
- The expression is a bare C# boolean expression (no `return` keyword — Roslyn
scripting returns the trailing expression's value), e.g.
`Attributes["Speed"] > 1000 && (string)Attributes["Mode"] == "Run"`.
- Entity types unchanged: both `TriggerConfiguration` fields stay `string?`.
Adding the `AlarmTriggerType` member touches three switch sites:
`AlarmActor.ParseEvalConfig`, `AlarmActor.HandleAttributeValueChanged`,
`AlarmTriggerConfigCodec`.
### 2. Runtime evaluation
- **`TriggerExpressionGlobals`** (new, `ScadaLink.SiteRuntime`) — a read-only
globals type exposing only `Attributes["X"]`, `Children["C"].Attributes["X"]`,
and `Parent.Attributes["X"]`, backed by an in-memory snapshot dictionary. No
side-effecting APIs. A missing attribute reads as `null` (never throws).
- The expression is compiled once via the existing Roslyn pipeline (same
forbidden-API trust checks) against `TriggerExpressionGlobals`; the compiled
delegate is cached on the actor.
- **Attribute snapshot:** `ScriptActor` and `AlarmActor` already receive every
`AttributeValueChanged`. Each keeps a local `Dictionary<string,object?>`
snapshot — seeded from the instance's initial attribute set at startup, then
updated on each change. The expression evaluates against the snapshot — no
`Ask` back to the `InstanceActor`; cheap and re-entrancy-free.
- **On each `AttributeValueChanged`:** update snapshot → run cached expression
`bool`.
- **Script (edge):** track the previous result; on `false→true`, run the
script (spawn `ScriptExecutionActor`, as the other triggers do).
- **Alarm (level):** the `bool` feeds the existing binary Normal↔Active state
machine — raise on `→Active`, clear on `→Normal`.
- Cost per attribute update: one cached-delegate call + one bool compare.
### 3. Editors & analysis
- **`ScriptTriggerEditor`:** add `Expression` to `ScriptTriggerKind` and
`ScriptTriggerConfigCodec` (round-trips `{ expression }`).
- **`AlarmTriggerEditor`:** add an `Expression` case to its trigger `@switch`.
- Both render the same **expression panel**: a compact `MonacoEditor`
(~120 px) with C# syntax, `Attributes["..."]` completion driven by the
template's attribute metadata (self / children / parent), and live compile
diagnostics. A one-line hint summarizes what fires.
- **Analysis:** reuse the existing `Template` analysis kind — completion and
diagnostics work with no new analyzer code. Editor completion is slightly
permissive (also shows `Instance`/`External`), but the runtime's restricted
`TriggerExpressionGlobals` is what enforces read-only. A dedicated strict
analysis kind is a possible later refinement, out of scope here.
### 4. Error handling & validation
- **Pre-deployment:** extend `ValidationService` to compile-check expression
triggers (against `TriggerExpressionGlobals`); compile errors block
deployment and surface like other validation errors. Unknown
`Attributes["..."]` keys are flagged as the existing trigger-reference
validation does.
- **Runtime — expression throws:** caught; treated as `false` for that update;
a script-error event is written to the site event log. The actor never
crashes.
- **Non-bool result:** treated as `false` and logged.
- **Missing attribute:** reads as `null` (handled in `TriggerExpressionGlobals`).
- **Blank expression:** the trigger is inert; validation emits a warning.
### 5. Testing & verification
- **Unit:** codec round-trip for script and alarm `{ expression }`; expression
compile (valid + invalid).
- **Runtime:** deploy an instance with an expression-triggered script and
alarm; drive attribute updates (bound Test Run / CLI); confirm the script
fires only on `false→true` and the alarm raises/clears with the expression.
- **UI:** the expression panel in both editors; save → reopen round-trip.
## Implementation tasks
- #25 — Implement expression trigger model + codecs
- #26 — Implement runtime expression evaluation (blocked by #25)
- #27 — Add expression panel to the trigger editors (blocked by #25)
- #28 — Validate expression triggers pre-deployment (blocked by #25, #26)