diff --git a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json
index 0758de6c..76fef22c 100644
--- a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json
+++ b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json
@@ -7,8 +7,8 @@
{"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [4, 5]},
{"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 5]},
{"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 4]},
- {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "pending", "blockedBy": [1, 2]},
- {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "pending", "blockedBy": [6]},
+ {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "completed", "blockedBy": [1, 2]},
+ {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]},
{"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "pending", "blockedBy": [8], "parallelWith": [11]},
{"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]},
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs
new file mode 100644
index 00000000..f2fa8cc2
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs
@@ -0,0 +1,49 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+/// One node captured from a driver's DiscoverAsync stream. Folder ids are
+/// path-composed ("/parent/child"); leaf ids are the driver FullName (the commit value).
+public sealed class CapturedNode
+{
+ /// Stable identifier. Folder = path-composed ("/parent/child"); leaf = driver FullName.
+ public required string Id { get; init; }
+
+ /// Browse name (the path segment under the parent).
+ public required string Name { get; init; }
+
+ /// Human-readable display name.
+ public required string DisplayName { get; init; }
+
+ /// True for a variable (terminal); false for a folder.
+ public required bool IsLeaf { get; init; }
+
+ /// Driver-side attribute metadata; set only on leaves.
+ public DriverAttributeInfo? Attribute { get; init; }
+
+ /// True when the driver marked this leaf as an alarm condition during discovery.
+ public bool IsAlarmMarked { get; set; }
+
+ /// Captured child nodes (folders first-or-interleaved in stream order).
+ public List Children { get; } = [];
+
+ /// Captured static properties attached to this node via AddProperty.
+ public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = [];
+}
+
+/// Immutable-after-Build snapshot of a whole discovery capture. May be shared by
+/// multiple coalesced browse sessions — sessions drop their reference on dispose, never mutate.
+public sealed class CapturedTree
+{
+ /// Synthetic root; its Children are the top-level captured nodes.
+ public required CapturedNode Root { get; init; }
+
+ /// True when the node cap was hit and recording stopped short.
+ public required bool Truncated { get; init; }
+
+ /// Number of nodes actually recorded (bounded by the node cap).
+ public required int Count { get; init; }
+
+ /// Flat lookup from node id to node (for O(1) Expand/Attributes serving).
+ public required IReadOnlyDictionary ById { get; init; }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs
new file mode 100644
index 00000000..47ac6fdd
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs
@@ -0,0 +1,71 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+///
+/// Serves a captured discovery snapshot through the standard picker session interface.
+/// No I/O after open; the tree is point-in-time (picker offers Refresh = close + re-open).
+/// The tree may be shared by coalesced sessions — dispose drops only this session's
+/// reference. See design §4.3.
+///
+public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSession
+{
+ private CapturedTree? _tree = tree;
+
+ ///
+ public Guid Token { get; } = Guid.NewGuid();
+
+ ///
+ public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
+
+ ///
+ public Task> RootAsync(CancellationToken cancellationToken)
+ {
+ var t = Tree();
+ var nodes = t.Root.Children.Select(Map).ToList();
+ if (t.Truncated)
+ nodes.Add(new BrowseNode("__truncated__",
+ "⚠ Results truncated — narrow the driver config or use manual entry",
+ BrowseNodeKind.Folder, HasChildrenHint: false));
+ LastUsedUtc = DateTime.UtcNow;
+ return Task.FromResult>(nodes);
+ }
+
+ ///
+ public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken)
+ {
+ var t = Tree();
+ LastUsedUtc = DateTime.UtcNow;
+ return Task.FromResult>(
+ t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []);
+ }
+
+ ///
+ public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken)
+ {
+ var t = Tree();
+ LastUsedUtc = DateTime.UtcNow;
+ if (!t.ById.TryGetValue(nodeId, out var n))
+ return Task.FromResult>([]);
+ var attrs = new List();
+ if (n.Attribute is { } a)
+ attrs.Add(new AttributeInfo(n.Name, a.DriverDataType.ToString(), a.IsArray,
+ a.SecurityClass.ToString(), a.IsAlarm || n.IsAlarmMarked));
+ attrs.AddRange(n.Properties.Select(p =>
+ new AttributeInfo(p.Name, p.Type.ToString(), false, "", false)));
+ return Task.FromResult>(attrs);
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ _tree = null; // drop this session's reference only — tree may be shared (coalescing)
+ return ValueTask.CompletedTask;
+ }
+
+ private CapturedTree Tree() => _tree ?? throw new ObjectDisposedException(nameof(CapturedTreeBrowseSession));
+
+ private static BrowseNode Map(CapturedNode n) => n.IsLeaf
+ ? new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Leaf, false)
+ : new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Folder, n.Children.Count > 0);
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs
new file mode 100644
index 00000000..ceae69df
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs
@@ -0,0 +1,112 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+///
+/// In-memory that records the streamed discovery tree
+/// for the universal browser instead of materializing OPC UA nodes. Single-threaded like
+/// every builder a driver streams into during DiscoverAsync. Enforces a node cap: once
+/// exceeded it stops recording (drivers keep streaming harmlessly) and marks the tree
+/// truncated. See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.1.
+///
+public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
+{
+ private sealed class CaptureState(int nodeCap)
+ {
+ public int Count;
+ public bool Truncated;
+ public readonly int NodeCap = nodeCap;
+ public readonly Dictionary ById = new(StringComparer.Ordinal);
+ public bool TryReserve()
+ {
+ if (Count >= NodeCap) { Truncated = true; return false; }
+ Count++;
+ return true;
+ }
+ }
+
+ private readonly CaptureState _state;
+ private readonly CapturedNode _scope;
+
+ /// Create a root capturing builder with the given node cap (default 50 000).
+ /// Maximum number of nodes recorded before the tree is marked truncated.
+ public CapturingAddressSpaceBuilder(int nodeCap = 50_000)
+ {
+ _state = new CaptureState(nodeCap);
+ _scope = new CapturedNode { Id = "", Name = "", DisplayName = "", IsLeaf = false };
+ }
+
+ private CapturingAddressSpaceBuilder(CaptureState state, CapturedNode scope)
+ {
+ _state = state;
+ _scope = scope;
+ }
+
+ ///
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ var node = new CapturedNode
+ {
+ Id = _scope.Id + "/" + browseName,
+ Name = browseName,
+ DisplayName = displayName,
+ IsLeaf = false,
+ };
+ if (_state.TryReserve())
+ {
+ _scope.Children.Add(node);
+ _state.ById.TryAdd(node.Id, node);
+ }
+ // Over cap: the child builder writes into a detached node — recording stops, streaming doesn't break.
+ return new CapturingAddressSpaceBuilder(_state, node);
+ }
+
+ ///
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ var node = new CapturedNode
+ {
+ Id = attributeInfo.FullName,
+ Name = browseName,
+ DisplayName = displayName,
+ IsLeaf = true,
+ Attribute = attributeInfo,
+ };
+ if (_state.TryReserve())
+ {
+ _scope.Children.Add(node);
+ _state.ById.TryAdd(node.Id, node);
+ }
+ return new CapturedVariableHandle(node);
+ }
+
+ ///
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
+ _scope.Properties.Add((browseName, dataType, value));
+
+ /// Snapshot the captured tree. Only valid on the root builder (the one the browser constructed).
+ /// An immutable-after-build .
+ public CapturedTree Build() => new()
+ {
+ Root = _scope,
+ Truncated = _state.Truncated,
+ Count = _state.Count,
+ ById = _state.ById,
+ };
+
+ private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
+ {
+ public string FullReference => node.Id;
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ {
+ node.IsAlarmMarked = true;
+ return NoopAlarmSink.Instance;
+ }
+ }
+
+ private sealed class NoopAlarmSink : IAlarmConditionSink
+ {
+ public static readonly NoopAlarmSink Instance = new();
+ public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs
new file mode 100644
index 00000000..9aa3a433
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs
@@ -0,0 +1,115 @@
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+public class CapturedTreeBrowseSessionTests
+{
+ private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
+ new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);
+
+ // A small captured graph: a folder Plc1 with a Speed leaf and an alarm leaf, plus a root-level leaf.
+ // truncated=true builds with a tiny cap so recording stops short and Truncated flips.
+ private static CapturedTree SampleTree(bool truncated = false)
+ {
+ var b = new CapturingAddressSpaceBuilder(nodeCap: truncated ? 2 : 100);
+ var plc = b.Folder("Plc1", "PLC 1");
+ plc.Variable("Speed", "Speed", Attr("Plc1.Speed"));
+ plc.Variable("Alarm", "Alarm", Attr("Plc1.Alarm")).MarkAsAlarmCondition(
+ new AlarmConditionInfo("Alarm", AlarmSeverity.High, null));
+ b.Variable("Direct", "Direct", Attr("Root.Direct"));
+ return b.Build();
+ }
+
+ [Fact]
+ public async Task RootReturnsTopLevelNodes()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var root = await s.RootAsync(CancellationToken.None);
+
+ var folder = root.Single(n => n.NodeId == "/Plc1");
+ folder.Kind.ShouldBe(BrowseNodeKind.Folder);
+ folder.HasChildrenHint.ShouldBeTrue();
+
+ var leaf = root.Single(n => n.NodeId == "Root.Direct");
+ leaf.Kind.ShouldBe(BrowseNodeKind.Leaf);
+ leaf.NodeId.ShouldBe("Root.Direct"); // leaf NodeId IS the driver FullName
+ }
+
+ [Fact]
+ public async Task ExpandReturnsCapturedChildren()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var kids = await s.ExpandAsync("/Plc1", CancellationToken.None);
+ kids.Select(n => n.NodeId).ShouldBe(new[] { "Plc1.Speed", "Plc1.Alarm" });
+ }
+
+ [Fact]
+ public async Task ExpandUnknownNodeReturnsEmpty()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ (await s.ExpandAsync("/nope", CancellationToken.None)).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task AttributesForLeafMapsDriverAttributeInfo()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var attrs = await s.AttributesAsync("Plc1.Speed", CancellationToken.None);
+ var a = attrs.Single();
+ a.Name.ShouldBe("Speed");
+ a.DriverDataType.ShouldBe(DriverDataType.Float64.ToString());
+ a.IsArray.ShouldBeFalse();
+ a.SecurityClass.ShouldBe(SecurityClassification.Operate.ToString());
+ a.IsAlarm.ShouldBeFalse();
+
+ // Alarm marked during discovery surfaces IsAlarm=true even though attr.IsAlarm is false.
+ var alarmAttrs = await s.AttributesAsync("Plc1.Alarm", CancellationToken.None);
+ alarmAttrs.Single().IsAlarm.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TruncatedTreeAppendsMarkerFolderAtRoot()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree(truncated: true));
+ var root = await s.RootAsync(CancellationToken.None);
+ var marker = root[^1];
+ marker.Kind.ShouldBe(BrowseNodeKind.Folder);
+ marker.HasChildrenHint.ShouldBeFalse();
+ marker.DisplayName.ToLowerInvariant().ShouldContain("truncated");
+ }
+
+ [Fact]
+ public async Task CallsRefreshLastUsedUtc()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var before = s.LastUsedUtc;
+ await Task.Delay(5, TestContext.Current.CancellationToken);
+ await s.RootAsync(CancellationToken.None);
+ s.LastUsedUtc.ShouldBeGreaterThan(before);
+ }
+
+ [Fact]
+ public async Task DisposedSessionThrowsObjectDisposed()
+ {
+ var s = new CapturedTreeBrowseSession(SampleTree());
+ await s.DisposeAsync();
+ await Should.ThrowAsync(() => s.RootAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task TwoSessionsOverSameTreeAreIndependent()
+ {
+ var tree = SampleTree();
+ var s1 = new CapturedTreeBrowseSession(tree);
+ var s2 = new CapturedTreeBrowseSession(tree);
+ s1.Token.ShouldNotBe(s2.Token);
+
+ await s1.DisposeAsync();
+ // Disposing s1 must not affect s2 — the tree is immutable and shared.
+ (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty();
+ await s2.DisposeAsync();
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs
new file mode 100644
index 00000000..7c7018e9
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs
@@ -0,0 +1,70 @@
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+public class CapturingAddressSpaceBuilderTests
+{
+ private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
+ new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);
+
+ [Fact]
+ public void CapturesNestedFoldersAndLeaves()
+ {
+ var b = new CapturingAddressSpaceBuilder(nodeCap: 100);
+ var plc = b.Folder("Plc1", "PLC 1");
+ plc.Variable("Speed", "Speed", Attr("Program:Main.Speed"));
+ var udt = plc.Folder("Motor", "Motor");
+ udt.Variable("Amps", "Amps", Attr("Program:Main.Motor.Amps"));
+
+ var tree = b.Build();
+ tree.Truncated.ShouldBeFalse();
+ tree.Root.Children.Count.ShouldBe(1);
+ var plcNode = tree.Root.Children[0];
+ plcNode.Id.ShouldBe("/Plc1");
+ plcNode.IsLeaf.ShouldBeFalse();
+ plcNode.Children.Select(c => c.Id).ShouldBe(new[] { "Program:Main.Speed", "/Plc1/Motor" });
+ tree.ById["Program:Main.Motor.Amps"].Attribute!.FullName.ShouldBe("Program:Main.Motor.Amps");
+ }
+
+ [Fact]
+ public void LeafNodeIdIsTheDriverFullName()
+ {
+ var b = new CapturingAddressSpaceBuilder(100);
+ var handle = b.Variable("T", "T", Attr("Device.Tag1"));
+ handle.FullReference.ShouldBe("Device.Tag1");
+ b.Build().ById["Device.Tag1"].IsLeaf.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void NodeCapTruncatesInsteadOfGrowing()
+ {
+ var b = new CapturingAddressSpaceBuilder(nodeCap: 3);
+ for (var i = 0; i < 10; i++) b.Variable($"T{i}", $"T{i}", Attr($"Dev.T{i}"));
+ var tree = b.Build();
+ tree.Truncated.ShouldBeTrue();
+ tree.Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public void MarkAsAlarmConditionRecordsAlarmAndReturnsNoopSink()
+ {
+ var b = new CapturingAddressSpaceBuilder(100);
+ var h = b.Variable("A", "A", Attr("Dev.Alarm1"));
+ var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("A", AlarmSeverity.High, null));
+ sink.ShouldNotBeNull();
+ Should.NotThrow(() => sink.OnTransition(null!)); // no-op — browse never delivers transitions
+ b.Build().ById["Dev.Alarm1"].IsAlarmMarked.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void AddPropertyAttachesToCurrentScope()
+ {
+ var b = new CapturingAddressSpaceBuilder(100);
+ var f = b.Folder("Dev", "Dev");
+ f.AddProperty("Model", DriverDataType.String, "1756-L83E");
+ b.Build().ById["/Dev"].Properties.ShouldContain(p => p.Name == "Model" && (string?)p.Value == "1756-L83E");
+ }
+}