# 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)
