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 2499e8b4..8d8e6167 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
@@ -19,7 +19,7 @@
{
"id": "T3",
"subject": "Phase 1: ComputeAddAnnouncements + AnnounceAddedNodes on the applier (dedup parent NodeAdded announces)",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"T1"
]
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index 2ab88bf3..677ffb15 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -218,6 +218,63 @@ public sealed class AddressSpaceApplier
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes);
}
+ ///
+ /// Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce
+ /// (Part 3 NodeAdded) after a pure-add apply's Materialise passes have created the new nodes.
+ /// For each added tag/vtag the parent is its materialise folder (the equipment folder, or the
+ /// FolderPath sub-folder — the exact ids the Materialise passes place children at); for each
+ /// added scripted alarm the parent is its equipment folder (where the condition node parents); for
+ /// each added equipment the parent is its UNS line folder when set, else the equipment's own new
+ /// folder id (a valid Part 3 announcement of the folder itself — the root has no announceable id).
+ /// Pure — no sink interaction — so it is unit-testable in isolation and safe to call before the
+ /// nodes exist. Ordinal-sorted so the announce sequence is stable.
+ ///
+ /// The plan whose added nodes' parents to announce.
+ /// The distinct affected parent node ids, ordinal-sorted.
+ public IReadOnlyList ComputeAddAnnouncements(AddressSpacePlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+
+ var ids = new HashSet(StringComparer.Ordinal);
+ foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
+ foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
+ foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
+ foreach (var eq in plan.AddedEquipment)
+ ids.Add(string.IsNullOrWhiteSpace(eq.UnsLineId) ? eq.EquipmentId : eq.UnsLineId);
+
+ var list = ids.ToList();
+ list.Sort(StringComparer.Ordinal);
+ return list;
+ }
+
+ /// The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs
+ /// under: the equipment folder when is null/empty, else the FolderPath
+ /// sub-folder. Mirrors the parent derivation the Materialise passes use.
+ /// The owning equipment folder id.
+ /// The tag/vtag FolderPath, or null/empty for "directly under the equipment".
+ /// The materialise-parent node id.
+ private static string MaterialiseParent(string equipmentId, string? folderPath) =>
+ string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath);
+
+ ///
+ /// Announce a Part 3 GeneralModelChangeEvent(NodeAdded) per distinct affected parent from
+ /// , so model-aware clients re-browse the parents the new nodes
+ /// were added under. Called by OpcUaPublishActor AFTER the Materialise passes (the nodes exist
+ /// by then) and ONLY on a non-rebuild apply — after a full rebuild the announcement is moot
+ /// (subscriptions are dead anyway). Uses the existing, guard-covered
+ /// — no new sink member. Each call is
+ /// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
+ ///
+ /// The plan whose added nodes to announce.
+ public void AnnounceAddedNodes(AddressSpacePlan plan)
+ {
+ foreach (var id in ComputeAddAnnouncements(plan))
+ {
+ try { _sink.RaiseNodesAddedModelChange(id); }
+ catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
+ }
+ }
+
///
/// Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
index f713686d..d1bb299d 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
@@ -1825,6 +1825,105 @@ public sealed class AddressSpaceApplierTests
sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
+ // ----- R2-07 T3: pure-add NodeAdded announcements -----
+
+ /// Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the
+ /// equipment id.
+ [Fact]
+ public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one()
+ {
+ var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance);
+
+ var plan = EmptyPlan with
+ {
+ AddedEquipmentTags = new[]
+ {
+ new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
+ new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
+ },
+ };
+
+ applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
+ }
+
+ /// A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment.
+ [Fact]
+ public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder()
+ {
+ var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance);
+
+ var plan = EmptyPlan with
+ {
+ AddedEquipmentTags = new[]
+ {
+ new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
+ },
+ };
+
+ applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") });
+ }
+
+ /// An added scripted alarm announces its equipment folder (where its condition node parents).
+ [Fact]
+ public void ComputeAddAnnouncements_added_alarm_announces_equipment()
+ {
+ var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance);
+
+ var plan = EmptyPlan with
+ {
+ AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") },
+ };
+
+ applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" });
+ }
+
+ /// An added equipment WITH a UnsLineId announces its parent line; WITHOUT one announces its own
+ /// new folder id (a valid Part 3 NodeAdded of the folder itself; the root has no announceable id).
+ [Fact]
+ public void ComputeAddAnnouncements_added_equipment_announces_line_or_self()
+ {
+ var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance);
+
+ var withLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-7") } };
+ applier.ComputeAddAnnouncements(withLine).ShouldBe(new[] { "line-7" });
+
+ var noLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "") } };
+ applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" });
+ }
+
+ /// A plan with no additions yields no announcements.
+ [Fact]
+ public void ComputeAddAnnouncements_empty_add_plan_yields_none()
+ {
+ var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance);
+ applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty();
+ }
+
+ /// AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected
+ /// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped.
+ [Fact]
+ public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent()
+ {
+ var sink = new RecordingSink();
+ var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
+
+ var plan = EmptyPlan with
+ {
+ AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
+ AddedEquipmentTags = new[]
+ {
+ new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
+ new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
+ new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null),
+ },
+ };
+
+ applier.AnnounceAddedNodes(plan);
+
+ sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
+ new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
+ }
+
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
new(
areas, Array.Empty(), Array.Empty(),