diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 2e14b96..0bfe61c 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -5,6 +5,7 @@
+
@@ -30,6 +31,7 @@
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs
new file mode 100644
index 0000000..6d466b9
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs
@@ -0,0 +1,84 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// Persistent per-alarm state tracked by the Part 9 state machine. Every field
+/// carried here either participates in the state machine or contributes to the
+/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11).
+///
+///
+///
+/// is re-derived from the predicate at startup per Phase 7
+/// decision #14 — the engine runs every alarm's predicate against current tag
+/// values at Load, overriding whatever Active state is in the store.
+/// Every other state field persists verbatim across server restarts so
+/// operators don't re-ack active alarms after an outage + shelved alarms stay
+/// shelved + audit history survives.
+///
+///
+/// is append-only; comments + ack/confirm user identities
+/// are the audit surface regulators consume. The engine never rewrites past
+/// entries.
+///
+///
+public sealed record AlarmConditionState(
+ string AlarmId,
+ AlarmEnabledState Enabled,
+ AlarmActiveState Active,
+ AlarmAckedState Acked,
+ AlarmConfirmedState Confirmed,
+ ShelvingState Shelving,
+ DateTime LastTransitionUtc,
+ DateTime? LastActiveUtc,
+ DateTime? LastClearedUtc,
+ DateTime? LastAckUtc,
+ string? LastAckUser,
+ string? LastAckComment,
+ DateTime? LastConfirmUtc,
+ string? LastConfirmUser,
+ string? LastConfirmComment,
+ IReadOnlyList Comments)
+{
+ /// Initial-load state for a newly registered alarm — everything in the "no-event" position.
+ public static AlarmConditionState Fresh(string alarmId, DateTime nowUtc) => new(
+ AlarmId: alarmId,
+ Enabled: AlarmEnabledState.Enabled,
+ Active: AlarmActiveState.Inactive,
+ Acked: AlarmAckedState.Acknowledged,
+ Confirmed: AlarmConfirmedState.Confirmed,
+ Shelving: ShelvingState.Unshelved,
+ LastTransitionUtc: nowUtc,
+ LastActiveUtc: null,
+ LastClearedUtc: null,
+ LastAckUtc: null,
+ LastAckUser: null,
+ LastAckComment: null,
+ LastConfirmUtc: null,
+ LastConfirmUser: null,
+ LastConfirmComment: null,
+ Comments: []);
+}
+
+///
+/// Shelving state — kind plus, for , the UTC
+/// timestamp at which the shelving auto-expires. The engine polls the timer on its
+/// evaluation cadence; callers should not rely on millisecond-precision expiry.
+///
+public sealed record ShelvingState(ShelvingKind Kind, DateTime? UnshelveAtUtc)
+{
+ public static readonly ShelvingState Unshelved = new(ShelvingKind.Unshelved, null);
+}
+
+///
+/// A single append-only audit record — acknowledgement / confirmation / explicit
+/// comment / shelving action. Every entry carries a monotonic UTC timestamp plus the
+/// user identity Phase 6.2 authenticated.
+///
+/// When the action happened.
+/// OS / LDAP identity of the actor. For engine-internal events (shelving expiry, startup recovery) this is "system".
+/// Human-readable classification — "Acknowledge", "Confirm", "ShelveOneShot", "ShelveTimed", "Unshelve", "AddComment", "Enable", "Disable", "AutoUnshelve".
+/// Operator-supplied comment or engine-generated message.
+public sealed record AlarmComment(
+ DateTime TimestampUtc,
+ string User,
+ string Kind,
+ string Text);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs
new file mode 100644
index 0000000..87b26d8
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs
@@ -0,0 +1,55 @@
+using Serilog;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// subclass for alarm predicate evaluation. Reads from
+/// the engine's shared tag cache (driver + virtual tags), writes are rejected —
+/// predicates must be side-effect free so their output doesn't depend on evaluation
+/// order or drive cascade behavior.
+///
+///
+/// Per Phase 7 plan Shape A decision, alarm scripts are one-script-per-alarm
+/// returning bool. They read any tag they want but should not write
+/// anything (the owning alarm's state is tracked by the engine, not the script).
+///
+public sealed class AlarmPredicateContext : ScriptContext
+{
+ private readonly IReadOnlyDictionary _readCache;
+ private readonly Func _clock;
+
+ public AlarmPredicateContext(
+ IReadOnlyDictionary readCache,
+ ILogger logger,
+ Func? clock = null)
+ {
+ _readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
+ Logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _clock = clock ?? (() => DateTime.UtcNow);
+ }
+
+ public override DataValueSnapshot GetTag(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return new DataValueSnapshot(null, 0x80340000u, null, _clock());
+ return _readCache.TryGetValue(path, out var v)
+ ? v
+ : new DataValueSnapshot(null, 0x80340000u, null, _clock());
+ }
+
+ public override void SetVirtualTag(string path, object? value)
+ {
+ // Predicates must be pure — writing from an alarm script couples alarm state to
+ // virtual-tag state in a way that's near-impossible to reason about. Rejected
+ // at runtime with a clear message; operators see it in the scripts-*.log.
+ throw new InvalidOperationException(
+ "Alarm predicate scripts cannot write to virtual tags. Move the write logic " +
+ "into a virtual tag whose value the alarm predicate then reads.");
+ }
+
+ public override DateTime Now => _clock();
+
+ public override ILogger Logger { get; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs
new file mode 100644
index 0000000..fa545e4
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs
@@ -0,0 +1,40 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// The concrete OPC UA Part 9 alarm subtype a scripted alarm materializes as. The
+/// engine's internal state machine is identical regardless of kind — the
+/// AlarmKind only affects how the alarm node appears to OPC UA clients
+/// (which ObjectType it maps to) and what diagnostic fields are populated.
+///
+public enum AlarmKind
+{
+ /// Base AlarmConditionType — no numeric or discrete interpretation.
+ AlarmCondition,
+ /// LimitAlarmType — the condition reflects a numeric setpoint / threshold breach.
+ LimitAlarm,
+ /// DiscreteAlarmType — the condition reflects a specific discrete value match.
+ DiscreteAlarm,
+ /// OffNormalAlarmType — the condition reflects deviation from a configured "normal" state.
+ OffNormalAlarm,
+}
+
+/// OPC UA Part 9 EnabledState — operator-controlled alarm enable/disable.
+public enum AlarmEnabledState { Enabled, Disabled }
+
+/// OPC UA Part 9 ActiveState — reflects the current predicate truth.
+public enum AlarmActiveState { Inactive, Active }
+
+/// OPC UA Part 9 AckedState — operator has acknowledged the active transition.
+public enum AlarmAckedState { Unacknowledged, Acknowledged }
+
+/// OPC UA Part 9 ConfirmedState — operator has confirmed the clear transition.
+public enum AlarmConfirmedState { Unconfirmed, Confirmed }
+
+///
+/// OPC UA Part 9 shelving mode.
+/// suppresses the next active transition; once cleared
+/// the shelving expires and the alarm returns to normal behavior.
+/// suppresses until a configured expiry timestamp passes.
+/// is the default state — no suppression.
+///
+public enum ShelvingKind { Unshelved, OneShot, Timed }
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs
new file mode 100644
index 0000000..bba047c
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs
@@ -0,0 +1,47 @@
+using System.Collections.Concurrent;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// Persistence for across server restarts. Phase 7
+/// plan decision #14: operator-supplied state (EnabledState / AckedState /
+/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is
+/// recomputed from the live predicate on startup so operators never re-ack.
+///
+///
+/// Stream E wires this to a SQL-backed store against the ScriptedAlarmState
+/// table with audit logging through IAuditLogger.
+/// Tests + local dev use .
+///
+public interface IAlarmStateStore
+{
+ Task LoadAsync(string alarmId, CancellationToken ct);
+ Task> LoadAllAsync(CancellationToken ct);
+ Task SaveAsync(AlarmConditionState state, CancellationToken ct);
+ Task RemoveAsync(string alarmId, CancellationToken ct);
+}
+
+/// In-memory default — used by tests + by dev deployments without a SQL backend.
+public sealed class InMemoryAlarmStateStore : IAlarmStateStore
+{
+ private readonly ConcurrentDictionary _map
+ = new(StringComparer.Ordinal);
+
+ public Task LoadAsync(string alarmId, CancellationToken ct)
+ => Task.FromResult(_map.TryGetValue(alarmId, out var v) ? v : null);
+
+ public Task> LoadAllAsync(CancellationToken ct)
+ => Task.FromResult>(_map.Values.ToArray());
+
+ public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
+ {
+ _map[state.AlarmId] = state;
+ return Task.CompletedTask;
+ }
+
+ public Task RemoveAsync(string alarmId, CancellationToken ct)
+ {
+ _map.TryRemove(alarmId, out _);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs
new file mode 100644
index 0000000..fff97a7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs
@@ -0,0 +1,64 @@
+using System.Text.RegularExpressions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution
+/// templates. The engine resolves {TagPath} tokens at event emission time
+/// against current tag values; unresolvable tokens become {?} so the event
+/// still fires but the operator sees where the reference broke.
+///
+///
+///
+/// Token syntax: {path/with/slashes}. Brace-stripped the contents must
+/// match a path the caller's resolver function can look up. No escaping
+/// currently — if you need literal braces in the message, reach for a feature
+/// request.
+///
+///
+/// Pure function. Same inputs always produce the same string. Tests verify the
+/// edge cases (no tokens / one token / many / nested / unresolvable / bad
+/// quality / null value).
+///
+///
+public static class MessageTemplate
+{
+ private static readonly Regex TokenRegex = new(@"\{([^{}]+)\}",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// Resolve every {path} token in using
+ /// . Tokens whose returned
+ /// has a non-Good or a null
+ /// resolve to {?}.
+ ///
+ public static string Resolve(string template, Func resolveTag)
+ {
+ if (string.IsNullOrEmpty(template)) return template ?? string.Empty;
+ if (resolveTag is null) throw new ArgumentNullException(nameof(resolveTag));
+
+ return TokenRegex.Replace(template, match =>
+ {
+ var path = match.Groups[1].Value.Trim();
+ if (path.Length == 0) return "{?}";
+ var snap = resolveTag(path);
+ if (snap is null) return "{?}";
+ if (snap.StatusCode != 0u) return "{?}";
+ return snap.Value?.ToString() ?? "{?}";
+ });
+ }
+
+ /// Enumerate the token paths the template references. Used at publish time to validate references exist.
+ public static IReadOnlyList ExtractTokenPaths(string? template)
+ {
+ if (string.IsNullOrEmpty(template)) return Array.Empty();
+ var tokens = new List();
+ foreach (Match m in TokenRegex.Matches(template))
+ {
+ var path = m.Groups[1].Value.Trim();
+ if (path.Length > 0) tokens.Add(path);
+ }
+ return tokens;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs
new file mode 100644
index 0000000..b27abcb
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs
@@ -0,0 +1,294 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the
+/// current + the event; output = the new state +
+/// optional emission hint. The engine calls these; persistence happens around them.
+///
+///
+///
+/// No instance state, no I/O, no mutation of the input record. Every transition
+/// returns a fresh record. Makes the state machine trivially unit-testable —
+/// tests assert on (input, event) -> (output) without standing anything else up.
+///
+///
+/// Two invariants the machine enforces:
+/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState
+/// — all predicate evaluations while disabled produce a no-op result and a
+/// diagnostic log line. Re-enable restores normal flow with ActiveState
+/// re-derived from the next predicate evaluation.
+/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to
+/// subscribers, but the state record still advances so that when shelving
+/// expires the ActiveState reflects current reality. OneShot expires on the
+/// next clear; Timed expires at .
+///
+///
+public static class Part9StateMachine
+{
+ ///
+ /// Apply a predicate re-evaluation result. Handles activation, clearing,
+ /// branch-stack increment when a new active arrives while prior active is
+ /// still un-acked, and shelving suppression.
+ ///
+ public static TransitionResult ApplyPredicate(
+ AlarmConditionState current,
+ bool predicateTrue,
+ DateTime nowUtc)
+ {
+ if (current.Enabled == AlarmEnabledState.Disabled)
+ return TransitionResult.NoOp(current, "disabled — predicate result ignored");
+
+ // Expire timed shelving if the configured clock has passed.
+ var shelving = MaybeExpireShelving(current.Shelving, nowUtc);
+ var stateWithShelving = current with { Shelving = shelving };
+
+ // Shelved alarms still update state but skip event emission.
+ var shelved = shelving.Kind != ShelvingKind.Unshelved;
+
+ if (predicateTrue && current.Active == AlarmActiveState.Inactive)
+ {
+ // Inactive -> Active transition.
+ // OneShotShelving is consumed on the NEXT clear, not activation — so we
+ // still suppress this transition's emission.
+ var next = stateWithShelving with
+ {
+ Active = AlarmActiveState.Active,
+ Acked = AlarmAckedState.Unacknowledged,
+ Confirmed = AlarmConfirmedState.Unconfirmed,
+ LastActiveUtc = nowUtc,
+ LastTransitionUtc = nowUtc,
+ };
+ return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated);
+ }
+
+ if (!predicateTrue && current.Active == AlarmActiveState.Active)
+ {
+ // Active -> Inactive transition.
+ var next = stateWithShelving with
+ {
+ Active = AlarmActiveState.Inactive,
+ LastClearedUtc = nowUtc,
+ LastTransitionUtc = nowUtc,
+ // OneShotShelving expires on clear — resetting here so the next
+ // activation fires normally.
+ Shelving = shelving.Kind == ShelvingKind.OneShot
+ ? ShelvingState.Unshelved
+ : shelving,
+ };
+ return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared);
+ }
+
+ // Predicate matches current Active — no state change beyond possible shelving
+ // expiry.
+ return new TransitionResult(stateWithShelving, EmissionKind.None);
+ }
+
+ /// Operator acknowledges the currently-active transition.
+ public static TransitionResult ApplyAcknowledge(
+ AlarmConditionState current,
+ string user,
+ string? comment,
+ DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user))
+ throw new ArgumentException("User identity required for audit.", nameof(user));
+
+ if (current.Acked == AlarmAckedState.Acknowledged)
+ return TransitionResult.NoOp(current, "already acknowledged");
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
+ var next = current with
+ {
+ Acked = AlarmAckedState.Acknowledged,
+ LastAckUtc = nowUtc,
+ LastAckUser = user,
+ LastAckComment = comment,
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Acknowledged);
+ }
+
+ /// Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.
+ public static TransitionResult ApplyConfirm(
+ AlarmConditionState current,
+ string user,
+ string? comment,
+ DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user))
+ throw new ArgumentException("User identity required for audit.", nameof(user));
+
+ if (current.Confirmed == AlarmConfirmedState.Confirmed)
+ return TransitionResult.NoOp(current, "already confirmed");
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment);
+ var next = current with
+ {
+ Confirmed = AlarmConfirmedState.Confirmed,
+ LastConfirmUtc = nowUtc,
+ LastConfirmUser = user,
+ LastConfirmComment = comment,
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Confirmed);
+ }
+
+ public static TransitionResult ApplyOneShotShelve(
+ AlarmConditionState current, string user, DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
+ if (current.Shelving.Kind == ShelvingKind.OneShot)
+ return TransitionResult.NoOp(current, "already one-shot shelved");
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null);
+ var next = current with
+ {
+ Shelving = new ShelvingState(ShelvingKind.OneShot, null),
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Shelved);
+ }
+
+ public static TransitionResult ApplyTimedShelve(
+ AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
+ if (unshelveAtUtc <= nowUtc)
+ throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future.");
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed",
+ $"UnshelveAtUtc={unshelveAtUtc:O}");
+ var next = current with
+ {
+ Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc),
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Shelved);
+ }
+
+ public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
+ if (current.Shelving.Kind == ShelvingKind.Unshelved)
+ return TransitionResult.NoOp(current, "not shelved");
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null);
+ var next = current with
+ {
+ Shelving = ShelvingState.Unshelved,
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Unshelved);
+ }
+
+ public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
+ if (current.Enabled == AlarmEnabledState.Enabled)
+ return TransitionResult.NoOp(current, "already enabled");
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null);
+ var next = current with
+ {
+ Enabled = AlarmEnabledState.Enabled,
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Enabled);
+ }
+
+ public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
+ if (current.Enabled == AlarmEnabledState.Disabled)
+ return TransitionResult.NoOp(current, "already disabled");
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null);
+ var next = current with
+ {
+ Enabled = AlarmEnabledState.Disabled,
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Disabled);
+ }
+
+ public static TransitionResult ApplyAddComment(
+ AlarmConditionState current, string user, string text, DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
+ if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text));
+
+ var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text);
+ var next = current with { Comments = audit };
+ return new TransitionResult(next, EmissionKind.CommentAdded);
+ }
+
+ ///
+ /// Re-evaluate whether a currently timed-shelved alarm has expired. Returns
+ /// the (possibly unshelved) state + emission hint so the engine knows to
+ /// publish an Unshelved event at the right moment.
+ ///
+ public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
+ {
+ if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
+ if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t)
+ {
+ var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve",
+ $"Timed shelving expired at {nowUtc:O}");
+ var next = current with
+ {
+ Shelving = ShelvingState.Unshelved,
+ LastTransitionUtc = nowUtc,
+ Comments = audit,
+ };
+ return new TransitionResult(next, EmissionKind.Unshelved);
+ }
+ return TransitionResult.None(current);
+ }
+
+ private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc)
+ {
+ if (s.Kind != ShelvingKind.Timed) return s;
+ return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s;
+ }
+
+ private static IReadOnlyList AppendComment(
+ IReadOnlyList existing, DateTime ts, string user, string kind, string? text)
+ {
+ var list = new List(existing.Count + 1);
+ list.AddRange(existing);
+ list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
+ return list;
+ }
+}
+
+/// Result of a state-machine operation — new state + what to emit (if anything).
+public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission)
+{
+ public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
+ public static TransitionResult NoOp(AlarmConditionState state, string reason) => new(state, EmissionKind.None);
+}
+
+/// What kind of event, if any, the engine should emit after a transition.
+public enum EmissionKind
+{
+ /// State did not change meaningfully — no event to emit.
+ None,
+ /// Predicate transitioned to true while shelving was suppressing events.
+ Suppressed,
+ Activated,
+ Cleared,
+ Acknowledged,
+ Confirmed,
+ Shelved,
+ Unshelved,
+ Enabled,
+ Disabled,
+ CommentAdded,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs
new file mode 100644
index 0000000..05f10e7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs
@@ -0,0 +1,50 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// Operator-authored scripted-alarm configuration. Phase 7 Stream E (config DB schema)
+/// materializes these from the ScriptedAlarm + Script tables on publish.
+///
+///
+/// Stable identity for the alarm — used as the OPC UA ConditionId + the key in the
+/// state store. Should be globally unique within the cluster; convention is
+/// {EquipmentPath}::{AlarmName}.
+///
+///
+/// UNS path of the Equipment node the alarm hangs under. Alarm browse lives here;
+/// ACL binding inherits this equipment's scope per Phase 6.2.
+///
+/// Human-readable alarm name — used in the browse tree + Admin UI.
+/// Concrete OPC UA Part 9 subtype the alarm materializes as.
+/// Static severity per Phase 7 plan decision #13; not currently computed by the predicate.
+///
+/// Message text with {TagPath} tokens resolved at event-emission time per
+/// Phase 7 plan decision #13. Unresolvable tokens emit {?} + a structured
+/// error so operators can spot stale references.
+///
+///
+/// Roslyn C# script returning bool. true = alarm condition currently holds (active);
+/// false = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6.
+///
+///
+/// When true, every transition emission of this alarm flows to the Historian alarm
+/// sink (Stream D). Defaults to true — plant alarm history is usually the
+/// operator's primary diagnostic. Galaxy-native alarms default false since Galaxy
+/// historises them directly.
+///
+///
+/// Part 9 retain flag — when true, the condition node remains visible after the
+/// predicate clears as long as it has un-acknowledged or un-confirmed transitions.
+/// Default true.
+///
+public sealed record ScriptedAlarmDefinition(
+ string AlarmId,
+ string EquipmentPath,
+ string AlarmName,
+ AlarmKind Kind,
+ AlarmSeverity Severity,
+ string MessageTemplate,
+ string PredicateScriptSource,
+ bool HistorizeToAveva = true,
+ bool Retain = true);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
new file mode 100644
index 0000000..13e0d06
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
@@ -0,0 +1,429 @@
+using System.Collections.Concurrent;
+using Serilog;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// Phase 7 scripted-alarm orchestrator. Compiles every configured alarm's predicate
+/// against the Stream A sandbox, subscribes to the referenced upstream tags,
+/// re-evaluates the predicate on every input change + on a shelving-check timer,
+/// applies the resulting transition through ,
+/// persists state via , and emits the resulting events
+/// through (which wires into the existing
+/// IAlarmSource fan-out).
+///
+///
+///
+/// Scripted alarms are leaves in the evaluation DAG — no alarm's state drives
+/// another alarm's predicate. The engine maintains only an inverse index from
+/// upstream tag path → alarms referencing it; no topological sort needed
+/// (unlike the virtual-tag engine).
+///
+///
+/// Evaluation errors (script throws, timeout, coercion fail) surface as
+/// structured errors in the dedicated scripts-*.log sink plus a WARN companion
+/// in the main log. The alarm's ActiveState stays at its prior value — the
+/// engine does NOT invent a clear transition just because the predicate broke.
+/// Operators investigating a broken predicate shouldn't see a phantom
+/// clear-event preceding the failure.
+///
+///
+public sealed class ScriptedAlarmEngine : IDisposable
+{
+ private readonly ITagUpstreamSource _upstream;
+ private readonly IAlarmStateStore _store;
+ private readonly ScriptLoggerFactory _loggerFactory;
+ private readonly ILogger _engineLogger;
+ private readonly Func _clock;
+ private readonly TimeSpan _scriptTimeout;
+
+ private readonly Dictionary _alarms = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _valueCache
+ = new(StringComparer.Ordinal);
+ private readonly Dictionary> _alarmsReferencing
+ = new(StringComparer.Ordinal); // tag path -> alarm ids
+
+ private readonly List _upstreamSubscriptions = [];
+ private readonly SemaphoreSlim _evalGate = new(1, 1);
+ private Timer? _shelvingTimer;
+ private bool _loaded;
+ private bool _disposed;
+
+ public ScriptedAlarmEngine(
+ ITagUpstreamSource upstream,
+ IAlarmStateStore store,
+ ScriptLoggerFactory loggerFactory,
+ ILogger engineLogger,
+ Func? clock = null,
+ TimeSpan? scriptTimeout = null)
+ {
+ _upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
+ _store = store ?? throw new ArgumentNullException(nameof(store));
+ _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
+ _engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
+ _clock = clock ?? (() => DateTime.UtcNow);
+ _scriptTimeout = scriptTimeout ?? TimedScriptEvaluator.DefaultTimeout;
+ }
+
+ /// Raised for every emission the Part9StateMachine produces that the engine should publish.
+ public event EventHandler? OnEvent;
+
+ public IReadOnlyCollection LoadedAlarmIds => _alarms.Keys;
+
+ ///
+ /// Load a batch of alarm definitions. Compiles every predicate, aggregates any
+ /// compile failures into one , subscribes
+ /// to upstream input tags, seeds the value cache, loads persisted state from
+ /// the store (falling back to Fresh for first-load alarms), and recomputes
+ /// ActiveState per Phase 7 plan decision #14 (startup recovery).
+ ///
+ public async Task LoadAsync(IReadOnlyList definitions, CancellationToken ct)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
+ if (definitions is null) throw new ArgumentNullException(nameof(definitions));
+
+ await _evalGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ UnsubscribeFromUpstream();
+ _alarms.Clear();
+ _alarmsReferencing.Clear();
+
+ var compileFailures = new List();
+ foreach (var def in definitions)
+ {
+ try
+ {
+ var extraction = DependencyExtractor.Extract(def.PredicateScriptSource);
+ if (!extraction.IsValid)
+ {
+ var joined = string.Join("; ", extraction.Rejections.Select(r => r.Message));
+ compileFailures.Add($"{def.AlarmId}: dependency extraction rejected — {joined}");
+ continue;
+ }
+
+ var evaluator = ScriptEvaluator.Compile(def.PredicateScriptSource);
+ var timed = new TimedScriptEvaluator(evaluator, _scriptTimeout);
+ var logger = _loggerFactory.Create(def.AlarmId);
+
+ var templateTokens = MessageTemplate.ExtractTokenPaths(def.MessageTemplate);
+ var allInputs = new HashSet(extraction.Reads, StringComparer.Ordinal);
+ foreach (var t in templateTokens) allInputs.Add(t);
+
+ _alarms[def.AlarmId] = new AlarmState(def, timed, extraction.Reads, templateTokens, logger,
+ AlarmConditionState.Fresh(def.AlarmId, _clock()));
+
+ foreach (var path in allInputs)
+ {
+ if (!_alarmsReferencing.TryGetValue(path, out var set))
+ _alarmsReferencing[path] = set = new HashSet(StringComparer.Ordinal);
+ set.Add(def.AlarmId);
+ }
+ }
+ catch (Exception ex)
+ {
+ compileFailures.Add($"{def.AlarmId}: {ex.Message}");
+ }
+ }
+
+ if (compileFailures.Count > 0)
+ {
+ throw new InvalidOperationException(
+ $"ScriptedAlarmEngine load failed. {compileFailures.Count} alarm(s) did not compile:\n "
+ + string.Join("\n ", compileFailures));
+ }
+
+ // Seed the value cache with current upstream values + subscribe for changes.
+ foreach (var path in _alarmsReferencing.Keys)
+ {
+ _valueCache[path] = _upstream.ReadTag(path);
+ _upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
+ }
+
+ // Restore persisted state, falling back to Fresh where nothing was saved,
+ // then re-derive ActiveState from the current predicate per decision #14.
+ foreach (var (alarmId, state) in _alarms)
+ {
+ var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false);
+ var seed = persisted ?? state.Condition;
+ var afterPredicate = await EvaluatePredicateToStateAsync(state, seed, nowUtc: _clock(), ct)
+ .ConfigureAwait(false);
+ _alarms[alarmId] = state with { Condition = afterPredicate };
+ await _store.SaveAsync(afterPredicate, ct).ConfigureAwait(false);
+ }
+
+ _loaded = true;
+ _engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
+
+ // Start the shelving-check timer — ticks every 5s, expires any timed shelves
+ // that have passed their UnshelveAtUtc.
+ _shelvingTimer = new Timer(_ => RunShelvingCheck(),
+ null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
+ }
+ finally
+ {
+ _evalGate.Release();
+ }
+ }
+
+ ///
+ /// Current persisted state for . Returns null for
+ /// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
+ ///
+ public AlarmConditionState? GetState(string alarmId)
+ => _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
+
+ public IReadOnlyCollection GetAllStates()
+ => _alarms.Values.Select(a => a.Condition).ToArray();
+
+ public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
+
+ public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
+
+ public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
+
+ public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
+
+ public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
+
+ public Task EnableAsync(string alarmId, string user, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
+
+ public Task DisableAsync(string alarmId, string user, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
+
+ public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
+ => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
+
+ private async Task ApplyAsync(string alarmId, CancellationToken ct, Func op)
+ {
+ EnsureLoaded();
+ if (!_alarms.TryGetValue(alarmId, out var state))
+ throw new ArgumentException($"Unknown alarm {alarmId}", nameof(alarmId));
+
+ await _evalGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var result = op(state.Condition);
+ _alarms[alarmId] = state with { Condition = result.State };
+ await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
+ if (result.Emission != EmissionKind.None) EmitEvent(state, result.State, result.Emission);
+ }
+ finally { _evalGate.Release(); }
+ }
+
+ ///
+ /// Upstream-change callback. Updates the value cache + enqueues predicate
+ /// re-evaluation for every alarm referencing the changed path. Fire-and-forget
+ /// so driver-side dispatch isn't blocked.
+ ///
+ internal void OnUpstreamChange(string path, DataValueSnapshot value)
+ {
+ _valueCache[path] = value;
+ if (_alarmsReferencing.TryGetValue(path, out var alarmIds))
+ {
+ _ = ReevaluateAsync(alarmIds.ToArray(), CancellationToken.None);
+ }
+ }
+
+ private async Task ReevaluateAsync(IReadOnlyList alarmIds, CancellationToken ct)
+ {
+ try
+ {
+ await _evalGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ foreach (var id in alarmIds)
+ {
+ if (!_alarms.TryGetValue(id, out var state)) continue;
+ var newState = await EvaluatePredicateToStateAsync(
+ state, state.Condition, _clock(), ct).ConfigureAwait(false);
+ if (!ReferenceEquals(newState, state.Condition))
+ {
+ _alarms[id] = state with { Condition = newState };
+ await _store.SaveAsync(newState, ct).ConfigureAwait(false);
+ }
+ }
+ }
+ finally { _evalGate.Release(); }
+ }
+ catch (Exception ex)
+ {
+ _engineLogger.Error(ex, "ScriptedAlarmEngine reevaluate failed");
+ }
+ }
+
+ ///
+ /// Evaluate the predicate + apply the resulting state-machine transition.
+ /// Returns the new condition state. Emits the appropriate event if the
+ /// transition produces one.
+ ///
+ private async Task EvaluatePredicateToStateAsync(
+ AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct)
+ {
+ var inputs = BuildReadCache(state.Inputs);
+ var context = new AlarmPredicateContext(inputs, state.Logger, _clock);
+
+ bool predicateTrue;
+ try
+ {
+ predicateTrue = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (ScriptTimeoutException tex)
+ {
+ state.Logger.Warning("Alarm predicate timed out after {Timeout} — state unchanged", tex.Timeout);
+ return seed;
+ }
+ catch (Exception ex)
+ {
+ state.Logger.Error(ex, "Alarm predicate threw — state unchanged");
+ return seed;
+ }
+
+ var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
+ if (result.Emission != EmissionKind.None)
+ EmitEvent(state, result.State, result.Emission);
+ return result.State;
+ }
+
+ private IReadOnlyDictionary BuildReadCache(IReadOnlySet inputs)
+ {
+ var d = new Dictionary(StringComparer.Ordinal);
+ foreach (var p in inputs)
+ d[p] = _valueCache.TryGetValue(p, out var v) ? v : _upstream.ReadTag(p);
+ return d;
+ }
+
+ private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind)
+ {
+ // Suppressed kind means shelving ate the emission — we don't fire for subscribers
+ // but the state record still advanced so startup recovery reflects reality.
+ if (kind == EmissionKind.Suppressed || kind == EmissionKind.None) return;
+
+ var message = MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup);
+ var evt = new ScriptedAlarmEvent(
+ AlarmId: state.Definition.AlarmId,
+ EquipmentPath: state.Definition.EquipmentPath,
+ AlarmName: state.Definition.AlarmName,
+ Kind: state.Definition.Kind,
+ Severity: state.Definition.Severity,
+ Message: message,
+ Condition: condition,
+ Emission: kind,
+ TimestampUtc: _clock());
+ try { OnEvent?.Invoke(this, evt); }
+ catch (Exception ex)
+ {
+ _engineLogger.Warning(ex, "ScriptedAlarmEngine OnEvent subscriber threw for {AlarmId}", state.Definition.AlarmId);
+ }
+ }
+
+ private DataValueSnapshot? TryLookup(string path)
+ => _valueCache.TryGetValue(path, out var v) ? v : null;
+
+ private void RunShelvingCheck()
+ {
+ if (_disposed) return;
+ var ids = _alarms.Keys.ToArray();
+ _ = ShelvingCheckAsync(ids, CancellationToken.None);
+ }
+
+ private async Task ShelvingCheckAsync(IReadOnlyList alarmIds, CancellationToken ct)
+ {
+ try
+ {
+ await _evalGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var now = _clock();
+ foreach (var id in alarmIds)
+ {
+ if (!_alarms.TryGetValue(id, out var state)) continue;
+ var result = Part9StateMachine.ApplyShelvingCheck(state.Condition, now);
+ if (!ReferenceEquals(result.State, state.Condition))
+ {
+ _alarms[id] = state with { Condition = result.State };
+ await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
+ if (result.Emission != EmissionKind.None)
+ EmitEvent(state, result.State, result.Emission);
+ }
+ }
+ }
+ finally { _evalGate.Release(); }
+ }
+ catch (Exception ex)
+ {
+ _engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed");
+ }
+ }
+
+ private void UnsubscribeFromUpstream()
+ {
+ foreach (var s in _upstreamSubscriptions)
+ {
+ try { s.Dispose(); } catch { }
+ }
+ _upstreamSubscriptions.Clear();
+ }
+
+ private void EnsureLoaded()
+ {
+ if (!_loaded) throw new InvalidOperationException(
+ "ScriptedAlarmEngine not loaded. Call LoadAsync first.");
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _shelvingTimer?.Dispose();
+ UnsubscribeFromUpstream();
+ _alarms.Clear();
+ _alarmsReferencing.Clear();
+ }
+
+ private sealed record AlarmState(
+ ScriptedAlarmDefinition Definition,
+ TimedScriptEvaluator Evaluator,
+ IReadOnlySet Inputs,
+ IReadOnlyList TemplateTokens,
+ ILogger Logger,
+ AlarmConditionState Condition);
+}
+
+///
+/// One alarm emission the engine pushed to subscribers. Carries everything
+/// downstream consumers (OPC UA alarm-source adapter + historian sink) need to
+/// publish the event without re-querying the engine.
+///
+public sealed record ScriptedAlarmEvent(
+ string AlarmId,
+ string EquipmentPath,
+ string AlarmName,
+ AlarmKind Kind,
+ AlarmSeverity Severity,
+ string Message,
+ AlarmConditionState Condition,
+ EmissionKind Emission,
+ DateTime TimestampUtc);
+
+///
+/// Upstream source abstraction — intentionally identical shape to the virtual-tag
+/// engine's so Stream G can compose them behind one driver bridge.
+///
+public interface ITagUpstreamSource
+{
+ DataValueSnapshot ReadTag(string path);
+ IDisposable SubscribeTag(string path, Action observer);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
new file mode 100644
index 0000000..080ca56
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
@@ -0,0 +1,122 @@
+using System.Collections.Concurrent;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+///
+/// Adapter that exposes through the driver-agnostic
+/// surface. The existing Phase 6.1 AlarmTracker
+/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm
+/// sources — no per-source branching in the fan-out.
+///
+///
+///
+/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA
+/// method calls per-condition. This adapter implements
+/// from the base interface; the richer Part 9 methods (Confirm / Shelve /
+/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA
+/// method handlers wired up in Stream G.
+///
+///
+/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an
+/// Equipment path prefix). When the list is empty every alarm matches. The
+/// adapter doesn't maintain per-subscription state beyond the filter set — it
+/// checks each emission against every live subscription.
+///
+///
+public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
+{
+ private readonly ScriptedAlarmEngine _engine;
+ private readonly ConcurrentDictionary _subscriptions
+ = new(StringComparer.Ordinal);
+ private bool _disposed;
+
+ public ScriptedAlarmSource(ScriptedAlarmEngine engine)
+ {
+ _engine = engine ?? throw new ArgumentNullException(nameof(engine));
+ _engine.OnEvent += OnEngineEvent;
+ }
+
+ public event EventHandler? OnAlarmEvent;
+
+ public Task SubscribeAlarmsAsync(
+ IReadOnlyList sourceNodeIds, CancellationToken cancellationToken)
+ {
+ if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds));
+ var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
+ _subscriptions[handle.DiagnosticId] = new Subscription(handle,
+ new HashSet(sourceNodeIds, StringComparer.Ordinal));
+ return Task.FromResult(handle);
+ }
+
+ public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
+ {
+ if (handle is null) throw new ArgumentNullException(nameof(handle));
+ _subscriptions.TryRemove(handle.DiagnosticId, out _);
+ return Task.CompletedTask;
+ }
+
+ public async Task AcknowledgeAsync(
+ IReadOnlyList acknowledgements, CancellationToken cancellationToken)
+ {
+ if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
+ foreach (var a in acknowledgements)
+ {
+ // The base interface doesn't carry a user identity — Stream G provides the
+ // authenticated principal at the OPC UA dispatch layer + proxies through
+ // the engine's richer AcknowledgeAsync. Here we default to "opcua-client"
+ // so callers using the raw IAlarmSource still produce an audit entry.
+ await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt)
+ {
+ if (_disposed) return;
+
+ foreach (var sub in _subscriptions.Values)
+ {
+ if (!Matches(sub, evt)) continue;
+ var payload = new AlarmEventArgs(
+ SubscriptionHandle: sub.Handle,
+ SourceNodeId: evt.EquipmentPath,
+ ConditionId: evt.AlarmId,
+ AlarmType: evt.Kind.ToString(),
+ Message: evt.Message,
+ Severity: evt.Severity,
+ SourceTimestampUtc: evt.TimestampUtc);
+ try { OnAlarmEvent?.Invoke(this, payload); }
+ catch { /* subscriber exceptions don't crash the adapter */ }
+ }
+ }
+
+ private static bool Matches(Subscription sub, ScriptedAlarmEvent evt)
+ {
+ if (sub.Filter.Count == 0) return true;
+ // A subscription matches if any filter is a prefix of the alarm's equipment
+ // path — typical use is "Enterprise/Site/Area/Line" filtering a whole line.
+ foreach (var f in sub.Filter)
+ {
+ if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true;
+ if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true;
+ }
+ return false;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _engine.OnEvent -= OnEngineEvent;
+ _subscriptions.Clear();
+ }
+
+ private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
+ {
+ public SubscriptionHandle(string id) { DiagnosticId = id; }
+ public string DiagnosticId { get; }
+ }
+
+ private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet Filter);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj
new file mode 100644
index 0000000..9196811
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
new file mode 100644
index 0000000..9182cf2
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
@@ -0,0 +1,61 @@
+using System.Collections.Concurrent;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
+
+public sealed class FakeUpstream : ITagUpstreamSource
+{
+ private readonly ConcurrentDictionary _values = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary>> _subs
+ = new(StringComparer.Ordinal);
+ public int ActiveSubscriptionCount { get; private set; }
+
+ public void Set(string path, object? value, uint statusCode = 0u)
+ {
+ var now = DateTime.UtcNow;
+ _values[path] = new DataValueSnapshot(value, statusCode, now, now);
+ }
+
+ public void Push(string path, object? value, uint statusCode = 0u)
+ {
+ Set(path, value, statusCode);
+ if (_subs.TryGetValue(path, out var list))
+ {
+ Action[] snap;
+ lock (list) { snap = list.ToArray(); }
+ foreach (var obs in snap) obs(path, _values[path]);
+ }
+ }
+
+ public DataValueSnapshot ReadTag(string path)
+ => _values.TryGetValue(path, out var v) ? v
+ : new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
+
+ public IDisposable SubscribeTag(string path, Action observer)
+ {
+ var list = _subs.GetOrAdd(path, _ => []);
+ lock (list) { list.Add(observer); }
+ ActiveSubscriptionCount++;
+ return new Unsub(this, path, observer);
+ }
+
+ private sealed class Unsub : IDisposable
+ {
+ private readonly FakeUpstream _up;
+ private readonly string _path;
+ private readonly Action _observer;
+ public Unsub(FakeUpstream up, string path, Action observer)
+ { _up = up; _path = path; _observer = observer; }
+ public void Dispose()
+ {
+ if (_up._subs.TryGetValue(_path, out var list))
+ {
+ lock (list)
+ {
+ if (list.Remove(_observer)) _up.ActiveSubscriptionCount--;
+ }
+ }
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/MessageTemplateTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/MessageTemplateTests.cs
new file mode 100644
index 0000000..126ef6a
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/MessageTemplateTests.cs
@@ -0,0 +1,107 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class MessageTemplateTests
+{
+ private static DataValueSnapshot Good(object? v) =>
+ new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
+ private static DataValueSnapshot Bad() =>
+ new(null, 0x80050000u, null, DateTime.UtcNow);
+
+ private static DataValueSnapshot? Resolver(Dictionary map, string path)
+ => map.TryGetValue(path, out var v) ? v : null;
+
+ [Fact]
+ public void No_tokens_returns_template_unchanged()
+ {
+ MessageTemplate.Resolve("No tokens here", _ => null).ShouldBe("No tokens here");
+ }
+
+ [Fact]
+ public void Single_token_substituted()
+ {
+ var map = new Dictionary { ["Tank/Temp"] = Good(75.5) };
+ MessageTemplate.Resolve("Temp={Tank/Temp}C", p => Resolver(map, p)).ShouldBe("Temp=75.5C");
+ }
+
+ [Fact]
+ public void Multiple_tokens_substituted()
+ {
+ var map = new Dictionary
+ {
+ ["A"] = Good(10),
+ ["B"] = Good("on"),
+ };
+ MessageTemplate.Resolve("{A}/{B}", p => Resolver(map, p)).ShouldBe("10/on");
+ }
+
+ [Fact]
+ public void Bad_quality_token_becomes_question_mark()
+ {
+ var map = new Dictionary { ["Bad"] = Bad() };
+ MessageTemplate.Resolve("value={Bad}", p => Resolver(map, p)).ShouldBe("value={?}");
+ }
+
+ [Fact]
+ public void Unknown_path_becomes_question_mark()
+ {
+ MessageTemplate.Resolve("value={DoesNotExist}", _ => null).ShouldBe("value={?}");
+ }
+
+ [Fact]
+ public void Null_value_with_good_quality_becomes_question_mark()
+ {
+ var map = new Dictionary { ["X"] = Good(null) };
+ MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}");
+ }
+
+ [Fact]
+ public void Tokens_with_slashes_and_dots_resolved()
+ {
+ var map = new Dictionary
+ {
+ ["Line1/Pump.Speed"] = Good(1200),
+ };
+ MessageTemplate.Resolve("rpm={Line1/Pump.Speed}", p => Resolver(map, p))
+ .ShouldBe("rpm=1200");
+ }
+
+ [Fact]
+ public void Empty_template_returns_empty()
+ {
+ MessageTemplate.Resolve("", _ => null).ShouldBe("");
+ }
+
+ [Fact]
+ public void Null_template_returns_empty_without_throwing()
+ {
+ MessageTemplate.Resolve(null!, _ => null).ShouldBe("");
+ }
+
+ [Fact]
+ public void ExtractTokenPaths_returns_every_distinct_token()
+ {
+ var tokens = MessageTemplate.ExtractTokenPaths("{A}/{B}/{A}/{C}");
+ tokens.ShouldBe(new[] { "A", "B", "A", "C" });
+ }
+
+ [Fact]
+ public void ExtractTokenPaths_empty_for_tokenless_template()
+ {
+ MessageTemplate.ExtractTokenPaths("No tokens").ShouldBeEmpty();
+ MessageTemplate.ExtractTokenPaths("").ShouldBeEmpty();
+ MessageTemplate.ExtractTokenPaths(null).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Whitespace_inside_token_is_trimmed()
+ {
+ var map = new Dictionary { ["A"] = Good(42) };
+ MessageTemplate.Resolve("{ A }", p => Resolver(map, p)).ShouldBe("42");
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/Part9StateMachineTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/Part9StateMachineTests.cs
new file mode 100644
index 0000000..debe808
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/Part9StateMachineTests.cs
@@ -0,0 +1,205 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
+
+///
+/// Pure state-machine tests — no engine, no I/O, no async. Every transition rule
+/// from Phase 7 plan Stream C.2 / C.3 has at least one locking test so regressions
+/// surface as clear failures rather than subtle alarm-behavior drift.
+///
+[Trait("Category", "Unit")]
+public sealed class Part9StateMachineTests
+{
+ private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
+ private static AlarmConditionState Fresh() => AlarmConditionState.Fresh("alarm-1", T0);
+
+ [Fact]
+ public void Predicate_true_on_inactive_becomes_active_and_emits_Activated()
+ {
+ var r = Part9StateMachine.ApplyPredicate(Fresh(), predicateTrue: true, T0.AddSeconds(1));
+ r.State.Active.ShouldBe(AlarmActiveState.Active);
+ r.State.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
+ r.State.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
+ r.Emission.ShouldBe(EmissionKind.Activated);
+ r.State.LastActiveUtc.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared()
+ {
+ var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
+ var r = Part9StateMachine.ApplyPredicate(active, false, T0.AddSeconds(2));
+ r.State.Active.ShouldBe(AlarmActiveState.Inactive);
+ r.Emission.ShouldBe(EmissionKind.Cleared);
+ r.State.LastClearedUtc.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Predicate_unchanged_state_emits_None()
+ {
+ var r = Part9StateMachine.ApplyPredicate(Fresh(), false, T0);
+ r.Emission.ShouldBe(EmissionKind.None);
+ }
+
+ [Fact]
+ public void Disabled_alarm_ignores_predicate()
+ {
+ var disabled = Part9StateMachine.ApplyDisable(Fresh(), "op1", T0.AddSeconds(1)).State;
+ var r = Part9StateMachine.ApplyPredicate(disabled, true, T0.AddSeconds(2));
+ r.State.Active.ShouldBe(AlarmActiveState.Inactive);
+ r.Emission.ShouldBe(EmissionKind.None);
+ }
+
+ [Fact]
+ public void Acknowledge_from_unacked_records_user_and_emits()
+ {
+ var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
+ var r = Part9StateMachine.ApplyAcknowledge(active, "alice", "looking into it", T0.AddSeconds(2));
+ r.State.Acked.ShouldBe(AlarmAckedState.Acknowledged);
+ r.State.LastAckUser.ShouldBe("alice");
+ r.State.LastAckComment.ShouldBe("looking into it");
+ r.State.Comments.Count.ShouldBe(1);
+ r.Emission.ShouldBe(EmissionKind.Acknowledged);
+ }
+
+ [Fact]
+ public void Acknowledge_when_already_acked_is_noop()
+ {
+ var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
+ var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", null, T0.AddSeconds(2)).State;
+ var r = Part9StateMachine.ApplyAcknowledge(acked, "alice", null, T0.AddSeconds(3));
+ r.Emission.ShouldBe(EmissionKind.None);
+ }
+
+ [Fact]
+ public void Acknowledge_without_user_throws()
+ {
+ Should.Throw(() =>
+ Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0));
+ }
+
+ [Fact]
+ public void Confirm_after_clear_records_user_and_emits()
+ {
+ // Walk: activate -> ack -> clear -> confirm
+ var s = Fresh();
+ s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)).State;
+ s = Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)).State;
+ s = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)).State;
+
+ var r = Part9StateMachine.ApplyConfirm(s, "bob", "resolved", T0.AddSeconds(4));
+ r.State.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
+ r.State.LastConfirmUser.ShouldBe("bob");
+ r.Emission.ShouldBe(EmissionKind.Confirmed);
+ }
+
+ [Fact]
+ public void OneShotShelve_suppresses_next_activation_emission()
+ {
+ var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0.AddSeconds(1)).State;
+ var r = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2));
+ r.State.Active.ShouldBe(AlarmActiveState.Active, "state still advances");
+ r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it");
+ }
+
+ [Fact]
+ public void OneShotShelve_expires_on_clear()
+ {
+ var s = Fresh();
+ s = Part9StateMachine.ApplyOneShotShelve(s, "alice", T0.AddSeconds(1)).State;
+ s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)).State;
+ var r = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3));
+ r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear");
+ }
+
+ [Fact]
+ public void TimedShelve_requires_future_unshelve_time()
+ {
+ Should.Throw(() =>
+ Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5)));
+ }
+
+ [Fact]
+ public void TimedShelve_expires_via_shelving_check()
+ {
+ var until = T0.AddMinutes(5);
+ var shelved = Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", until, T0).State;
+ shelved.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
+
+ // Before expiry — still shelved.
+ var earlier = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(3));
+ earlier.State.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
+ earlier.Emission.ShouldBe(EmissionKind.None);
+
+ // After expiry — auto-unshelved + emission.
+ var after = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(6));
+ after.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
+ after.Emission.ShouldBe(EmissionKind.Unshelved);
+ after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Unshelve_from_unshelved_is_noop()
+ {
+ var r = Part9StateMachine.ApplyUnshelve(Fresh(), "alice", T0);
+ r.Emission.ShouldBe(EmissionKind.None);
+ }
+
+ [Fact]
+ public void Explicit_Unshelve_emits_event()
+ {
+ var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0).State;
+ var r = Part9StateMachine.ApplyUnshelve(s, "bob", T0.AddSeconds(30));
+ r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
+ r.Emission.ShouldBe(EmissionKind.Unshelved);
+ }
+
+ [Fact]
+ public void AddComment_appends_to_audit_trail_with_event()
+ {
+ var r = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "investigating", T0.AddSeconds(5));
+ r.State.Comments.Count.ShouldBe(1);
+ r.State.Comments[0].Kind.ShouldBe("AddComment");
+ r.State.Comments[0].User.ShouldBe("alice");
+ r.State.Comments[0].Text.ShouldBe("investigating");
+ r.Emission.ShouldBe(EmissionKind.CommentAdded);
+ }
+
+ [Fact]
+ public void Comments_are_append_only_never_rewritten()
+ {
+ var s = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "first", T0.AddSeconds(1)).State;
+ s = Part9StateMachine.ApplyAddComment(s, "bob", "second", T0.AddSeconds(2)).State;
+ s = Part9StateMachine.ApplyAddComment(s, "carol", "third", T0.AddSeconds(3)).State;
+ s.Comments.Count.ShouldBe(3);
+ s.Comments[0].User.ShouldBe("alice");
+ s.Comments[1].User.ShouldBe("bob");
+ s.Comments[2].User.ShouldBe("carol");
+ }
+
+ [Fact]
+ public void Full_lifecycle_walk_produces_every_expected_emission()
+ {
+ // Walk a condition through its whole lifecycle and make sure emissions line up.
+ var emissions = new List();
+ var s = Fresh();
+
+ s = Capture(Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)));
+ s = Capture(Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)));
+ s = Capture(Part9StateMachine.ApplyAddComment(s, "alice", "need to investigate", T0.AddSeconds(3)));
+ s = Capture(Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(4)));
+ s = Capture(Part9StateMachine.ApplyConfirm(s, "bob", null, T0.AddSeconds(5)));
+
+ emissions.ShouldBe(new[] {
+ EmissionKind.Activated,
+ EmissionKind.Acknowledged,
+ EmissionKind.CommentAdded,
+ EmissionKind.Cleared,
+ EmissionKind.Confirmed,
+ });
+
+ AlarmConditionState Capture(TransitionResult r) { emissions.Add(r.Emission); return r.State; }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
new file mode 100644
index 0000000..d88ec5d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
@@ -0,0 +1,316 @@
+using Serilog;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
+
+///
+/// End-to-end engine tests: load, predicate evaluation, change-triggered
+/// re-evaluation, state persistence, startup recovery, error isolation.
+///
+[Trait("Category", "Unit")]
+public sealed class ScriptedAlarmEngineTests
+{
+ private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store)
+ {
+ store = new InMemoryAlarmStateStore();
+ var logger = new LoggerConfiguration().CreateLogger();
+ return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
+ }
+
+ private static ScriptedAlarmDefinition Alarm(string id, string predicate,
+ string msg = "condition", AlarmSeverity sev = AlarmSeverity.High) =>
+ new(AlarmId: id,
+ EquipmentPath: "Plant/Line1/Reactor",
+ AlarmName: id,
+ Kind: AlarmKind.AlarmCondition,
+ Severity: sev,
+ MessageTemplate: msg,
+ PredicateScriptSource: predicate);
+
+ [Fact]
+ public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ using var eng = Build(up, out _);
+
+ await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ eng.LoadedAlarmIds.ShouldContain("a1");
+ up.ActiveSubscriptionCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Compile_failures_aggregated_into_one_error()
+ {
+ var up = new FakeUpstream();
+ using var eng = Build(up, out _);
+
+ var ex = await Should.ThrowAsync(async () =>
+ await eng.LoadAsync([
+ Alarm("bad1", "return unknownIdentifier;"),
+ Alarm("good", "return true;"),
+ Alarm("bad2", "var x = alsoUnknown; return x;"),
+ ], TestContext.Current.CancellationToken));
+ ex.Message.ShouldContain("2 alarm(s) did not compile");
+ }
+
+ [Fact]
+ public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ up.Push("Temp", 150);
+ await WaitForAsync(() => events.Count > 0);
+
+ events[0].AlarmId.ShouldBe("HighTemp");
+ events[0].Emission.ShouldBe(EmissionKind.Activated);
+ eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
+ }
+
+ [Fact]
+ public async Task Clearing_upstream_emits_Cleared_event()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 150);
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ // Startup sees 150 → active.
+ eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
+
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ up.Push("Temp", 50);
+ await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
+ eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
+ }
+
+ [Fact]
+ public async Task Message_template_resolves_tag_values_at_emission()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ up.Set("Limit", 100);
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([
+ new ScriptedAlarmDefinition(
+ "HighTemp", "Plant/Line1", "HighTemp",
+ AlarmKind.LimitAlarm, AlarmSeverity.High,
+ "Temp {Temp}C exceeded limit {Limit}C",
+ """return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""),
+ ], TestContext.Current.CancellationToken);
+
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ up.Push("Temp", 150);
+ await WaitForAsync(() => events.Any());
+
+ events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
+ }
+
+ [Fact]
+ public async Task Ack_records_user_and_persists_to_store()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 150);
+ using var eng = Build(up, out var store);
+ await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken);
+
+ var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken);
+ persisted.ShouldNotBeNull();
+ persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
+ persisted.LastAckUser.ShouldBe("alice");
+ persisted.LastAckComment.ShouldBe("checking");
+ persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50); // predicate will go false on second load
+
+ // First run — alarm goes active + operator acks.
+ using (var eng1 = Build(up, out var sharedStore))
+ {
+ up.Set("Temp", 150);
+ await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+ eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
+
+ await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken);
+ eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
+ }
+
+ // Simulate restart — temp is back to 50 (below threshold).
+ up.Set("Temp", 50);
+ var logger = new LoggerConfiguration().CreateLogger();
+ var store2 = new InMemoryAlarmStateStore();
+ // seed store2 with the acked state from before restart
+ await store2.SaveAsync(new AlarmConditionState(
+ "HighTemp",
+ AlarmEnabledState.Enabled,
+ AlarmActiveState.Active, // was active pre-restart
+ AlarmAckedState.Acknowledged, // ack persisted
+ AlarmConfirmedState.Unconfirmed,
+ ShelvingState.Unshelved,
+ DateTime.UtcNow,
+ DateTime.UtcNow, null,
+ DateTime.UtcNow, "alice", null,
+ null, null, null,
+ [new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]),
+ TestContext.Current.CancellationToken);
+
+ using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger);
+ await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ var s = eng2.GetState("HighTemp")!;
+ s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value");
+ s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart");
+ s.LastAckUser.ShouldBe("alice");
+ }
+
+ [Fact]
+ public async Task Shelved_active_transitions_state_but_suppresses_emission()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
+
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ up.Push("Temp", 150);
+ await Task.Delay(200);
+
+ events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse(
+ "OneShot shelve suppresses activation emission");
+ eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
+ "state still advances so startup recovery is consistent");
+ }
+
+ [Fact]
+ public async Task Predicate_runtime_exception_does_not_transition_state()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 150);
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([
+ Alarm("BadScript", """throw new InvalidOperationException("boom");"""),
+ Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""),
+ ], TestContext.Current.CancellationToken);
+
+ // Bad script doesn't activate + doesn't disable other alarms.
+ eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive);
+ eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
+ }
+
+ [Fact]
+ public async Task Disable_prevents_activation_until_re_enabled()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
+ up.Push("Temp", 150);
+ await Task.Delay(100);
+ eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive,
+ "disabled alarm ignores predicate");
+
+ await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
+ up.Push("Temp", 160);
+ await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
+ }
+
+ [Fact]
+ public async Task AddComment_appends_to_audit_without_state_change()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ using var eng = Build(up, out var store);
+ await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken);
+
+ await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken);
+
+ var s = await store.LoadAsync("A", TestContext.Current.CancellationToken);
+ s.ShouldNotBeNull();
+ s!.Comments.Count.ShouldBe(1);
+ s.Comments[0].User.ShouldBe("alice");
+ s.Comments[0].Kind.ShouldBe("AddComment");
+ }
+
+ [Fact]
+ public async Task Predicate_scripts_cannot_SetVirtualTag()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 100);
+ using var eng = Build(up, out _);
+
+ // The script compiles fine but throws at runtime when SetVirtualTag is called.
+ // The engine swallows the exception + leaves state unchanged.
+ await eng.LoadAsync([
+ new ScriptedAlarmDefinition(
+ "Bad", "Plant/Line1", "Bad",
+ AlarmKind.AlarmCondition, AlarmSeverity.High, "bad",
+ """
+ ctx.SetVirtualTag("NotAllowed", 1);
+ return true;
+ """),
+ ], TestContext.Current.CancellationToken);
+
+ // Bad alarm's predicate threw — state unchanged.
+ eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
+ }
+
+ [Fact]
+ public async Task Dispose_releases_upstream_subscriptions()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ var eng = Build(up, out _);
+ await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+ up.ActiveSubscriptionCount.ShouldBe(1);
+
+ eng.Dispose();
+ up.ActiveSubscriptionCount.ShouldBe(0);
+ }
+
+ private static async Task WaitForAsync(Func cond, int timeoutMs = 2000)
+ {
+ var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
+ while (DateTime.UtcNow < deadline)
+ {
+ if (cond()) return;
+ await Task.Delay(25);
+ }
+ throw new TimeoutException("Condition did not become true in time");
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs
new file mode 100644
index 0000000..6526de1
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs
@@ -0,0 +1,142 @@
+using Serilog;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class ScriptedAlarmSourceTests
+{
+ private static async Task<(ScriptedAlarmEngine e, ScriptedAlarmSource s, FakeUpstream u)> BuildAsync()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ var logger = new LoggerConfiguration().CreateLogger();
+ var engine = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
+ new ScriptLoggerFactory(logger), logger);
+ await engine.LoadAsync([
+ new ScriptedAlarmDefinition(
+ "Plant/Line1::HighTemp",
+ "Plant/Line1",
+ "HighTemp",
+ AlarmKind.LimitAlarm,
+ AlarmSeverity.High,
+ "Temp {Temp}C",
+ """return (int)ctx.GetTag("Temp").Value > 100;"""),
+ new ScriptedAlarmDefinition(
+ "Plant/Line2::OtherAlarm",
+ "Plant/Line2",
+ "OtherAlarm",
+ AlarmKind.AlarmCondition,
+ AlarmSeverity.Low,
+ "other",
+ """return false;"""),
+ ], CancellationToken.None);
+
+ var source = new ScriptedAlarmSource(engine);
+ return (engine, source, up);
+ }
+
+ [Fact]
+ public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
+ {
+ var (engine, source, up) = await BuildAsync();
+ using var _e = engine;
+ using var _s = source;
+
+ var events = new List();
+ source.OnAlarmEvent += (_, e) => events.Add(e);
+ var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
+
+ up.Push("Temp", 150);
+ await Task.Delay(200);
+
+ events.Count.ShouldBe(1);
+ events[0].ConditionId.ShouldBe("Plant/Line1::HighTemp");
+ events[0].SourceNodeId.ShouldBe("Plant/Line1");
+ events[0].Severity.ShouldBe(AlarmSeverity.High);
+ events[0].AlarmType.ShouldBe("LimitAlarm");
+ events[0].Message.ShouldBe("Temp 150C");
+
+ await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
+ {
+ var (engine, source, up) = await BuildAsync();
+ using var _e = engine;
+ using var _s = source;
+
+ var events = new List();
+ source.OnAlarmEvent += (_, e) => events.Add(e);
+
+ // Subscribe only to Line1 alarms.
+ var handle = await source.SubscribeAlarmsAsync(["Plant/Line1"], TestContext.Current.CancellationToken);
+
+ up.Push("Temp", 150);
+ await Task.Delay(200);
+
+ events.Count.ShouldBe(1);
+ events[0].SourceNodeId.ShouldBe("Plant/Line1");
+
+ await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task Unsubscribe_stops_further_events()
+ {
+ var (engine, source, up) = await BuildAsync();
+ using var _e = engine;
+ using var _s = source;
+
+ var events = new List();
+ source.OnAlarmEvent += (_, e) => events.Add(e);
+ var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
+ await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
+
+ up.Push("Temp", 150);
+ await Task.Delay(200);
+
+ events.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
+ {
+ var (engine, source, up) = await BuildAsync();
+ using var _e = engine;
+ using var _s = source;
+
+ up.Push("Temp", 150);
+ await Task.Delay(200);
+ engine.GetState("Plant/Line1::HighTemp")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
+
+ await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
+ "Plant/Line1", "Plant/Line1::HighTemp", "ack via opcua")],
+ TestContext.Current.CancellationToken);
+
+ var state = engine.GetState("Plant/Line1::HighTemp")!;
+ state.Acked.ShouldBe(AlarmAckedState.Acknowledged);
+ state.LastAckUser.ShouldBe("opcua-client");
+ state.LastAckComment.ShouldBe("ack via opcua");
+ }
+
+ [Fact]
+ public async Task Null_arguments_rejected()
+ {
+ var (engine, source, _) = await BuildAsync();
+ using var _e = engine;
+ using var _s = source;
+
+ await Should.ThrowAsync(async () =>
+ await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
+ await Should.ThrowAsync(async () =>
+ await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
+ await Should.ThrowAsync(async () =>
+ await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken));
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj
new file mode 100644
index 0000000..5767971
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+