Files
lmxopcua/docs/v2/f14b-part9-sdk-notes.md
T

33 KiB
Raw Blame History

F14b — OPC UA Part 9 Alarm & Condition SDK Notes (T13)

Reference notes for materialising real OPC UA Part 9 Alarm & Condition nodes in our CustomNodeManager2-based OtOpcUaNodeManager, firing events on state transitions, and accepting inbound Acknowledge / Confirm / Shelve / AddComment method calls. This replaces the current placeholder in OtOpcUaNodeManager.WriteAlarmState, which only writes a two-element [active, acknowledged] BaseDataVariableState and fires no events.

Consumed by implementation tasks T14T17.

Provenance (read this first)

  • DeepWiki MCP: NOT AVAILABLE in this session. mcp__deepwiki__* tools were not surfaced by the tool registry (only Gmail/Calendar/Drive/Chrome MCP servers were). All findings below come from sources 2 and 3 instead.
  • Local NuGet cache (authoritative, version-exact). Every API name, ctor, delegate, and method signature below was confirmed by decompiling the exact assembly we referenceOPCFoundation.NetStandard.Opc.Ua.* v1.5.378.106 (pinned in Directory.Packages.props), net10.0 TFM — with ilspycmd. The Part 9 condition types live in Opc.Ua.Core.dll; NodeState / ISystemContext / IUserIdentity live in Opc.Ua.Types.dll; CustomNodeManager2 lives in Opc.Ua.Server.dll.
  • GitHub reference sample (pattern-level, master branch). The end-to-end node-creation / event-firing / handler-wiring patterns were cross-checked against the OPC Foundation reference server at Applications/Quickstarts.Servers/Alarms/ (the AlarmHolders/* classes + AlarmNodeManager.cs). Caveat: these samples are on master, which is slightly ahead of 1.5.378.106 — they use a few newer optional members (e.g. SilenceState, OutOfServiceState, LatchedState, PropertyState<T>.With<VariantBuilder>, ByteString). The core alarm pattern (Create → set state → ReportEvent; OnAcknowledge/OnConfirm delegates) is identical in both. Anything sample-only that I could not also confirm in the 1.5.378.106 assembly is flagged [SAMPLE-ONLY] below.

What's verified vs. uncertain

Claim Status
Condition/Alarm type names, class hierarchy, ctors Verified in 1.5.378.106 assembly
SetActiveState / SetAcknowledgedState / SetConfirmedState / SetEnableState / SetSeverity / SetShelvingState / SetSuppressedState signatures Verified in assembly
OnAcknowledge / OnConfirm / OnAddComment / OnEnableDisable / OnShelve / OnTimedUnshelve delegate field types + signatures Verified in assembly
NodeState.Create(...) / ReportEvent(...) / AddNotifier(...) signatures Verified in assembly
CustomNodeManager2.AddRootNotifier / SubscribeToEvents / ConditionRefresh / Call behaviour Verified by decompiling CustomNodeManager2
Principal surfacing via ISessionOperationContext.UserIdentity (GetCurrentUserId) Verified in assembly
End-to-end wiring sequence (folder EventNotifier → AddRootNotifier → alarm.Create → AddChild → ReportEvent) Verified by decompile + cross-checked against reference sample
No packaged base alarm server class — must hand-roll on CustomNodeManager2 Verified (no AlarmConditionServer/SampleNodeManager type in the Opc.Ua.Server NuGet)
InitializeAlarmConditionEvent helper DOES NOT EXIST in this SDK — see Q3

Q1 — Node creation under a CustomNodeManager2

Type hierarchy (all in namespace Opc.Ua, assembly Opc.Ua.Core)

BaseObjectState
 └─ BaseEventState                       (EventId, EventType, SourceNode, SourceName, Time, ReceiveTime, Message, Severity)
     └─ ConditionState                   (EnabledState, Retain, ConditionClassId, ConditionName, BranchId, Quality, Comment, AddComment method)
         └─ AcknowledgeableConditionState (AckedState, ConfirmedState, Acknowledge + Confirm methods)
             └─ AlarmConditionState       (ActiveState, SuppressedState, ShelvingState, Shelve/Unshelve methods, ...)
                 ├─ LimitAlarmState        (HighLimit / HighHighLimit / LowLimit / LowLowLimit : PropertyState<double>)
                 │   ├─ ExclusiveLimitAlarmState     (LimitState : ExclusiveLimitStateMachineState; SetLimitState)
                 │   │   ├─ ExclusiveLevelAlarmState
                 │   │   ├─ ExclusiveDeviationAlarmState
                 │   │   └─ ExclusiveRateOfChangeAlarmState
                 │   └─ NonExclusiveLimitAlarmState  (HighState/HighHighState/LowState/LowLowState : TwoStateVariableState; SetLimitState)
                 │       ├─ NonExclusiveLevelAlarmState
                 │       ├─ NonExclusiveDeviationAlarmState
                 │       └─ NonExclusiveRateOfChangeAlarmState
                 ├─ DiscreteAlarmState
                 │   ├─ OffNormalAlarmState
                 │   │   ├─ SystemOffNormalAlarmState
                 │   │   └─ TripAlarmState
                 │   └─ (CertificateExpirationAlarmState, etc.)
                 └─ DiscrepancyAlarmState, InstrumentDiagnosticAlarmState, ...

These are all ModelCompiler-generated ([GeneratedCode("Opc.Ua.ModelCompiler")]). Each carries an embedded base64 InitializationString: calling Create(...) builds the entire mandatory child set (EnabledState, AckedState, ConfirmedState, ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, etc.) automatically. You do not hand-build child variables/methods.

Constructors (verified)

AlarmConditionState (and only it, among these) has two ctors:

public AlarmConditionState(NodeState parent)                              // classic
public AlarmConditionState(ITelemetryContext telemetry, NodeState parent) // 1.5.378 telemetry-aware

ConditionState, AcknowledgeableConditionState, LimitAlarmState, ExclusiveLimitAlarmState, NonExclusiveLimitAlarmState, OffNormalAlarmState, etc. expose only the (NodeState parent) ctor in 1.5.378.106.

Recommendation: prefer the telemetry ctor for AlarmConditionState when a telemetry context is reachable (Server.Telemetry), matching the reference sample (new AlarmConditionState(Server.Telemetry, parent)). The (NodeState parent) ctor is fine and is the only option for the non-alarm condition types.

The create-and-parent pattern (verified shape, from AlarmHolder.InitializeInternal)

// 'parent' is the owning ObjectState/FolderState already in the address space.
// 'trigger' is the source variable the alarm reports against (optional).
var alarm = new AlarmConditionState(Server.Telemetry, parent);

var nodeId      = new NodeId($"{parentId}.{alarmName}", NamespaceIndex);
var browseName  = new QualifiedName(alarmName, NamespaceIndex);
var displayName = new LocalizedText(alarmName);

alarm.SymbolicName    = alarmName;
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;   // how the parent links to it

// Builds the full mandatory child tree from the embedded type definition.
alarm.Create(
    SystemContext,   // ISystemContext
    nodeId,          // NodeId   (or null/default to auto-assign)
    browseName,      // QualifiedName
    displayName,     // LocalizedText
    assignNodeIds: true);

// (optional) point the source variable at the condition
trigger.AddReference(ReferenceTypeIds.HasCondition, isInverse: false, alarm.NodeId);

parent.AddChild(alarm);                          // wires the parent→alarm reference
AddPredefinedNode(SystemContext, alarm);         // registers node + all children with the manager

NodeState.Create signature (verified):

public virtual void Create(ISystemContext context, NodeId nodeId,
                           QualifiedName browseName, LocalizedText displayName,
                           bool assignNodeIds)

Important: AddPredefinedNode(SystemContext, alarm) registers the alarm and all the children Create minted into PredefinedNodes, so browse / read / method-call routing all work. Our existing EnsureFolder / EnsureVariable already use AddPredefinedNode; the alarm node is the same call. When tearing down, mirror RebuildAddressSpace: parent.RemoveChild

  • remove from PredefinedNodes.

What makes a node an event-notifier (so clients can subscribe to its events)

Two things, both required:

  1. The notifier object (typically the folder/area the alarms live under, or our _root) must set EventNotifier = EventNotifiers.SubscribeToEvents and be registered as a root notifier:

    alarmsFolder.EventNotifier = EventNotifiers.SubscribeToEvents;
    AddRootNotifier(alarmsFolder);   // protected, on CustomNodeManager2
    

    AddRootNotifier (verified) does three things: sets notifier.OnReportEvent = OnReportEvent (which forwards to Server.ReportEvent), adds an inverse HasNotifier reference to the Server object (so the Server object becomes the well-known event entry point clients subscribe to), and back-fills any existing "monitor all events" subscriptions.

  2. The condition node must have a notifier path up to that root notifier. alarm.Create(...) under a parent that is itself a root notifier is the simplest path; ReportEvent walks inverse notifier references upward (see Q3). If a condition is several folders deep, each intermediate folder needs EventNotifier = SubscribeToEvents and an AddNotifier / HasNotifier link, OR just make each alarm folder its own root notifier.

NodeState.AddNotifier (verified):

public virtual void AddNotifier(ISystemContext context, NodeId referenceTypeId,
                                bool isInverse, NodeState target)
// referenceTypeId null ⇒ HasEventSource. Use HasNotifier for object→object notifier chains.

Simplest robust design for us: make the single OtOpcUa alarm-root folder a root notifier (SubscribeToEvents + AddRootNotifier) and Create every alarm condition directly (or via plain folders that are themselves root notifiers) under it. Don't over-engineer the notifier hierarchy until a deep-tree requirement appears.


Q2 — State setters (which take an ISystemContext)

All the Set* mutators below are on the condition/alarm types and take an ISystemContext first arg — pass the manager's SystemContext (a ServerSystemContext) or a copy of it. They internally update the relevant TwoStateVariableState (.Id.Value, .Value true/false text, TransitionTime) and flag change masks.

Goal API (verified signature) Defined on
Active / inactive void SetActiveState(ISystemContext context, bool active) AlarmConditionState
Acked / unacked void SetAcknowledgedState(ISystemContext context, bool acknowledged) AcknowledgeableConditionState
Confirmed / unconfirmed void SetConfirmedState(ISystemContext context, bool confirmed) AcknowledgeableConditionState
Enabled / disabled void SetEnableState(ISystemContext context, bool enabled) ConditionState
Suppressed void SetSuppressedState(ISystemContext context, bool suppressed) AlarmConditionState
Shelving (all 3 transitions) void SetShelvingState(ISystemContext context, bool shelved, bool oneShot, double shelvingTime) AlarmConditionState
Severity void SetSeverity(ISystemContext context, EventSeverity severity) ConditionState
Comment (+ user) void SetComment(ISystemContext context, LocalizedText comment, string clientUserId) ConditionState
Exclusive limit zone void SetLimitState(ISystemContext context, LimitAlarmStates limit) ExclusiveLimitAlarmState
Non-exclusive limit zone void SetLimitState(ISystemContext context, LimitAlarmStates limit) NonExclusiveLimitAlarmState

SupportsConfirm() (verified, on AcknowledgeableConditionState) gates whether the Confirm sub-state machine is active. The SDK auto-decides this from whether ConfirmedState was materialised. To enable Confirm you must have a ConfirmedState child + call SetConfirmedState during init.

Properties you set directly (not via a Set* helper)

These are inherited PropertyState<T> nodes; assign .Value and (for event fields) let ReportEvent snapshot them:

Property Type (verified) Notes
Message PropertyState<LocalizedText> event message text
Severity PropertyState<ushort> prefer SetSeverity (also stamps LastSeverity)
Time PropertyState<DateTime> event source time (UTC); set per event in ReportEvent
ReceiveTime PropertyState<DateTime> usually = Time.Value
EventId PropertyState<byte[]> new GUID-bytes per event (see Q3)
EventType PropertyState<NodeId> set by Create from the type; can override
SourceNode / SourceName PropertyState<NodeId> / PropertyState<string> the originating tag
Retain PropertyState<bool> drives ConditionRefresh replay (see Q5)
ConditionClassId PropertyState<NodeId> e.g. ObjectTypeIds.ProcessConditionClassType
ConditionName PropertyState<string> human name of the condition
BranchId PropertyState<NodeId> new NodeId() (null) for the main branch
Quality PropertyState<StatusCode> usually StatusCodes.Good
EnabledState TwoStateVariableState set via SetEnableState, not directly

EventSeverity is the Opc.Ua.EventSeverity enum (Low/Medium/High/...); SetSeverity maps it to the ushort Severity value and stamps LastSeverity.


Q3 — Firing an event on a state transition

There is NO InitializeAlarmConditionEvent in this SDK

The method named in the task brief does not exist in 1.5.378.106. The real firing path is: set the per-event fields, then call ReportEvent passing a filter target that is a snapshot of the node.

NodeState.ReportEvent (verified):

public virtual void ReportEvent(ISystemContext context, IFilterTarget e)

The reference sample's verbatim pattern (from ConditionTypeHolder.ReportEvent):

if (alarm.EnabledState.Id.Value)              // only fire if enabled
{
    alarm.EventId.Value     = Uuid.NewUuid().ToByteString(); // unique per event
    alarm.Time.Value        = DateTime.UtcNow;
    alarm.ReceiveTime.Value = alarm.Time.Value;
    // (Message / Severity / ActiveState / AckedState already set by the Set* calls)

    alarm.ClearChangeMasks(SystemContext, includeChildren: true);

    var snapshot = new InstanceStateSnapshot();
    snapshot.Initialize(SystemContext, alarm);
    alarm.ReportEvent(SystemContext, snapshot);   // IFilterTarget = InstanceStateSnapshot
}

Key points:

  • InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire time, so the event a client receives reflects the values at that instant even if the live node mutates afterwards. Initialize reads the node's children into the snapshot.
  • A fresh EventId per event is mandatory — Acknowledge/Confirm/AddComment inbound calls are correlated back to a specific event by this EventId (GetEventByEventId / GetBranch). Reusing one breaks ack routing.
  • ReportEvent then walks inverse notifier references upward: each notifier in the chain re-reports, and the root notifier's OnReportEvent hands off to Server.ReportEvent, which queues the event to subscribed monitored items.
  • Typical transition flow: SetActiveState/SetAcknowledgedState/... → set Message/Severity → set Retain (active or unacked ⇒ true) → ReportEvent.

Threading / locking

  • Every node-manager mutation must hold the manager's Lock (we already do this in OtOpcUaNodeManager — see WriteValue/EnsureFolder). Creating the alarm, the Set* calls, and ReportEvent must all run under lock (Lock). CustomNodeManager2.AddRootNotifier, ConditionRefresh, etc. all take Lock internally; if you call them while already holding Lock that's fine (it's the same monitor — Lock is a plain object used with C# lock, which is re-entrant on the same thread).
  • ReportEvent itself does not require any extra lock beyond the manager Lock you already hold for the mutation; the Server's event queue is internally synchronised.

Q4 — Inbound method calls (Acknowledge / Confirm / AddComment / Shelve)

You do NOT route methods by hand

alarm.Create(...) materialises the standard Part 9 methods (Acknowledge, Confirm, AddComment, Enable, Disable, OneShotShelve, TimedShelve, Unshelve) as MethodState children, and the condition types wire their own OnCall handlers in OnAfterCreate (verified):

// AcknowledgeableConditionState.OnAfterCreate:
Acknowledge.OnCall = OnAcknowledgeCalled;
Confirm.OnCall     = OnConfirmCalled;
// ConditionState.OnAfterCreate:
AddComment.OnCall  = OnAddCommentCalled;

When a client calls Acknowledge, the path is: MasterNodeManager.CallCustomNodeManager2.Call(...) (verified: routes to MethodState.OnCall) → AcknowledgeableConditionState.OnAcknowledgeCalled(...). The built-in OnAcknowledgeCalled (verified body) already does all of this for you:

  1. ProcessBeforeAcknowledge (validates eventId, checks EnabledState, invokes your OnAcknowledge delegate for veto/permission — see below);
  2. resolves the branch via GetAcknowledgeableBranch(eventId);
  3. SetAcknowledgedState(context, true) (+ SetConfirmedState(false) if it supports confirm);
  4. SetComment(context, comment, GetCurrentUserId(context));
  5. UpdateRetainState();
  6. if events are monitored, ReportStateChange(...) (fires the condition event for you) plus an AuditConditionAcknowledgeEventState audit event.

So for a basic implementation you don't even need to fire an event after an ack — the SDK does it. You only supply the veto/permission/business delegates.

Delegate hooks (the wiring point) — verified field types + signatures

Hook field Delegate type Signature
AcknowledgeableConditionState.OnAcknowledge ConditionAddCommentEventHandler (ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment) → ServiceResult
AcknowledgeableConditionState.OnConfirm ConditionAddCommentEventHandler same as above
ConditionState.OnAddComment ConditionAddCommentEventHandler same as above
ConditionState.OnEnableDisable ConditionEnableEventHandler (ISystemContext, ConditionState, bool enabling) → ServiceResult
AlarmConditionState.OnShelve AlarmConditionShelveEventHandler (ISystemContext, AlarmConditionState alarm, bool shelving, bool oneShot, double shelvingTime) → ServiceResult
AlarmConditionState.OnTimedUnshelve AlarmConditionTimedUnshelveEventHandler (ISystemContext, AlarmConditionState alarm) → ServiceResult

Note: OnAcknowledge, OnConfirm, and OnAddComment all use the same delegate type ConditionAddCommentEventHandler (context, condition, eventId, comment), matching the task brief exactly. There is no separate AcknowledgeMethodHandler / ConfirmMethodHandler type — those are not in this SDK.

Wire them right after Create (and after setting initial state):

alarm.OnAcknowledge   = OnAcknowledgeHandler;   // ServiceResult OnAcknowledgeHandler(ISystemContext ctx, ConditionState c, byte[] eventId, LocalizedText comment)
alarm.OnConfirm       = OnConfirmHandler;
alarm.OnAddComment    = OnAddCommentHandler;
alarm.OnEnableDisable = OnEnableDisableHandler;
alarm.OnShelve        = OnShelveHandler;         // only if shelving is supported
alarm.OnTimedUnshelve = OnTimedUnshelveHandler;

Returning a bad ServiceResult (e.g. StatusCodes.BadUserAccessDenied, StatusCodes.BadConditionBranchAlreadyAcked, StatusCodes.BadEventIdUnknown) vetoes the operation — the SDK aborts the state change and returns that status to the client. This is the natural seam for our AlarmAck permission gate (T17): check the caller's role in OnAcknowledge, return BadUserAccessDenied if they lack AlarmAck.

How the calling principal reaches the handler

The ISystemContext context passed into the handler is a ServerSystemContext (: SessionSystemContext) carrying the session's identity. The SDK's own ConditionState.GetCurrentUserId (verified) reads it as:

if (context is ISessionOperationContext { UserIdentity: not null } s)
    return s.UserIdentity.DisplayName;   // IUserIdentity

So in our handlers:

var identity = (context as ISessionOperationContext)?.UserIdentity; // Opc.Ua.IUserIdentity
// identity.DisplayName    : string
// identity.GrantedRoleIds : NodeIdCollection   ← use for the AlarmAck gate
// identity.TokenType      : UserTokenType

IUserIdentity (verified) exposes DisplayName, TokenType, IssuedTokenType, and GrantedRoleIds (NodeIdCollection) — the latter is what the AlarmAck gate should check (or carry our own claims via the identity we set during authentication). ServerSystemContext is constructed from the OperationContext / ISession (UserIdentity = session.Identity), so the principal is whoever authenticated the calling session via our IOpcUaUserAuthenticator / LdapOpcUaUserAuthenticator.

The reference sample also uses context.UserId (a string) inside handlers — that resolves on the concrete SystemContext/IOperationContext. Prefer the ISessionOperationContext.UserIdentity cast above: it's what the SDK itself uses and gives the full IUserIdentity (roles), not just a display string.

Shelve specifics

  • Client OneShotShelve / TimedShelve / Unshelve methods call into AlarmConditionState.OnOneShotShelve / OnTimedShelve / OnUnshelve, which invoke your OnShelve (with shelving/oneShot/shelvingTime flags) and OnTimedUnshelve delegates, then call SetShelvingState.
  • The ShelvingState is a ShelvedStateMachineState child; for timed shelve the SDK runs an internal timer (UnshelveTimeUpdateRate, OnTimerExpired/OnUnshelveTimeUpdate) that auto-unshelves. UnshelveTime is a calculated property (read handler OnReadUnshelveTime).
  • [SAMPLE-ONLY] SilenceState, OutOfServiceState, LatchedState, MaxTimeShelved are created as optional children in the master sample. In 1.5.378.106 the ShelvingState/SuppressedState/OutOfServiceState/ SilenceState/LatchedState properties exist on AlarmConditionState, but you must materialise the optional ones yourself before Create (e.g. alarm.ShelvingState = new ShelvedStateMachineState(alarm) then ShelvingState.Create(...)). For our first pass we likely only need ActiveState + AckedState + (optional) ConfirmedState + ShelvingState.

Q5 — Gotchas / threading / ConditionRefresh / base server

Dispatcher / threading

  • All node-manager mutations under lock (Lock) (already our convention). Build + Create + Set* + ReportEvent for one transition in a single critical section so a client never observes a half-applied state.
  • CustomNodeManager2.Lock is a plain object used with lock — re-entrant on the same thread, so calling AddRootNotifier/ConditionRefresh while holding it is safe.

EventId / branches

  • Fresh EventId per ReportEvent (GUID bytes). Inbound Acknowledge/Confirm carry the eventId of the event being acked; the SDK matches it via GetEventByEventId / GetAcknowledgeableBranch / GetBranch(eventId).
  • Branches (CreateBranch(context, branchId)ConditionState) model "a previous unacked occurrence while a new one is already active". They are optional — only needed if we want the ack-the-old-while-new-is-active semantics. For T14T17 we can skip branching initially (main branch BranchId = new NodeId() / null) and add it later; our Part9StateMachine doesn't model branches today.

ConditionRefresh (must work, or clients can't recover retained state)

  • When a client (re)subscribes to events and calls the ConditionRefresh method, CustomNodeManager2.ConditionRefresh (verified) iterates the monitored items, calls node.ConditionRefresh(context, events, true) on each root notifier (or the specific source node), collects the retained condition events, and queues a refresh batch to that monitored item.
  • For this to replay our active/unacked alarms, each live condition must have Retain.Value == true while it should appear in a refresh (Part 9 rule: retain while active OR unacknowledged OR unconfirmed). The built-in UpdateRetainState/GetRetainState handles this if you keep ack/active state in sync; if you compute retain yourself, set alarm.Retain.Value on every transition. CustomNodeManager2.ConditionRefresh is public virtual and already implemented — we get refresh "for free" as long as the alarms are registered (AddPredefinedNode) and reachable from a root notifier with correct Retain.

Is there a base alarm server to lean on? — No (in NuGet).

  • The OPCFoundation.NetStandard.Opc.Ua.Server NuGet package contains no AlarmConditionServer, SampleNodeManager, or alarm-holder helper. Confirmed: the only alarm/condition type in Opc.Ua.Server is the interface IConditionRefreshAsyncNodeManager. The AlarmConditionServer / Quickstarts.Servers code lives only in the GitHub repo's Applications/ tree, which is not packaged.
  • Therefore we hand-roll on CustomNodeManager2 (which we already subclass). The base class gives us everything load-bearing:
    • Call → routes client method calls to MethodState.OnCall (so Acknowledge/Confirm/AddComment/Shelve "just work" once Create runs);
    • SubscribeToEvents / SubscribeToAllEvents → flips node.SetAreEventsMonitored so EventsMonitored() gates audit-event emission;
    • AddRootNotifier / RemoveRootNotifier → notifier wiring to the Server object;
    • ConditionRefresh → retained-event replay;
    • AddPredefinedNode → node registration (we use it already).
  • The reference AlarmHolders/* classes are a good pattern reference to copy selectively (Create → set state → ReportEvent; delegate wiring), but they're tangled with the sample's controllers/branching/optional-children scaffolding we don't need. Lift the shape, not the whole hierarchy.

Mapping to our existing domain model (for T14T17)

We already have a pure Part 9 state machine and domain records in src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/: Part9StateMachine (ApplyAcknowledge/ApplyConfirm/ApplyOneShotShelve/ ApplyTimedShelve/ApplyUnshelve/ApplyEnable/ApplyDisable/ApplyAddComment/ ApplyShelvingCheck) and the AlarmConditionState / ShelvingState / AlarmComment records.

The SDK node is a projection of that domain state, not a second source of truth. Recommended split:

  • Inbound (client → us): keep the SDK delegates thin. In OnAcknowledge/OnConfirm/OnShelve/etc., (a) run the AlarmAck permission gate off ISessionOperationContext.UserIdentity, returning a bad ServiceResult to veto if denied; (b) translate to the matching Part9StateMachine.Apply* call against our domain state; (c) let the resulting domain transition drive the authoritative store. You can either let the SDK's built-in OnAcknowledgeCalled post-processing apply the SDK-side state change + event, or set state yourself — but don't double-fire.
  • Outbound (engine → clients): when our ScriptedAlarmEngine produces a new AlarmConditionState (active/severity/ack changes), project it onto the SDK node: SetActiveState / SetAcknowledgedState / SetConfirmedState / SetSeverity / set Message + Retain, then ReportEvent — all under lock (Lock).
  • This replaces OtOpcUaNodeManager.WriteAlarmState's [active, ack] boolean pair with a real AlarmConditionState node keyed by the same alarm node id.

Minimal end-to-end sketch (what T14T16 build toward)

// --- one-time, in CreateAddressSpace or first alarm registration (under Lock) ---
_alarmsFolder.EventNotifier = EventNotifiers.SubscribeToEvents;
AddRootNotifier(_alarmsFolder);

// --- register one alarm condition (under Lock) ---
var alarm = new AlarmConditionState(Server.Telemetry, _alarmsFolder);
alarm.SymbolicName    = name;
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
alarm.Create(SystemContext, new NodeId($"{parentId}.{name}", NamespaceIndex),
             new QualifiedName(name, NamespaceIndex), new LocalizedText(name), true);

alarm.ConditionName.Value   = name;
alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;   // or a specific class
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
alarm.SetAcknowledgedState(SystemContext, true);
alarm.Retain.Value = false;

alarm.OnAcknowledge   = OnAck;     // permission gate + domain Apply
alarm.OnConfirm       = OnConfirm;
alarm.OnAddComment    = OnAddComment;
alarm.OnEnableDisable = OnEnableDisable;

_alarmsFolder.AddChild(alarm);
AddPredefinedNode(SystemContext, alarm);
_alarms[alarmNodeId] = alarm;

// --- on an engine-driven transition (under Lock) ---
alarm.SetActiveState(SystemContext, active: true);
alarm.SetAcknowledgedState(SystemContext, acknowledged: false);
alarm.SetSeverity(SystemContext, EventSeverity.High);
alarm.Message.Value = new LocalizedText("en", "High temperature");
alarm.Retain.Value  = true;                          // active|unacked ⇒ retain
alarm.EventId.Value = Uuid.NewUuid().ToByteString();
alarm.Time.Value = alarm.ReceiveTime.Value = DateTime.UtcNow;
alarm.ClearChangeMasks(SystemContext, true);
var snap = new InstanceStateSnapshot();
snap.Initialize(SystemContext, alarm);
alarm.ReportEvent(SystemContext, snap);

// --- inbound ack handler (called by SDK Call routing; on a server thread) ---
ServiceResult OnAck(ISystemContext ctx, ConditionState c, byte[] eventId, LocalizedText comment)
{
    var id = (ctx as ISessionOperationContext)?.UserIdentity;
    if (!HasAlarmAck(id)) return StatusCodes.BadUserAccessDenied;   // T17 gate
    // translate to Part9StateMachine.ApplyAcknowledge(...) against domain state
    return ServiceResult.Good;   // returning Good lets the SDK set acked state + fire the event
}

Open uncertainties to validate during T14T17

  1. Telemetry ctor vs classic ctor at runtime. We verified both ctors exist; confirm Server.Telemetry (ITelemetryContext) is non-null in our host before relying on the telemetry ctor — fall back to new AlarmConditionState(parent) if not. (Not load-bearing; pick whichever the host supports.)
  2. Optional children before Create. Whether ShelvingState / ConfirmedState are auto-created by Create or must be instantiated first. RESOLVED in T14 (real-server integration test, 1.5.378.106): Create auto-builds the full optional Part 9 child set from the embedded type definition with no pre-setting — for OffNormalAlarmState, both ConfirmedState AND ShelvingState come back non-null after Create (richer than the [SAMPLE-ONLY] caveat predicted). So T15/T16 can call SetConfirmedState / SetShelvingState directly; no manual child materialisation is needed. Gotcha also found: BranchId.Value is left a null reference by Create, and the very first Set* call (SetEnableStateUpdateRetainStateGetRetainStateIsBranch()) NREs on it. Fix: set alarm.BranchId.Value = NodeId.Null (the main branch) before any Set* call. T14's MaterialiseAlarmCondition does this. (Covered by SdkAddressSpaceSinkTests.MaterialiseAlarmCondition_*.)
  3. InstanceStateSnapshot vs reporting the node directly. The sample uses an InstanceStateSnapshot as the IFilterTarget. Confirm whether reporting the alarm node itself (which is also an IFilterTarget) is acceptable — the snapshot is safer (frozen values) and is the documented pattern; default to it.
  4. Double-event on inbound ack. The built-in OnAcknowledgeCalled already fires ReportStateChange after a successful ack. Make sure our handler does not also call ReportEvent for the same transition (would double-emit). Decide whether to drive SDK state from the handler return (let SDK fire) or suppress the SDK's auto-state-change and project from the engine.
  5. GrantedRoleIds vs our own claim model. Confirm whether our LdapOpcUaUserAuthenticator populates IUserIdentity.GrantedRoleIds, or whether the AlarmAck permission lives on a custom identity we set. The gate in OnAcknowledge must read whichever surface our auth actually fills.

All API names/signatures verified against OPCFoundation.NetStandard.Opc.Ua.* v1.5.378.106 (net10.0) via ilspycmd decompilation, cross-checked against the OPC Foundation reference server samples at UA-.NETStandard/Applications/Quickstarts.Servers/Alarms/ (master). DeepWiki MCP was unavailable; no claim here rests on it.