Files
lmxopcua/docs/plans/2026-07-15-universal-discovery-browser-implementation.md
T
Joseph Doherty c006363ec2
v2-ci / build (push) Successful in 3m31s
v2-ci / unit-tests (push) Failing after 8m18s
docs(driver-expansion): Wave-0 universal-browser implementation plan (18 tasks, parallel-batched)
2026-07-15 17:09:31 -04:00

1092 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 910)
**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 910):
```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 610 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.
---
## 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).