# 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~<ClassName>"`.

---

### 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
    /// <summary>
    ///     True when <see cref="DiscoverAsync"/> 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.
    /// </summary>
    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 `<ItemGroup>` (Commons currently has NO project references — `Core.Abstractions` is a leaf with zero ProjectReferences, so this is cycle-free; verified 2026-07-15):

```xml
    <ItemGroup>
        <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
    </ItemGroup>
```

Also add `<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>` **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
    /// <summary>Controller enumeration is real (CIP Symbol Object walk) — config-gated on
    /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
    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;

/// <summary>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).</summary>
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<CapturedNode> Children { get; } = [];
    public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = [];
}

/// <summary>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.</summary>
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<string, CapturedNode> ById { get; init; }
}
```

`CapturingAddressSpaceBuilder.cs`:

```csharp
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;

/// <summary>
///     In-memory <see cref="IAddressSpaceBuilder"/> 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.
/// </summary>
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<string, CapturedNode> 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;

/// <summary>
///     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.
/// </summary>
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<IReadOnlyList<BrowseNode>> 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<IReadOnlyList<BrowseNode>>(nodes);
    }

    public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct)
    {
        var t = Tree();
        LastUsedUtc = DateTime.UtcNow;
        return Task.FromResult<IReadOnlyList<BrowseNode>>(
            t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []);
    }

    public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
    {
        var t = Tree();
        LastUsedUtc = DateTime.UtcNow;
        if (!t.ById.TryGetValue(nodeId, out var n))
            return Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
        var attrs = new List<AttributeInfo>();
        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<IReadOnlyList<AttributeInfo>>(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<IAddressSpaceBuilder, Task>` 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;

/// <summary>
///     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.
/// </summary>
public interface IUniversalDriverBrowser
{
    /// <summary>Cheap gate (construct + inspect, no connect): can this driver type + config be
    /// browsed? Never throws. Drives the picker's Browse-button visibility.</summary>
    bool CanBrowse(string driverType, string configJson);

    /// <summary>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.</summary>
    Task<IBrowseSession> 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;

/// <summary>See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2.</summary>
public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
{
    /// <summary>Whole-open bound: construct + connect + discover (+ settle). Separate from the
    /// 20 s per-call browse timeout, which only covers serving from memory.</summary>
    public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);

    /// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
    /// in discovery may be equally wedged in shutdown).</summary>
    public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);

    public const int NodeCap = 50_000;

    /// <summary>Per-type declarative config patch guaranteeing browse mode at open time (§5.1).
    /// A dictionary entry per config-gated driver — not code.</summary>
    internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
        new Dictionary<string, string>(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<DiscoveryDriverBrowser> _logger;
    private readonly TimeSpan _openTimeout;
    private readonly TimeSpan _shutdownTimeout;

    public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
        : this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }

    // Test seam: short timeouts.
    internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> 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<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
    {
        var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false);
        return new CapturedTreeBrowseSession(tree);
    }

    private async Task<CapturedTree> 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<CapturedTree> 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<CapturedTree> 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
    /// <summary>Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot.</summary>
    public const int MaxConcurrentCaptures = 4;

    private readonly ConcurrentDictionary<string, Lazy<Task<CapturedTree>>> _inflight = new();
    private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures);
```

Rewrite `OpenAsync`:

```csharp
    public async Task<IBrowseSession> 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<Task<CapturedTree>>(() => 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<CapturedTree> 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
    /// <summary>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.</summary>
    bool CanBrowse(string driverType, string configJson);
```

`BrowserSessionService` — add the ctor dependency (keep the primary-constructor style):

```csharp
public sealed class BrowserSessionService(
    IEnumerable<IDriverBrowser> browsers,
    BrowseSessionRegistry registry,
    ILogger<BrowserSessionService> 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<BrowseOpenResult> 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<IUniversalDriverBrowser>(sp => new DiscoveryDriverBrowser(
            sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Abstractions.IDriverFactory>() ?? ZB.MOM.WW.OtOpcUa.Core.Abstractions.NullDriverFactory.Instance,
            sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<DiscoveryDriverBrowser>>()));
```

(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<string, object> BuildEditorParameters() => new Dictionary<string, object>
    {
        ["ConfigJson"] = _form.TagConfig,
        ["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
        ["DriverType"] = SelectedDriverType ?? "",
        ["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
    };
```

**Step 2:** Each of the 7 editors adds (next to the existing `ConfigJson` parameters):

```csharp
    /// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
    [Parameter] public string DriverType { get; set; } = "";
    /// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
    [Parameter] public Func<string> 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 `<AbCipAddressPickerBody …>` 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<string> 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 <date>; 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).
