diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 3327bf40..d10bafd7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -634,6 +634,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return ServiceResult.Good;
};
+ // H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
+ // here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
+ // (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
+ // "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
+ // AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
+ // engine enable/disable surface (Phase 3 decision #2) — they short-circuit to BadNotSupported.
+ alarm.OnEnableDisable = (context, condition, enabling) =>
+ {
+ if (_nativeAlarmNodeIds.Contains(alarmNodeId))
+ return new ServiceResult(StatusCodes.BadNotSupported);
+ return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
+ };
+
parent.AddChild(alarm);
// Promote the equipment folder to an event notifier + register it as a root notifier so
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 395cdc04..047f17a8 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
@@ -302,6 +302,115 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
+ /// H4 — OnEnableDisable with enabling:false and the AlarmAck role on a SCRIPTED
+ /// condition returns Good and routes exactly one with Operation == "Disable"
+ /// and User == the caller's DisplayName.
+ [Fact]
+ public async Task OnEnableDisable_disabling_with_AlarmAck_routes_Disable()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1");
+ nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
+ var condition = nm.TryGetAlarmCondition("alm-ed1");
+ condition.ShouldNotBeNull();
+ condition!.OnEnableDisable.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck);
+ // SDK Disable invokes OnEnableDisable(ctx, condition, enabling:false).
+ var result = condition.OnEnableDisable!(ctx, condition, enabling: false);
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].AlarmId.ShouldBe("alm-ed1");
+ captured[0].Operation.ShouldBe("Disable");
+ captured[0].User.ShouldBe("ivan");
+ captured[0].UnshelveAtUtc.ShouldBeNull();
+
+ await host.DisposeAsync();
+ }
+
+ /// H4 — OnEnableDisable with enabling:true maps to the Enable operation.
+ [Fact]
+ public async Task OnEnableDisable_enabling_routes_Enable()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2");
+ nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
+ var condition = nm.TryGetAlarmCondition("alm-ed2");
+ condition.ShouldNotBeNull();
+ condition!.OnEnableDisable.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "jane", OpcUaDataPlaneRoles.AlarmAck);
+ // SDK Enable invokes OnEnableDisable(ctx, condition, enabling:true).
+ var result = condition.OnEnableDisable!(ctx, condition, enabling: true);
+
+ result.ShouldBe(ServiceResult.Good);
+ captured.Count.ShouldBe(1);
+ captured[0].Operation.ShouldBe("Enable");
+ captured[0].User.ShouldBe("jane");
+
+ await host.DisposeAsync();
+ }
+
+ /// H4 — OnEnableDisable from an anonymous / role-less identity is vetoed
+ /// (BadUserAccessDenied) and the router is NOT invoked — the gate fails closed.
+ [Fact]
+ public async Task OnEnableDisable_anonymous_is_denied()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3");
+ nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
+ var condition = nm.TryGetAlarmCondition("alm-ed3");
+ condition.ShouldNotBeNull();
+
+ // ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity.
+ var ctx = new ServerSystemContext(server.CurrentInstance);
+ var result = condition!.OnEnableDisable!(ctx, condition, enabling: false);
+
+ result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
+ captured.ShouldBeEmpty();
+
+ await host.DisposeAsync();
+ }
+
+ /// H4 — OnEnableDisable on a NATIVE (driver-fed) condition returns BadNotSupported and does
+ /// NOT route — native conditions have no engine enable/disable surface (Phase 3 decision #2). The gate
+ /// short-circuits on the native flag before the role check, so even an AlarmAck caller gets BadNotSupported.
+ [Fact]
+ public async Task OnEnableDisable_on_native_condition_is_BadNotSupported()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var captured = new List();
+ nm.AlarmCommandRouter = captured.Add;
+
+ nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4");
+ nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
+ var condition = nm.TryGetAlarmCondition("alm-ed4");
+ condition.ShouldNotBeNull();
+ condition!.OnEnableDisable.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "kara", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition.OnEnableDisable!(ctx, condition, enabling: false);
+
+ result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported);
+ captured.ShouldBeEmpty();
+
+ 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()