diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 0acb5bea..cdd51cd9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -1064,6 +1064,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
/// Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure
/// and the SDK trace captures any audit-report failure.
+ /// internal (not private) so the behavioural test drives the blip-then-settle continuation
+ /// against a booted node manager — see NodeManagerWriteRevertTests.
///
/// The string id of the written variable node.
/// The device-write outcome routed back by the gateway.
@@ -1072,7 +1074,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The node's real status captured before the optimistic write.
/// The writing principal's user-id string (the identity's DisplayName), threaded
/// from to populate the audit event's ClientUserId; null when unknown.
- private void RevertOptimisticWriteIfNeeded(
+ internal void RevertOptimisticWriteIfNeeded(
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
string? clientUserId)
{
@@ -1132,7 +1134,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The node's real pre-write value (the audit OldValue).
/// The writing principal's user-id string; null when unknown.
/// A populated, unreported .
- private AuditWriteUpdateEventState BuildWriteFailureAuditEvent(
+ /// internal (not private) so NodeManagerWriteRevertTests can assert the populated
+ /// audit fields at the nearest deterministic seam (the end-to-end Server.ReportEvent dispatch would
+ /// need a subscribed event monitored-item to observe).
+ internal AuditWriteUpdateEventState BuildWriteFailureAuditEvent(
BaseDataVariableState variable, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue,
string? clientUserId)
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs
new file mode 100644
index 00000000..66057efa
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs
@@ -0,0 +1,220 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// Write-outcome self-correction — the BEHAVIOURAL half of the failed-device-write surfacing
+/// (the pure decision tables live in ). Boots a real
+/// through (the same harness
+/// uses) so the continuation runs against a live node
+/// manager (real Lock + SystemContext + Server), and drives
+/// +
+/// directly:
+///
+/// - Item B (Bad-quality blip + settle): a FAILED outcome on a node still holding the
+/// optimistic value settles the node back to its prior value/status; SUCCESS / a moved-on poll
+/// leave the node untouched.
+/// - Item C (AuditWriteUpdateEvent): the built audit event-state carries SourceNode=node,
+/// AttributeId=Value, OldValue=prior, NewValue=attempted, ClientUserId=writer, Status=false, and
+/// the device Reason in its Message. (The end-to-end Server.ReportEvent dispatch needs a
+/// subscribed event monitored-item to observe — the nearest deterministic seam is the builder.)
+///
+///
+public sealed class NodeManagerWriteRevertTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _pkiRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"otopcua-write-revert-{Guid.NewGuid():N}");
+
+ // ───────────────────────────── Item B — Bad-quality blip + settle ─────────────────────────────
+
+ /// (B1) A FAILED device-write outcome on a node that still holds the optimistic value SETTLES the
+ /// node back to its captured prior value + prior status (the transient Bad blip coalesces within one
+ /// publishing interval — see the method remarks — so we assert the observable settled state).
+ [Fact]
+ public async Task Failed_outcome_settles_node_back_to_prior_value_and_status()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
+ // Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject).
+ nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
+ var node = nm.TryGetVariable("eq-1/sp")!;
+ node.Value = 42; // simulate the SDK's optimistic apply of the rejected write
+
+ nm.RevertOptimisticWriteIfNeeded(
+ "eq-1/sp",
+ outcome: new NodeWriteOutcome(false, "device rejected register 42"),
+ optimisticValue: 42,
+ priorValue: 7,
+ priorStatus: StatusCodes.Good,
+ clientUserId: "op");
+
+ node.Value.ShouldBe(7); // settled back to prior value
+ node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad)
+
+ await host.DisposeAsync();
+ }
+
+ /// (B2) A SUCCESS outcome NEVER reverts — the optimistic value stands (the next poll re-confirms
+ /// it via the normal WriteValue path).
+ [Fact]
+ public async Task Successful_outcome_leaves_optimistic_value()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
+ nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
+ var node = nm.TryGetVariable("eq-1/sp")!;
+ node.Value = 42;
+
+ nm.RevertOptimisticWriteIfNeeded(
+ "eq-1/sp",
+ outcome: new NodeWriteOutcome(true, null),
+ optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
+
+ node.Value.ShouldBe(42); // success ⇒ optimistic value stands
+
+ await host.DisposeAsync();
+ }
+
+ /// (B3) A failed outcome where a fresh driver poll already moved the node OFF the optimistic value
+ /// does NOT clobber the poll — the node keeps the fresh polled value (the still-holds-optimistic guard).
+ [Fact]
+ public async Task Failed_outcome_does_not_clobber_a_fresh_poll()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
+ nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
+ var node = nm.TryGetVariable("eq-1/sp")!;
+ node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42)
+
+ nm.RevertOptimisticWriteIfNeeded(
+ "eq-1/sp",
+ outcome: new NodeWriteOutcome(false, "device rejected"),
+ optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
+
+ node.Value.ShouldBe(99); // poll value preserved — not reverted to 7
+
+ await host.DisposeAsync();
+ }
+
+ /// (B4) An unknown node id (rebuilt/removed in the interim) is a no-op — the continuation must not
+ /// throw out of its fire-and-forget body.
+ [Fact]
+ public async Task Failed_outcome_on_missing_node_is_a_noop()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded(
+ "eq-1/gone",
+ outcome: new NodeWriteOutcome(false, "device rejected"),
+ optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"));
+
+ await host.DisposeAsync();
+ }
+
+ // ───────────────────────────── Item C — AuditWriteUpdateEvent builder ─────────────────────────────
+
+ /// (C1) The built audit event reflects the rejected write: SourceNode = the node, AttributeId =
+ /// Value, OldValue = prior, NewValue = attempted, ClientUserId = the writer, the AuditEvent Status boolean
+ /// = false (failed), and the device Reason is carried in the Message.
+ [Fact]
+ public async Task Built_audit_event_reflects_the_failed_write()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
+ nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
+ var node = nm.TryGetVariable("eq-1/sp")!;
+
+ var audit = nm.BuildWriteFailureAuditEvent(
+ node,
+ outcome: new NodeWriteOutcome(false, "FC06 rejected"),
+ optimisticValue: 42,
+ priorValue: 7,
+ clientUserId: "op");
+
+ audit.ShouldNotBeNull();
+ audit.SourceNode.Value.ShouldBe(node.NodeId);
+ audit.AttributeId.Value.ShouldBe((uint)Attributes.Value);
+ audit.OldValue.Value.ShouldBe(7);
+ audit.NewValue.Value.ShouldBe(42);
+ audit.ClientUserId.Value.ShouldBe("op");
+ audit.Status.Value.ShouldBeFalse(); // AuditEvent Status = false ⇒ the write failed
+ audit.Message.Value.Text.ShouldContain("FC06 rejected"); // device Reason carried in the Message
+
+ await host.DisposeAsync();
+ }
+
+ /// (C2) When the device gives no Reason, the audit Message still carries a sensible default
+ /// ("device write rejected") rather than an empty/null message.
+ [Fact]
+ public async Task Built_audit_event_uses_default_reason_when_none_supplied()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
+ nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
+ var node = nm.TryGetVariable("eq-1/sp")!;
+
+ var audit = nm.BuildWriteFailureAuditEvent(
+ node,
+ outcome: new NodeWriteOutcome(false, null),
+ optimisticValue: 42, priorValue: 7, clientUserId: "op");
+
+ audit.Message.Value.Text.ShouldContain("device write rejected");
+
+ await host.DisposeAsync();
+ }
+
+ private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
+ {
+ var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.WriteRevertTest",
+ ApplicationUri = $"urn:OtOpcUa.WriteRevertTest:{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 */ }
+ }
+ }
+}