# Live address browsers (OpcUaClient + Galaxy) — implementation plan

> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` or `superpowers-extended-cc:subagent-driven-development` to implement this plan task-by-task.

**Goal:** Ship lazy, ad-hoc browse trees for the OpcUaClient and Galaxy address pickers in the AdminUI, so operators can navigate the remote server / galaxy hierarchy and pick an address rather than typing it.

**Architecture:** New `IDriverBrowser` abstraction in Commons, parallel to the runtime's `IDriverProbe`. One impl per driver type, housed in new sibling `*.Browser` projects under `src/Drivers/`. AdminUI holds browse sessions in-process via a `BrowseSessionRegistry` singleton with an `IHostedService` reaper enforcing a 2-minute idle TTL. Razor picker bodies call a scoped `IBrowserSessionService`. No new shared messages; no actor plumbing on the hot path.

**Tech stack:** .NET 10 / Blazor Server / OPCFoundation.NetStandard.Opc.Ua.Client / `ZB.MOM.WW.MxGateway.Client` (lazy-browse API: `LazyBrowseNode.ExpandAsync`).

**Design doc:** `docs/plans/2026-05-28-driver-browsers-design.md` (commit `fcd0b9b`). Each task below cites the design section it implements.

---

## Sequencing & parallelism overview

```
Phase 1 (abstractions) ──► Phase 2 (NamespaceMap extract) ──► Phase 3 (OpcUa browser) ─┐
                                                          └─► Phase 4 (Galaxy browser) ─┤
                                                                                        │
                                                          Phase 5 (AdminUI plumbing) ◄──┤
                                                                                        │
                                                          Phase 6 (DriverBrowseTree) ◄──┘
                                                                       │
                                                          Phase 7 (picker bodies) ◄─────┘
                                                                       │
                                                          Phase 8 (integration + docs) ◄┘
```

Phases 3 & 4 dispatch in parallel (no file overlap). Phase 5 starts after Phase 1 (only needs the abstractions). Phase 6 starts after Phase 5. Phase 7 needs 3, 4, 5, 6. Phase 8 last.

---

## Task 1 — Phase 1: Add browser abstractions to Commons

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (everything depends on this)

**Files:**
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IDriverBrowser.cs`
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs`
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs`

**Step 1: Add the three abstraction files**

`IDriverBrowser.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;

/// <summary>
///     Per-driver factory that opens an ad-hoc browse session against the configuration
///     supplied as JSON. Parallels <c>IDriverProbe</c> in the runtime — one implementation
///     per driver type, registered in AdminUI DI and indexed by <see cref="DriverType"/>.
/// </summary>
public interface IDriverBrowser
{
    /// <summary>Driver type key, matching the AdminUI's persisted DriverType string
    /// (e.g. "OpcUaClient", "Galaxy").</summary>
    string DriverType { get; }

    /// <summary>Opens a browse session against the supplied configuration.</summary>
    /// <param name="configJson">Driver options serialized as JSON; same shape the runtime
    /// driver would consume.</param>
    /// <param name="cancellationToken">Cancellation for the connect phase only.</param>
    Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
}
```

`IBrowseSession.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;

/// <summary>
///     A live, one-level-at-a-time browse over a remote address space. Owned by the
///     AdminUI <c>BrowseSessionRegistry</c>; disposed by the registry's TTL reaper or
///     the picker body on close.
/// </summary>
public interface IBrowseSession : IAsyncDisposable
{
    /// <summary>Opaque token identifying this session in the registry.</summary>
    Guid Token { get; }

    /// <summary>Wall-clock time of the most recent successful call. Refreshed on
    /// <see cref="RootAsync"/>, <see cref="ExpandAsync"/>, and
    /// <see cref="AttributesAsync"/>; used by the reaper for idle eviction.</summary>
    DateTime LastUsedUtc { get; }

    /// <summary>Returns the top-level browse nodes.</summary>
    Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);

    /// <summary>Returns the direct children of the node identified by
    /// <paramref name="nodeId"/>.</summary>
    Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);

    /// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
    /// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
    /// the attribute side-panel after the user selects an object.</summary>
    Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
```

`BrowseNode.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;

/// <summary>One node in a driver-agnostic browse tree.</summary>
/// <param name="NodeId">Stable identifier passed back to the picker on commit. For OPC UA
/// this is the <c>nsu=...;...</c> form; for Galaxy this is the <c>tag_name</c>.</param>
/// <param name="DisplayName">Label shown in the tree.</param>
/// <param name="Kind">Whether this node terminates the address (Leaf) or has children
/// (Folder). Galaxy never returns Leaves; only the attribute side-panel terminates.</param>
/// <param name="HasChildrenHint">When true, the UI renders an expand affordance before
/// the children have been fetched.</param>
public sealed record BrowseNode(
    string NodeId,
    string DisplayName,
    BrowseNodeKind Kind,
    bool HasChildrenHint);

/// <summary>Discriminates terminal vs. expandable nodes for UI rendering.</summary>
public enum BrowseNodeKind
{
    /// <summary>Expandable — has (or may have) children. UI shows expand affordance.</summary>
    Folder,
    /// <summary>Terminal — commit on select.</summary>
    Leaf,
}

/// <summary>Metadata for an attribute of a Galaxy object (or the equivalent
/// per-driver concept). Surfaced in the picker's attribute side-panel.</summary>
public sealed record AttributeInfo(
    string Name,
    string DriverDataType,
    bool   IsArray,
    string SecurityClass);
```

**Step 2: Build clean**
Run: `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj`
Expected: 0 errors, 0 warnings.

**Step 3: Commit**
```bash
git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/
git commit -m "feat(commons): add IDriverBrowser/IBrowseSession/BrowseNode abstractions"
```

---

## Task 2 — Phase 2: Extract NamespaceMap to OpcUaClient.Contracts

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (Phase 3 depends on this)

**Files:**
- Move: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/NamespaceMap.cs` → `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj` — add `PackageReference` to `OPCFoundation.NetStandard.Opc.Ua.Client` (NamespaceMap needs `ISession`/`NamespaceTable`/`NodeId`)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj` — add `<ProjectReference Include="../ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />` (if not already present)

**Step 1: Move the file**
```bash
git mv src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/NamespaceMap.cs \
       src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs
```

**Step 2: Change visibility**
In the moved file, change `internal sealed class NamespaceMap` → `public sealed class NamespaceMap` and add an XML `<summary>` doc declaring the visibility change (one-line at the class level is fine — there are detailed remarks already).

Also change any `internal` static methods that are part of the public surface (`FromSession`, `FromTable`, `TryResolve`, `ToStableReference`, etc.) to `public`. Keep the private ctor private.

**Step 3: Add Opc.Ua reference to Contracts csproj**
Replace the empty Contracts csproj with:
```xml
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
    </ItemGroup>
</Project>
```
(version pinned via `Directory.Packages.props` — confirm by `grep -n OPCFoundation Directory.Packages.props`.)

**Step 4: Ensure runtime driver references Contracts**
Check `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj` already has a `<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\...">` line. If not, add it.

