diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs
new file mode 100644
index 00000000..7595ebd2
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs
@@ -0,0 +1,20 @@
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
+
+///
+/// H6c — the payload routed when an OPC UA client Acknowledges a NATIVE (driver-fed, e.g. Galaxy)
+/// Part 9 condition. The scripted-alarm engine does not own native conditions, so the node manager
+/// branches a native condition's inbound Acknowledge to a separate
+/// seam (the host later wires it to the backing
+/// driver) instead of the scripted .
+///
+/// This record is intentionally OpcUaServer-local (the smallest scope) and Akka-free: it carries
+/// exactly what the driver-bound router needs — the condition node id to resolve back to a driver
+/// ref, the operator's acknowledge comment, and the authenticated operator's display name.
+///
+///
+/// The acknowledged condition's folder-scoped NodeId identifier string
+/// (the same value the DriverHostActor inverse map keys native conditions by), used to resolve the
+/// command back to its backing driver + alarm ref.
+/// The operator's acknowledge comment text, or null when none was supplied.
+/// The authenticated operator's display name (empty when none resolves).
+public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index d96d993f..c17ac430 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -97,6 +97,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
public Action? AlarmCommandRouter { get; set; }
+ ///
+ /// H6c — reverse-path sink for an inbound OPC UA Acknowledge on a NATIVE (driver-fed, e.g. Galaxy)
+ /// Part 9 condition. The scripted-alarm engine does not own native conditions, so when a client
+ /// Acknowledges one, the condition's OnAcknowledge handler (wired in
+ /// ) branches on and — after
+ /// the same AlarmAck role gate as — invokes THIS delegate
+ /// with a instead of routing an to the
+ /// scripted engine. The host sets it at boot to a non-blocking dispatch toward the backing driver
+ /// (a later task wires the driver linkage).
+ ///
+ /// Like , the handler delegate runs under the manager's
+ /// Lock, so the invoked action MUST be non-blocking (fire-and-forget). Null (the default)
+ /// makes the native-ack handler a safe no-op — it still gates + returns, just routes nowhere.
+ /// Only the Acknowledge of a native condition uses this seam; Confirm/AddComment/Shelve on a
+ /// native condition stay on the scripted path (H6c scope is the
+ /// Acknowledge → driver path only).
+ ///
+ ///
+ public Action? NativeAlarmAckRouter { get; set; }
+
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
///
@@ -602,8 +622,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
+ // H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
+ // scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
+ // ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/
+ // Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
alarm.OnAcknowledge = (context, condition, _, comment) =>
- HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
+ IsNativeAlarmNode(alarmNodeId)
+ ? HandleNativeAlarmAck(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) =>
@@ -716,6 +742,45 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return ServiceResult.Good;
}
+ ///
+ /// H6c — handler body for an inbound OPC UA Acknowledge on a NATIVE (driver-fed) condition. The
+ /// scripted engine does not own native conditions, so this routes a to
+ /// (the driver-bound seam) rather than a scripted
+ /// to . The calling principal is resolved
+ /// and the AlarmAck role gate applied EXACTLY as in
+ /// (fails closed: a missing identity or missing role is denied — no route, no state mutation).
+ ///
+ /// 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 Acknowledge targets; its NodeId identifier is the
+ /// folder-scoped condition node id the driver-bound router resolves back to a driver ref.
+ /// The acknowledge comment text, or null when none was supplied.
+ /// 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 HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
+ {
+ // Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
+ // contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
+ var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
+ if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+
+ // Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
+ // for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
+ // (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref).
+ // Non-blocking by contract (host wires a fire-and-forget dispatch); safe to call under Lock.
+ NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
+ ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
+ Comment: comment?.Text,
+ OperatorUser: identity.DisplayName ?? string.Empty));
+
+ // Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
+ return ServiceResult.Good;
+ }
+
///
/// The attached to a writable equipment-tag variable by
/// (Task 11). The OPC UA SDK invokes it when a client writes the
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 047f17a8..c2fbb8a2 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
@@ -411,6 +411,104 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
+ /// H6c — a NATIVE (driver-fed) condition's Acknowledge routes to (the driver-bound seam), NOT the scripted
+ /// . With the AlarmAck role present, OnAcknowledge
+ /// returns Good, the captured carries the condition NodeId, the operator
+ /// DisplayName, and the comment text, and the scripted router is NOT invoked.
+ [Fact]
+ public async Task Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ var scripted = new List();
+ var native = new List();
+ nm.AlarmCommandRouter = scripted.Add;
+ nm.NativeAlarmAckRouter = native.Add;
+
+ nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1");
+ nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
+ var condition = nm.TryGetAlarmCondition("alm-nak1");
+ condition.ShouldNotBeNull();
+ condition!.OnAcknowledge.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "nora", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("native ack"));
+
+ result.ShouldBe(ServiceResult.Good);
+ native.Count.ShouldBe(1);
+ native[0].ConditionNodeId.ShouldBe("alm-nak1"); // folder-scoped condition NodeId identifier
+ native[0].OperatorUser.ShouldBe("nora");
+ native[0].Comment.ShouldBe("native ack");
+ scripted.ShouldBeEmpty(); // scripted engine NOT involved
+
+ await host.DisposeAsync();
+ }
+
+ /// H6c — a SCRIPTED condition's Acknowledge still routes through the scripted
+ /// (Operation == "Acknowledge"); the native
+ /// router is NOT invoked.
+ [Fact]
+ public async Task Scripted_OnAcknowledge_still_uses_AlarmCommandRouter()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ var scripted = new List();
+ var native = new List();
+ nm.AlarmCommandRouter = scripted.Add;
+ nm.NativeAlarmAckRouter = native.Add;
+
+ nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2");
+ nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
+ var condition = nm.TryGetAlarmCondition("alm-nak2");
+ condition.ShouldNotBeNull();
+ condition!.OnAcknowledge.ShouldNotBeNull();
+
+ var ctx = SessionContext(server, "owen", OpcUaDataPlaneRoles.AlarmAck);
+ var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("scripted ack"));
+
+ result.ShouldBe(ServiceResult.Good);
+ scripted.Count.ShouldBe(1);
+ scripted[0].AlarmId.ShouldBe("alm-nak2");
+ scripted[0].Operation.ShouldBe("Acknowledge");
+ scripted[0].User.ShouldBe("owen");
+ scripted[0].Comment.ShouldBe("scripted ack");
+ native.ShouldBeEmpty(); // native seam NOT involved
+
+ await host.DisposeAsync();
+ }
+
+ /// H6c — a NATIVE condition's Acknowledge from an anonymous / role-less identity is vetoed
+ /// (BadUserAccessDenied) and NEITHER router is invoked — the gate fails closed before any route.
+ [Fact]
+ public async Task Native_OnAcknowledge_anonymous_is_denied()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ var scripted = new List();
+ var native = new List();
+ nm.AlarmCommandRouter = scripted.Add;
+ nm.NativeAlarmAckRouter = native.Add;
+
+ nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3");
+ nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
+ var condition = nm.TryGetAlarmCondition("alm-nak3");
+ condition.ShouldNotBeNull();
+
+ // ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity.
+ var ctx = new ServerSystemContext(server.CurrentInstance);
+ var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));
+
+ result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
+ native.ShouldBeEmpty();
+ scripted.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()