diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs
new file mode 100644
index 00000000..98e11aa5
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs
@@ -0,0 +1,13 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+/// Optional capability on an address-space sink: surgical in-place attribute updates on an
+/// EXISTING variable node, used by Phase7Applier to avoid a full RebuildAddressSpace for pure-property
+/// tag changes (Writable / Historizing). A sink that does not implement it ⇒ caller falls back to a
+/// full rebuild (safe default).
+public interface ISurgicalAddressSpaceSink
+{
+ /// Update an existing variable node's Writable (AccessLevel + inbound-write handler) and
+ /// Historizing (+ historian-tagname binding) IN PLACE, notifying subscribers (ClearChangeMasks)
+ /// without a rebuild. Returns false if the node does not exist (caller should rebuild instead).
+ bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 92669fc3..ee503ef5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -1332,9 +1332,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// access bit (on top of the writable composite) so clients can browse + HistoryRead it.
var historized = historianTagname is not null;
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
- // CurrentRead | CurrentWrite. OR-ing two byte constants promotes to int, so cast back.
- byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
- if (historized) access = (byte)(access | AccessLevels.HistoryRead);
+ // CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized.
+ var access = ComposeAccessLevel(writable, historized);
var variable = new BaseDataVariableState(parent)
{
NodeId = new NodeId(variableNodeId, NamespaceIndex),
@@ -1369,6 +1368,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
+ /// Compose the AccessLevel/UserAccessLevel byte for a tag variable from its Writable +
+ /// Historizing flags — the single source of truth shared by (node
+ /// creation) and (F10b surgical in-place update). The SDK exposes
+ /// the flags separately (no CurrentReadWrite composite): ReadWrite is CurrentRead | CurrentWrite,
+ /// with the HistoryRead bit OR-ed in when historized. OR-ing byte constants promotes to int, so cast back.
+ private static byte ComposeAccessLevel(bool writable, bool historized)
+ {
+ byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
+ if (historized) access = (byte)(access | AccessLevels.HistoryRead);
+ return access;
+ }
+
+ /// F10b surgical counterpart of : update an EXISTING tag variable's
+ /// Writable (AccessLevel + handler) and Historizing (+ historian-tagname
+ /// binding) in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node
+ /// survive. Returns false when the node id is unknown (caller falls back to a full rebuild).
+ public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
+ lock (Lock)
+ {
+ if (!_variables.TryGetValue(variableNodeId, out var v)) return false;
+ var historized = historianTagname is not null;
+ var access = ComposeAccessLevel(writable, historized);
+ v.AccessLevel = access;
+ v.UserAccessLevel = access;
+ v.Historizing = historized;
+ v.OnWriteValue = writable ? OnEquipmentTagWrite : null;
+ if (historized) _historizedTagnames[variableNodeId] = historianTagname!;
+ else _historizedTagnames.TryRemove(variableNodeId, out _);
+ v.ClearChangeMasks(SystemContext, includeChildren: false);
+ return true;
+ }
+ }
+
/// Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
/// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
/// (matches CreateVariable's default for lazy-created nodes).
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index 9894e33b..709b1828 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// . The host wires this in once the StandardServer has
/// been started (so the node manager exists).
///
-public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
+public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
private readonly OtOpcUaNodeManager _nodeManager;
@@ -65,6 +65,14 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
+ /// F10b: surgically update an existing variable node's Writable + Historizing in place
+ /// (no rebuild). Returns false when the node does not exist (caller falls back to a full rebuild).
+ /// The variable node identifier.
+ /// When true the node becomes read/write with the inbound-write handler; otherwise read-only.
+ /// null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.
+ public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
+ => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname);
+
/// Rebuilds the entire OPC UA address space.
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
}