**Step 5: Build the driver to verify the move**
Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj`
Expected: 0 errors. (Existing `OpcUaClientDriver.cs` uses `NamespaceMap.FromSession`, `TryResolve`, `ToStableReference` — those calls keep working with no source change because the namespace is unchanged.)

**Step 6: Build the full solution + run OpcUaClient tests**
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj`
Expected: all green. If the tests reference `NamespaceMap` directly (likely, since it's namespace-mapping-critical), they should still resolve because the namespace is unchanged.

**Step 7: Commit**
```bash
git add -A
git commit -m "refactor(opcuaclient): move NamespaceMap to Contracts, make public

Browser project (Phase 3) needs to share namespace-stable address encoding
with the runtime driver. Move keeps the same namespace, so existing usages
in OpcUaClientDriver compile unchanged."
```

---

## Task 3 — Phase 3a: Scaffold Driver.OpcUaClient.Browser project

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 7 (different Browser project)

**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` — add new project

**Step 1: Create csproj**
```xml
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    </PropertyGroup>
    <ItemGroup>
        <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
        <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
    </ItemGroup>
    <ItemGroup>
        <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
        <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" />
        <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
    </ItemGroup>
</Project>
```

**Step 2: Add project to slnx**
Open `ZB.MOM.WW.OtOpcUa.slnx`, locate the `<Folder Name="/src/Drivers/">` block, add:
```xml
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
```
in alphabetical position. Match the surrounding format (slnx uses `.slnx` XML, not legacy GUID-based `.sln`).

**Step 3: Verify build**
Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/`
Expected: 0 errors. Project compiles empty.

**Step 4: Commit**
```bash
git add -A
git commit -m "feat(opcuaclient.browser): scaffold project + slnx entry"
```

---

## Task 4 — Phase 3b: Implement OpcUaClientBrowseSession

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7 (different driver)

**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs`

**Step 1: Write the session class**
```csharp
using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;     // NamespaceMap (now in Contracts)

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;

/// <summary>
///     Live one-level-at-a-time browse over an OPC UA server session opened by
///     <see cref="OpcUaClientDriverBrowser"/>. Serializes all browse calls on
///     <see cref="_gate"/> because <c>Session.BrowseAsync</c> is not safe to call
///     concurrently from multiple threads (same constraint as <c>OpcUaClientDriver</c>).
/// </summary>
internal sealed class OpcUaClientBrowseSession : IBrowseSession
{
    private readonly ISession _session;
    private readonly NamespaceMap _nsMap;
    private readonly NodeId _rootNodeId;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private bool _disposed;

    public Guid Token { get; } = Guid.NewGuid();
    public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;

    internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
    {
        _session = session;
        _nsMap = nsMap;
        _rootNodeId = rootNodeId;
    }

    public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
        => BrowseOneLevelAsync(_rootNodeId, ct);

    public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string stableNodeId, CancellationToken ct)
    {
        if (!NamespaceMap.TryResolve(_session, stableNodeId, out var nodeId))
            throw new ArgumentException(
                $"Cannot resolve NodeId '{stableNodeId}' against the live session.", nameof(stableNodeId));
        return BrowseOneLevelAsync(nodeId, ct);
    }

    /// <summary>OPC UA variables don't have hierarchical sub-attributes the picker cares about,
    /// so this always returns an empty list. Galaxy uses this path; OPC UA leaves it inert.</summary>
    public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
        => Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());

    private async Task<IReadOnlyList<BrowseNode>> BrowseOneLevelAsync(NodeId node, CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        await _gate.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            var desc = new BrowseDescriptionCollection
            {
                new()
                {
                    NodeId = node,
                    BrowseDirection = BrowseDirection.Forward,
                    ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
                    IncludeSubtypes = true,
                    NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
                    ResultMask = (uint)(BrowseResultMask.BrowseName
                                        | BrowseResultMask.DisplayName
                                        | BrowseResultMask.NodeClass),
                },
            };
            var resp = await _session.BrowseAsync(null, null, 0, desc, ct).ConfigureAwait(false);
            if (resp.Results.Count == 0) return Array.Empty<BrowseNode>();
            var refs = resp.Results[0].References;
            var cp = resp.Results[0].ContinuationPoint;
            while (cp is { Length: > 0 })
            {
                var next = await _session.BrowseNextAsync(null, false, [cp], ct).ConfigureAwait(false);
                if (next.Results.Count == 0) break;
                if (next.Results[0].References is { Count: > 0 } more) refs.AddRange(more);
                cp = next.Results[0].ContinuationPoint;
            }

            LastUsedUtc = DateTime.UtcNow;
            return refs.Select(ToBrowseNode).ToList();
        }
        finally { _gate.Release(); }
    }

    private BrowseNode ToBrowseNode(ReferenceDescription rf)
    {
        var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
        var isObject = rf.NodeClass == NodeClass.Object;
        return new BrowseNode(
            NodeId: _nsMap.ToStableReference(childId),
            DisplayName: rf.DisplayName?.Text
                         ?? rf.BrowseName?.Name
                         ?? childId.ToString()
                         ?? "(unnamed)",
            Kind: isObject ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf,
            HasChildrenHint: isObject);
    }

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;
        try { if (_session is Session s) await s.CloseAsync().ConfigureAwait(false); }
        catch { /* best-effort */ }
        try { _session.Dispose(); } catch { /* best-effort */ }
        _gate.Dispose();
    }
}
```

**Step 2: Verify build**
Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/`
Expected: 0 errors.

**Step 3: Commit**
```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs
git commit -m "feat(opcuaclient.browser): add lazy browse session impl"
```

---

## Task 5 — Phase 3c: Implement OpcUaClientDriverBrowser factory

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7

**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs`

**Step 1: Write the factory**
```csharp
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;

