# Universal Discovery Browser (Wave 0) Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Implement the universal Discover-backed browser (`DiscoveryDriverBrowser`) so the AdminUI `/uns` address picker can browse any driver whose `DiscoverAsync` enumerates from the device — lighting up AbCip, TwinCAT, and FOCAS pickers with zero per-driver browser code. **Architecture:** A `CapturingAddressSpaceBuilder` records what a driver streams into `ITagDiscovery.DiscoverAsync`; a `CapturedTreeBrowseSession` serves that tree through the existing `IBrowseSession` picker plumbing; a `DiscoveryDriverBrowser` (separate `IUniversalDriverBrowser`, NOT one of the injected `IDriverBrowser` set — the `ToDictionary` dup-key constraint) orchestrates construct→patch→connect→capture→bounded-shutdown with capture coalescing and a global concurrency cap. `BrowserSessionService` falls back to it when no bespoke browser matches. **Tech Stack:** .NET 10, xUnit + Shouldly, Blazor Server (AdminUI), no new NuGet packages (only `Microsoft.Extensions.Logging.Abstractions` on Commons if not already transitive). **Spec:** `docs/plans/2026-07-15-universal-discovery-browser-design.md` (the authoritative design — read it FIRST). Umbrella: `docs/plans/2026-07-15-driver-expansion-program-design.md` §3.1 (cross-cutting rules) + §4. --- ## Orchestration rules for the executing agent (READ BEFORE DISPATCHING) 1. **Work on a feature branch**: `feat/universal-discovery-browser` (Task 0 creates it). Never commit to master. 2. **Safe parallelization.** Tasks marked `Parallelizable with:` have **disjoint `Files:` lists** and may be dispatched as concurrent subagents. Hard rules: - **Parallel subagents MUST NOT run any git command** (no add/commit/status). Parallel `git index` access races and corrupts the index (proven in this repo — see the "parallel implementers race the git index" memory). The orchestrator commits each batch's files **sequentially, itself**, after all subagents in the batch return. - A parallel subagent edits **only** the files in its task's `Files:` list. If it believes it needs another file, that is a plan defect — it must return the finding, not expand scope. - Never dispatch two tasks concurrently that share a file, even "read-mostly" ones like a `.csproj`. 3. **Batch order** (each batch waits for the previous; tasks inside a batch run in parallel): - Batch A: Task 1, Task 2, Task 13 - Batch B: Task 3, Task 4, Task 5 - Batch C: Task 6 → then Task 7 (sequential — same area) - Batch D: Task 8 - Batch E: Task 9, Task 11 - Batch F: Task 10, Task 12 - Batch G: Task 14, Task 15, Task 16 - Batch H: Task 17 → Task 18 (sequential) 4. **Verification gates:** run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` after every batch; run the named test project after every test-bearing task. Batch H's Task 18 is a **live `/run` gate** — AdminUI Razor changes are NOT trusted on unit tests + review alone (repo rule: no bUnit; live-verify or it didn't happen). 5. **TDD:** every code task writes the failing test first, sees it fail, implements, sees it pass. Test projects: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests` (builder/session/browser), `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` (service fallback), `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests` (interface default). 6. Single-test command shape: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~"`. --- ### Task 0: Branch setup **Classification:** trivial **Estimated implement time:** ~1 min **Parallelizable with:** none (must run first) **Step 1:** `git checkout -b feat/universal-discovery-browser` (from up-to-date master). --- ### Task 1: `ITagDiscovery.SupportsOnlineDiscovery` default member **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 2, Task 13 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs` (create) **Step 1: Write the failing test** ```csharp using Shouldly; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; public class TagDiscoveryDefaultsTests { private sealed class MinimalDiscovery : ITagDiscovery { public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask; } [Fact] public void SupportsOnlineDiscovery_DefaultsToFalse() => ((ITagDiscovery)new MinimalDiscovery()).SupportsOnlineDiscovery.ShouldBeFalse(); [Fact] public void RediscoverPolicy_DefaultRemainsUntilStable() => ((ITagDiscovery)new MinimalDiscovery()).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable); } ``` **Step 2: Run it — expect FAIL** (`SupportsOnlineDiscovery` doesn't compile yet): `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~TagDiscoveryDefaultsTests"` **Step 3: Implement** — add to `ITagDiscovery` directly below the `RediscoverPolicy` member (`ITagDiscovery.cs:32`): ```csharp /// /// True when enumerates the tag set from the live backend /// (browsable by the universal discovery browser), rather than replaying /// pre-declared/authored tags. Default false — flat-address drivers stay manual-entry. /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5. /// bool SupportsOnlineDiscovery => false; ``` **Step 4: Run test — expect PASS.** **Step 5:** (Orchestrator commits after Batch A.) --- ### Task 2: Commons project references **Classification:** trivial **Estimated implement time:** ~2 min **Parallelizable with:** Task 1, Task 13 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj` **Step 1:** Add to the existing `` (Commons currently has NO project references — `Core.Abstractions` is a leaf with zero ProjectReferences, so this is cycle-free; verified 2026-07-15): ```xml ``` Also add `` **only if** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons` fails on `ILogger<>` in Task 8 (Akka may already flow it transitively; the version is centrally pinned in `Directory.Packages.props` — do NOT add a `Version=` attribute). **Step 2:** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons` — expect success. --- ### Task 3: AbCip opt-in (`SupportsOnlineDiscovery => true`) **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 4, Task 5 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` - Test: the existing AbCip unit-test project under `tests/Drivers/` (add one fact to an existing test class, or a new `AbCipDiscoveryGateTests.cs` following that project's driver-construction pattern) **Step 1: Failing test** — construct the driver the way the project's existing tests do (fakes, no network) and assert: ```csharp ((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeTrue(); ``` **Step 2:** Run → FAIL (default false). **Step 3:** In `AbCipDriver`, next to its existing `RediscoverPolicy` override (or with the other ITagDiscovery members), add: ```csharp /// Controller enumeration is real (CIP Symbol Object walk) — config-gated on /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. public bool SupportsOnlineDiscovery => true; ``` **Step 4:** Run → PASS. Also run the full AbCip unit suite for the project you touched. --- ### Task 4: TwinCAT opt-in **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 3, Task 5 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` - Test: existing TwinCAT unit-test project under `tests/Drivers/` Same shape as Task 3 (ADS symbol upload behind `EnableControllerBrowse`; patch guarantees it). --- ### Task 5: FOCAS opt-in **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 3, Task 4 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` - Test: existing FOCAS unit-test project under `tests/Drivers/` Same shape as Task 3. Comment must note FOCAS is the `UntilStable` driver — its FixedTree fills post-connect; the universal browser's settle loop (Task 9) handles it; the `FixedTree.Enabled` patch turns it on. --- ### Task 6: `CapturingAddressSpaceBuilder` + captured-tree model **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (Batch C) **Files:** - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs` - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs` (create) **Step 1: Failing tests** — a fake `ITagDiscovery` streams a known graph; assert tree shape, ids, cap, alarm mark: ```csharp 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"); } } ``` (If `AlarmSeverity.High` doesn't exist, use any real member of `AlarmSeverity` — check `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.) **Step 2:** Run → FAIL (types don't exist). **Step 3: Implement.** `CapturedTree.cs`: ```csharp 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 { public required string Id { get; init; } public required string Name { get; init; } // browse name public required string DisplayName { get; init; } public required bool IsLeaf { get; init; } public DriverAttributeInfo? Attribute { get; init; } // leaf only public bool IsAlarmMarked { get; set; } public List Children { get; } = []; 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 { public required CapturedNode Root { get; init; } public required bool Truncated { get; init; } public required int Count { get; init; } public required IReadOnlyDictionary ById { get; init; } } ``` `CapturingAddressSpaceBuilder.cs`: ```csharp 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; 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)); 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 } } ``` `Build()` is only valid on the **root** builder (the one the browser constructed) — acceptable; the browser owns it. **Step 4:** Run the test class → PASS. **Step 5:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx`. --- ### Task 7: `CapturedTreeBrowseSession` **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** none (Batch C, after Task 6) **Files:** - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs` (create) **Step 1: Failing tests:** ```csharp public class CapturedTreeBrowseSessionTests { private static CapturedTree SampleTree(bool truncated = false) { /* build via CapturingAddressSpaceBuilder like Task 6, pass truncated via tiny cap when true */ } [Fact] public async Task RootReturnsTopLevelNodes() { /* RootAsync → BrowseNode list; folder Kind=Folder w/ HasChildrenHint, leaf Kind=Leaf, leaf NodeId == FullName */ } [Fact] public async Task ExpandReturnsCapturedChildren() { } [Fact] public async Task ExpandUnknownNodeReturnsEmpty() { } [Fact] public async Task AttributesForLeafMapsDriverAttributeInfo() { /* Name/DriverDataType.ToString()/IsArray/SecurityClass.ToString()/IsAlarm (incl. IsAlarmMarked OR attr.IsAlarm) */ } [Fact] public async Task TruncatedTreeAppendsMarkerFolderAtRoot() { /* last root node: Kind=Folder, HasChildrenHint=false, DisplayName contains "truncated" */ } [Fact] public async Task CallsRefreshLastUsedUtc() { } [Fact] public async Task DisposedSessionThrowsObjectDisposed() { } [Fact] public async Task TwoSessionsOverSameTreeAreIndependent() { /* dispose one; the other still serves — shared immutable tree, §4.3 */ } } ``` **Step 2:** FAIL. **Step 3: Implement:** ```csharp 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 ct) { 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 ct) { 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 ct) { 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); } ``` **Step 4:** PASS. **Step 5:** build the slnx. --- ### Task 8: `IUniversalDriverBrowser` + `DiscoveryDriverBrowser` core open path **Classification:** high-risk (driver lifecycle orchestration) **Estimated implement time:** ~5 min **Parallelizable with:** none (Batch D) **Files:** - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IUniversalDriverBrowser.cs` - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs` (create; includes the shared `FakeDiscoveryDriver` + `FakeDriverFactory` used by Tasks 9–10) **Step 1: Failing tests** (the fake driver implements `IDriver` + `ITagDiscovery` with all `IDriver` members trivial — read `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs` for the full member list; expose counters `InitializeCalls`/`DiscoverCalls`/`ShutdownCalls` and injectable `Func` discover behavior): - `OpenAsync_RunsInitializeDiscoverShutdown_InOrder` - `OpenAsync_ShutdownRuns_EvenWhenDiscoverThrows` (and the discovery exception — not a shutdown error — surfaces) - `OpenAsync_UnknownType_ThrowsCleanMessage` (factory returns null) - `OpenAsync_NotDiscoveryCapable_Throws` / `OpenAsync_FlagFalse_Throws` - `PatchForBrowse_MergesAbCipPatch` (config `{"Host":"1.2.3.4"}` + AbCip ⇒ JSON with both `Host` and `EnableControllerBrowse:true`; unknown type ⇒ unchanged) - `PatchForBrowse_DeepMergesFocasFixedTree` (config `{"FixedTree":{"Series":"30i"}}` ⇒ keeps `Series`, adds `Enabled:true`) - `CanBrowse_TrueForDiscoveryCapable` / `CanBrowse_FalseWhenTryCreateReturnsNull` - `CanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes` (design §4.2 hardening) - `CanBrowse_ShutsDownThrowawayInstance` (fake counts ShutdownCalls even on the false path) - `BoundedShutdown_AbandonsWedgedDriver` (fake `ShutdownAsync` = `Task.Delay(Timeout.Infinite, ct)` ignoring nothing — but also a variant that ignores the token entirely; open still returns on the discovery outcome; use a short `shutdownTimeout` via the test-visible ctor) **Step 2:** FAIL. **Step 3: Implement.** `IUniversalDriverBrowser.cs`: ```csharp namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; /// /// Universal fallback browser: captures any discovery-capable driver's DiscoverAsync /// output and serves it as a browse session. Resolved by BrowserSessionService only /// when no bespoke IDriverBrowser matches the driver type (bespoke-first). A separate /// interface — NOT an IDriverBrowser — because BrowserSessionService indexes those /// ToDictionary-by-DriverType and the universal browser has no single type. /// public interface IUniversalDriverBrowser { /// Cheap gate (construct + inspect, no connect): can this driver type + config be /// browsed? Never throws. Drives the picker's Browse-button visibility. bool CanBrowse(string driverType, string configJson); /// Construct the driver (with the per-type browse patch), connect, capture one /// full discovery pass (with UntilStable settle), shut the driver down (bounded), and /// return a session over the captured snapshot. Throws on failure — the caller /// (BrowserSessionService) converts to BrowseOpenResult. Task OpenAsync(string driverType, string configJson, CancellationToken ct); } ``` `DiscoveryDriverBrowser.cs` (Task 8 version — settle and coalescing arrive in Tasks 9–10): ```csharp using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2. public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser { /// Whole-open bound: construct + connect + discover (+ settle). Separate from the /// 20 s per-call browse timeout, which only covers serving from memory. public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60); /// Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged /// in discovery may be equally wedged in shutdown). public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10); public const int NodeCap = 50_000; /// Per-type declarative config patch guaranteeing browse mode at open time (§5.1). /// A dictionary entry per config-gated driver — not code. internal static readonly IReadOnlyDictionary BrowsePatches = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["AbCip"] = """{ "EnableControllerBrowse": true }""", ["TwinCAT"] = """{ "EnableControllerBrowse": true }""", ["FOCAS"] = """{ "FixedTree": { "Enabled": true } }""", // OpcUaClient/BACnet/MTConnect: no patch — their discovery is unconditional. }; private readonly IDriverFactory _driverFactory; private readonly ILogger _logger; private readonly TimeSpan _openTimeout; private readonly TimeSpan _shutdownTimeout; public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger logger) : this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { } // Test seam: short timeouts. internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger logger, TimeSpan openTimeout, TimeSpan shutdownTimeout) { _driverFactory = driverFactory; _logger = logger; _openTimeout = openTimeout; _shutdownTimeout = shutdownTimeout; } public bool CanBrowse(string driverType, string configJson) { IDriver? probe = null; try { probe = _driverFactory.TryCreate(driverType, $"browse-can-{Guid.NewGuid():N}", PatchForBrowse(driverType, configJson)); return probe is ITagDiscovery { SupportsOnlineDiscovery: true }; } catch { // TryCreate parses configJson and can throw on mid-authoring JSON. UI affordance // gate — must never throw into the page (§4.2). return false; } finally { if (probe is not null) _ = BoundedShutdownAsync(probe, driverType); // best-effort, never leak } } public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) { var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false); return new CapturedTreeBrowseSession(tree); } private async Task CaptureAsync(string driverType, string configJson, CancellationToken ct) { var patched = PatchForBrowse(driverType, configJson); var driver = _driverFactory.TryCreate(driverType, $"browse-{Guid.NewGuid():N}", patched) ?? throw new InvalidOperationException( $"Driver type '{driverType}' is not available in this host."); try { if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery) throw new InvalidOperationException( $"Driver type '{driverType}' has no online discovery — author tags manually."); using var openCts = CancellationTokenSource.CreateLinkedTokenSource(ct); openCts.CancelAfter(_openTimeout); await driver.InitializeAsync(patched, openCts.Token).ConfigureAwait(false); return await DiscoverTreeAsync(disc, openCts.Token).ConfigureAwait(false); } finally { await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false); } } // Task 9 replaces this with the UntilStable settle version. private static async Task DiscoverTreeAsync(ITagDiscovery disc, CancellationToken ct) { var builder = new CapturingAddressSpaceBuilder(NodeCap); await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); return builder.Build(); } private async Task BoundedShutdownAsync(IDriver driver, string driverType) { try { using var cts = new CancellationTokenSource(_shutdownTimeout); // WaitAsync: a token-ignoring wedged driver is ABANDONED (dropped), never awaited forever. await driver.ShutdownAsync(cts.Token).WaitAsync(cts.Token).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Browse-capture shutdown for {DriverType} failed/timed out; instance abandoned", driverType); } } internal static string PatchForBrowse(string driverType, string configJson) { if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson; var config = JsonNode.Parse(string.IsNullOrWhiteSpace(configJson) ? "{}" : configJson) ?.AsObject() ?? new JsonObject(); Merge(config, JsonNode.Parse(patch)!.AsObject()); return config.ToJsonString(); } private static void Merge(JsonObject target, JsonObject patch) { foreach (var (key, value) in patch) { if (value is JsonObject po && target[key] is JsonObject to) Merge(to, po); else target[key] = value?.DeepClone(); } } } ``` **Step 4:** PASS + slnx build. (If `ILogger<>` fails to resolve in Commons, apply Task 2's conditional PackageReference now.) --- ### Task 9: `UntilStable` settle loop (FOCAS) **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** Task 11 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs` (create) **Why:** FOCAS's FixedTree cache fills a couple of seconds *after* connect (`FocasDriver.cs` — comment above `DiscoverAsync`, background `FixedTreeLoopAsync`). A one-shot capture right after Initialize returns an **empty** tree. Design §4.2 step 4. **Step 1: Failing tests** — fake driver with `RediscoverPolicy = UntilStable` whose discover streams nothing on pass 1, three nodes on passes 2+: - `UntilStable_RerunsUntilNonEmptyAndStable` (final tree has 3 nodes; ≥3 discover calls: empty→3→3-stable) - `UntilStable_NeverStable_ReturnsLastNonEmptyAtTimeout` (node count grows every pass; short openTimeout via internal ctor; returns the last capture + logs — NOT an exception) - `UntilStable_NeverAnyNodes_FailsAtOpenTimeout` (all passes empty ⇒ `OperationCanceledException`/timeout error surfaces) - `OncePolicy_SingleDiscoverPass` (fake with `Once`: exactly 1 discover call) **Step 2:** FAIL. **Step 3:** Replace `DiscoverTreeAsync` with an instance method: ```csharp private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1); private async Task DiscoverTreeAsync(ITagDiscovery disc, string driverType, CancellationToken ct) { if (disc.RediscoverPolicy != DiscoveryRediscoverPolicy.UntilStable) { var builder = new CapturingAddressSpaceBuilder(NodeCap); await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); return builder.Build(); } // UntilStable (FOCAS): the discovered shape fills in asynchronously after connect — // re-capture until non-empty and stable across two consecutive passes, bounded by the // open-timeout. Mirrors the deploy-time contract in DriverInstanceActor. CapturedTree? previous = null; while (true) { var builder = new CapturingAddressSpaceBuilder(NodeCap); try { await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); var current = builder.Build(); if (current.Count > 0 && previous?.Count == current.Count) return current; previous = current; await Task.Delay(SettleInterval, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (previous is { Count: > 0 }) { _logger.LogWarning( "UntilStable settle for {DriverType} hit the open-timeout before stabilising; returning the last capture ({Count} nodes)", driverType, previous.Count); return previous; // best effort: a fresh-but-unsettled tree beats a failed open } } } ``` Update the call site to pass `driverType`. **Step 4:** PASS. --- ### Task 10: Capture coalescing + `MaxConcurrentCaptures` cap **Classification:** high-risk (concurrency) **Estimated implement time:** ~5 min **Parallelizable with:** Task 12 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs` (create) **Step 1: Failing tests** (gate fake drivers on `TaskCompletionSource` signals — no `Task.Delay` races): - `SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens` (fake counts Initialize calls == 1; two sessions, different `Token`s; both serve the same tree content) - `SameKey_DisposingOneSession_LeavesOtherBrowsable` - `SameKey_FailedCapture_FailsBothAwaiters` (same exception surfaces to both) - `SameKey_OpenAfterCompletion_RunsFreshCapture` (in-flight-only map — Initialize calls == 2; this is the Refresh path) - `DistinctKeys_CapLimitsInflightCaptures` (6 opens on 6 distinct keys, `MaxConcurrentCaptures = 4`: assert ≤4 fakes have entered Initialize while gated; release → all 6 complete) - `QueuedOpen_ObservesCallerCancellation` (a queued caller's ct cancels → its open throws OCE; the in-flight captures are unaffected) - `CoalescedAwaiter_CancellationDoesNotCancelSharedCapture` (first caller cancels; second caller still gets the tree) **Step 2:** FAIL. **Step 3: Implement** — add to `DiscoveryDriverBrowser`: ```csharp /// Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot. public const int MaxConcurrentCaptures = 4; private readonly ConcurrentDictionary>> _inflight = new(); private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures); ``` Rewrite `OpenAsync`: ```csharp public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) { var key = CaptureKey(driverType, configJson); // Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races. var capture = _inflight.GetOrAdd(key, _ => new Lazy>(() => RunCaptureAsync(key, driverType, configJson))); // The shared capture runs independent of any single caller (its own OpenTimeout bounds it); // each caller — including a queued one — observes ITS OWN ct via WaitAsync. var tree = await capture.Value.WaitAsync(ct).ConfigureAwait(false); return new CapturedTreeBrowseSession(tree); } private async Task RunCaptureAsync(string key, string driverType, string configJson) { try { await _captureSlots.WaitAsync().ConfigureAwait(false); // FIFO-ish queue for a slot try { return await CaptureAsync(driverType, configJson, CancellationToken.None).ConfigureAwait(false); } finally { _captureSlots.Release(); } } finally { _inflight.TryRemove(key, out _); // in-flight only — no result caching (Refresh = fresh capture) } } internal static string CaptureKey(string driverType, string configJson) => driverType.ToLowerInvariant() + ":" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( System.Text.Encoding.UTF8.GetBytes(configJson ?? ""))); ``` (`CaptureAsync` gets `CancellationToken.None` deliberately: the capture is shared and internally bounded by `_openTimeout`; per-caller cancellation must not kill another caller's capture. If every caller abandons, the capture still completes and is dropped — bounded waste, by design.) Add `using System.Collections.Concurrent;`. **Step 4:** PASS. Run the WHOLE Commons.Tests project (Tasks 6–10 together). --- ### Task 11: `BrowserSessionService` universal fallback + `CanBrowse` on the service **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** Task 9 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend/create `BrowserSessionServiceTests.cs` (follow the project's existing test style if a class exists) **Step 1: Failing tests:** - `Open_BespokeBrowserWinsOverUniversal` (both available for the type ⇒ bespoke's session) - `Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse` (fake `IUniversalDriverBrowser`) - `Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage` (existing message text unchanged) - `Open_UniversalThrow_ReturnsOkFalseWithMessage` (never throws out) - `CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise` - Existing tests still green (no `ToDictionary` change). **Step 2:** FAIL. **Step 3: Implement.** `IBrowserSessionService` — add: ```csharp /// True when a browse affordance exists for this driver type: a bespoke browser /// is registered, or the universal browser can serve it. Cheap (no connect); never throws. /// Drives the picker's Browse-button visibility. bool CanBrowse(string driverType, string configJson); ``` `BrowserSessionService` — add the ctor dependency (keep the primary-constructor style): ```csharp public sealed class BrowserSessionService( IEnumerable browsers, BrowseSessionRegistry registry, ILogger logger, IUniversalDriverBrowser universalBrowser) : IBrowserSessionService ``` Replace the `OpenAsync` miss branch (currently `BrowserSessionService.cs:27-28`): ```csharp if (!_browsersByType.TryGetValue(driverType, out var browser)) return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false); ``` And add: ```csharp public bool CanBrowse(string driverType, string configJson) => _browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson); private async Task OpenUniversalAsync(string driverType, string configJson, CancellationToken ct) { if (!universalBrowser.CanBrowse(driverType, configJson)) return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty); try { var session = await universalBrowser.OpenAsync(driverType, configJson, ct).ConfigureAwait(false); registry.Register(session); return new(true, null, session.Token); } catch (Exception ex) { logger.LogInformation(ex, "Universal browse open failed for driverType={DriverType}: {Message}", driverType, ex.Message); return new(false, ex.Message, Guid.Empty); } } ``` **Step 4:** PASS: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`. --- ### Task 12: DI registration in `AddAdminUI` **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 10 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` **Step 1:** In `AddAdminUI`, after the two bespoke `IDriverBrowser` lines (`EndpointRouteBuilderExtensions.cs:49-50`), add: ```csharp // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the // fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory // → CanBrowse false → pickers gracefully stay manual-entry (design §6). services.AddSingleton(sp => new DiscoveryDriverBrowser( sp.GetService() ?? ZB.MOM.WW.OtOpcUa.Core.Abstractions.NullDriverFactory.Instance, sp.GetRequiredService>())); ``` (Hoist the namespaces into `using`s per file style.) **Step 2:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — success. The Host smoke: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` still green. --- ### Task 13: TagModal → editor parameter plumbing (`DriverType` + `GetDriverConfigJson`) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 1, Task 2 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (only `BuildEditorParameters`, ~line 352) - Modify: all 7 editors in `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/` (`AbCipTagConfigEditor.razor`, `TwinCATTagConfigEditor.razor`, `FocasTagConfigEditor.razor`, `ModbusTagConfigEditor.razor`, `S7TagConfigEditor.razor`, `AbLegacyTagConfigEditor.razor`, `OpcUaClientTagConfigEditor.razor`) **Why:** typed editors today receive only `ConfigJson`/`ConfigJsonChanged` — the browse-capable picker bodies need the selected driver's type + DriverConfig JSON to open a session. `DynamicComponent` throws on unmatched parameters, so **every** editor must declare the new params (unused ones just ignore them). **Step 1:** `TagModal.razor` `BuildEditorParameters()` becomes: ```csharp private IDictionary BuildEditorParameters() => new Dictionary { ["ConfigJson"] = _form.TagConfig, ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), ["DriverType"] = SelectedDriverType ?? "", ["GetDriverConfigJson"] = (Func)(() => SelectedDriverConfig), }; ``` **Step 2:** Each of the 7 editors adds (next to the existing `ConfigJson` parameters): ```csharp /// DriverType of the selected driver — lets browse-capable pickers open a universal session. [Parameter] public string DriverType { get; set; } = ""; /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; ``` **Step 3:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — success. (No unit test — AdminUI has no bUnit; Task 18 live-verifies.) --- ### Task 14: AbCip picker body — universal browse mode **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 15, Task 16 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor` (pass-through only: add `DriverType="@DriverType" GetConfigJson="@GetDriverConfigJson"` to the `` element, ~line 36) **Step 1:** Rework `AbCipAddressPickerBody.razor` following the **exact** pattern of `OpcUaClientAddressPickerBody.razor` (same directory — read it first; it is the house template: `IBrowserSessionService` + `AuthenticationStateProvider`/`IAuthorizationService` DriverOperator gate + `DriverBrowseTree` + fire-and-forget close in `DisposeAsync`). Keep the existing manual tag-name/element-index builder markup **unchanged** at the top; append the browse affordance below it: - New params: `[Parameter] public string DriverType { get; set; } = "AbCip";` and `[Parameter] public Func GetConfigJson { get; set; } = () => "{}";` - Browse-button visibility: `_canOperate && BrowserService.CanBrowse(DriverType, GetConfigJson())` — evaluate once in `OnInitializedAsync` into `_canBrowse` (CanBrowse constructs a throwaway driver; don't call it per-render). - Open: `await BrowserService.OpenAsync(DriverType, GetConfigJson(), default)` — NOT a hardcoded type string. - While open, show **two** buttons: `Close` and `Refresh` (Refresh = `await CloseBrowseAsync(); await OpenBrowseAsync();` — a fresh capture per design §4.3). Label the tree header "snapshot taken at open/refresh time". - `OnTreeSelectAsync(BrowseNode node)`: only when `node.Kind == BrowseNodeKind.Leaf` (check `DriverBrowseTree`'s callback contract — if it already filters to leaves, match it): set `_tagName = node.NodeId` (the captured leaf NodeId IS the driver FullName / CIP tag path), clear `_useIndex`, and fire the existing `OnChangedAsync()`. **Step 2:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx`. **Step 3:** visual sanity deferred to Task 18 (live gate). --- ### Task 15: TwinCAT picker body — universal browse mode **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 14, Task 16 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor` (pass-through) Identical shape to Task 14 with `DriverType = "TwinCAT"` (confirm the exact registered DriverType string via `grep -rn "DriverTypeName" src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/` — use what `Register()` uses, casing matters only for display since lookups are OrdinalIgnoreCase). Tree leaf NodeId = ADS symbol path → the editor's address field. --- ### Task 16: FOCAS picker body — universal browse mode **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 14, Task 15 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor` (pass-through) Identical shape (confirm DriverType string from the FOCAS `Register()`). Note in a comment: FOCAS opens take a few seconds longer (UntilStable settle, Task 9) — the existing `_opening` spinner covers it. --- ### Task 17: Full sweep + doc status flip **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** none **Files:** - Modify: `docs/plans/2026-07-15-universal-discovery-browser-design.md` (header only: `**Status:** draft` → `**Status:** P1 implemented ; P2/P3 pending`) - Modify: `docs/plans/2026-07-15-driver-expansion-program-design.md` (§9 Status: note Wave 0 code landed) **Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero warnings-as-errors failures. **Step 2:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — full suite. **Known pre-existing failures are possible in unrelated suites; compare against a master baseline run if anything unrelated is red — do not chase unrelated rot, report it.** DB-backed suites need the central SQL Server reachable (`10.100.0.35,14330`); integration suites (`*.IntegrationTests`) need their fixtures — if offline, scope to: `Core.Abstractions.Tests`, `Commons.Tests`, `AdminUI.Tests`, plus the three driver unit projects touched. **Step 3:** Doc edits. **Step 4:** Commit. --- ### Task 18: Live `/run` verify on docker-dev (GATE — do not skip) **Classification:** high-risk (the repo's Razor-inertness trap: binding bugs pass unit tests + review) **Estimated implement time:** ~15 min wall (mostly container builds) **Parallelizable with:** none **Files:** none (verification only) **Step 1:** Bring the AbCip fixture up on the docker host: `lmxopcua-fix up abcip controllogix` (helper in `~/bin`; the ControlLogix sim listens on `10.100.0.35:44818`). **Step 2:** Rebuild **BOTH** docker-dev centrals (`:9200` round-robins central-1/central-2 — rebuilding one gives flapping results; durable repo fact): `docker compose -f docker-dev/docker-compose.yml up -d --build` (or the repo's documented docker-dev rebuild recipe — check `docker-dev/README` / `docs/v2/dev-environment.md`). **Step 3:** AdminUI at `http://localhost:9200` (login DISABLED — auto-admin; do this yourself, do not defer to the user). Using the Chrome browser tools (or playwright-skill): 1. Create/locate an AbCip driver whose DriverConfig points at `10.100.0.35:44818` with a device entry (follow `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests` fixture config for the exact device shape). Leave `EnableControllerBrowse` **absent** — the browse patch must supply it. 2. Equipment page → Tags tab → Add tag → select the AbCip driver → typed editor → open the address picker. 3. **Assert:** Browse button visible (CanBrowse=true) → click → tree of controller tags renders → Refresh works → pick a leaf → the address field holds the tag path → save the tag → TagConfig JSON contains it. 4. **Negative:** select a Modbus driver → its picker shows NO Browse button (SupportsOnlineDiscovery=false ⇒ CanBrowse=false). **Step 4:** If any leg fails: debug, fix, re-run. This gate finding a dead binding is EXPECTED value, not failure of the plan. **Step 5:** Record the result (screenshots/notes) in the final report; commit any fixes. --- ## Live-gate result (Task 18 — docker-dev, 2026-07-15) Executed against the local docker-dev rig (both centrals rebuilt from `feat/universal-discovery-browser`) with the ControlLogix `ab_server` fixture up on `10.100.0.35:44818`. An AbCip driver (device `ab://10.100.0.35:44818/1,0`, **`EnableControllerBrowse` deliberately left off**) was authored under an Equipment-kind namespace and bound to an equipment; a tag was added on the Tags-tab TagModal. - ✅ **Positive binding** — the AbCip address picker renders the **"Browse controller"** button. This exercises the whole `CanBrowse` chain end-to-end: `SupportsOnlineDiscovery=true` → `BrowserSessionService.CanBrowse` → `DiscoveryDriverBrowser.CanBrowse` (constructs a throwaway driver with `PatchForBrowse`) → TagModal `DriverType`/`GetDriverConfigJson` params → `AbCipTagConfigEditor` pass-through → `AbCipAddressPickerBody._canBrowse`. - ✅ **Browse invocation runs in production** — clicking Browse drove the full capture path; central-2 logged `BrowserSessionService.OpenUniversalAsync → DiscoveryDriverBrowser.OpenAsync → RunCaptureAsync → CaptureAsync → DiscoverTreeAsync`. Because the driver's authored config did **not** set `EnableControllerBrowse` yet the capture attempted the controller `@tags` walk (which only runs when that flag is true), **`PatchForBrowse` is demonstrably applied end-to-end**. The error surfaced as a graceful UI chip — no dead binding, no crash, no hang; bounded shutdown ran. - ✅ **Negative binding** — the Modbus picker shows only the manual Register/Offset/Length builder, **no Browse button** (`SupportsOnlineDiscovery` false; picker body unchanged). - ⚠️ **Not verifiable on this fixture** — a *populated* tree render + leaf-commit. The libplctag `ab_server` test sim returns `ErrorUnsupported` for controller symbol enumeration (it implements tag read/write, not the `@tags` Symbol Object walk). This is a **fixture limitation, not a code defect** — the binding is proven alive. ### Follow-ups discovered during the gate 1. **Full tree-render verification is fixture-blocked.** `ab_server` doesn't implement the `@tags` controller walk. To live-verify a populated tree + `TagConfig.FullName` leaf commit, point the universal browser at a symbol-enumeration-capable AB backend (a real ControlLogix, or a sim that serves `@tags`). 2. **Driver-page address pickers pass no live config.** `AbCipDriverPage`/`TwinCATDriverPage`/`FOCASDriverPage` host the same `*AddressPickerBody` but do **not** pass `DriverType`/`GetConfigJson`, so their (now-visible) Browse button would capture against `"{}"` (no device). Out of Task 14's scope (which listed only the TagEditor pass-through); wire the live form config through there too, or hide the Browse affordance on the driver-config picker. ## Out of scope (explicitly) - TwinCAT/FOCAS live-fixture verification (no shipped fixtures assumed reachable; AbCip is the gate). - The fixture-gated integration test (design §9) — follow-up once the AbCip fixture harness pattern is settled. - v3 Raw-tree re-targeting (design §11) — v3 consumes this unchanged. - Bespoke lazy browsers (P3), MTConnect/BACnet opt-ins (P2 — ride their driver builds).