Files
lmxopcua/docs/ScriptedAlarms.md
T
Joseph Doherty 4624141a5d test+docs: retire the stale EquipmentTagsDarkBatch4 skips, guard the deploy-time
tag gate, and fix three silently-broken OpcUaClient integration tests

Continues working through deferment.md's remaining items.

SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
  - REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
    the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
    E2E. The behaviour never went dark, only the v2 seeding did.
  - FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
    already compared RawTagPlan record-equal and RawTagPlan carries array /
    historize / alarm intent, so widening its corpus by two tags covers what
    three empty placeholder suites had been promising. Those suites are deleted.
  - DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
    and both dot-joint {{equip}}.X token suites (that resolver was deleted in
    Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.

G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.

OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.

DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-28 14:32:13 -04:00

32 KiB
Raw Blame History

Scripted Alarms

⚠️ Correction (2026-07-28): GenericDriverNodeManager is NOT a production dispatch path. It is test scaffolding — its own source says so (Core/OpcUa/GenericDriverNodeManager.cs:71, "verified 07/#10: zero production references"). The production chain is OpcUaPublishActorIOpcUaAddressSpaceSink (SdkAddressSpaceSink) → OtOpcUaNodeManager, materialising the two v3 namespaces (.../raw and .../uns). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture through that class describe a design, not the running server. The same correction is already carried by AddressSpace.md and IncrementalSync.md; this file was missed in that pass.

Core.ScriptedAlarms is the Phase 7 subsystem that raises OPC UA Part 9 alarms from operator-authored C# predicates rather than from driver-native alarm streams. Scripted alarms are additive: Galaxy, AB CIP, FOCAS, and OPC UA Client drivers keep their native IAlarmSource implementations unchanged, and a ScriptedAlarmSource simply registers as another source in the same fan-out. Predicates read tags from any source (driver tags or virtual tags) through the shared ITagUpstreamSource and emit condition transitions through the engine's Part 9 state machine.

This file covers the engine internals — predicate evaluation, state machine, persistence, and the engine-to-IAlarmSource adapter. The server-side plumbing that turns those emissions into OPC UA AlarmConditionState nodes, applies retries, persists alarm transitions to the Historian, and routes operator acks through the session's AlarmAck permission lives in AlarmTracking.md and is not repeated here.

Authoring (AdminUI)

Scripted-alarm definitions are created and edited per-equipment on the /uns/equipment/{id} page's Alarms tab; the standalone /scripted-alarms management pages were retired in favour of this equipment-scoped surface. The /alerts page remains the live runtime view — it is where operators acknowledge, confirm, and shelve active alarm instances.

Definition shape

ScriptedAlarmDefinition (src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs) is the runtime contract the engine consumes. The generation-publish path materialises these from the ScriptedAlarm + Script config tables via AddressSpaceComposer.Compose + the driver-role host actor startup path.

Field Notes
AlarmId Stable identity. Also the OPC UA ConditionId and the key in IAlarmStateStore. Convention: {EquipmentPath}::{AlarmName}.
EquipmentPath UNS path the alarm hangs under in the address space. ACL scope inherits from the equipment node.
AlarmName Browse-tree display name.
Kind AlarmKindAlarmCondition, LimitAlarm, DiscreteAlarm, or OffNormalAlarm. Controls only the OPC UA ObjectType the node surfaces as; the internal state machine is identical for all four.
Severity AlarmSeverity enum (Low / Medium / High / Critical), defined in Core.Abstractions/IAlarmSource.cs. Static per decision #13 — the predicate does not compute severity. The publish path bands the configured value into this four-value enum before materialising the ScriptedAlarmDefinition.
MessageTemplate String with {TagPath} placeholders, resolved at emission time. See below.
PredicateScriptSource Roslyn C# script returning bool. true = condition active; false = cleared.
HistorizeToAveva Per-alarm opt-out of durable historization. When false, HistorianAdapterActor skips the IAlarmHistorianSink write for this alarm's transitions; the live /alerts fan-out is unaffected (the UI still shows every transition). Default true. Galaxy-native alarms default false since Galaxy historises them directly.
Retain Part 9 retain flag — keep the condition visible after clear while un-acked/un-confirmed transitions remain. Default true.

Illustrative definition:

new ScriptedAlarmDefinition(
    AlarmId:       "Plant/Line1/Oven::OverTemp",
    EquipmentPath: "Plant/Line1/Oven",
    AlarmName:     "OverTemp",
    Kind:          AlarmKind.LimitAlarm,
    Severity:      AlarmSeverity.High,
    MessageTemplate: "Oven {Plant/Line1/Oven/Temp} exceeds limit {Plant/Line1/Oven/TempLimit}",
    PredicateScriptSource: "return GetTag(\"Plant/Line1/Oven/Temp\").AsDouble() > GetTag(\"Plant/Line1/Oven/TempLimit\").AsDouble();");

Predicate evaluation

Alarm predicates reuse the same Roslyn sandbox as virtual tags — ScriptEvaluator<AlarmPredicateContext, bool> compiles the source, TimedScriptEvaluator wraps it with the configured timeout (default from TimedScriptEvaluator.DefaultTimeout), and DependencyExtractor statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in VirtualTags.md; ScriptedAlarms does not redefine them. The known resource limits — unbounded script-side memory and the orphan-thread CPU-budget caveat — are documented in that file as well; per-publish assembly accretion was resolved by the Core.Scripting-008 collectible-AssemblyLoadContext rewrite and no longer requires periodic server restarts.

AlarmPredicateContext (AlarmPredicateContext.cs) is the script's ScriptContext subclass:

  • GetTag(path) returns a DataValueSnapshot from the engine-maintained read cache. Missing path → DataValueSnapshot(null, 0x80340000u, null, now) (BadNodeIdUnknown). An empty path returns the same.
  • SetVirtualTag(path, value) throws InvalidOperationException. Predicates must be side-effect free per plan decision #6; writes would couple alarm state to virtual-tag state in ways that are near-impossible to reason about. Operators see the rejection in scripts-*.log.
  • Now and Logger are provided by the engine.

Evaluation cadence:

  • On every upstream tag change that any alarm's input set references (OnUpstreamChangeReevaluateAsync). The engine maintains an inverse index tag path → alarm ids (_alarmsReferencing); only affected alarms re-run.
  • On a 5-second shelving-check timer (_shelvingTimer) for timed-shelve expiry.
  • At LoadAsync for every alarm, to re-derive ActiveState per plan decision #14 (startup recovery).

If a predicate throws or times out, the engine logs the failure and leaves the prior ActiveState intact — it does not synthesise a clear. Operators investigating a broken predicate should never see a phantom clear preceding the error.

Part 9 state machine

Part9StateMachine (Part9StateMachine.cs) is a pure static function set. Every transition takes the current AlarmConditionState plus the event, returns a new record and an EmissionKind. No I/O, no mutation, trivially unit-testable. Transitions map to OPC UA Part 9:

  • ApplyPredicate(current, predicateTrue, nowUtc) — predicate re-evaluation. Inactive → Active sets Acked = Unacknowledged and Confirmed = Unconfirmed; Active → Inactive updates LastClearedUtc and consumes OneShot shelving. Disabled alarms no-op.
  • ApplyAcknowledge / ApplyConfirm — operator ack/confirm. Require a non-empty user string (audit requirement). Each appends an AlarmComment with Kind = "Acknowledge" / "Confirm".
  • ApplyOneShotShelve / ApplyTimedShelve(unshelveAtUtc) / ApplyUnshelve — shelving transitions. Timed requires unshelveAtUtc > nowUtc.
  • ApplyEnable / ApplyDisable — operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable, ActiveState is re-derived from the next evaluation.
  • ApplyAddComment(text) — append-only audit entry, no state change.
  • ApplyShelvingCheck(nowUtc) — called by the 5s timer; promotes expired Timed shelving to Unshelved with a system / AutoUnshelve audit entry.

Two invariants the machine enforces:

  1. Disabled alarms ignore every predicate evaluation — they never transition ActiveState / AckedState / ConfirmedState until re-enabled.
  2. Shelved alarms still advance their internal state but emit EmissionKind.Suppressed instead of Activated / Cleared. The engine advances the state record (so startup recovery reflects reality) but ScriptedAlarmSource does not publish the suppressed transition to subscribers. OneShot expires on the next clear; Timed expires at ShelvingState.UnshelveAtUtc.

EmissionKind values: None, Suppressed, Activated, Cleared, Acknowledged, Confirmed, Shelved, Unshelved, Enabled, Disabled, CommentAdded.

Message templates

MessageTemplate (MessageTemplate.cs) resolves {path} placeholders in the configured message at emission time. Syntax:

  • {path/with/slashes} — brace-stripped contents are looked up via the engine's tag cache.
  • No escaping. Literal braces in messages are not currently supported.
  • ExtractTokenPaths(template) is called at LoadAsync so the engine subscribes to every referenced path (ensuring the value cache is populated before the first resolve).

Fallback rules: a resolved DataValueSnapshot with a non-zero StatusCode, a null Value, or an unknown path becomes {?}. The event still fires — the operator sees where the reference broke rather than having the alarm swallowed.

Input-quality policy

Predicate evaluation and message-template resolution deliberately treat tag-input quality differently:

Surface Quality bar Rationale
ScriptedAlarmEngine.AreInputsReady (predicate gate) Bad rejected (StatusCode bit 31 set). Good and Uncertain are both accepted. Uncertain quality still carries a value the predicate can inspect; rejecting it would mask a transitional alarm condition. Predicate evaluation is a state-machine input — operators want it to track reality as closely as the quality allows.
MessageTemplate.Resolve (operator-facing message) Any non-zero StatusCode rejected — only Good substitutes; Uncertain / Bad / unknown all render as {?}. The message is a human-readable signal; substituting an Uncertain value would let operators act on a questionable reading without seeing the qualifier. Rendering {?} makes the doubt explicit.

AlarmPredicateContext.GetTag returns a BadNodeIdUnknown (0x80340000) snapshot for missing or empty paths, so a typo in the predicate flows through AreInputsReady (Bad → predicate skipped, prior state held) and MessageTemplate.Resolve (non-Good → {?}) without crashing the engine. (Core.ScriptedAlarms-010)

State persistence

IAlarmStateStore (IAlarmStateStore.cs) is the persistence contract: LoadAsync(alarmId), LoadAllAsync, SaveAsync(state), RemoveAsync(alarmId). InMemoryAlarmStateStore in the same file is the default for tests and dev deployments without a SQL backend. The production implementation is EfAlarmActorStateStore (src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs), which persists to the ScriptedAlarmState config-DB table via IAlarmActorStateStore.

Persisted scope per plan decision #14: Enabled, Acked, Confirmed, Shelving, LastTransitionUtc, the LastAck* / LastConfirm* audit fields, and the append-only Comments list. Active is not trusted across restart — the engine re-runs the predicate at LoadAsync so operators never re-ack an alarm that was already acknowledged before an outage, and alarms whose condition cleared during downtime settle to Inactive without a spurious clear-event.

Every mutation the state machine produces is immediately persisted inside the engine's _evalGate semaphore, so the store's view is always consistent with the in-memory state.

Post-(re)load condition-node re-assert (#487)

A deploy that triggers a full address-space rebuild clears _alarmConditions and re-materialises every condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each alarm's persisted state and re-derives Active from the predicate, so it ends up correct; but a still-active alarm has no transition to report, so LoadAsync yields EmissionKind.None and OnEngineEmission drops it. Nothing then writes the node. Before this fix, an active alarm with static dependencies read normal to every OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may never come.

ScriptedAlarmHostActor.ReassertConditionNodes closes it. After each load completes it reads ScriptedAlarmEngine.GetProjections() — a plain read returning ScriptedAlarmProjection (condition state + the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one OpcUaPublishActor.AlarmStateUpdate per alarm that is not in the Part 9 no-event position.

Four properties are load-bearing:

  • Node-only. It sends AlarmStateUpdate and nothing else — never the alerts topic, never the telemetry hub. alerts is the historization path, so an alerts row per alarm per deploy would append a duplicate historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of re-emitting: ScriptedAlarmProjection carries no EmissionKind, so there is nothing for the emission machinery — or a future refactor — to mistake for a transition.
  • Only non-normal alarms. An alarm in the no-event position (enabled, inactive, acked, confirmed, unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the alarm's display name — and writes nothing at all on the common all-normal deploy.
  • Ordering. DriverHostActor tells RebuildAddressSpace to the publish actor before it tells ApplyScriptedAlarms to the host, and the re-assert runs later still (after an awaited LoadAsync pipes back). Both are local Tells, which enqueue synchronously, so the re-materialise is already queued ahead of these writes.
  • No duplicate Part 9 events. WriteAlarmCondition's delta-gate compares the incoming snapshot against the node's current state: a re-assert onto a freshly materialised (normal) node is a genuine delta and fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.

The timestamp carried is the condition's own LastTransitionUtc, not the deploy instant, so a client reading Time / ReceiveTime after a rebuild still sees when the alarm actually went active.

Source integration

ScriptedAlarmSource (ScriptedAlarmSource.cs) adapts the engine to the driver-agnostic IAlarmSource interface. The existing AlarmSurfaceInvoker + GenericDriverNodeManager fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into AlarmConditionState nodes, the AlarmAck session check, and the Historian sink is shared — see AlarmTracking.md.

Two mapping notes specific to this adapter:

  • SubscribeAlarmsAsync accepts a list of source-node-id filters, interpreted as Equipment-path prefixes. Empty list matches every alarm. Each emission is matched against every live subscription — the adapter keeps no per-subscription cursor.
  • IAlarmSource.AcknowledgeAsync accepts an AlarmAcknowledgeRequest list; the OperatorUser field carries the authenticated principal when available. The adapter passes OperatorUser through to the engine's AcknowledgeAsync; when OperatorUser is null (non-OPC-UA callers using the raw interface) it falls back to "opcua-client" so callers still produce an audit entry. The server's Part 9 method handlers call the engine's richer AcknowledgeAsync / ConfirmAsync / OneShotShelveAsync / TimedShelveAsync / UnshelveAsync / AddCommentAsync directly with the authenticated principal instead of going through this adapter.

Native driver alarms (equipment-tag path)

Design reference: docs/plans/2026-06-14-galaxy-phase-b-native-alarms-design.md

A native driver alarm is an authored equipment Tag bound to an IAlarmSource driver (today: Galaxy GalaxyMxGateway) whose TagConfig carries an "alarm" object alongside the usual "FullName":

{ "FullName": "TestMachine_002.HiAlarm", "alarm": { "alarmType": "OffNormalAlarm", "severity": 700 } }

"alarm" absent → the tag materialises as an ordinary value variable (unchanged behaviour). "alarm" present → the tag materialises as a Part 9 AlarmConditionState under its equipment folder instead of a value variable. No EF/schema change is required; the intent rides in the schemaless TagConfig blob and is parsed byte-parity in both the compose (AddressSpaceComposer) and deploy (DeploymentArtifact) paths.

v3 Batch 4 — multi-notifier fan-out. The "alarm" object now rides on a raw tag authored in /raw (referenced into equipment), and the Part 9 condition materializes once at the raw tag — its ConditionId is the tag's RawPath, its parent notifier is the raw device/group folder. For each referencing equipment, the node manager (WireAlarmNotifiers) wires the SDK AddNotifier pattern (alarm.AddNotifier(equipFolder, isInverse:true) + equipFolder.AddNotifier(alarm) + EnsureFolderIsEventNotifier(equipFolder)) so the single condition fans to the raw device folder AND every equipment folder. A single ReportEvent fans to all notifier roots — a Server-object subscriber sees exactly one copy (the Part 9 shared-snapshot dedup), never one-per-root. Teardown is symmetric (RemoveNotifier(..., bidirectional:true) on rebuild / reference removal) so inverse-notifier entries never leak across redeploys. Ack/confirm/shelve route on ConditionId = RawPath (never SourceNodeId), and the /alerts row lists the referencing equipment paths. See AlarmTracking.md and the WP4 section of docs/plans/2026-07-15-v3-batch4-address-space-plan.md.

TagConfig alarm fields

Field Values Default
alarmType "AlarmCondition", "OffNormalAlarm", "DiscreteAlarm", "LimitAlarm" "AlarmCondition"
severity OPC UA 11000 scale 500
historizeToAveva true / false / absent absent (historize)

An unknown alarmType string falls back to the base AlarmCondition OPC UA ObjectType. severity seeds the condition's initial severity at materialisation; the driver's live alarm events may carry a different severity that overrides it at runtime.

historizeToAveva is a bool? opt-out for the AVEVA Historian durable write. Set it to false to suppress writing this alarm's transitions to AVEVA Historian; absent or true historizes as usual. The live /alerts feed and OPC UA condition events are always unaffected regardless of this flag. The field is gated in HistorianAdapterActor with is not false semantics — a missing field (e.g. from an older config) defaults to historizing rather than silently dropping an audit row during a rolling restart.

On the Tag modal, the "Historize to AVEVA" checkbox (in the alarm section) controls this field. The Galaxy address picker pre-fills a default alarm object ({"alarmType":"OffNormalAlarm","severity":700}) when it detects that the selected attribute is itself an alarm (IsAlarm == true); the pre-fill never overwrites an already-authored alarm section.

Distinct from tag-value historization. alarm.historizeToAveva controls alarm-transition AVEVA writes only. Tag-value history is controlled by the top-level isHistorized field (see Historian.md).

Severity mapping

The authored severity (11000) seeds the OPC UA condition node at materialisation time. Every native transition maps the driver's AlarmSeverity to one of four fixed buckets via NativeAlarmProjector.MapSeverity, overriding the authored severity seed from the first transition onward:

AlarmSeverity enum Projected value
Low 200
Medium 500
High 700
Critical 900
(default/unspecified) 500

That projected integer is then mapped to the OPC UA SDK EventSeverity enum by OtOpcUaNodeManager.MapSeverity on each AlarmStateUpdate write:

Projected value range EventSeverity
< 200 Low
200 399 MediumLow
400 599 Medium
600 799 MediumHigh
≥ 800 High

Consequently the four authored buckets land as: Low→MediumLow, Medium→Medium, High→MediumHigh, Critical→High. The authored severity field is overridden by live driver events on every transition.

Runtime flow

The driver's live IAlarmSource.OnAlarmEvent is the sole input. Transition kinds (Raise, Clear, Acknowledge, Retrigger) drive the condition's Active / Acknowledged / Severity / Message fields via NativeAlarmProjector, a pure per-condition state projector. The projected AlarmConditionSnapshot is forwarded through DriverHostActorOpcUaPublishActor.AlarmStateUpdateOtOpcUaNodeManager.WriteAlarmCondition — the same delta-gated Part 9 condition-event path that scripted alarms use, reused verbatim.

The same AlarmTransitionEvent that scripted alarms publish is emitted to the cluster alerts topic, so the AdminUI /alerts page and the Historian sink see native alarm transitions identically. Publication to the alerts topic is Primary-gated (only the redundancy Primary publishes cluster-wide transitions); the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm for failover.

The alarm is authored on the raw tag in /raw by including the "alarm" object in the tag's TagConfig JSON (v3 Batch 4 — equipment no longer authors tags; it references raw tags). No other configuration is required: any equipment that references the raw tag automatically receives the alarm at its equipment folder via the multi-notifier fan-out above.

Native-alarm OPC UA operator operations

An OPC UA client can Acknowledge a native (e.g. Galaxy) condition and the ack now propagates to the device. The OnAcknowledge handler on a native condition routes through a separate NativeAlarmAckRouter seam (instead of the scripted AlarmCommandRouter) → DriverHostActor (a condition NodeId → (DriverInstanceId, FullName) inverse map, Primary-gated) → the owning driver's IAlarmSource.AcknowledgeAsync → Galaxy gateway → AVEVA. The call carries the authenticated operator's display name via the AlarmAcknowledgeRequest.OperatorUser field. This is fire-and-forget — a failed upstream ack is not surfaced back to the OPC UA client (mirrors the Galaxy write-outcome limitation). See AlarmTracking.md §Native alarm acknowledge → AVEVA for the full routing diagram.

Enable/Disable on a native condition returns BadNotSupported — the driver backing a native alarm has no enable/disable surface distinct from OPC UA; the Part 9 enable/disable concept maps to the scripted-alarm engine only.

Inbound operator ack/shelve

Operators interact with active scripted alarms through two surfaces — both converge on the same alarm-commands DPS topic consumed by ScriptedAlarmHostActor.

AlarmAck gate (OPC UA method path)

OtOpcUaNodeManager wires the OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve / Enable / Disable) on each AlarmConditionState node. Every method call is gated on the AlarmAck LDAP role — fail-closed: a session with no resolved roles or no AlarmAck group membership receives BadUserAccessDenied immediately without reaching the engine. The role is carried on the session by RoleCarryingUserIdentity (a UserIdentity subclass that preserves the LDAP-resolved role set past OpcUaApplicationHost).

On allow, the handler publishes a Commons.OpcUa.AlarmCommand (containing command kind, condition id, comment, and operator principal) onto the alarm-commands DPS topic. The node manager itself stays Akka-free: the dispatch action is a settable Action<AlarmCommand> injected at boot by the hosted service.

Scripted vs native conditions — Enable/Disable and Acknowledge:

  • Scripted conditions — all eight Part 9 operations (including Enable/Disable) route through AlarmCommandRouter onto the alarm-commands topic, which ScriptedAlarmHostActor dispatches to the engine (EnableAsync / DisableAsync / AcknowledgeAsync / …). On enable, ActiveState is re-derived from the next predicate evaluation.
  • Native (driver-fed) conditionsOnAcknowledge branches to NativeAlarmAckRouter and routes the ack to the owning driver rather than the scripted engine (see §Native-alarm OPC UA operator operations above). OnEnableDisable returns BadNotSupported immediately — native conditions have no engine-side enable/disable surface.

OnTimedUnshelve (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.

Delta-gate de-duplication

WriteAlarmCondition fires a Part 9 condition event only when the incoming state differs from the node's current live state. This suppresses the double-emit that would otherwise occur when the OPC UA SDK auto-applies the acked state on the node and the engine's re-projection then attempts to fire a duplicate event.

AdminUI path

The AdminUI /alerts page (Alerts.razor) shows per-row Acknowledge / Shelve / Unshelve buttons. These are gated by the DriverOperator AdminUI policy and routed through the AdminOperationsActor cluster singleton (AcknowledgeAlarmCommand / ShelveAlarmCommand), which publishes onto the same alarm-commands topic. Cross-node routing is handled by the cluster singleton — the command always reaches the driver-role node hosting the engine that owns the alarm regardless of which AdminUI instance the operator is connected to. The Shelve button accepts a duration (minutes) and passes it to ShelveAlarmAsync(Timed, unshelveAtUtc); result chips auto-clear after ~8 s. The /alerts and /script-log live-status pills reflect real feed health driven by the SignalR bridge actors' DPS subscription state.

Under redundancy, the alerts topic rows are deduplicated to the Primary node — see Redundancy.md §Primary-gated alarm emission and historization.

Client.CLI path

The Client.CLI supports ack, confirm, and shelve commands that call the Part 9 condition methods directly on the OPC UA server:

dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- ack   -u opc.tcp://localhost:4840 -c "ns=2;s=Plant/Line1/Oven::OverTemp" -m "Acknowledged by ops"
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- confirm -u opc.tcp://localhost:4840 -c "ns=2;s=Plant/Line1/Oven::OverTemp" -m "Confirmed"
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- shelve  -u opc.tcp://localhost:4840 -c "ns=2;s=Plant/Line1/Oven::OverTemp" --timed 300000

TimedShelve duration is sent as OPC UA Duration (milliseconds). The CLI uses IOpcUaClientService.ConfirmAlarmAsync / ShelveAlarmAsync.

ScriptedAlarmHostActor dispatch

ScriptedAlarmHostActor subscribes to the alarm-commands DPS topic, ownership-filters each command (each node only acts on the alarms it owns), and dispatches to the matching ScriptedAlarmEngine operation (AcknowledgeAsync / ConfirmAsync / OneShotShelveAsync / TimedShelveAsync / UnshelveAsync / EnableAsync / DisableAsync / AddCommentAsync). No explicit re-projection is needed — the engine's existing OnEvent callback updates the OPC UA node after the transition.

Emissions map into AlarmEventArgs as AlarmType = Kind.ToString(), SourceNodeId = EquipmentPath, ConditionId = AlarmId, Message = resolved template string, Severity carried verbatim, SourceTimestampUtc = emission time.

Composition

AddressSpaceComposer (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs) is a pure data composer; it has no knowledge of ScriptedAlarmEngine. It maps ScriptedAlarm config-DB rows into ScriptedAlarmPlan records that the driver-role host actor startup path consumes.

In the v2 actor system, scripted-alarm engine composition is owned by the driver-role host:

  1. The host reads the generation's ScriptedAlarm + Script rows and resolves each row's PredicateScriptId to produce a ScriptedAlarmDefinition list. Unknown or disabled scripts fail fast — the DB publish guarantees referential integrity but this is a belt-and-braces check.
  2. A ScriptedAlarmEngine is constructed with the upstream-tag source, an IAlarmStateStore (production: EfAlarmActorStateStore), a shared ScriptLoggerFactory keyed to scripts-*.log, and the root Serilog logger.
  3. alarmEngine.OnEvent is wired to the historian sink. Fire-and-forget — the SQLite store-and-forward sink is already non-blocking.
  4. LoadAsync(alarmDefs) runs on startup: it compiles every predicate, subscribes to the union of predicate inputs and message-template tokens, seeds the value cache, loads persisted state, re-derives ActiveState from a fresh predicate evaluation, and starts the 5s shelving timer. Compile failures are aggregated into one InvalidOperationException so operators see every bad predicate in one startup log line rather than one at a time.
  5. A ScriptedAlarmSource is created for the event stream. The v2 ScriptedAlarmActor (src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs) owns the active-state surface for OPC UA variable reads on the alarm's condition-state node — unknown alarm ids return BadNodeIdUnknown rather than silently reading false.

Both engine and source are disposed on server shutdown via the driver-role host teardown path.

Key source files

  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs — orchestrator, cascade wiring, shelving timer, OnEvent emission
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.csIAlarmSource adapter over the engine
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs — runtime definition record
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs — pure-function state machine + TransitionResult / EmissionKind
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs — persisted state record + AlarmComment audit entry + ShelvingState
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/AlarmPredicateContext.cs — script-side ScriptContext (read-only, write rejected)
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.csAlarmKind + ShelvingKind + four Part 9 state enums (AlarmEnabledState, AlarmActiveState, AlarmAckedState, AlarmConfirmedState); AlarmSeverity (Low/Medium/High/Critical) lives in src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs{path} placeholder resolver
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs — persistence contract + InMemoryAlarmStateStore default
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs — pure data composer: config-DB entities → AddressSpaceComposition (UNS topology + raw subtree + driver/alarm plans)
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs — applies the composed plan into the SDK node manager
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs — actor that owns the per-alarm state machine; publishes AlarmTransitionEvent on the cluster alerts DPS topic
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs — production IAlarmActorStateStore backed by the ScriptedAlarmState config-DB table
  • src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs — production Roslyn predicate evaluator