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

5.9 KiB

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)