# 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; /// /// Per-driver factory that opens an ad-hoc browse session against the configuration /// supplied as JSON. Parallels IDriverProbe in the runtime — one implementation /// per driver type, registered in AdminUI DI and indexed by . /// public interface IDriverBrowser { /// Driver type key, matching the AdminUI's persisted DriverType string /// (e.g. "OpcUaClient", "Galaxy"). string DriverType { get; } /// Opens a browse session against the supplied configuration. /// Driver options serialized as JSON; same shape the runtime /// driver would consume. /// Cancellation for the connect phase only. Task OpenAsync(string configJson, CancellationToken cancellationToken); } ``` `IBrowseSession.cs`: ```csharp namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; /// /// A live, one-level-at-a-time browse over a remote address space. Owned by the /// AdminUI BrowseSessionRegistry; disposed by the registry's TTL reaper or /// the picker body on close. /// public interface IBrowseSession : IAsyncDisposable { /// Opaque token identifying this session in the registry. Guid Token { get; } /// Wall-clock time of the most recent successful call. Refreshed on /// , , and /// ; used by the reaper for idle eviction. DateTime LastUsedUtc { get; } /// Returns the top-level browse nodes. Task> RootAsync(CancellationToken cancellationToken); /// Returns the direct children of the node identified by /// . Task> ExpandAsync(string nodeId, CancellationToken cancellationToken); /// Returns the attributes of the node identified by . /// 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. Task> AttributesAsync(string nodeId, CancellationToken cancellationToken); } ``` `BrowseNode.cs`: ```csharp namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; /// One node in a driver-agnostic browse tree. /// Stable identifier passed back to the picker on commit. For OPC UA /// this is the nsu=...;... form; for Galaxy this is the tag_name. /// Label shown in the tree. /// Whether this node terminates the address (Leaf) or has children /// (Folder). Galaxy never returns Leaves; only the attribute side-panel terminates. /// When true, the UI renders an expand affordance before /// the children have been fetched. public sealed record BrowseNode( string NodeId, string DisplayName, BrowseNodeKind Kind, bool HasChildrenHint); /// Discriminates terminal vs. expandable nodes for UI rendering. public enum BrowseNodeKind { /// Expandable — has (or may have) children. UI shows expand affordance. Folder, /// Terminal — commit on select. Leaf, } /// Metadata for an attribute of a Galaxy object (or the equivalent /// per-driver concept). Surfaced in the picker's attribute side-panel. 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 `` (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 `` 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 net10.0 enable enable true ``` (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 `` 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 net10.0 enable enable true ``` **Step 2: Add project to slnx** Open `ZB.MOM.WW.OtOpcUa.slnx`, locate the `` block, add: ```xml ``` 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; /// /// Live one-level-at-a-time browse over an OPC UA server session opened by /// . Serializes all browse calls on /// because Session.BrowseAsync is not safe to call /// concurrently from multiple threads (same constraint as OpcUaClientDriver). /// 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> RootAsync(CancellationToken ct) => BrowseOneLevelAsync(_rootNodeId, ct); public Task> 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); } /// 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. public Task> AttributesAsync(string nodeId, CancellationToken ct) => Task.FromResult>(Array.Empty()); private async Task> 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(); 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; /// /// 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. /// public sealed class OpcUaClientDriverBrowser : IDriverBrowser { private static readonly JsonSerializerOptions JsonOpts = new() { UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, PropertyNameCaseInsensitive = true, }; private readonly ILogger _logger; public OpcUaClientDriverBrowser(ILogger? logger = null) { _logger = logger ?? NullLogger.Instance; } public string DriverType => "OpcUaClient"; public async Task OpenAsync(string configJson, CancellationToken ct) { var opts = JsonSerializer.Deserialize(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); } /// /// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from /// the runtime driver (%LocalAppData%/OtOpcUa/adminui-browse-pki/). Keeps /// browse-time cert trust decisions out of the deployed driver's trust store. /// private static async Task 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 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 ``` **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( () => _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( () => _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( () => _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; /// /// Live-server tests against the opc-plc Docker fixture at /// opc.tcp://10.100.0.35:50000. Bring up with /// lmxopcua-fix up opcuaclient before running. /// /// Skipped automatically when the fixture is unreachable. [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 net10.0 enable enable true ``` (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; /// /// Lazy browse over a Galaxy via the gateway client's . /// Stateless gRPC under the hood; per-call locking lives inside . /// Tracks already-handed-out nodes by tag_name so /// and can dispatch against the right cached handle. /// internal sealed class GalaxyBrowseSession : IBrowseSession { private readonly MxGatewaySession _session; private readonly GalaxyRepositoryClient _client; private readonly ConcurrentDictionary _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> 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> 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); } /// /// Fetches the attributes of one Galaxy object via /// DiscoverHierarchyAsync(MaxDepth=0, RootTagName, IncludeAttributes=true). /// This returns the object itself (single-element list) with attributes populated. /// public async Task> 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(); var result = new List(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 Project(IReadOnlyList nodes) { var result = new List(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; /// /// Opens transient gateway sessions for the AdminUI address picker. Distinct /// ClientName from the runtime driver so the gateway can attribute load. /// public sealed class GalaxyDriverBrowser : IDriverBrowser { private static readonly JsonSerializerOptions JsonOpts = new() { UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, PropertyNameCaseInsensitive = true, }; private readonly ILogger _logger; public GalaxyDriverBrowser(ILogger? logger = null) { _logger = logger ?? NullLogger.Instance; } public string DriverType => "Galaxy"; public async Task OpenAsync(string configJson, CancellationToken ct) { var opts = JsonSerializer.Deserialize(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; /// /// In-process registry of live browse sessions, keyed by opaque . /// Singleton. Process-local; sticky-cookie routing keeps tokens bound to the AdminUI /// replica that created them. /// public sealed class BrowseSessionRegistry { private readonly ConcurrentDictionary _sessions = new(); public void Register(IBrowseSession session) => _sessions[session.Token] = session; public bool TryGet(Guid token, out IBrowseSession session) => _sessions.TryGetValue(token, out session!); /// Removes the session from the registry without disposing it. public bool TryRemove(Guid token, out IBrowseSession session) => _sessions.TryRemove(token, out session!); /// Snapshot for the reaper. Stable across iteration. 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; /// /// Periodically evicts browse sessions idle for more than . /// 30s tick, 2 min idle window. Disposes evicted sessions outside the dictionary so /// a concurrent BrowseAsync sees the session disappear cleanly and gets a /// translated NotFound error from the service layer. /// public sealed class BrowseSessionReaper( BrowseSessionRegistry registry, ILogger 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 OpenAsync(string driverType, string configJson, CancellationToken ct); Task> RootAsync(Guid token, CancellationToken ct); Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct); Task> 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 browsers, BrowseSessionRegistry registry, ILogger logger) : IBrowserSessionService { public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20); private readonly IReadOnlyDictionary _browsersByType = browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase); public async Task 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> RootAsync(Guid token, CancellationToken ct) => InvokeAsync(token, ct, (s, c) => s.RootAsync(c)); public Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c)); public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => InvokeAsync>(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 InvokeAsync( Guid token, CancellationToken callerCt, Func> 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 ``` **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(); services.AddHostedService(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); 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>>? RootHandler; public Func>>? ExpandHandler; public Task> RootAsync(CancellationToken ct) => RootHandler?.Invoke(ct) ?? Task.FromResult>(Array.Empty()); public Task> ExpandAsync(string nodeId, CancellationToken ct) => ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult>(Array.Empty()); public Task> AttributesAsync(string nodeId, CancellationToken ct) => Task.FromResult>(Array.Empty()); 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
@if (_loading) {
Loading…
} else if (_error is not null) {
@_error
} else if (_roots is null || _roots.Count == 0) {
No nodes.
} else { @foreach (var n in _roots) { @RenderNode(n, 0) } }
@code { [Parameter, EditorRequired] public Guid SessionToken { get; set; } [Parameter] public string SelectedNodeId { get; set; } = ""; [Parameter] public EventCallback OnNodeSelected { get; set; } private bool _loading = true; private string? _error; private List? _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" : "";
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint) { } else { } @item.Node.DisplayName @if (item.Node.Kind == BrowseNodeKind.Leaf) { leaf }
@if (item.Expanded && item.Loading) {
Loading…
} else if (item.Expanded && item.Error is not null) {
@item.Error
} else if (item.Expanded && item.Loaded && item.Children is { Count: > 0 }) { @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 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? 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 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
OPC UA NodeId string, e.g. ns=2;s=Channel.Device.Tag or i=1001. Use Browse to navigate the remote server.
@if (_canOperate) {
@if (_token == Guid.Empty) { } else { Browser open } @if (_openError is not null) { @TruncatedError() }
@if (_token != Guid.Empty) {
} }
Result: @_built
@code { [Parameter] public string CurrentAddress { get; set; } = ""; [Parameter] public EventCallback CurrentAddressChanged { get; set; } [Parameter, EditorRequired] public Func 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 `` and `` (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
@if (_canOperate) {
@if (_token == Guid.Empty) { } else { Browser open } @if (_openError is not null) { @TruncatedError() }
@if (_token != Guid.Empty) {
@if (_attrsLoading) { } else if (_attrsError is not null) { @_attrsError } else if (_attrs is null) { Pick an object. } else if (_attrs.Count == 0) { No attributes. } else { @foreach (var a in _attrs) { var sel = _attributeName == a.Name ? "bg-primary-subtle" : "";
@a.Name @a.DriverDataType@(a.IsArray ? "[]" : "") · @a.SecurityClass
} }
} }
Result: @_built
@code { [Parameter] public string CurrentAddress { get; set; } = ""; [Parameter] public EventCallback CurrentAddressChanged { get; set; } [Parameter, EditorRequired] public Func 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? _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=" ``` **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.