diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
new file mode 100644
index 0000000..85a79ec
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
@@ -0,0 +1,173 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
+
+///
+/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
+/// from the Config DB's
+/// UnsArea / UnsLine / Equipment / Tag rows. Runs during
+/// address-space build per whose
+/// Namespace.Kind = Equipment; SystemPlatform-kind namespaces (Galaxy) are
+/// exempt per decision #120 and reach this walker only indirectly through
+/// .
+///
+///
+///
+/// Composition strategy. ADR-001 (2026-04-20) accepted Option A — Config
+/// primary. The walker treats the supplied
+/// snapshot as the authoritative published surface. Every Equipment row becomes a
+/// folder node at the UNS level-5 segment; every bound to an
+/// Equipment (non-null ) becomes a variable node under
+/// it. Driver-discovered tags that have no Config-DB row are not added by this
+/// walker — the ITagDiscovery path continues to exist for the SystemPlatform case +
+/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
+///
+///
+///
+/// Under each Equipment node. Five identifier properties per decision #121
+/// (EquipmentId, EquipmentUuid, MachineCode, ZTag,
+/// SAPID) are added as OPC UA properties — external systems (ERP, SAP PM)
+/// resolve equipment by whichever identifier they natively use without a sidecar.
+/// materializes the OPC 40010
+/// Identification sub-folder with the nine decision-#139 fields when at least one
+/// is non-null; when all nine are null the sub-folder is omitted rather than
+/// appearing empty.
+///
+///
+///
+/// Address resolution. Variable nodes carry the driver-side full reference
+/// in copied from Tag.TagConfig
+/// (the wire-level address JSON blob whose interpretation is driver-specific). At
+/// runtime the dispatch layer routes Read/Write calls through the configured
+/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
+/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
+/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
+/// UI + OPC UA client inspection of node status.
+///
+///
+///
+/// Pure function. This class has no dependency on the OPC UA SDK, no
+/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
+/// into the supplied . The server-side wiring
+/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
+/// PR alongside NodeScopeResolver's Config-DB join.
+///
+///
+public static class EquipmentNodeWalker
+{
+ ///
+ /// Walk into .
+ /// The builder is scoped to the Equipment-kind namespace root; the walker emits
+ /// Area → Line → Equipment folders under it, then identifier properties + the
+ /// Identification sub-folder + variable nodes per bound Tag under each Equipment.
+ ///
+ ///
+ /// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
+ /// creating this (e.g. rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)).
+ ///
+ /// Pre-loaded + pre-filtered rows for a single published generation.
+ public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
+ {
+ ArgumentNullException.ThrowIfNull(namespaceBuilder);
+ ArgumentNullException.ThrowIfNull(content);
+
+ // Group lines by area + equipment by line + tags by equipment up-front. Avoids an
+ // O(N·M) re-scan at each UNS level on large fleets.
+ var linesByArea = content.Lines
+ .GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
+
+ var equipmentByLine = content.Equipment
+ .GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
+
+ var tagsByEquipment = content.Tags
+ .Where(t => !string.IsNullOrEmpty(t.EquipmentId))
+ .GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
+
+ foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
+ {
+ var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
+ if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue;
+
+ foreach (var line in areaLines)
+ {
+ var lineBuilder = areaBuilder.Folder(line.Name, line.Name);
+ if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue;
+
+ foreach (var equipment in lineEquipment)
+ {
+ var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name);
+ AddIdentifierProperties(equipmentBuilder, equipment);
+ IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
+
+ if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
+ foreach (var tag in equipmentTags)
+ AddTagVariable(equipmentBuilder, tag);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Adds the five operator-facing identifiers from decision #121 as OPC UA properties
+ /// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
+ /// MachineCode is required per ; ZTag + SAPID are nullable in
+ /// the data model so they're skipped when null to avoid empty-string noise in the
+ /// browse tree.
+ ///
+ private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
+ {
+ equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId);
+ equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString());
+ equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode);
+ if (!string.IsNullOrEmpty(equipment.ZTag))
+ equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag);
+ if (!string.IsNullOrEmpty(equipment.SAPID))
+ equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID);
+ }
+
+ ///
+ /// Emit a single Tag row as an . The driver
+ /// full reference lives in Tag.TagConfig (wire-level address, driver-specific
+ /// JSON blob); the variable node's data type derives from Tag.DataType.
+ /// Unreachable-address behavior per ADR-001 Option A: the variable is created; the
+ /// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
+ ///
+ private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
+ {
+ var attr = new DriverAttributeInfo(
+ FullName: tag.TagConfig,
+ DriverDataType: ParseDriverDataType(tag.DataType),
+ IsArray: false,
+ ArrayDim: null,
+ SecurityClass: SecurityClassification.FreeAccess,
+ IsHistorized: false);
+ equipmentBuilder.Variable(tag.Name, tag.Name, attr);
+ }
+
+ ///
+ /// Parse (stored as the enum
+ /// name string, decision #138) into the enum value. Unknown names fall back to
+ /// so a one-off driver-specific type doesn't
+ /// abort the whole walk; the underlying driver still sees the original TagConfig
+ /// address + can surface its own typed value via the OPC UA variant at read time.
+ ///
+ private static DriverDataType ParseDriverDataType(string raw) =>
+ Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
+}
+
+///
+/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
+/// DB rows. All four collections are scoped to the same
+/// + the same
+/// row. The walker assumes this filter
+/// was applied by the caller + does no cross-generation or cross-namespace validation.
+///
+public sealed record EquipmentNamespaceContent(
+ IReadOnlyList Areas,
+ IReadOnlyList Lines,
+ IReadOnlyList Equipment,
+ IReadOnlyList Tags);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs
new file mode 100644
index 0000000..71d0203
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs
@@ -0,0 +1,221 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
+
+[Trait("Category", "Unit")]
+public sealed class EquipmentNodeWalkerTests
+{
+ [Fact]
+ public void Walk_EmptyContent_EmitsNothing()
+ {
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], []));
+
+ rec.Children.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
+ {
+ var content = new EquipmentNamespaceContent(
+ Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
+ Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
+ Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")],
+ Tags: []);
+
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, content);
+
+ rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name
+ var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
+ warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
+ warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
+ }
+
+ [Fact]
+ public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
+ {
+ var uuid = Guid.NewGuid();
+ var eq = Eq("eq-1", "line-1", "oven-3");
+ eq.EquipmentUuid = uuid;
+ eq.MachineCode = "MC-42";
+ eq.ZTag = null;
+ eq.SAPID = null;
+ var content = new EquipmentNamespaceContent(
+ [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
+
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, content);
+
+ var equipmentNode = rec.Children[0].Children[0].Children[0];
+ var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
+ props.ShouldContain("EquipmentId");
+ props.ShouldContain("EquipmentUuid");
+ props.ShouldContain("MachineCode");
+ props.ShouldNotContain("ZTag");
+ props.ShouldNotContain("SAPID");
+ equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
+ }
+
+ [Fact]
+ public void Walk_Adds_ZTag_And_SAPID_When_Present()
+ {
+ var eq = Eq("eq-1", "line-1", "oven-3");
+ eq.ZTag = "ZT-0042";
+ eq.SAPID = "10000042";
+ var content = new EquipmentNamespaceContent(
+ [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
+
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, content);
+
+ var equipmentNode = rec.Children[0].Children[0].Children[0];
+ equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
+ equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
+ }
+
+ [Fact]
+ public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
+ {
+ var eq = Eq("eq-1", "line-1", "oven-3");
+ eq.Manufacturer = "Trumpf";
+ eq.Model = "TruLaser-3030";
+ var content = new EquipmentNamespaceContent(
+ [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
+
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, content);
+
+ var equipmentNode = rec.Children[0].Children[0].Children[0];
+ var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
+ identification.ShouldNotBeNull();
+ identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
+ identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
+ }
+
+ [Fact]
+ public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
+ {
+ var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields
+ var content = new EquipmentNamespaceContent(
+ [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
+
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, content);
+
+ var equipmentNode = rec.Children[0].Children[0].Children[0];
+ equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
+ }
+
+ [Fact]
+ public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
+ {
+ var eq = Eq("eq-1", "line-1", "oven-3");
+ var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1");
+ var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1");
+ var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips
+ var content = new EquipmentNamespaceContent(
+ [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
+ [eq], [tag1, tag2, unboundTag]);
+
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, content);
+
+ var equipmentNode = rec.Children[0].Children[0].Children[0];
+ equipmentNode.Variables.Count.ShouldBe(2);
+ equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]);
+ equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01");
+ equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
+ }
+
+ [Fact]
+ public void Walk_FallsBack_To_String_For_Unparseable_DataType()
+ {
+ var eq = Eq("eq-1", "line-1", "oven-3");
+ var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1");
+ var content = new EquipmentNamespaceContent(
+ [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]);
+
+ var rec = new RecordingBuilder("root");
+ EquipmentNodeWalker.Walk(rec, content);
+
+ var variable = rec.Children[0].Children[0].Children[0].Variables.Single();
+ variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
+ }
+
+ // ----- builders for test seed rows -----
+
+ private static UnsArea Area(string id, string name) => new()
+ {
+ UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1,
+ };
+
+ private static UnsLine Line(string id, string areaId, string name) => new()
+ {
+ UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1,
+ };
+
+ private static Equipment Eq(string equipmentId, string lineId, string name) => new()
+ {
+ EquipmentRowId = Guid.NewGuid(),
+ GenerationId = 1,
+ EquipmentId = equipmentId,
+ EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = "drv",
+ UnsLineId = lineId,
+ Name = name,
+ MachineCode = "MC-" + name,
+ };
+
+ private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new()
+ {
+ TagRowId = Guid.NewGuid(),
+ GenerationId = 1,
+ TagId = tagId,
+ DriverInstanceId = "drv",
+ EquipmentId = equipmentId,
+ Name = name,
+ DataType = dataType,
+ AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite,
+ TagConfig = address,
+ };
+
+ // ----- recording IAddressSpaceBuilder -----
+
+ private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
+ {
+ public string BrowseName { get; } = browseName;
+ public List Children { get; } = new();
+ public List Variables { get; } = new();
+ public List Properties { get; } = new();
+
+ public IAddressSpaceBuilder Folder(string name, string _)
+ {
+ var child = new RecordingBuilder(name);
+ Children.Add(child);
+ return child;
+ }
+
+ public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
+ {
+ var v = new RecordingVariable(name, attr);
+ Variables.Add(v);
+ return v;
+ }
+
+ public void AddProperty(string name, DriverDataType _, object? value) =>
+ Properties.Add(new RecordingProperty(name, value));
+ }
+
+ private sealed record RecordingProperty(string BrowseName, object? Value);
+
+ private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
+ {
+ public string FullReference => AttributeInfo.FullName;
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
+ }
+}