/// <summary>
///     Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
///     Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
///     trust decisions cannot poison the runtime driver's cert store.
/// </summary>
public sealed class OpcUaClientDriverBrowser : IDriverBrowser
{
    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
        PropertyNameCaseInsensitive = true,
    };

    private readonly ILogger<OpcUaClientDriverBrowser> _logger;

    public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
    {
        _logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
    }

    public string DriverType => "OpcUaClient";

    public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct)
    {
        var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, JsonOpts)
            ?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");

        // Use EndpointUrls[0] if set, else EndpointUrl (same precedence as the runtime driver).
        var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
        if (string.IsNullOrWhiteSpace(endpoint))
            throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");

        var appConfig = await BuildBrowseAppConfigurationAsync(ct).ConfigureAwait(false);
        var identity = BuildBrowseUserIdentity(opts);

        var perEndpointBudget = TimeSpan.FromSeconds(
            Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
        using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        connectCts.CancelAfter(perEndpointBudget);

        var endpointDesc = await SelectEndpointAsync(
            appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
        var endpointCfg = EndpointConfiguration.Create(appConfig);
        endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
        var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);

        var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
            appConfig,
            configuredEndpoint,
            updateBeforeConnect: false,
            sessionName: "OtOpcUa AdminUI Browse",
            (uint)opts.SessionTimeout.TotalMilliseconds,
            identity,
            preferredLocales: null,
            connectCts.Token).ConfigureAwait(false);

        // Build the namespace map from the live session for stable NodeId encoding.
        var nsMap = NamespaceMap.FromSession(session);

        var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
            ? ObjectIds.ObjectsFolder
            : NodeId.Parse(session.MessageContext, opts.BrowseRoot);

        _logger.LogInformation(
            "AdminUI browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
            endpoint, opts.SecurityPolicy, opts.SecurityMode);

        return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
    }

    /// <summary>
    ///     Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
    ///     the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Keeps
    ///     browse-time cert trust decisions out of the deployed driver's trust store.
    /// </summary>
    private static async Task<ApplicationConfiguration> BuildBrowseAppConfigurationAsync(CancellationToken ct)
    {
#pragma warning disable CS0618
        var app = new ApplicationInstance
        {
            ApplicationName = "OtOpcUa AdminUI Browse",
            ApplicationType = ApplicationType.Client,
        };
#pragma warning restore CS0618

        var pkiRoot = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "OtOpcUa", "adminui-browse-pki");

        var config = new ApplicationConfiguration
        {
            ApplicationName = "OtOpcUa AdminUI Browse",
            ApplicationType = ApplicationType.Client,
            ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
            SecurityConfiguration = new SecurityConfiguration
            {
                ApplicationCertificate = new CertificateIdentifier
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "own"),
                    SubjectName = "CN=OtOpcUa AdminUI Browse",
                },
                TrustedPeerCertificates = new CertificateTrustList
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "trusted"),
                },
                TrustedIssuerCertificates = new CertificateTrustList
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "issuers"),
                },
                RejectedCertificateStore = new CertificateTrustList
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(pkiRoot, "rejected"),
                },
                AutoAcceptUntrustedCertificates = false,
            },
            TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
            DisableHiResClock = true,
        };
        await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
        app.ApplicationConfiguration = config;
        await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
            .ConfigureAwait(false);
        return config;
    }

    private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
    {
        OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
        OpcUaAuthType.Username => new UserIdentity(
            opts.Username ?? string.Empty,
            System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
        // Certificate auth path can call the runtime driver's BuildCertificateIdentity if
        // exposed; for v1 surface a clear failure so the operator uses a different auth
        // mode for browsing. (Browser-time certificate-token auth deferred to follow-up.)
        OpcUaAuthType.Certificate => throw new InvalidOperationException(
            "Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username."),
        _ => new UserIdentity(new AnonymousIdentityToken()),
    };

    private static async Task<EndpointDescription> SelectEndpointAsync(
        ApplicationConfiguration appConfig, string url,
        OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
    {
        using var client = await DiscoveryClient.CreateAsync(
            appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
        var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);

        var wantedPolicy = MapPolicy(policy);
        var wantedMode = mode switch
        {
            OpcUaSecurityMode.None => MessageSecurityMode.None,
            OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
            OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
            _ => throw new ArgumentOutOfRangeException(nameof(mode)),
        };
        var match = all.FirstOrDefault(e =>
            e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
        if (match is null)
        {
            var advertised = string.Join(", ", all.Select(e =>
                $"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
            throw new InvalidOperationException(
                $"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
                $"Server advertises: {advertised}");
        }
        return match;
    }

    private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
    {
        OpcUaSecurityPolicy.None => SecurityPolicies.None,
        OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
        OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
        OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
        OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
        OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
        _ => throw new ArgumentOutOfRangeException(nameof(p)),
    };

    private static string ShortName(string uri) =>
        uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
}
```

**Step 2: Verify build**
Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/`
Expected: 0 errors.

**Step 3: Commit**
```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
git commit -m "feat(opcuaclient.browser): add transient-session factory"
```

---

## Task 6 — Phase 3d: OpcUaClient.Browser tests (against opc-plc fixture)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10 (different driver)

**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` — add the test project

**Step 1: csproj (mirror the existing OpcUaClient.Tests pattern)**
Look at `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj` for the test SDK + xUnit + Shouldly package set and copy verbatim. References should include:
```xml
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
```

**Step 2: OpcUaClientDriverBrowserTests.cs** (unit-flavored — no live server)
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;

public class OpcUaClientDriverBrowserTests
{
    private readonly OpcUaClientDriverBrowser _sut = new();

    [Fact]
    public void DriverType_is_OpcUaClient() => _sut.DriverType.ShouldBe("OpcUaClient");

    [Fact]
    public async Task OpenAsync_with_empty_endpoint_throws()
    {
        var json = """{"EndpointUrl":"","EndpointUrls":[]}""";
        var ex = await Should.ThrowAsync<InvalidOperationException>(
            () => _sut.OpenAsync(json, TestContext.Current.CancellationToken).AsTask());
        ex.Message.ShouldContain("EndpointUrl");
    }

    [Fact]
    public async Task OpenAsync_with_null_json_throws()
    {
        var ex = await Should.ThrowAsync<InvalidOperationException>(
            () => _sut.OpenAsync("null", TestContext.Current.CancellationToken).AsTask());
        ex.Message.ShouldContain("null");
    }

    [Fact]
    public async Task OpenAsync_with_certificate_auth_throws_clear_message()
    {
        var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
        var ex = await Should.ThrowAsync<InvalidOperationException>(
            () => _sut.OpenAsync(json, TestContext.Current.CancellationToken).AsTask());
        ex.Message.ShouldContain("Certificate");
    }
}
```

Note: `TestContext.Current.CancellationToken` is xUnit v3 style. If the existing OpcUaClient.Tests uses v2 (`async Task` without parameter), match that. Skim the existing test file to be sure.

**Step 3: OpcUaClientBrowseSessionTests.cs** (live — gated on opc-plc fixture)
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;

/// <summary>
///     Live-server tests against the opc-plc Docker fixture at
///     <c>opc.tcp://10.100.0.35:50000</c>. Bring up with
///     <c>lmxopcua-fix up opcuaclient</c> before running.
/// </summary>
/// <remarks>Skipped automatically when the fixture is unreachable.</remarks>
[Trait("Category", "RequiresOpcPlc")]
public class OpcUaClientBrowseSessionTests
{
    private static string Endpoint =>
        Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") ?? "opc.tcp://10.100.0.35:50000";

    private static string ConfigJson => $$"""
        {
            "EndpointUrl":"{{Endpoint}}",
            "SecurityPolicy":"None",
            "SecurityMode":"None",
            "AuthType":"Anonymous",
            "SessionTimeout":"00:01:00",
            "Timeout":"00:00:10",
            "PerEndpointConnectTimeout":"00:00:10"
        }
        """;

    [Fact]
    public async Task RootAsync_returns_at_least_one_node()
    {
        var browser = new OpcUaClientDriverBrowser();
        await using var session = await browser.OpenAsync(ConfigJson, TestContext.Current.CancellationToken);
        var roots = await session.RootAsync(TestContext.Current.CancellationToken);
        roots.Count.ShouldBeGreaterThan(0);
    }

    [Fact]
    public async Task ExpandAsync_round_trips_stable_NodeId()
    {
        var browser = new OpcUaClientDriverBrowser();
        await using var session = await browser.OpenAsync(ConfigJson, TestContext.Current.CancellationToken);
        var roots = await session.RootAsync(TestContext.Current.CancellationToken);
        var folder = roots.FirstOrDefault(n => n.Kind == BrowseNodeKind.Folder);
        folder.ShouldNotBeNull("expected at least one Folder under ObjectsFolder");
        var children = await session.ExpandAsync(folder!.NodeId, TestContext.Current.CancellationToken);
        children.ShouldNotBeNull();
    }

    [Fact]
    public async Task AttributesAsync_is_empty_for_opcuaclient()
    {
        var browser = new OpcUaClientDriverBrowser();
        await using var session = await browser.OpenAsync(ConfigJson, TestContext.Current.CancellationToken);
        var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85",
            TestContext.Current.CancellationToken);
        attrs.ShouldBeEmpty();
    }
}
```

**Step 4: Add test project to slnx**
Add the new test csproj path to `ZB.MOM.WW.OtOpcUa.slnx` (under the `tests/Drivers/` solution folder).

**Step 5: Run tests**
Run: `lmxopcua-fix up opcuaclient` (from PowerShell on the dev host)
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/`
Expected: all green. If the fixture is unreachable, the `RequiresOpcPlc` tests fail; the unit-only tests still pass.

**Step 6: Commit**
```bash
git add -A
git commit -m "test(opcuaclient.browser): unit + opc-plc live coverage"
```

---

## Task 7 — Phase 4a: Scaffold Driver.Galaxy.Browser project

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3 (different driver)

**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` — add new project

**Step 1: Create csproj**
```xml
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    </PropertyGroup>
    <ItemGroup>
        <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
        <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
    </ItemGroup>
    <ItemGroup>
        <PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
        <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
    </ItemGroup>
</Project>
```
(Pin via `Directory.Packages.props` — verify the gateway client package name matches the one consumed elsewhere.)

**Step 2: Add to slnx (alphabetical position under `src/Drivers/`)**

**Step 3: Build clean**
Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/`
Expected: 0 errors.

**Step 4: Commit**
```bash
git add -A
git commit -m "feat(galaxy.browser): scaffold project + slnx entry"
```

---

## Task 8 — Phase 4b: Implement GalaxyBrowseSession

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4 (different driver)

**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs`

**Step 1: Write the session class**
```csharp
using System.Collections.Concurrent;
using MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;

/// <summary>
///     Lazy browse over a Galaxy via the gateway client's <see cref="LazyBrowseNode"/>.
///     Stateless gRPC under the hood; per-call locking lives inside <see cref="LazyBrowseNode"/>.
///     Tracks already-handed-out nodes by <c>tag_name</c> so <see cref="ExpandAsync"/>
///     and <see cref="AttributesAsync"/> can dispatch against the right cached handle.
/// </summary>
internal sealed class GalaxyBrowseSession : IBrowseSession
{
    private readonly MxGatewaySession _session;
    private readonly GalaxyRepositoryClient _client;
    private readonly ConcurrentDictionary<string, LazyBrowseNode> _byTagName = new();
    private bool _disposed;

    public Guid Token { get; } = Guid.NewGuid();
    public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;

    internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client)
    {
        _session = session;
        _client = client;
    }

    public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        var roots = await _client.BrowseAsync(new BrowseChildrenOptions(), ct).ConfigureAwait(false);
        LastUsedUtc = DateTime.UtcNow;
        return Project(roots);
    }

    public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string tagName, CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (!_byTagName.TryGetValue(tagName, out var node))
            throw new ArgumentException(
                $"Galaxy object '{tagName}' is not in the current browse-session cache. " +
                "Re-open the browser or expand its parent first.", nameof(tagName));
        await node.ExpandAsync(ct).ConfigureAwait(false);
        LastUsedUtc = DateTime.UtcNow;
        return Project(node.Children);
    }

    /// <summary>
    ///     Fetches the attributes of one Galaxy object via
    ///     <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName, IncludeAttributes=true)</c>.
    ///     This returns the object itself (single-element list) with attributes populated.
    /// </summary>
    public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string tagName, CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        var rows = await _client.DiscoverHierarchyAsync(
            new DiscoverHierarchyOptions
            {
                RootTagName = tagName,
                MaxDepth = 0,
                IncludeAttributes = true,
            }, ct).ConfigureAwait(false);

        LastUsedUtc = DateTime.UtcNow;
        var obj = rows.FirstOrDefault();
        if (obj is null) return Array.Empty<AttributeInfo>();

        var result = new List<AttributeInfo>(obj.Attributes.Count);
        foreach (var attr in obj.Attributes)
        {
            result.Add(new AttributeInfo(
                Name: attr.AttributeName,
                DriverDataType: attr.DataTypeName ?? attr.MxDataType.ToString(),
                IsArray: attr.IsArray,
                SecurityClass: MapSecurityClass(attr.SecurityClassification)));
        }
        return result;
    }

    private IReadOnlyList<BrowseNode> Project(IReadOnlyList<LazyBrowseNode> nodes)
    {
        var result = new List<BrowseNode>(nodes.Count);
        foreach (var n in nodes)
        {
            var tagName = n.Object.TagName;
            _byTagName[tagName] = n;
            result.Add(new BrowseNode(
                NodeId: tagName,
                DisplayName: string.IsNullOrEmpty(n.Object.ContainedName)
                    ? n.Object.TagName
                    : n.Object.ContainedName,
                Kind: BrowseNodeKind.Folder, // Galaxy objects always expandable in tree
                HasChildrenHint: n.HasChildrenHint));
        }
        return result;
    }

    // Galaxy security_classification raw integer mapping (per docs/GalaxyRepository.md).
    // Same buckets the runtime SecurityMap uses — strings are display-only.
    private static string MapSecurityClass(int raw) => raw switch
    {
        0 => "FreeAccess",
        1 => "Operate",
        2 => "Tune",
        3 => "Configure",
        4 => "ViewOnly",
        _ => $"Unknown({raw})",
    };

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;
        try { await _session.DisposeAsync().ConfigureAwait(false); }
        catch { /* best-effort */ }
    }
}
```

**Step 2: Verify build**
Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/`
Expected: 0 errors. If the `MxGateway.Client` namespace differs from what `GatewayGalaxyHierarchySource.cs` already uses, copy the exact namespaces from that file.

