diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index eda6f98f..d922ed72 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -67,6 +67,10 @@ public sealed class Phase7Applier
SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts);
removedCount++;
}
+ // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
+ // before tear-down), but they ARE real removals — count them so Phase7ApplyOutcome.RemovedNodes
+ // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below.
+ removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count;
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
index c794bfce..6bdc71b3 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -622,6 +622,50 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(0);
}
+ /// H1a (review follow-up) — a deploy that ONLY removes existing equipment tag / VirtualTag
+ /// nodes must rebuild AND tally the removals. Removed tags/VirtualTags are plain variable nodes (no
+ /// Part 9 condition to write), so before the fix they reached the rebuild path but were never added
+ /// to removedCount — Phase7ApplyOutcome.RemovedNodes reported 0, a misleading audit
+ /// entry. This pins both the rebuild and the accurate count.
+ [Fact]
+ public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var previous = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentTags = new[]
+ {
+ new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
+ },
+ EquipmentVirtualTags = new[]
+ {
+ new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
+ Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
+ },
+ };
+ var next = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty());
+
+ var plan = Phase7Planner.Compute(previous, next);
+
+ // Guard the arrange: ONLY the two Removed sets are populated.
+ plan.RemovedEquipmentTags.Count.ShouldBe(1);
+ plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1);
+ plan.AddedEquipmentTags.ShouldBeEmpty();
+ plan.ChangedEquipmentTags.ShouldBeEmpty();
+ plan.RemovedEquipment.ShouldBeEmpty();
+ plan.RemovedAlarms.ShouldBeEmpty();
+
+ var outcome = applier.Apply(plan);
+
+ outcome.RebuildCalled.ShouldBeTrue();
+ sink.RebuildCalls.ShouldBe(1);
+ outcome.RemovedNodes.ShouldBe(2); // both removals counted (was 0 before the fix)
+ }
+
private static Phase7CompositionResult CompositionWithTags(params EquipmentTagPlan[] tags) =>
new(
Array.Empty(), Array.Empty(), Array.Empty())