diff --git a/docs/plans/2026-05-16-expression-trigger.md b/docs/plans/2026-05-16-expression-trigger.md
new file mode 100644
index 0000000..90c8754
--- /dev/null
+++ b/docs/plans/2026-05-16-expression-trigger.md
@@ -0,0 +1,220 @@
+# Expression Trigger Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add an "Expression" trigger to template scripts and alarms — a read-only boolean C# expression evaluated on attribute updates that fires the script (edge) or activates the alarm (level).
+
+**Architecture:** A new restricted read-only globals type (`TriggerExpressionGlobals`) backed by an in-memory attribute snapshot; the expression is compiled once via the existing Roslyn pipeline and cached on `ScriptActor`/`AlarmActor`, which already receive every `AttributeValueChanged`. The CentralUI trigger editors gain an Expression panel. See the approved design: `docs/plans/2026-05-16-expression-trigger-design.md`.
+
+**Tech Stack:** C#/.NET, Akka.NET (site runtime actors), Roslyn C# scripting, Blazor Server (CentralUI), Docker cluster.
+
+**Verification note:** This repo has no CentralUI/Commons unit-test project; pure-logic correctness is verified by `dotnet build` + the editor round-trip, and runtime behavior by `bash docker/deploy.sh` + a browser/CLI walkthrough (the established pattern in this codebase). Steps below follow that.
+
+---
+
+### Task 1: Trigger model + codecs
+
+**Files:**
+- Modify: `src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs`
+- Modify: `src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs`
+- Modify: `src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs`
+
+**Step 1: Add the `Expression` alarm trigger type.**
+In `AlarmTriggerType.cs`, add `Expression` as the last enum member (append — do not reorder; the enum is persisted by value):
+```csharp
+public enum AlarmTriggerType
+{
+ ValueMatch,
+ RangeViolation,
+ RateOfChange,
+ HiLo,
+ Expression
+}
+```
+
+**Step 2: Extend `ScriptTriggerConfigCodec`.**
+- Add `Expression` to `ScriptTriggerKind` (before `Unknown`).
+- `ParseKind`: map `"expression"` → `ScriptTriggerKind.Expression`.
+- `KindToString`: `Expression` → `"Expression"`.
+- Add `string? Expression` to `ScriptTriggerModel`.
+- `Parse`: for `Expression`, read `model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;`
+- `Serialize`: for `Expression`, write `w.WriteString("expression", model.Expression ?? "");`
+
+**Step 3: Extend `AlarmTriggerConfigCodec`.**
+- Add `string? Expression` to `AlarmTriggerModel`.
+- `Parse`: `case AlarmTriggerType.Expression:` → `model.Expression = TryReadString(root, "expression");`
+- `Serialize`: `case AlarmTriggerType.Expression:` → `w.WriteString("expression", model.Expression ?? "");` (note: this codec always writes `attributeName` first — for Expression that key is unused; leave it written empty, harmless, or guard it. Prefer: skip the `attributeName` write when `type == Expression`.)
+
+**Step 4: Build.**
+Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj -nologo`
+Expected: `Build succeeded`.
+
+**Step 5: Commit.**
+```bash
+git add src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs
+git commit -m "feat(triggers): add Expression to the script & alarm trigger codecs"
+```
+
+---
+
+### Task 2: Runtime expression evaluation
+
+**Files:**
+- Create: `src/ScadaLink.SiteRuntime/Scripts/TriggerExpressionGlobals.cs`
+- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs`
+- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs`
+- Modify: `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs`
+
+**Step 1: Create `TriggerExpressionGlobals`.**
+A read-only globals type backed by a snapshot dictionary. Exposes only attribute reads — no `Instance`/`Scripts`/`ExternalSystem`/`Database`/`Notify`. Mirror the shape of `ScopeAccessors` but read straight from the dict (no actor Ask). Missing key → `null`.
+```csharp
+namespace ScadaLink.SiteRuntime.Scripts;
+
+///
+/// Read-only globals a trigger expression is compiled against. Exposes only
+/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask.
+///
+public sealed class TriggerExpressionGlobals
+{
+ private readonly IReadOnlyDictionary _snapshot;
+ public TriggerExpressionGlobals(IReadOnlyDictionary snapshot) => _snapshot = snapshot;
+
+ public ReadOnlyAttributes Attributes => new(_snapshot, "");
+ public ReadOnlyChildren Children => new(_snapshot);
+ public ReadOnlyComposition? Parent { get; init; } // set by caller for derived/composed scopes; null at root
+
+ public sealed class ReadOnlyAttributes
+ {
+ private readonly IReadOnlyDictionary _s;
+ private readonly string _prefix;
+ public ReadOnlyAttributes(IReadOnlyDictionary s, string prefix) { _s = s; _prefix = prefix; }
+ public object? this[string key] =>
+ _s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null;
+ }
+
+ public sealed class ReadOnlyComposition
+ {
+ private readonly IReadOnlyDictionary _s;
+ private readonly string _path;
+ public ReadOnlyComposition(IReadOnlyDictionary s, string path) { _s = s; _path = path; }
+ public ReadOnlyAttributes Attributes => new(_s, _path);
+ }
+
+ public sealed class ReadOnlyChildren
+ {
+ private readonly IReadOnlyDictionary _s;
+ public ReadOnlyChildren(IReadOnlyDictionary s) => _s = s;
+ public ReadOnlyComposition this[string compositionName] => new(_s, compositionName);
+ }
+}
+```
+Note: confirm against `ScopeAccessors.cs` whether canonical attribute keys are dotted (`TempSensor.Reading`) — they are; the prefix logic matches `AttributeAccessor.Resolve`.
+
+**Step 2: Add expression compilation to `ScriptCompilationService`.**
+Add a method that compiles a bare C# boolean expression against `TriggerExpressionGlobals`, reusing the existing `ScriptOptions` (references/imports) and the forbidden-API trust check. Return `Script