diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index bda644cd..eda6f98f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -77,15 +77,19 @@ public sealed class Phase7Applier
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
- // Any add/remove of Equipment, ScriptedAlarm, Equipment tag, or Equipment VirtualTag topology
- // requires a real address-space rebuild. Driver-instance changes don't touch the address-space
- // topology directly — they go through DriverHostActor's spawn-plan in Runtime.
- // TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
+ // Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
+ // VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
+ // every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
+ // re-severitied alarm, a flipped tag dataType/Writable, or an edited VirtualTag expression) must
+ // still rebuild or the running server keeps the stale node.
+ // ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the
+ // address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which
+ // re-spawns the affected driver actor without re-materialising any nodes.
var needsRebuild =
- plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
- plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
- plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
- plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;
+ plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
+ plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
+ plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.ChangedEquipmentTags.Count > 0 ||
+ plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Count > 0;
if (needsRebuild)
{
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 b30c6403..c794bfce 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -498,6 +498,153 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
+ /// H1a — a deploy that ONLY changes an existing equipment tag (e.g. flips its dataType or
+ /// Writable bit) must rebuild the address space. The planner diffs the tag into
+ /// ChangedEquipmentTags with no Added/Removed of anything else; the applier must still drive
+ /// exactly one rebuild so the running server drops the stale node and re-materialises it.
+ [Fact]
+ public void Changed_equipment_tags_only_trigger_rebuild()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var previous = CompositionWithTags(
+ new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
+ // Same tag id, but DataType + Writable flipped — the planner classifies this as a change.
+ var next = CompositionWithTags(
+ new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null));
+
+ var plan = Phase7Planner.Compute(previous, next);
+
+ // Guard the arrange: ONLY ChangedEquipmentTags is populated.
+ plan.ChangedEquipmentTags.Count.ShouldBe(1);
+ plan.AddedEquipmentTags.ShouldBeEmpty();
+ plan.RemovedEquipmentTags.ShouldBeEmpty();
+ plan.AddedEquipment.ShouldBeEmpty();
+ plan.RemovedEquipment.ShouldBeEmpty();
+
+ var outcome = applier.Apply(plan);
+
+ outcome.RebuildCalled.ShouldBeTrue();
+ outcome.ChangedNodes.ShouldBe(1);
+ sink.RebuildCalls.ShouldBe(1);
+ }
+
+ /// H1a — a deploy that ONLY edits an existing VirtualTag's expression must rebuild the
+ /// address space. The planner diffs it into ChangedEquipmentVirtualTags alone; the applier
+ /// must drive exactly one rebuild.
+ [Fact]
+ public void Changed_equipment_virtual_tags_only_trigger_rebuild()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var previous = CompositionWithVirtualTags(
+ new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
+ Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));
+ // Same VirtualTag id, edited expression — the planner classifies this as a change.
+ var next = CompositionWithVirtualTags(
+ new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
+ Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));
+
+ var plan = Phase7Planner.Compute(previous, next);
+
+ // Guard the arrange: ONLY ChangedEquipmentVirtualTags is populated.
+ plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
+ plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
+ plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
+
+ var outcome = applier.Apply(plan);
+
+ outcome.RebuildCalled.ShouldBeTrue();
+ outcome.ChangedNodes.ShouldBe(1);
+ sink.RebuildCalls.ShouldBe(1);
+ }
+
+ /// H1a — a deploy that ONLY edits an existing scripted alarm (here its message template)
+ /// must rebuild the address space. The planner diffs the thin ScriptedAlarmPlan projection
+ /// into ChangedAlarms alone; the applier must drive exactly one rebuild so the condition node
+ /// reflects the edit.
+ [Fact]
+ public void Changed_alarms_only_trigger_rebuild()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var previous = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp high"));
+ // Same alarm id, edited message template — the planner classifies this as a change.
+ var next = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp critically high"));
+
+ var plan = Phase7Planner.Compute(previous, next);
+
+ // Guard the arrange: ONLY ChangedAlarms is populated.
+ plan.ChangedAlarms.Count.ShouldBe(1);
+ plan.AddedAlarms.ShouldBeEmpty();
+ plan.RemovedAlarms.ShouldBeEmpty();
+
+ var outcome = applier.Apply(plan);
+
+ outcome.RebuildCalled.ShouldBeTrue();
+ outcome.ChangedNodes.ShouldBe(1);
+ sink.RebuildCalls.ShouldBe(1);
+ }
+
+ /// H1a guard — a deploy that ONLY changes a driver instance's config must NOT rebuild the
+ /// address space. Driver-instance changes route through DriverHostActor's spawn-plan in Runtime, not
+ /// the address-space topology, so ChangedDrivers is intentionally excluded from
+ /// needsRebuild. This pins the exclusion against accidental inclusion. (The pre-existing
+ /// uses a hand-built plan;
+ /// this one drives the planner end-to-end.)
+ [Fact]
+ public void Changed_drivers_only_do_not_trigger_rebuild()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var previous = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"));
+ var next = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}"));
+
+ var plan = Phase7Planner.Compute(previous, next);
+
+ // Guard the arrange: ONLY ChangedDrivers is populated.
+ plan.ChangedDrivers.Count.ShouldBe(1);
+ plan.AddedDrivers.ShouldBeEmpty();
+ plan.RemovedDrivers.ShouldBeEmpty();
+ plan.ChangedEquipment.ShouldBeEmpty();
+ plan.ChangedAlarms.ShouldBeEmpty();
+ plan.ChangedEquipmentTags.ShouldBeEmpty();
+ plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
+
+ var outcome = applier.Apply(plan);
+
+ outcome.RebuildCalled.ShouldBeFalse();
+ outcome.ChangedNodes.ShouldBe(1); // driver change is still tallied, just not rebuild-forcing
+ sink.RebuildCalls.ShouldBe(0);
+ }
+
+ private static Phase7CompositionResult CompositionWithTags(params EquipmentTagPlan[] tags) =>
+ new(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentTags = tags,
+ };
+
+ private static Phase7CompositionResult CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
+ new(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentVirtualTags = vtags,
+ };
+
+ private static Phase7CompositionResult CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) =>
+ // ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms.
+ new(
+ Array.Empty(), Array.Empty(), alarms);
+
+ private static Phase7CompositionResult CompositionWithDrivers(params DriverInstancePlan[] drivers) =>
+ new(
+ Array.Empty(), drivers, Array.Empty());
+
private static Phase7Plan EmptyPlan => new(
Array.Empty(), Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(), Array.Empty(),