**Step 3: Commit**
```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs
git commit -m "feat(galaxy.browser): add lazy browse session with attribute fetch"
```

---

## Task 9 — Phase 4c: Implement GalaxyDriverBrowser factory

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5

**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs`

**Step 1: Write the factory**
```csharp
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;

/// <summary>
///     Opens transient gateway sessions for the AdminUI address picker. Distinct
///     <c>ClientName</c> from the runtime driver so the gateway can attribute load.
/// </summary>
public sealed class GalaxyDriverBrowser : IDriverBrowser
{
    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
        PropertyNameCaseInsensitive = true,
    };

    private readonly ILogger<GalaxyDriverBrowser> _logger;

    public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
    {
        _logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
    }

    public string DriverType => "Galaxy";

    public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct)
    {
        var opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, JsonOpts)
            ?? throw new InvalidOperationException("Galaxy options deserialized to null.");

        if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint))
            throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint.");
        if (string.IsNullOrWhiteSpace(opts.MxAccess.GalaxyName))
            throw new InvalidOperationException("Galaxy browser requires MxAccess.GalaxyName.");

        var clientOpts = new MxGatewayClientOptions
        {
            Endpoint = new Uri(opts.Gateway.Endpoint),
            ApiKey = opts.Gateway.ApiKeySecretRef, // browser is a one-shot — pass through verbatim
            ClientName = "OtOpcUa-AdminUI-Browse",
            UseTls = opts.Gateway.UseTls,
        };

        using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        connectCts.CancelAfter(TimeSpan.FromSeconds(30));

        MxGatewaySession session = await MxGatewaySession.OpenAsync(clientOpts, connectCts.Token).ConfigureAwait(false);
        try
        {
            var repoClient = session.GalaxyRepository(opts.MxAccess.GalaxyName);
            _logger.LogInformation(
                "AdminUI Galaxy browse session opened against {Endpoint} galaxy {Galaxy}",
                opts.Gateway.Endpoint, opts.MxAccess.GalaxyName);
            return new GalaxyBrowseSession(session, repoClient);
        }
        catch
        {
            await session.DisposeAsync().ConfigureAwait(false);
            throw;
        }
    }
}
```

**NB**: the precise gateway client API names (`MxGatewaySession.OpenAsync`, `session.GalaxyRepository(...)`, `MxGatewayClientOptions.ApiKey`/`UseTls`) MUST be verified against the actual `ZB.MOM.WW.MxGateway.Client` namespace before commit. If the existing in-repo `GatewayGalaxyHierarchySource.cs` already opens a session, **use that exact code path** as the source of truth and adapt — don't invent a parallel pattern. Mirror its wiring.

**Step 2: Verify build**
Run: `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/`
Expected: 0 errors.

**Step 3: Commit**
```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs
git commit -m "feat(galaxy.browser): add transient gateway-session factory"
```

---

## Task 10 — Phase 4d: Galaxy.Browser tests (fake transport)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 6

**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`

**Step 1: csproj** (mirror existing Galaxy.Tests pattern; reference the fake-transport project from the gateway client repo if accessible, OR copy the fake into this test project — confirm what the existing Galaxy.Tests does).

