diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
index 67d9ef55..ab7d208c 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// no-op against , so the actor stays safe to
/// receive messages from the moment it boots.
///
-public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
+public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
private volatile IOpcUaAddressSpaceSink _inner = NullOpcUaAddressSpaceSink.Instance;
@@ -69,4 +69,17 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
/// Rebuilds the address space through the inner sink.
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
+
+ /// Forwards an in-place tag-attribute update (F10b) to the inner sink when it supports the
+ /// surgical capability. Returns false otherwise — before the real SdkAddressSpaceSink is
+ /// swapped in (inner is still the null sink), or any inner sink that isn't surgical — so the caller
+ /// (Phase7Applier) falls back to a full rebuild. Without this forward the surgical optimization is
+ /// inert on every driver-role host, because actors inject THIS wrapper, not the inner sink.
+ /// The node ID of the variable to update in place.
+ /// Whether the node should be read/write.
+ /// null ⇒ not historized; non-null ⇒ Historizing + historian binding.
+ /// True when the inner sink applied the update; false when it lacks the capability or the node is missing.
+ public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
+ => _inner is ISurgicalAddressSpaceSink surgical
+ && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname);
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
index d4df9737..2fdae40e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -83,6 +83,53 @@ public sealed class DeferredAddressSpaceSinkTests
call.HistorianTagname.ShouldBe("MyTag.PV");
}
+ /// F10b regression: the deferred wrapper MUST forward the surgical capability to a
+ /// surgical inner sink — otherwise Phase7Applier (which injects THIS wrapper on every
+ /// driver-role host, not the inner SdkAddressSpaceSink) never sees the capability and the
+ /// in-place tag-attribute optimization is inert in production (it silently falls back to rebuild).
+ [Fact]
+ public void UpdateTagAttributes_forwards_to_a_surgical_inner_sink()
+ {
+ var deferred = new DeferredAddressSpaceSink();
+ var inner = new SurgicalRecordingSink { Result = true };
+ deferred.SetSink(inner);
+
+ ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV")
+ .ShouldBeTrue();
+
+ var call = inner.SurgicalCalls.ShouldHaveSingleItem();
+ call.NodeId.ShouldBe("v-1");
+ call.Writable.ShouldBeTrue();
+ call.Historian.ShouldBe("MyTag.PV");
+ }
+
+ /// The surgical forward returns the inner's own result (false ⇒ node missing) so the caller
+ /// falls back to a full rebuild.
+ [Fact]
+ public void UpdateTagAttributes_returns_inner_result_when_node_missing()
+ {
+ var deferred = new DeferredAddressSpaceSink();
+ deferred.SetSink(new SurgicalRecordingSink { Result = false });
+
+ ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null)
+ .ShouldBeFalse();
+ }
+
+ /// When the inner sink does NOT implement (e.g. the
+ /// null sink before the real one is swapped in, or any non-surgical sink) the wrapper returns false
+ /// so the caller rebuilds.
+ [Fact]
+ public void UpdateTagAttributes_returns_false_when_inner_is_not_surgical()
+ {
+ var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical)
+ ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null)
+ .ShouldBeFalse();
+
+ deferred.SetSink(new RecordingSink()); // a non-surgical inner
+ ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null)
+ .ShouldBeFalse();
+ }
+
/// Builds a minimal for the forwarding tests (the
/// inner sink only records the node id, so the exact state values don't matter here).
private static AlarmConditionSnapshot Snapshot(bool active = false) =>
@@ -123,4 +170,32 @@ public sealed class DeferredAddressSpaceSinkTests
///
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
}
+
+ private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
+ {
+ /// Gets or sets the value returns.
+ public bool Result { get; set; } = true;
+ /// Gets the recorded surgical calls.
+ public List<(string NodeId, bool Writable, string? Historian)> SurgicalCalls { get; } = new();
+
+ ///
+ public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
+ {
+ SurgicalCalls.Add((variableNodeId, writable, historianTagname));
+ return Result;
+ }
+
+ ///
+ public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
+ ///
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
+ ///
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
+ ///
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
+ ///
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
+ ///
+ public void RebuildAddressSpace() { }
+ }
}