diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
new file mode 100644
index 00000000..34994e2a
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
@@ -0,0 +1,37 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+///
+/// Commons-level command carried from an inbound OPC UA Part 9 alarm method call
+/// (Acknowledge / Confirm / Shelve / AddComment …) back to the scripted-alarm engine. The SDK
+/// node manager builds one of these in its condition method-handler delegates after the
+/// AlarmAck role gate passes, then the host routes it onto the cluster
+/// alarm-commands DistributedPubSub topic; T19's engine-side subscriber consumes it and
+/// drives the matching Part9StateMachine.Apply* transition. This is a pure DTO — it makes
+/// no auth decision and holds no SDK/Akka handle.
+///
+///
+/// The alarm's ScriptedAlarmId — equal to the materialised condition node's NodeId identifier
+/// (T14 aligned the condition NodeId to the ScriptedAlarmId). The engine keys its domain state by
+/// this id.
+///
+///
+/// The Part 9 operation, one of: Acknowledge, Confirm, OneShotShelve,
+/// TimedShelve, Unshelve, Enable, Disable, AddComment. These map
+/// 1:1 onto the engine's Part9StateMachine.Apply* calls on the consuming side (T19).
+///
+/// The acting user — the authenticated session identity's display/name.
+///
+/// The free-text comment supplied with the call (the OPC UA LocalizedText payload's text),
+/// or null when none was provided.
+///
+///
+/// For TimedShelve, the absolute UTC instant the shelve auto-expires
+/// (DateTime.UtcNow + shelvingTime, where the OPC UA Duration shelvingTime is
+/// in milliseconds); null for every other operation.
+///
+public sealed record AlarmCommand(
+ string AlarmId,
+ string Operation,
+ string User,
+ string? Comment,
+ DateTime? UnshelveAtUtc);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
index 32506e50..d0639780 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
@@ -1,9 +1,12 @@
+using Akka.Actor;
+using Akka.Cluster.Tools.PublishSubscribe;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
@@ -24,6 +27,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly DeferredAddressSpaceSink _deferredSink;
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
+ private readonly Func _actorSystemAccessor;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
@@ -37,18 +41,23 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// The deferred address space sink that receives the real sink once the server is ready.
/// The deferred service level publisher that receives the real publisher once the server is ready.
/// The OPC UA user authenticator.
+ /// Lazy accessor for the running , used to
+ /// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
+ /// lazily (mirroring DpsScriptLogPublisher) so construction never races Akka startup.
/// The logger factory for creating loggers.
public OtOpcUaServerHostedService(
IOptions options,
DeferredAddressSpaceSink deferredSink,
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
+ Func actorSystemAccessor,
ILoggerFactory loggerFactory)
{
_options = options.Value;
_deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
+ _actorSystemAccessor = actorSystemAccessor;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger();
}
@@ -88,6 +97,30 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
+ // Wire the reverse-path inbound-alarm-command router: a client Acknowledge/Confirm/Shelve that
+ // passes the node manager's AlarmAck gate publishes the mapped AlarmCommand onto the cluster
+ // `alarm-commands` topic (same DistributedPubSub mediator the `alerts`/`script-logs` topics use).
+ // The Tell is fire-and-forget so the handler — which runs under the SDK's Lock — never blocks.
+ // The mediator is resolved per-publish via the lazy ActorSystem accessor so a transient cluster
+ // condition is tolerated and construction never raced Akka startup.
+ _server.SetAlarmCommandRouter(cmd =>
+ {
+ try
+ {
+ var mediator = DistributedPubSub.Get(_actorSystemAccessor()).Mediator;
+ mediator.Tell(new Publish(ScriptedAlarmHostActor.AlarmCommandsTopic, cmd));
+ }
+ catch (Exception ex)
+ {
+ // The router runs under the SDK Lock on a server thread; a cluster hiccup must not
+ // escape into the SDK's Call path. Log + drop — the client still gets Good for the
+ // node-state change; the missed command surfaces as a non-applied engine transition.
+ _logger.LogWarning(ex,
+ "OtOpcUaServerHostedService: failed to route inbound alarm command {Operation} for {AlarmId}",
+ cmd.Operation, cmd.AlarmId);
+ }
+ });
+
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index f2a5109e..2eb9aa82 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -147,6 +147,10 @@ if (hasDriver)
builder.Services.AddValidatedOptions(
builder.Configuration, "OpcUa");
+ // Lazy ActorSystem accessor so OtOpcUaServerHostedService can resolve the DistributedPubSub
+ // mediator (for the inbound alarm-command router) without racing Akka startup — same pattern the
+ // DpsScriptLogPublisher above uses. TryAdd so a fused admin+driver node registers it exactly once.
+ builder.Services.TryAddSingleton>(sp => () => sp.GetRequiredService());
builder.Services.AddHostedService();
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index de2c51bb..c61295b2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -52,6 +53,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Gets the count of real Part 9 nodes currently managed.
public int AlarmConditionCount => _alarmConditions.Count;
+ ///
+ /// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
+ /// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
+ /// handler (wired in ) gates on the caller's
+ /// AlarmAck role and, when allowed, builds an and invokes this
+ /// delegate. The host sets it at boot to a non-blocking mediator.Tell onto the
+ /// alarm-commands DistributedPubSub topic; T19's engine-side subscriber consumes it.
+ ///
+ /// This is the ONLY reverse coupling out of the node manager — by design it is a plain
+ /// (no Akka / IActorRef / DI handle). The handler
+ /// delegates run under the manager's Lock; the invoked action MUST be non-blocking
+ /// (a fire-and-forget Tell) so there is no deadlock. Null (the default) makes every
+ /// handler a safe no-op — it still gates + returns, just routes nowhere.
+ ///
+ ///
+ public Action? AlarmCommandRouter { get; set; }
+
/// Look up a materialised Part 9 alarm-condition node by its alarm node id (the
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.
/// The alarm node identifier (== ScriptedAlarmId).
@@ -316,6 +334,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.Message.Value = new LocalizedText(displayName);
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
+ // T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
+ // AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
+ // routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
+ // state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
+ // routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
+ // SDK applies its node state + auto-fires its own event.
+ // T20: the engine re-projects that same logical transition through WriteAlarmCondition, which
+ // also fires — the resulting double-emit is de-duped in a later task (T20), NOT here.
+ alarm.OnAcknowledge = (context, condition, _, comment) =>
+ HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
+ alarm.OnConfirm = (context, condition, _, comment) =>
+ HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
+ alarm.OnAddComment = (context, condition, _, comment) =>
+ HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
+ alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
+ {
+ // SDK invocation shapes (verified against the decompiled AlarmConditionState):
+ // OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
+ // TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
+ // Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
+ // shelvingTime is an OPC UA Duration (milliseconds).
+ var (operation, unshelveAt) =
+ !shelving ? ("Unshelve", (DateTime?)null)
+ : oneShot ? ("OneShotShelve", null)
+ : ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
+ return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
+ };
+ // The auto-unshelve timer firing is an unshelve transition driven by the SDK (no client user);
+ // route it as Unshelve so the engine clears its shelve state. Same AlarmAck gate applies.
+ alarm.OnTimedUnshelve = (context, condition) =>
+ HandleAlarmCommand(context, condition, "Unshelve", comment: null, unshelveAt: null);
+
parent.AddChild(alarm);
// Promote the equipment folder to an event notifier + register it as a root notifier so
@@ -328,6 +378,49 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
+ ///
+ /// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
+ /// principal off the SDK , applies the AlarmAck role gate
+ /// (fails closed: a missing identity or a missing role is denied), and on success builds a
+ /// mapped and routes it through .
+ ///
+ /// The SDK context the handler delegate was invoked with — a
+ /// ServerSystemContext (an ) carrying the session
+ /// identity. T17 attached the LDAP roles as a .
+ /// The condition the method targets; its NodeId identifier is the
+ /// ScriptedAlarmId (T14 aligned them), which becomes .
+ /// The Part 9 operation name (e.g. Acknowledge, TimedShelve).
+ /// The call's comment text, or null when none was supplied.
+ /// For TimedShelve, the computed UTC expiry; otherwise null.
+ /// ServiceResult.Good when allowed (the SDK then applies state + auto-fires its
+ /// event); BadUserAccessDenied when the gate vetoes (no route, no state mutation).
+ private ServiceResult HandleAlarmCommand(
+ ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
+ {
+ // Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
+ // role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
+ var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
+ if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
+ {
+ // Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
+ // state change and surfaces the status to the client; we never route or mutate.
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+
+ var cmd = new AlarmCommand(
+ AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
+ Operation: operation,
+ User: identity.DisplayName ?? string.Empty,
+ Comment: comment?.Text,
+ UnshelveAtUtc: unshelveAt);
+
+ // Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
+ AlarmCommandRouter?.Invoke(cmd);
+
+ // Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
+ return ServiceResult.Good;
+ }
+
/// Map our domain AlarmType string to the matching SDK condition subtype. Script
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
/// (see remarks).
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
index aa628562..adce251f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
@@ -1,5 +1,6 @@
using Opc.Ua;
using Opc.Ua.Server;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -17,6 +18,24 @@ public sealed class OtOpcUaSdkServer : StandardServer
/// . Null until the SDK has bootstrapped.
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
+ ///
+ /// Wire the reverse-path sink for inbound Part 9 alarm method calls onto the created
+ /// . The host calls this after start with a non-blocking
+ /// mediator.Tell that publishes each onto the
+ /// alarm-commands DistributedPubSub topic. No-op (returns false) when the node
+ /// manager has not been created yet, so the caller can detect a too-early call.
+ ///
+ /// The router invoked by the condition handlers once the AlarmAck
+ /// gate passes; may be null to clear it.
+ /// true when the router was set on a live node manager; false when no node
+ /// manager exists yet.
+ public bool SetAlarmCommandRouter(Action? router)
+ {
+ if (_otOpcUaNodeManager is null) return false;
+ _otOpcUaNodeManager.AlarmCommandRouter = router;
+ return true;
+ }
+
///
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs
new file mode 100644
index 00000000..0b81841d
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs
@@ -0,0 +1,25 @@
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+///
+/// Canonical string constants for the OPC UA data-plane roles the LDAP group→role map
+/// produces and carries onto the session identity.
+/// These are distinct from the control-plane AdminRole enum (Admin UI capabilities) — the
+/// two planes share zero runtime code path by design.
+///
+/// Across the codebase these data-plane roles (ReadOnly, WriteOperate,
+/// WriteTune, WriteConfigure, AlarmAck, …) are used as bare strings
+/// (they originate as LDAP group names mapped through RoleMapper). T18 introduced this
+/// single shared const for the one role the inbound alarm-method gate reads, so the gate and
+/// its tests reference one symbol instead of a re-typed literal. Comparison is case-insensitive
+/// (the role set is built with ), so the
+/// gate matches with that comparer too.
+///
+///
+public static class OpcUaDataPlaneRoles
+{
+ /// The role that grants OPC UA Part 9 alarm acknowledge / confirm / shelve / comment
+ /// authority. A session must carry this role for the inbound alarm-condition method handlers to
+ /// route the command to the engine; absent it, the call is denied with
+ /// BadUserAccessDenied.
+ public const string AlarmAck = "AlarmAck";
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
index 6ff420dc..d9872016 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
@@ -58,6 +58,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// the constant the (retired) ScriptedAlarmActor used so subscribers stay wired.
public const string AlertsTopic = "alerts";
+ /// The cluster DistributedPubSub topic inbound OPC UA Part 9 alarm method calls
+ /// (Acknowledge / Confirm / Shelve / AddComment) are routed onto as s.
+ /// The OPC UA node manager's condition handlers build the command (after the AlarmAck role
+ /// gate); the host's boot wiring publishes it here; T19's engine-side subscriber consumes it.
+ public const string AlarmCommandsTopic = "alarm-commands";
+
/// Reconcile the loaded alarm set to exactly the enabled subset of :
/// builds s (skipping disabled plans), reloads the engine, and
/// re-registers mux interest for the union of dependency refs.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
new file mode 100644
index 00000000..60bdfbf6
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
@@ -0,0 +1,367 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// T18 — inbound OPC UA Part 9 alarm method handlers. Boots a real ,
+/// materialises a condition, sets a capturing ,
+/// then drives the condition's public SDK handler delegates (OnAcknowledge/OnShelve)
+/// directly. Verifies (a) the AlarmAck role gate fails closed and (b) the routed
+/// is mapped correctly.
+///
+public sealed class AlarmCommandRouterTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _pkiRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"otopcua-alarmcmd-{Guid.NewGuid():N}");
+
+ /// With the AlarmAck role present, OnAcknowledge returns Good and routes exactly one
+ /// correctly-mapped AlarmCommand (AlarmId = ScriptedAlarmId, Operation, User, Comment).
+ [Fact]
+ public async Task OnAcknowledge_with_AlarmAck_returns_good_and_routes_mapped_command()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
+ nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-1");
+ condition.ShouldNotBeNull();
+ condition!.OnAcknowledge.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "alice", "ReadOnly", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack note"));
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ var cmd = captured[0];
+ cmd.AlarmId.ShouldBe("alm-1"); // == ScriptedAlarmId (condition NodeId identifier)
+ cmd.Operation.ShouldBe("Acknowledge");
+ cmd.User.ShouldBe("alice");
+ cmd.Comment.ShouldBe("ack note");
+ cmd.UnshelveAtUtc.ShouldBeNull();
+
+ await host.DisposeAsync();
+ }
+
+ /// Without the AlarmAck role, OnAcknowledge is vetoed (BadUserAccessDenied) and the
+ /// router is NOT invoked — the gate fails closed and never mutates state.
+ [Fact]
+ public async Task OnAcknowledge_without_AlarmAck_returns_denied_and_does_not_route()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
+ nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-2");
+ condition.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "bob", "ReadOnly"); // no AlarmAck
+ var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope"));
+
+ result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
+ captured.ShouldBeEmpty();
+
+ await host.DisposeAsync();
+ }
+
+ /// A null identity (anonymous / no role-carrying identity on the context) is denied and does
+ /// not route — the gate fails closed on a missing principal.
+ [Fact]
+ public async Task OnAcknowledge_with_null_identity_returns_denied_and_does_not_route()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
+ nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-3");
+ condition.ShouldNotBeNull();
+
+ // ServerSystemContext with no UserIdentity set ⇒ (context as ISessionOperationContext).UserIdentity is null.
+ var ctx = new ServerSystemContext(server.CurrentInstance);
+ var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));
+
+ result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
+ captured.ShouldBeEmpty();
+
+ await host.DisposeAsync();
+ }
+
+ /// OnConfirm with AlarmAck maps to the Confirm operation.
+ [Fact]
+ public async Task OnConfirm_with_AlarmAck_routes_confirm_operation()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C");
+ nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-c");
+ condition.ShouldNotBeNull();
+ condition!.OnConfirm.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "carol", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: null);
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].Operation.ShouldBe("Confirm");
+ captured[0].Comment.ShouldBeNull(); // null LocalizedText ⇒ null Comment
+ captured[0].User.ShouldBe("carol");
+
+ await host.DisposeAsync();
+ }
+
+ /// OnAddComment with AlarmAck maps to the AddComment operation, carrying the comment text.
+ [Fact]
+ public async Task OnAddComment_with_AlarmAck_routes_add_comment_operation()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC");
+ nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-ac");
+ condition.ShouldNotBeNull();
+ condition!.OnAddComment.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "dave", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition.OnAddComment!(ctx, condition, EventIdBytes(), new LocalizedText("look here"));
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].Operation.ShouldBe("AddComment");
+ captured[0].Comment.ShouldBe("look here");
+
+ await host.DisposeAsync();
+ }
+
+ /// OnShelve with oneShot=true maps to OneShotShelve with no UnshelveAtUtc.
+ [Fact]
+ public async Task OnShelve_oneshot_routes_one_shot_shelve_with_no_expiry()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1");
+ nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-s1");
+ condition.ShouldNotBeNull();
+ condition!.OnShelve.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "erin", OpcUaDataPlaneRoles.AlarmAck);
+ // SDK OneShotShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:true, 0.0).
+ var result = condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].Operation.ShouldBe("OneShotShelve");
+ captured[0].UnshelveAtUtc.ShouldBeNull();
+
+ await host.DisposeAsync();
+ }
+
+ /// OnShelve with oneShot=false and shelving=true maps to TimedShelve with UnshelveAtUtc
+ /// derived from the millisecond Duration (UtcNow + ms).
+ [Fact]
+ public async Task OnShelve_timed_routes_timed_shelve_with_ms_derived_expiry()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2");
+ nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-s2");
+ condition.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "frank", OpcUaDataPlaneRoles.AlarmAck);
+ const double shelvingTimeMs = 60_000.0; // OPC UA Duration = milliseconds
+ var before = DateTime.UtcNow;
+ // SDK TimedShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:false, shelvingTime).
+ var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: shelvingTimeMs);
+ var after = DateTime.UtcNow;
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].Operation.ShouldBe("TimedShelve");
+ captured[0].UnshelveAtUtc.ShouldNotBeNull();
+ // UtcNow + 60s, bracketed by the before/after capture instants.
+ captured[0].UnshelveAtUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddMilliseconds(shelvingTimeMs));
+ captured[0].UnshelveAtUtc!.Value.ShouldBeLessThanOrEqualTo(after.AddMilliseconds(shelvingTimeMs));
+
+ await host.DisposeAsync();
+ }
+
+ /// OnShelve with shelving=false maps to Unshelve with no UnshelveAtUtc.
+ [Fact]
+ public async Task OnShelve_unshelve_routes_unshelve_operation()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3");
+ nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-s3");
+ condition.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck);
+ // SDK Unshelve invokes OnShelve(ctx, alarm, shelving:false, oneShot:false, 0.0).
+ var result = condition!.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0.0);
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].Operation.ShouldBe("Unshelve");
+ captured[0].UnshelveAtUtc.ShouldBeNull();
+
+ await host.DisposeAsync();
+ }
+
+ /// OnShelve without AlarmAck is vetoed and does not route.
+ [Fact]
+ public async Task OnShelve_without_AlarmAck_returns_denied_and_does_not_route()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4");
+ nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-s4");
+ condition.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "heidi", "ReadOnly"); // no AlarmAck
+ var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);
+
+ result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
+ captured.ShouldBeEmpty();
+
+ await host.DisposeAsync();
+ }
+
+ /// OnTimedUnshelve with AlarmAck maps to the Unshelve operation (the timer fired, the alarm
+ /// auto-unshelves).
+ [Fact]
+ public async Task OnTimedUnshelve_with_AlarmAck_routes_unshelve_operation()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU");
+ nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-tu");
+ condition.ShouldNotBeNull();
+ condition!.OnTimedUnshelve.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition.OnTimedUnshelve!(ctx, condition);
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].Operation.ShouldBe("Unshelve");
+
+ await host.DisposeAsync();
+ }
+
+ /// A null router is a safe no-op: handler still gates + returns Good, just routes nowhere.
+ [Fact]
+ public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ // No router set (default null).
+
+ nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR");
+ nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700);
+ var condition = nm.TryGetAlarmCondition("alm-nr");
+ condition.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "judy", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ok"));
+
+ result.ShouldBe(ServiceResult.Good);
+
+ await host.DisposeAsync();
+ }
+
+ /// Builds a (an )
+ /// carrying a with the given name + roles — the exact seam the
+ /// gate reads via (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity.
+ private static ServerSystemContext SessionContext(OtOpcUaSdkServer server, string user, params string[] roles)
+ {
+ var identity = new RoleCarryingUserIdentity(
+ new UserNameIdentityToken { UserName = user },
+ roles);
+ return new ServerSystemContext(server.CurrentInstance) { UserIdentity = identity };
+ }
+
+ private static byte[] EventIdBytes() => Guid.NewGuid().ToByteArray();
+
+ private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
+ {
+ var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.AlarmCmdTest",
+ ApplicationUri = $"urn:OtOpcUa.AlarmCmdTest:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance);
+
+ var server = new OtOpcUaSdkServer();
+ await host.StartAsync(server, Ct);
+ return (host, server);
+ }
+
+ private static int AllocateFreePort()
+ {
+ using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ /// Cleans up the PKI root directory.
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+}