**Step 2: GalaxyBrowseSessionTests.cs** — uses fake `IGalaxyRepositoryClientTransport` (the gateway client tests have one at `tests/.../FakeGalaxyRepositoryTransport.cs` in `/Users/dohertj2/Desktop/MxAccessGateway/clients/dotnet/`). Either:
  - Reference it from there (if NuGet-published as part of `*.Testing`)
  - Or build a minimal in-test fake satisfying the transport contract

Tests:
- `RootAsync_returns_projected_browse_nodes` — fake returns 2 canned `GalaxyObject` rows → SessionRootAsync → 2 BrowseNodes with correct TagName/ContainedName/HasChildrenHint
- `ExpandAsync_unknown_tag_throws_ArgumentException`
- `ExpandAsync_cached_node_returns_children` — fake child rows wired to a parent's gobject_id
- `AttributesAsync_returns_projected_attribute_infos` — fake DiscoverHierarchy returns one object with attributes
- `Project_uses_TagName_when_ContainedName_empty`
- `MapSecurityClass_buckets_match_runtime_SecurityMap`

**Step 3: GalaxyDriverBrowserTests.cs** — bad JSON, missing endpoint, missing galaxy name → clear errors.

**Step 4: Add test project to slnx**

**Step 5: Run**
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/`
Expected: all green. No fixture needed.

**Step 6: Commit**
```bash
git add -A
git commit -m "test(galaxy.browser): unit coverage with fake transport"
```

---

## Task 11 — Phase 5a: BrowseSessionRegistry + reaper + service in AdminUI

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 3-10 (different files)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add ProjectReference to Commons (if not present)

**Step 1: BrowseSessionRegistry**
```csharp
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;

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

/// <summary>
///     In-process registry of live browse sessions, keyed by opaque <see cref="Guid"/>.
///     Singleton. Process-local; sticky-cookie routing keeps tokens bound to the AdminUI
///     replica that created them.
/// </summary>
public sealed class BrowseSessionRegistry
{
    private readonly ConcurrentDictionary<Guid, IBrowseSession> _sessions = new();

    public void Register(IBrowseSession session) => _sessions[session.Token] = session;

    public bool TryGet(Guid token, out IBrowseSession session) =>
        _sessions.TryGetValue(token, out session!);

    /// <summary>Removes the session from the registry without disposing it.</summary>
    public bool TryRemove(Guid token, out IBrowseSession session) =>
        _sessions.TryRemove(token, out session!);

    /// <summary>Snapshot for the reaper. Stable across iteration.</summary>
    public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() =>
        _sessions.Select(kv => (kv.Key, kv.Value)).ToList();
}
```

**Step 2: BrowseSessionReaper**
```csharp
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

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

/// <summary>
///     Periodically evicts browse sessions idle for more than <see cref="IdleTtl"/>.
///     30s tick, 2 min idle window. Disposes evicted sessions outside the dictionary so
///     a concurrent <c>BrowseAsync</c> sees the session disappear cleanly and gets a
///     translated NotFound error from the service layer.
/// </summary>
public sealed class BrowseSessionReaper(
    BrowseSessionRegistry registry,
    ILogger<BrowseSessionReaper> logger) : BackgroundService
{
    public static readonly TimeSpan IdleTtl = TimeSpan.FromMinutes(2);
    public static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TickInterval);
        while (!stoppingToken.IsCancellationRequested &&
               await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
        {
            try { await ReapOnceAsync(stoppingToken).ConfigureAwait(false); }
            catch (Exception ex)
            {
                logger.LogWarning(ex, "Browse-session reaper iteration failed; will retry next tick.");
            }
        }

        // Final drain on shutdown.
        await DrainAllAsync().ConfigureAwait(false);
    }

    internal async Task ReapOnceAsync(CancellationToken ct)
    {
        var now = DateTime.UtcNow;
        foreach (var (token, session) in registry.Snapshot())
        {
            if (now - session.LastUsedUtc < IdleTtl) continue;
            if (!registry.TryRemove(token, out var taken)) continue;
            try { await taken.DisposeAsync().ConfigureAwait(false); }
            catch (Exception ex)
            {
                logger.LogDebug(ex,
                    "Best-effort dispose of idle-evicted browse session {Token} failed.", token);
            }
            logger.LogDebug("Browse session {Token} closed reason=idle-ttl", token);
        }
    }

    private async Task DrainAllAsync()
    {
        foreach (var (token, session) in registry.Snapshot())
        {
            if (!registry.TryRemove(token, out var taken)) continue;
            try { await taken.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ }
            logger.LogDebug("Browse session {Token} closed reason=shutdown", token);
        }
    }
}
```

**Step 3: IBrowserSessionService + impl**
```csharp
// IBrowserSessionService.cs
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;

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

public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token);

public interface IBrowserSessionService
{
    Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
    Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct);
    Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
    Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
    Task CloseAsync(Guid token);
}

public sealed class BrowseSessionNotFoundException(Guid token)
    : InvalidOperationException($"Browse session {token} not found (may have been reaped).");
```

```csharp
// BrowserSessionService.cs
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;

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

public sealed class BrowserSessionService(
    IEnumerable<IDriverBrowser> browsers,
    BrowseSessionRegistry registry,
    ILogger<BrowserSessionService> logger) : IBrowserSessionService
{
    public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);

    private readonly IReadOnlyDictionary<string, IDriverBrowser> _browsersByType =
        browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);

    public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
    {
        if (!_browsersByType.TryGetValue(driverType, out var browser))
            return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
        try
        {
            var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false);
            registry.Register(session);
            return new(true, null, session.Token);
        }
        catch (Exception ex)
        {
            logger.LogInformation(ex,
                "Browser open failed for driverType={DriverType}: {Message}", driverType, ex.Message);
            return new(false, ex.Message, Guid.Empty);
        }
    }

    public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
        InvokeAsync(token, ct, (s, c) => s.RootAsync(c));

    public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) =>
        InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c));

    public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
        InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));

    public async Task CloseAsync(Guid token)
    {
        if (!registry.TryRemove(token, out var session)) return;
        try { await session.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ }
        logger.LogDebug("Browse session {Token} closed reason=user-close", token);
    }

    private async Task<T> InvokeAsync<T>(
        Guid token, CancellationToken callerCt, Func<IBrowseSession, CancellationToken, Task<T>> op)
    {
        if (!registry.TryGet(token, out var session))
            throw new BrowseSessionNotFoundException(token);
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(callerCt);
        cts.CancelAfter(PerCallTimeout);
        return await op(session, cts.Token).ConfigureAwait(false);
    }
}
```

**Step 4: Build clean**
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/`
Expected: 0 errors.

**Step 5: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/
git commit -m "feat(adminui): in-process browse session registry + TTL reaper + service"
```

---

## Task 12 — Phase 5b: Wire DI in AddAdminUI()

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Task 11)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:38-43` (the `AddAdminUI` method)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add ProjectReferences to the two `*.Browser` projects

**Step 1: Add project references**
```xml
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
```

**Step 2: Extend AddAdminUI**
```csharp
public static IServiceCollection AddAdminUI(this IServiceCollection services)
{
    services.AddRazorComponents().AddInteractiveServerComponents();
    services.AddOtOpcUaDriverStatusServices();

    // Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
    services.AddSingleton<Browsing.BrowseSessionRegistry>();
    services.AddHostedService<Browsing.BrowseSessionReaper>();
    services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
    services.AddSingleton<Commons.Browsing.IDriverBrowser,
        Driver.OpcUaClient.Browser.OpcUaClientDriverBrowser>();
    services.AddSingleton<Commons.Browsing.IDriverBrowser,
        Driver.Galaxy.Browser.GalaxyDriverBrowser>();

    return services;
}
```

Imports at top: `using ZB.MOM.WW.OtOpcUa;` if needed for full-qualified resolution.

**Step 3: Build full solution**
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
Expected: 0 errors.

