diff --git a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json
index 9bd094a1..17476ca4 100644
--- a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json
+++ b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json
@@ -5,7 +5,7 @@
"tasks": [
{"id": 0, "nativeTaskId": 86, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "completed", "blockedBy": []},
{"id": 1, "nativeTaskId": 87, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "completed", "blockedBy": [86]},
- {"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "pending", "blockedBy": [87]},
+ {"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "completed", "blockedBy": [87]},
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [87]},
{"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [88]},
{"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [88, 89]}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index 58278d67..70ff35d4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -172,6 +172,67 @@ public sealed class Phase7Applier
composition.GalaxyTags.Count, foldersCreated.Count);
}
+ ///
+ /// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal
+ /// analogue of . For each ,
+ /// ensure its optional FolderPath sub-folder under the existing equipment folder, then
+ /// ensure a Variable (NodeId = FullName, the driver-side ref) inside it. Variables
+ /// start BadWaitingForInitialData; the driver fills live values in a later milestone.
+ /// Idempotent.
+ ///
+ /// Task 0 architecture decisions (recorded per the equipment-namespace-structure
+ /// plan). Decision #1 = A — a sink-based pass, NOT a reuse of
+ /// EquipmentNodeWalker: no sink-backed IAddressSpaceBuilder adapter exists
+ /// (GenericDriverNodeManager.CapturingBuilder decorates another builder, not the
+ /// sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path
+ /// NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the
+ /// already-materialised equipment folders (decision #4). Decision #4 = this pass adds
+ /// ONLY variables (and any per-tag sub-folder); owns
+ /// the equipment folders and this pass never re-creates them. The sink's
+ /// EnsureVariable takes a plain string dataType (not a DriverAttributeInfo).
+ ///
+ ///
+ /// The composition result containing the equipment tags to materialise.
+ public void MaterialiseEquipmentTags(Phase7CompositionResult composition)
+ {
+ ArgumentNullException.ThrowIfNull(composition);
+ if (composition.EquipmentTags.Count == 0) return;
+
+ // Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
+ // (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
+ // equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create
+ // the equipment folder here).
+ var foldersCreated = new HashSet(StringComparer.Ordinal);
+ foreach (var tag in composition.EquipmentTags)
+ {
+ if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
+ var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
+ if (!foldersCreated.Add(folderNodeId)) continue;
+ SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
+ }
+
+ // Variables: NodeId = FullName (the driver-side reference → read/write routing key). Parent
+ // is the FolderPath sub-folder when set, else the equipment folder directly. Like the Galaxy
+ // pass, per-variable idempotency relies on the sink's own EnsureVariable idempotency.
+ foreach (var tag in composition.EquipmentTags)
+ {
+ var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
+ ? tag.EquipmentId
+ : EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
+ SafeEnsureVariable(tag.FullName, parent, tag.Name, tag.DataType);
+ }
+
+ _logger.LogInformation(
+ "Phase7Applier: equipment tags materialised (tags={Tags}, equipment={Equipment})",
+ composition.EquipmentTags.Count,
+ composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count());
+ }
+
+ /// Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
+ /// folder so two equipments' identically-named sub-folders never collide.
+ private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
+ $"{equipmentId}/{folderPath}";
+
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
{
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
index ff8e5ecc..9f005cc1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -230,6 +230,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
// + Variable node exist so clients can browse them. The Galaxy driver fills values
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
_applier.MaterialiseGalaxyTags(composition);
+ // Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
+ // FolderPath sub-folder) exists under its already-materialised equipment folder so
+ // clients can browse them. Live values arrive in a later milestone; until then the
+ // variables show BadWaitingForInitialData.
+ _applier.MaterialiseEquipmentTags(composition);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild"));
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
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 7cbae453..c9dabda1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -177,6 +177,58 @@ public sealed class Phase7ApplierTests
sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
}
+ /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
+ /// under its existing equipment folder (NodeId == FullName, parent == EquipmentId,
+ /// displayName == Name) and does NOT re-create the equipment folder (decision #4).
+ [Fact]
+ public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var composition = new Phase7CompositionResult(
+ UnsAreas: Array.Empty(),
+ UnsLines: Array.Empty(),
+ EquipmentNodes: Array.Empty(),
+ DriverInstancePlans: Array.Empty(),
+ ScriptedAlarmPlans: Array.Empty(),
+ GalaxyTags: Array.Empty())
+ {
+ EquipmentTags = new[]
+ {
+ new EquipmentTagPlan("eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
+ },
+ };
+
+ applier.MaterialiseEquipmentTags(composition);
+
+ sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
+ sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("40001", "eq-1", "Speed", "Float"));
+ }
+
+ /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
+ /// folder (not the namespace root), with the variable parented to that sub-folder.
+ [Fact]
+ public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var composition = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentTags = new[]
+ {
+ new EquipmentTagPlan("eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
+ },
+ };
+
+ applier.MaterialiseEquipmentTags(composition);
+
+ sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
+ sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("40002", "eq-1/Diagnostics", "Temp", "Float"));
+ }
+
/// Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.
[Fact]
public void Added_galaxy_tags_trigger_rebuild()