Files
lmxopcua/docs/plans/2026-07-15-universal-discovery-browser-implementation.md
T
Joseph Doherty 6b3da93151
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 16m47s
fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.

The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.

Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.

--- and the other half, which the fixture was hiding ---

Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:

  * @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
    @udt/ are), so it goes out as an ordinary symbolic name that no controller
    defines, and the controller's "tag does not exist" IS the successful answer.
  * ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
    controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
    classifies oppositely. Fixed in vendored patch 0002.
  * With the fixture then telling the truth, the real defect surfaced:
    AbCipDriverProbe.IsReachableException matched the native C status spellings
    ("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
    ("ErrorNotFound"). Those substrings can never match, so the reachable
    classification could not fire on the exception path — which is the path every
    no-authored-tags probe takes. This was failing on real hardware too, not just
    against the sim. It now recovers the Status and defers to IsReachableStatus,
    so there is one classification rather than two that can drift.

Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.

Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".

Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 15:20:07 -04:00

57 KiB
Raw Blame History

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

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):

    /// <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):

    <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:

((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:

    /// <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:

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:

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:

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:

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:

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:

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):

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:

    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 Tokens; 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:

    /// <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:

    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:

    /// <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):

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):

        if (!_browsersByType.TryGetValue(driverType, out var browser))
            return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);

And add:

    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:

        // 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 usings 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:

    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):

    /// <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. CLOSED 2026-08-01 — see the second gate below.

Second live-gate result (Gitea #468 — docker-dev, 2026-08-01)

The fixture gap was closed by teaching ab_server to serve the Symbol Object listing (tests/Drivers/…AbCip.IntegrationTests/Docker/patches/), which was the option #468's own definition of done offered alongside real hardware. All three criteria ran green against the patched fixture on 10.100.0.35:44818:

  • ✅ Populated controller-tag tree — the AbCip browse rendered all nine seeded tags as leaves under AbCip → Device1 → Discovered, in wire order: Motor_Run, Line1_Count, Pump_1_Speed, TestArray, TestString, TestSINT, TestBOOL, TestREAL, TestDINT.
  • ✅ Refresh re-captures — Refresh collapsed to the root and re-expansion returned the same nine leaves from a fresh capture.
  • ✅ Leaf commit round-trip — selecting Pump_1_Speed + TestDINT and committing wrote both into the raw tree with the CIP tag path in TagConfig ({"tagPath":"TestDINT",…}) and saved.

The gate earned its keep — it found a defect no unit test could have. CipSymbolObjectDecoder advanced to an even byte boundary after each symbol name, but Symbol Object entries are packed with no padding. Motor_Run is 9 bytes, so under the old decoder the browse above would have shown Motor_Run alone and silently dropped the other eight. Every existing multi-entry test passed, because the test builder padded exactly as the decoder did — the pair agreed with each other and neither agreed with a controller. Fixed, with the layout now pinned by a captured reply (CipSymbolObjectWireCaptureTests) rather than by a restatement of the assumption.

Follow-ups discovered during the second gate

  1. Browse-commit discards the discovered data type — CLOSED 2026-08-01 (Gitea #536). A browsed REAL committed with "dataType":"DInt", and AbCip/TwinCAT/FOCAS decode off that field rather than the raw row's DataType, so the tag published a plausible wrong number with no error raised. FOCAS was missed when the issue was filed. Fixed by carrying the driver's own type name on DriverAttributeInfo.NativeDataType → AttributeInfo.AddressFields["dataType"] → RawBrowseCommitMapper. It could not be fixed by reverse-mapping the leaf's driver-agnostic DriverDataType: SInt/Int/DInt/USInt/UInt all collapse into Int32, so that "fix" would still bind a 16-bit INT as a 32-bit DINT while looking correct on a REAL.
  2. Test Connect fails against this fixture regardless of the device — CLOSED 2026-08-01, and the stated cause was wrong. @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags, @udt/ are), so it is sent as an ordinary symbolic name and the controller's "tag does not exist" IS the successful answer. Two real defects hid behind the "fixture limitation" label: the sim answered CIP 0x03 where a controller answers 0x04 (fixed in vendored patch 0002), and AbCipDriverProbe.IsReachableException matched the native C status spellings (STATUS_NOT_FOUND) that libplctag.NET never emits — it throws with the managed enum name — so the reachable classification could not fire on the exception path, which is the path every no-authored-tags probe takes. Test Connect was therefore failing on real hardware too, not just against the sim. Pinned by AbCip_WithNoAuthoredTags_ReportsTheControllerReachable plus offline classification tests.

Follow-ups discovered during the first gate

  1. Full tree-render verification is fixture-blocked. CLOSED by the second gate above.
  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).