**Step 4: Commit**
```bash
git add -A
git commit -m "feat(adminui): register browse services in AddAdminUI"
```

---

## Task 13 — Phase 5c: Tests for registry, reaper, service

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 6, 10

**Files:**
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs`
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs`
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs`
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs` — shared test double
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (if a ProjectReference to Commons/Browsing isn't already transitive)

**Step 1: FakeBrowseSession**
```csharp
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;

internal sealed class FakeBrowseSession : IBrowseSession
{
    public Guid Token { get; } = Guid.NewGuid();
    public DateTime LastUsedUtc { get; set; } = DateTime.UtcNow;
    public bool Disposed { get; private set; }
    public Func<CancellationToken, Task<IReadOnlyList<BrowseNode>>>? RootHandler;
    public Func<string, CancellationToken, Task<IReadOnlyList<BrowseNode>>>? ExpandHandler;

    public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
        => RootHandler?.Invoke(ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
    public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct)
        => ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
    public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
        => Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
    public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
}
```

**Step 2: BrowseSessionRegistryTests** — register/get/remove; concurrent Register from N tasks → all visible; TryGet on unknown returns false; TryRemove on unknown returns false.

**Step 3: BrowseSessionReaperTests**
- `ReapOnceAsync_evicts_idle_sessions`: register a FakeBrowseSession with `LastUsedUtc = UtcNow - 3 min`; call `reaper.ReapOnceAsync(default)`; assert session is no longer in registry and `Disposed == true`
- `ReapOnceAsync_preserves_non_idle_sessions`: `LastUsedUtc = UtcNow`; reap; still in registry
- `ReapOnceAsync_concurrent_removal_does_not_double_dispose`: pre-remove the session; reap should be a no-op for that token

**Step 4: BrowserSessionServiceTests** — using a fake `IDriverBrowser`:
- `OpenAsync_unknown_driver_returns_Ok_false`
- `OpenAsync_happy_path_returns_token_and_registers`
- `RootAsync_unknown_token_throws_BrowseSessionNotFoundException`
- `RootAsync_invokes_session_RootAsync`
- `RootAsync_enforces_per_call_timeout`: fake session that delays 30s → assert throws OperationCanceledException
- `CloseAsync_removes_and_disposes_session`

**Step 5: Run**
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/`
Expected: existing 51 + new ~12 = ~63 green.

**Step 6: Commit**
```bash
git add -A
git commit -m "test(adminui): browse session registry, reaper, service"
```

---

## Task 14 — Phase 6: Shared DriverBrowseTree.razor component

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Phase 7 depends on this)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor`
- Optional: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Components/DriverBrowseTreeTests.cs` (bUnit, only if bUnit already used; otherwise skip — covered by manual smoke + picker integration in Task 15/16)

**Step 1: DriverBrowseTree.razor**
```razor
@* Lazy tree component with per-node text filter. Driver-agnostic — consumes
   IBrowserSessionService for root/expand. Selected leaf is bound back via
   SelectedNodeId. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService

<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
    @if (_loading)
    {
        <div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;</div>
    }
    else if (_error is not null)
    {
        <div class="text-danger small">@_error</div>
    }
    else if (_roots is null || _roots.Count == 0)
    {
        <div class="text-muted small">No nodes.</div>
    }
    else
    {
        @foreach (var n in _roots) { @RenderNode(n, 0) }
    }
</div>

@code {
    [Parameter, EditorRequired] public Guid SessionToken { get; set; }
    [Parameter] public string SelectedNodeId { get; set; } = "";
    [Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }

    private bool _loading = true;
    private string? _error;
    private List<TreeItem>? _roots;

    protected override async Task OnInitializedAsync()
    {
        await LoadRootAsync();
    }

    private async Task LoadRootAsync()
    {
        try
        {
            var roots = await BrowserService.RootAsync(SessionToken, default);
            _roots = roots.Select(n => new TreeItem(n)).ToList();
        }
        catch (Exception ex) { _error = ex.Message; }
        finally { _loading = false; }
    }

    private async Task ExpandAsync(TreeItem item)
    {
        if (item.Loaded || item.Loading) return;
        item.Loading = true; StateHasChanged();
        try
        {
            var kids = await BrowserService.ExpandAsync(SessionToken, item.Node.NodeId, default);
            item.Children = kids.Select(k => new TreeItem(k)).ToList();
            item.Loaded = true;
        }
        catch (Exception ex) { item.Error = ex.Message; }
        finally { item.Loading = false; StateHasChanged(); }
    }

    private async Task SelectAsync(TreeItem item)
    {
        SelectedNodeId = item.Node.NodeId;
        await OnNodeSelected.InvokeAsync(item.Node);
    }

    private RenderFragment RenderNode(TreeItem item, int depth) => __builder =>
    {
        var indent = $"padding-left:{depth * 18}px";
        var selectedCls = SelectedNodeId == item.Node.NodeId ? "bg-primary-subtle" : "";
        <div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
            @if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
            {
                <button type="button" class="btn btn-sm btn-link p-0"
                        @onclick="@(() => ToggleAsync(item))" style="width:18px">
                    @(item.Expanded ? "▼" : "▶")
                </button>
            }
            else
            {
                <span style="width:18px"></span>
            }
            <a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
               class="text-decoration-none mono small">@item.Node.DisplayName</a>
            @if (item.Node.Kind == BrowseNodeKind.Leaf)
            {
                <span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
            }
        </div>
        @if (item.Expanded && item.Loading)
        {
            <div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
                <span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
            </div>
        }
        else if (item.Expanded && item.Error is not null)
        {
            <div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
                @item.Error
            </div>
        }
        else if (item.Expanded && item.Loaded && item.Children is { Count: > 0 })
        {
            <input type="text" class="form-control form-control-sm mt-1"
                   placeholder="filter children..."
                   style="@($"width:calc(100% - {(depth + 2) * 18}px); margin-left:{(depth + 2) * 18}px")"
                   @bind="item.Filter" @bind:event="oninput" />
            @foreach (var c in FilterChildren(item))
            {
                @RenderNode(c, depth + 1)
            }
        }
    };

    private async Task ToggleAsync(TreeItem item)
    {
        item.Expanded = !item.Expanded;
        if (item.Expanded && !item.Loaded) await ExpandAsync(item);
    }

    private static IEnumerable<TreeItem> FilterChildren(TreeItem item)
    {
        if (item.Children is null) yield break;
        var f = item.Filter?.Trim();
        foreach (var c in item.Children)
        {
            if (string.IsNullOrEmpty(f)) { yield return c; continue; }
            if (c.Node.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase) ||
                c.Node.NodeId.Contains(f, StringComparison.OrdinalIgnoreCase))
                yield return c;
        }
    }

    private sealed class TreeItem(BrowseNode node)
    {
        public BrowseNode Node { get; } = node;
        public bool Expanded { get; set; }
        public bool Loaded { get; set; }
        public bool Loading { get; set; }
        public string? Error { get; set; }
        public List<TreeItem>? Children { get; set; }
        public string? Filter { get; set; }
    }
}
```

**Step 2: Build**
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/`
Expected: 0 errors. Razor compilation may need to round-trip through the SDK.

**Step 3: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor
git commit -m "feat(adminui): shared lazy DriverBrowseTree component with per-node filter"
```

---

## Task 15 — Phase 7a: Wire OpcUaClient picker body to browser

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 16

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor`

**Step 1: Replace body** (preserve manual entry as a fallback; wrap browse behind DriverOperator gate)

Add the necessary `@implements IAsyncDisposable`, `@using`s for browse + auth, and parameters `[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";` so the picker knows where to read the form JSON. Inject the browse service + auth.

