diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs
new file mode 100644
index 0000000..634bd1a
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs
@@ -0,0 +1,86 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
+
+///
+/// Loads the snapshot the
+/// consumes, scoped to a single
+/// (driverInstanceId, generationId) pair. Joins the four row sets the walker expects:
+/// UnsAreas for the driver's cluster, UnsLines under those areas, Equipment bound to
+/// this driver + its lines, and Tags bound to this driver + its equipment — all at the
+/// supplied generation.
+///
+///
+/// The walker is driver-instance-scoped (decisions #116–#121 put the UNS in the
+/// Equipment-kind namespace owned by one driver instance at a time), so this loader is
+/// too — a single call returns one driver's worth of rows, never the whole fleet.
+///
+/// Returns null when the driver instance has no Equipment rows at the
+/// supplied generation. The wire-in in treats null as
+/// "this driver has no UNS content, skip the walker and let DiscoverAsync own the whole
+/// address space" — the backward-compat path for drivers whose namespace kind is not
+/// Equipment (Modbus / AB CIP / TwinCAT / FOCAS).
+///
+public sealed class EquipmentNamespaceContentLoader
+{
+ private readonly OtOpcUaConfigDbContext _db;
+
+ public EquipmentNamespaceContentLoader(OtOpcUaConfigDbContext db)
+ {
+ _db = db;
+ }
+
+ ///
+ /// Load the walker-shaped snapshot for at
+ /// . Returns null when the driver has no
+ /// Equipment rows at that generation.
+ ///
+ public async Task LoadAsync(
+ string driverInstanceId, long generationId, CancellationToken ct)
+ {
+ var equipment = await _db.Equipment
+ .AsNoTracking()
+ .Where(e => e.DriverInstanceId == driverInstanceId && e.GenerationId == generationId && e.Enabled)
+ .ToListAsync(ct).ConfigureAwait(false);
+
+ if (equipment.Count == 0)
+ return null;
+
+ // Filter UNS tree to only the lines + areas that host at least one Equipment bound to
+ // this driver — skips loading unrelated UNS branches from the cluster. LinesByArea
+ // grouping is driven off the Equipment rows so an empty line (no equipment) doesn't
+ // pull a pointless folder into the walker output.
+ var lineIds = equipment.Select(e => e.UnsLineId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+
+ var lines = await _db.UnsLines
+ .AsNoTracking()
+ .Where(l => l.GenerationId == generationId && lineIds.Contains(l.UnsLineId))
+ .ToListAsync(ct).ConfigureAwait(false);
+
+ var areaIds = lines.Select(l => l.UnsAreaId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+
+ var areas = await _db.UnsAreas
+ .AsNoTracking()
+ .Where(a => a.GenerationId == generationId && areaIds.Contains(a.UnsAreaId))
+ .ToListAsync(ct).ConfigureAwait(false);
+
+ // Tags belonging to this driver at this generation. Walker skips Tags with null
+ // EquipmentId (those are SystemPlatform-kind Galaxy tags per decision #120) but we
+ // load them anyway so the same rowset can drive future non-Equipment-kind walks
+ // without re-hitting the DB. Filtering here is a future optimization; today the
+ // per-tag cost is bounded by driver scope.
+ var tags = await _db.Tags
+ .AsNoTracking()
+ .Where(t => t.DriverInstanceId == driverInstanceId && t.GenerationId == generationId)
+ .ToListAsync(ct).ConfigureAwait(false);
+
+ return new EquipmentNamespaceContent(
+ Areas: areas,
+ Lines: lines,
+ Equipment: equipment,
+ Tags: tags);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentNamespaceContentLoaderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentNamespaceContentLoaderTests.cs
new file mode 100644
index 0000000..17e13ec
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentNamespaceContentLoaderTests.cs
@@ -0,0 +1,172 @@
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class EquipmentNamespaceContentLoaderTests : IDisposable
+{
+ private const string DriverId = "galaxy-prod";
+ private const string OtherDriverId = "galaxy-dev";
+ private const long Gen = 5;
+
+ private readonly OtOpcUaConfigDbContext _db;
+ private readonly EquipmentNamespaceContentLoader _loader;
+
+ public EquipmentNamespaceContentLoaderTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"eq-content-loader-{Guid.NewGuid():N}")
+ .Options;
+ _db = new OtOpcUaConfigDbContext(options);
+ _loader = new EquipmentNamespaceContentLoader(_db);
+ }
+
+ public void Dispose() => _db.Dispose();
+
+ [Fact]
+ public async Task Returns_Null_When_Driver_Has_No_Equipment_At_Generation()
+ {
+ var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Loads_Areas_Lines_Equipment_Tags_For_Driver_At_Generation()
+ {
+ SeedBaseline();
+
+ var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
+
+ result.ShouldNotBeNull();
+ result!.Areas.ShouldHaveSingleItem().UnsAreaId.ShouldBe("area-1");
+ result.Lines.ShouldHaveSingleItem().UnsLineId.ShouldBe("line-a");
+ result.Equipment.Count.ShouldBe(2);
+ result.Equipment.ShouldContain(e => e.EquipmentId == "eq-oven-3");
+ result.Equipment.ShouldContain(e => e.EquipmentId == "eq-press-7");
+ result.Tags.Count.ShouldBe(2);
+ result.Tags.ShouldContain(t => t.TagId == "tag-temp");
+ result.Tags.ShouldContain(t => t.TagId == "tag-press");
+ }
+
+ [Fact]
+ public async Task Skips_Other_Drivers_Equipment()
+ {
+ SeedBaseline();
+
+ // Equipment + Tag owned by a different driver at the same generation — must not leak.
+ _db.Equipment.Add(new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
+ EquipmentId = "eq-other", EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = OtherDriverId, UnsLineId = "line-a", Name = "other-eq",
+ MachineCode = "MC-other",
+ });
+ _db.Tags.Add(new Tag
+ {
+ TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-other",
+ DriverInstanceId = OtherDriverId, EquipmentId = "eq-other",
+ Name = "OtherTag", DataType = "Int32",
+ AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-other",
+ });
+ await _db.SaveChangesAsync();
+
+ var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
+
+ result.ShouldNotBeNull();
+ result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-other");
+ result.Tags.ShouldNotContain(t => t.TagId == "tag-other");
+ }
+
+ [Fact]
+ public async Task Skips_Other_Generations()
+ {
+ SeedBaseline();
+
+ // Same driver, different generation — must not leak in. Walker consumes one sealed
+ // generation per bootstrap per decision #148.
+ _db.Equipment.Add(new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = 99,
+ EquipmentId = "eq-futuristic", EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "futuristic",
+ MachineCode = "MC-fut",
+ });
+ await _db.SaveChangesAsync();
+
+ var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
+
+ result.ShouldNotBeNull();
+ result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-futuristic");
+ }
+
+ [Fact]
+ public async Task Skips_Disabled_Equipment()
+ {
+ SeedBaseline();
+
+ _db.Equipment.Add(new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
+ EquipmentId = "eq-disabled", EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "disabled-eq",
+ MachineCode = "MC-dis", Enabled = false,
+ });
+ await _db.SaveChangesAsync();
+
+ var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
+
+ result.ShouldNotBeNull();
+ result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-disabled");
+ }
+
+ private void SeedBaseline()
+ {
+ _db.UnsAreas.Add(new UnsArea
+ {
+ UnsAreaRowId = Guid.NewGuid(), UnsAreaId = "area-1", ClusterId = "c-warsaw",
+ Name = "warsaw", GenerationId = Gen,
+ });
+ _db.UnsLines.Add(new UnsLine
+ {
+ UnsLineRowId = Guid.NewGuid(), UnsLineId = "line-a", UnsAreaId = "area-1",
+ Name = "line-a", GenerationId = Gen,
+ });
+ _db.Equipment.AddRange(
+ new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
+ EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
+ MachineCode = "MC-oven-3",
+ },
+ new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
+ EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "press-7",
+ MachineCode = "MC-press-7",
+ });
+ _db.Tags.AddRange(
+ new Tag
+ {
+ TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-temp",
+ DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
+ Name = "Temperature", DataType = "Int32",
+ AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
+ },
+ new Tag
+ {
+ TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-press",
+ DriverInstanceId = DriverId, EquipmentId = "eq-press-7",
+ Name = "PressTemp", DataType = "Int32",
+ AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-press-temp",
+ });
+ _db.SaveChanges();
+ }
+}