diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json
index df9b97d7..6e179a63 100644
--- a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json
+++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json
@@ -87,7 +87,7 @@
{
"id": "T10",
"subject": "Phase 2: OtOpcUaNodeManager.RemoveEquipmentSubtree (descendants + notifier demotion) + SdkAddressSpaceSink forwards (high-risk)",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"T8",
"T9"
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index cbcb0de2..27919037 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -1847,7 +1847,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
///
- public bool RemoveEquipmentSubtree(string equipmentNodeId) => false;
+ public bool RemoveEquipmentSubtree(string equipmentNodeId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
+ EnsureAddressSpaceCreated();
+
+ GeneralModelChangeEventState e;
+ lock (Lock)
+ {
+ // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
+ if (!_folders.ContainsKey(equipmentNodeId)) return false;
+
+ // Folder-scoped NodeIds mean every descendant's id is the equipment id itself or begins with
+ // "/" (sub-folders, variables, condition nodes). Snapshot each map's in-scope keys
+ // first (ToList) so we don't mutate while enumerating.
+ var prefix = equipmentNodeId + "/";
+ bool InScope(string id) => id == equipmentNodeId || id.StartsWith(prefix, StringComparison.Ordinal);
+
+ // Value variables (+ their historized-tagname registrations).
+ foreach (var id in _variables.Keys.Where(InScope).ToList())
+ {
+ if (_variables.TryRemove(id, out var v))
+ {
+ v.Parent?.RemoveChild(v);
+ PredefinedNodes?.Remove(v.NodeId);
+ }
+ _historizedTagnames.TryRemove(id, out _);
+ }
+
+ // Part 9 condition nodes (+ their native flags).
+ foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
+ {
+ if (_alarmConditions.TryRemove(id, out var c))
+ {
+ c.Parent?.RemoveChild(c);
+ PredefinedNodes?.Remove(c.NodeId);
+ }
+ _nativeAlarmNodeIds.Remove(id);
+ }
+
+ // Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
+ // every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
+ // alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
+ // _notifierFolders is keyed by the folder's NodeId; match on its string identifier.
+ foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.Identifier?.ToString() ?? string.Empty)).ToList())
+ {
+ RemoveRootNotifier(kvp.Value);
+ _notifierFolders.Remove(kvp.Key);
+ }
+ // Drop the event-history source registrations for the subtree (keyed by the folder id string).
+ foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
+ _eventNotifierSources.TryRemove(src, out _);
+
+ // Sub-folders + the equipment folder itself (children already detached above).
+ foreach (var id in _folders.Keys.Where(InScope).ToList())
+ {
+ if (_folders.TryRemove(id, out var f))
+ {
+ f.Parent?.RemoveChild(f);
+ PredefinedNodes?.Remove(f.NodeId);
+ }
+ }
+
+ e = BuildNodesRemovedModelChange(equipmentNodeId);
+ }
+ ReportModelChangeOutsideLock(e, equipmentNodeId);
+ return true;
+ }
private FolderState ResolveParentFolder(string? parentNodeId)
{