```razor
@* OPC UA Client address picker:
     1. Manual NodeId entry (existing, always available)
     2. (DriverOperator-gated) Browse remote server with live tree, lazy expand. *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService

<div class="row g-3">
    <div class="col-md-10">
        <label class="form-label">NodeId</label>
        <input type="text" class="form-control form-control-sm mono"
               placeholder="ns=2;s=Channel.Device.Tag"
               @bind="_nodeId" @bind:after="OnChangedAsync" />
        <div class="form-text">
            OPC UA NodeId string, e.g. <code>ns=2;s=Channel.Device.Tag</code> or
            <code>i=1001</code>. Use Browse to navigate the remote server.
        </div>
    </div>
</div>

@if (_canOperate)
{
    <div class="mt-3 d-flex align-items-center gap-2">
        @if (_token == Guid.Empty)
        {
            <button type="button" class="btn btn-outline-primary btn-sm"
                    disabled="@_opening" @onclick="OpenBrowseAsync">
                @if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
                Browse remote server
            </button>
        }
        else
        {
            <span class="chip chip-ok">Browser open</span>
            <button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">
                Close
            </button>
        }
        @if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
    </div>

    @if (_token != Guid.Empty)
    {
        <div class="mt-2">
            <DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
                              SelectedNodeId="_nodeId" />
        </div>
    }
}

<div class="mt-3">
    <span class="text-muted small">Result:</span>
    <code class="mono ms-2">@_built</code>
</div>

@code {
    [Parameter] public string CurrentAddress { get; set; } = "";
    [Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
    [Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";

    private string _nodeId = "";
    private string _built = "";
    private Guid _token = Guid.Empty;
    private bool _opening;
    private bool _canOperate;
    private string? _openError;

    protected override async Task OnInitializedAsync()
    {
        var auth = await AuthState.GetAuthenticationStateAsync();
        var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
        _canOperate = authResult.Succeeded;
        _built = _nodeId;
        await CurrentAddressChanged.InvokeAsync(_built);
    }

    private async Task OpenBrowseAsync()
    {
        _opening = true; _openError = null; StateHasChanged();
        try
        {
            var json = GetConfigJson() ?? "{}";
            var result = await BrowserService.OpenAsync("OpcUaClient", json, default);
            if (result.Ok) _token = result.Token;
            else _openError = result.Message;
        }
        finally { _opening = false; StateHasChanged(); }
    }

    private async Task CloseBrowseAsync()
    {
        var t = _token; _token = Guid.Empty; StateHasChanged();
        if (t != Guid.Empty) await BrowserService.CloseAsync(t);
    }

    private async Task OnTreeSelectAsync(BrowseNode node)
    {
        _nodeId = node.NodeId;
        await OnChangedAsync();
    }

    private async Task OnChangedAsync()
    {
        _built = _nodeId;
        await CurrentAddressChanged.InvokeAsync(_built);
    }

    private string TruncatedError() =>
        _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);

    public async ValueTask DisposeAsync()
    {
        if (_token != Guid.Empty)
        {
            try { await BrowserService.CloseAsync(_token); } catch { /* circuit teardown */ }
        }
    }
}
```

**Step 2: Update the parent OpcUaClient driver page to pass `GetConfigJson`**
Locate the parent that hosts `<DriverTagPicker>` and `<OpcUaClientAddressPickerBody>` (likely `src/Server/.../Components/Pages/Drivers/OpcUaClientDriverPage.razor`). Pass a `GetConfigJson` parameter that serializes the live form state. Example one-liner: `GetConfigJson="() => JsonSerializer.Serialize(_form.ToOptions())"` using the existing form model.

**Step 3: Build + smoke**
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/`
Expected: 0 errors.

Manual smoke (later in Task 18): launch AdminUI, edit an OpcUaClient driver, click Browse against opc-plc, navigate, pick a Variable, save, verify the stored NodeId reads cleanly via the Client CLI.

**Step 4: Commit**
```bash
git add -A
git commit -m "feat(adminui): wire OpcUaClient picker to live browser"
```

---

## Task 16 — Phase 7b: Wire Galaxy picker body to browser + attribute side-panel

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 15

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor`

**Step 1: Replace body** (manual entry stays as fallback; gated Browse + tree + attribute side-panel when an object is selected)

Structure: manual tag/attribute inputs (existing, always shown), then DriverOperator-gated browse panel. The browse panel uses `DriverBrowseTree` for objects; when the user selects an object, the picker fetches its attributes via `BrowserService.AttributesAsync(_token, selectedTag)` and renders them as a side-panel list. Selecting an attribute commits `_built = $"{selectedTag}.{attr.Name}"`.

```razor
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService

<div class="row g-3">
    <div class="col-md-5">
        <label class="form-label">Tag name</label>
        <input type="text" class="form-control form-control-sm mono" placeholder="DelmiaReceiver_001"
               @bind="_tagName" @bind:after="OnChangedAsync" />
    </div>
    <div class="col-md-5">
        <label class="form-label">Attribute name</label>
        <input type="text" class="form-control form-control-sm mono" placeholder="DownloadPath"
               @bind="_attributeName" @bind:after="OnChangedAsync" />
    </div>
</div>

@if (_canOperate)
{
    <div class="mt-3 d-flex align-items-center gap-2">
        @if (_token == Guid.Empty)
        {
            <button type="button" class="btn btn-outline-primary btn-sm" disabled="@_opening"
                    @onclick="OpenBrowseAsync">
                @if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
                Browse galaxy
            </button>
        }
        else
        {
            <span class="chip chip-ok">Browser open</span>
            <button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
        }
        @if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
    </div>

    @if (_token != Guid.Empty)
    {
        <div class="row g-3 mt-1">
            <div class="col-md-7">
                <label class="form-label small">Objects</label>
                <DriverBrowseTree SessionToken="_token" OnNodeSelected="OnObjectSelectAsync"
                                  SelectedNodeId="_tagName" />
            </div>
            <div class="col-md-5">
                <label class="form-label small">Attributes of @(_tagName ?? "—")</label>
                <div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
                    @if (_attrsLoading) { <span class="spinner-border spinner-border-sm me-1"></span> }
                    else if (_attrsError is not null) { <span class="text-danger small">@_attrsError</span> }
                    else if (_attrs is null) { <span class="text-muted small">Pick an object.</span> }
                    else if (_attrs.Count == 0) { <span class="text-muted small">No attributes.</span> }
                    else
                    {
                        @foreach (var a in _attrs)
                        {
                            var sel = _attributeName == a.Name ? "bg-primary-subtle" : "";
                            <div class="d-flex justify-content-between align-items-center py-1 @sel"
                                 style="cursor:pointer" @onclick="@(() => SelectAttributeAsync(a))">
                                <span class="mono small">@a.Name</span>
                                <span class="text-muted small">@a.DriverDataType@(a.IsArray ? "[]" : "") · @a.SecurityClass</span>
                            </div>
                        }
                    }
                </div>
            </div>
        </div>
    }
}

<div class="mt-3">
    <span class="text-muted small">Result:</span>
    <code class="mono ms-2">@_built</code>
</div>

@code {
    [Parameter] public string CurrentAddress { get; set; } = "";
    [Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
    [Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";

    private string _tagName = "";
    private string _attributeName = "";
    private string _built = "";
    private Guid _token = Guid.Empty;
    private bool _opening, _attrsLoading, _canOperate;
    private string? _openError, _attrsError;
    private IReadOnlyList<AttributeInfo>? _attrs;

    protected override async Task OnInitializedAsync()
    {
        var auth = await AuthState.GetAuthenticationStateAsync();
        var ar = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
        _canOperate = ar.Succeeded;
        _built = Build(); await CurrentAddressChanged.InvokeAsync(_built);
    }

    private async Task OpenBrowseAsync()
    {
        _opening = true; _openError = null; StateHasChanged();
        try
        {
            var result = await BrowserService.OpenAsync("Galaxy", GetConfigJson() ?? "{}", default);
            if (result.Ok) _token = result.Token;
            else _openError = result.Message;
        }
        finally { _opening = false; StateHasChanged(); }
    }

    private async Task CloseBrowseAsync()
    {
        var t = _token; _token = Guid.Empty; _attrs = null; StateHasChanged();
        if (t != Guid.Empty) await BrowserService.CloseAsync(t);
    }

    private async Task OnObjectSelectAsync(BrowseNode node)
    {
        _tagName = node.NodeId;
        _attributeName = "";
        _attrs = null; _attrsLoading = true; _attrsError = null; StateHasChanged();
        try
        {
            _attrs = await BrowserService.AttributesAsync(_token, _tagName, default);
        }
        catch (Exception ex) { _attrsError = ex.Message; }
        finally { _attrsLoading = false; await OnChangedAsync(); }
    }

    private async Task SelectAttributeAsync(AttributeInfo a)
    {
        _attributeName = a.Name;
        await OnChangedAsync();
    }

    private async Task OnChangedAsync()
    {
        _built = Build();
        await CurrentAddressChanged.InvokeAsync(_built);
    }

    private string Build() =>
        string.IsNullOrWhiteSpace(_tagName) ? "" :
        string.IsNullOrWhiteSpace(_attributeName) ? _tagName : $"{_tagName}.{_attributeName}";

    private string TruncatedError() =>
        _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);

    public async ValueTask DisposeAsync()
    {
        if (_token != Guid.Empty) { try { await BrowserService.CloseAsync(_token); } catch { } }
    }
}
```

