diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
index 34994e2a..7def14ed 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs
@@ -18,6 +18,9 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// 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).
+/// Note: Enable and Disable are part of the vocabulary but are not yet wired at the
+/// node-manager seam (T18 wired Acknowledge/Confirm/AddComment/Shelve/TimedUnshelve only);
+/// OnEnableDisable delegate wiring is a future task.
///
/// The acting user — the authenticated session identity's display/name.
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index c61295b2..9261dd2b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -361,10 +361,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
: ("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.
+ // The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK
+ // fires it with the node manager's system context — there is NO session and NO user identity.
+ // Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied,
+ // leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the
+ // same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve
+ // state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated.
alarm.OnTimedUnshelve = (context, condition) =>
- HandleAlarmCommand(context, condition, "Unshelve", comment: null, unshelveAt: null);
+ {
+ var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty;
+ AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", string.Empty, null, null));
+ return ServiceResult.Good;
+ };
parent.AddChild(alarm);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
index 60bdfbf6..1594c2c1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
@@ -268,10 +268,12 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
- /// OnTimedUnshelve with AlarmAck maps to the Unshelve operation (the timer fired, the alarm
- /// auto-unshelves).
+ /// OnTimedUnshelve fires with the SDK's system context (no session, no user identity) —
+ /// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be
+ /// Good, the router must be invoked exactly once with Operation == "Unshelve" and User == empty,
+ /// and no UnshelveAtUtc is carried.
[Fact]
- public async Task OnTimedUnshelve_with_AlarmAck_routes_unshelve_operation()
+ public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
@@ -284,12 +286,18 @@ public sealed class AlarmCommandRouterTests : IDisposable
condition.ShouldNotBeNull();
condition!.OnTimedUnshelve.ShouldNotBeNull();
- var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck);
+ // Reproduce the real SDK path: system context has no session and no UserIdentity — exactly what
+ // the SDK's internal timer fires the callback with when a TimedShelve duration expires.
+ var ctx = new ServerSystemContext(server.CurrentInstance); // no UserIdentity set
var result = condition.OnTimedUnshelve!(ctx, condition);
result.ShouldBe(ServiceResult.Good);
captured.Count.ShouldBe(1);
- captured[0].Operation.ShouldBe("Unshelve");
+ var cmd = captured[0];
+ cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier
+ cmd.Operation.ShouldBe("Unshelve");
+ cmd.User.ShouldBe(string.Empty); // no client principal — system-initiated
+ cmd.UnshelveAtUtc.ShouldBeNull();
await host.DisposeAsync();
}