**Step 2: Update GalaxyDriverPage** to pass `GetConfigJson` (mirror Task 15).

**Step 3: Build clean**
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/`
Expected: 0 errors.

**Step 4: Commit**
```bash
git add -A
git commit -m "feat(adminui): wire Galaxy picker to live browser + attribute side-panel"
```

---

## Task 17 — Phase 8a: opc-plc integration test (gated)

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 18

**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj` (mirror existing OpcUaClient.IntegrationTests)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`

**Step 1: csproj** — copy from existing OpcUaClient.IntegrationTests; ProjectRefs include Browser + Commons.

**Step 2: BrowseRoundTripTests.cs**
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests;

[Trait("Category", "Integration"), Trait("Fixture", "opc-plc")]
public class BrowseRoundTripTests
{
    [Fact]
    public async Task Three_level_expand_round_trip_resolves_back()
    {
        // (1) Open against opc-plc. (2) Drill down 3 levels by alternately expanding the
        // first Folder. (3) Take the deepest returned NodeId string. (4) Re-ExpandAsync(string)
        // — must succeed and yield a list (children OR empty for a leaf-like folder).
        //
        // This regression-tests the namespace-stable encoding: the string returned by step
        // (3) must round-trip cleanly through TryResolve against the same session.
        var endpoint = Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT")
                       ?? "opc.tcp://10.100.0.35:50000";
        var json = $$"""
            {
                "EndpointUrl":"{{endpoint}}",
                "SecurityPolicy":"None","SecurityMode":"None","AuthType":"Anonymous",
                "SessionTimeout":"00:01:00","Timeout":"00:00:10","PerEndpointConnectTimeout":"00:00:10"
            }
            """;
        var browser = new OpcUaClientDriverBrowser();
        await using var session = await browser.OpenAsync(json, TestContext.Current.CancellationToken);
        var roots = await session.RootAsync(TestContext.Current.CancellationToken);
        var current = roots.FirstOrDefault(n => n.HasChildrenHint);
        current.ShouldNotBeNull();
        for (var depth = 0; depth < 2; depth++)
        {
            var next = await session.ExpandAsync(current!.NodeId, TestContext.Current.CancellationToken);
            var step = next.FirstOrDefault(n => n.HasChildrenHint);
            if (step is null) break;
            current = step;
        }
        // Round-trip the leaf-most reachable folder string
        var children = await session.ExpandAsync(current!.NodeId, TestContext.Current.CancellationToken);
        children.ShouldNotBeNull();
    }
}
```

**Step 3: Run**
Run: `lmxopcua-fix up opcuaclient`
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/`
Expected: green when the fixture is up; skipped/failed obviously when down.

**Step 4: Commit**
```bash
git add -A
git commit -m "test(opcuaclient.browser): opc-plc integration round-trip"
```

---

## Task 18 — Phase 8b: Manual smoke, CLAUDE.md update, final commit

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 17

**Files:**
- Modify: `CLAUDE.md` (only if the browse pipeline warrants a one-liner in the Testing or Architecture section)
- Modify (optional): `docs/Client.CLI.md` cross-ref if relevant

**Step 1: Full solution build + test sweep**
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/
# OPC UA test suites need lmxopcua-fix up opcuaclient first
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/
```
Expected: all green; OPC UA tests only if fixture up.

**Step 2: Manual smoke**
- `dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host`
- Sign in to AdminUI as a `DriverOperator`
- Open an OpcUaClient driver edit page, fill in `opc.tcp://10.100.0.35:50000`, click **Browse remote server** → tree appears → expand → pick a variable → "Use this address" → save
- Open a Galaxy driver edit page (if a galaxy is reachable), click **Browse galaxy** → tree appears → pick object → attribute side-panel appears → pick attribute → save
- Verify in `Client CLI`:
  ```bash
  dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<picked node>"
  ```

**Step 3: CLAUDE.md (optional, one-liner)**
Append a single sentence to the "Testing" section: "Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`."

**Step 4: Final commit**
```bash
git add -A
git commit -m "docs: link driver-browsers design from CLAUDE.md" || echo "no doc changes"
```

---

## Verification & gates

After Task 18 the branch should:
- Build clean (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`)
- AdminUI tests: existing 51 + new ~12 = ~63 green
- Galaxy.Browser.Tests: green without fixture
- OpcUaClient.Browser.Tests + IntegrationTests: green with opc-plc up
- Manual smoke validates the end-to-end UX

## Risk hot-spots (for reviewers)

1. **NamespaceMap visibility change.** Runtime driver references `NamespaceMap.FromSession`/`TryResolve`/`ToStableReference` — they all need to remain accessible (now `public` not `internal`). Check `Driver.OpcUaClient.Tests` keeps passing post-Phase 2.
2. **Per-call timeout cancellation.** `BrowserSessionService.InvokeAsync` uses a linked CTS; if the inner session impl doesn't propagate the token to the SDK, a wedged remote stalls the click. OpcUa session impl threads `ct` into `BrowseAsync` — verified. Galaxy session threads `ct` into both `BrowseAsync` and `LazyBrowseNode.ExpandAsync` — verified.
3. **Reaper race with in-flight ExpandAsync.** The reaper does `TryRemove` then `DisposeAsync` outside the dictionary; a concurrent `ExpandAsync` already inside the session's gate will throw `ObjectDisposedException` from `OpcUaClientBrowseSession.BrowseOneLevelAsync` — surface as a clear UI error. Test coverage in Task 13 explicitly asserts this.
4. **Galaxy gateway client API names.** Task 9 names (`MxGatewaySession.OpenAsync`, `session.GalaxyRepository(name)`, `MxGatewayClientOptions.ApiKey`) MUST be verified against the actual client surface — mirror `src/Drivers/.../Galaxy/Browse/GatewayGalaxyHierarchySource.cs` for the exact connect path used in production.
5. **Certificate-auth in OpcUaClient browser.** v1 explicitly rejects with a clear message — verify the error surfaces cleanly to the UI rather than crashing the picker.
