Files
ScadaBridge/docs/plans/2026-05-28-opcua-tag-browser.md
T
Joseph Doherty 2aad9b533a plan: implementation plan for OPC UA tag browser popup (22 tasks)
Five phases, PR-shippable per phase: schema/contracts, DCL browse capability,
flattening uses override, Central UI popup + integration, docs. Per-task
classification, time estimates, and parallelism declared.
2026-05-28 11:43:04 -04:00

69 KiB
Raw Blame History

OPC UA Tag Browser Popup — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Add a popup OPC UA address-space browser to InstanceConfigure.razor so users can pick the actual physical tag for each attribute binding, with the picked value stored as a per-instance override that beats the template's DataSourceReference at flattening time.

Architecture: ClusterClient BrowseOpcUaNodeCommandSiteCommunicationActorDataConnectionManagerActor → new IBrowsableDataConnection capability on OpcUaDataConnectionRealOpcUaClient.BrowseChildrenAsync against the live Opc.Ua.Client.Session. Override value carried additively on the existing ConnectionBinding record; persisted on InstanceConnectionBinding; applied once during flattening in FlatteningService.ApplyConnectionBindings.

Tech Stack: C# 12 / .NET 10, Akka.NET, EF Core 9, MS SQL Server 2022, Blazor Server + Bootstrap 5, OPC Foundation .NET Standard SDK.

Source design doc: docs/plans/2026-05-28-opcua-tag-browser-design.md — read this first if any task is ambiguous.

Deviation from design (one): The design proposed a site-side Design-role check on the browse command. The existing site-side actors (SiteCommunicationActor) do not unwrap ManagementEnvelope — central is trusted. We match that pattern: enforce the role at the CentralUI page/service layer, not at the site. See Task 14 for the CentralUI-side guard.

Verification commands (used throughout):

  • Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx
  • All tests in a project: dotnet test tests/<ProjectName>/<ProjectName>.csproj
  • Single test: dotnet test tests/<ProjectName>/<ProjectName>.csproj --filter "FullyQualifiedName~<TestName>"
  • Cluster rebuild for UI smoke: bash docker/deploy.sh
  • CLI for inspection: dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI -- --url http://localhost:9000 --username multi-role --password password instance get <id>

Phase 1 — Schema + Contracts (foundation)

Task 1: Add DataSourceReferenceOverride to InstanceConnectionBinding entity

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 5, Task 6

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs

Step 1: Edit the POCO

Add a single nullable string property. The file currently has Id, InstanceId, AttributeName, DataConnectionId. Append after DataConnectionId:

/// <summary>
/// Optional per-instance override of the OPC UA node identifier (or other
/// protocol address) for this attribute. When non-null, this value replaces
/// the template's <c>DataSourceReference</c> during flattening. When null,
/// the template default is used.
/// </summary>
public string? DataSourceReferenceOverride { get; set; }

Step 2: Build

Run: dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj Expected: 0 errors.

Step 3: Commit

git add src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceConnectionBinding.cs
git commit -m "feat(commons): add DataSourceReferenceOverride to InstanceConnectionBinding"

Task 2: Add override to ConnectionBinding wire record + map through ManagementActor

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 5, Task 6

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs:16
  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs (HandleSetConnectionBindings, around line 676)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs (new)

Step 1: Update the record (additive — new field defaulted to null)

InstanceCommands.cs line 16, replace:

public record ConnectionBinding(string AttributeName, int DataConnectionId);

with:

public record ConnectionBinding(
    string AttributeName,
    int DataConnectionId,
    string? DataSourceReferenceOverride = null);

Updating the existing XML doc comment block above to mention the override is fine but not required.

Step 2: Map the field through ManagementActor.HandleSetConnectionBindings

Read src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs around line 676 — HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, AuthenticatedUser user). The method translates cmd.Bindings into InstanceConnectionBinding entities for EF. Find the entity construction (it currently sets AttributeName and DataConnectionId) and add the override:

new InstanceConnectionBinding(b.AttributeName)
{
    DataConnectionId = b.DataConnectionId,
    DataSourceReferenceOverride = b.DataSourceReferenceOverride
}

(If the existing code uses a different shape — e.g., upsert by AttributeName updating an existing entity — apply the same single-field change there.)

Step 3: Write the failing serialization test

Create tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs:

using System.Text.Json;
using FluentAssertions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using Xunit;

namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;

public class ConnectionBindingSerializationTests
{
    [Fact]
    public void Roundtrip_preserves_override_when_set()
    {
        var original = new ConnectionBinding("Speed", 7, "ns=2;s=Pump1.Speed");

        var json = JsonSerializer.Serialize(original);
        var roundtripped = JsonSerializer.Deserialize<ConnectionBinding>(json)!;

        roundtripped.Should().Be(original);
        roundtripped.DataSourceReferenceOverride.Should().Be("ns=2;s=Pump1.Speed");
    }

    [Fact]
    public void Roundtrip_defaults_override_to_null_when_absent()
    {
        // Older site builds will not emit the new field — deserialization
        // must produce a null override and equal an explicit-null instance.
        const string legacyJson = """{"AttributeName":"Speed","DataConnectionId":7}""";

        var deserialized = JsonSerializer.Deserialize<ConnectionBinding>(legacyJson)!;

        deserialized.AttributeName.Should().Be("Speed");
        deserialized.DataConnectionId.Should().Be(7);
        deserialized.DataSourceReferenceOverride.Should().BeNull();
    }
}

Step 4: Run the tests + build

dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter "FullyQualifiedName~ConnectionBindingSerializationTests"
dotnet build ZB.MOM.WW.ScadaBridge.slnx

Expected: both tests pass, build clean.

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs \
        src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs \
        tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs
git commit -m "feat(commons): carry DataSourceReferenceOverride on ConnectionBinding (additive)"

Task 3: EF mapping for the new column

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 5, Task 6 (NOT Task 4 — Task 4 runs the migration generator and needs this mapping in place)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs

Step 1: Locate the InstanceConnectionBinding mapping

Open the file. EF entity mappings for InstanceConnectionBinding are configured here (the file is named InstanceConfiguration.cs and covers Instance + child collections). Locate the Entity<InstanceConnectionBinding> block (it may be a separate IEntityTypeConfiguration<InstanceConnectionBinding> nested class or inline in Configure(...)).

Step 2: Add the column

Inside that block, append:

builder.Property(b => b.DataSourceReferenceOverride)
    .HasMaxLength(512)
    .IsRequired(false);

If the mapping file uses the EntityTypeBuilder<InstanceConnectionBinding> builder under a different variable name, adapt accordingly. The intent: NVARCHAR(512) NULL (matches the existing DataSourceReference length on TemplateAttribute).

Step 3: Build

dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj

Expected: 0 errors.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs
git commit -m "feat(configdb): map InstanceConnectionBinding.DataSourceReferenceOverride"

Task 4: EF Core migration AddInstanceConnectionBindingOverride

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5, Task 6 (blocked by Task 3)

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddInstanceConnectionBindingOverride.cs
  • Create: src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddInstanceConnectionBindingOverride.Designer.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs (auto-regenerated)

Step 1: Generate the migration

cd src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase
dotnet ef migrations add AddInstanceConnectionBindingOverride --context ScadaBridgeDbContext
cd -

(If the project layout requires --project / --startup-project flags, mirror the most recent migration command in docker/deploy.sh or the README. As of the last migration 20260523201950_AddNotificationSourceNode, this command worked from the project folder.)

Step 2: Verify the generated Up is exactly the column add

Open the generated <timestamp>_AddInstanceConnectionBindingOverride.cs. The Up method should contain only:

migrationBuilder.AddColumn<string>(
    name: "DataSourceReferenceOverride",
    table: "InstanceConnectionBindings",
    type: "nvarchar(512)",
    maxLength: 512,
    nullable: true);

and Down should drop the same column. If EF generated extra changes (renames, type alterations on unrelated columns), STOP — that means another mapping change drifted in. Surface to the user; do not silently commit.

Step 3: Apply against the running dev MS SQL

dotnet ef database update --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --context ScadaBridgeDbContext --connection "Server=localhost,11433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"

(Port 11433 is docker/'s mapped MS SQL port — check docker/docker-compose.yml if differs.)

Expected: migration applies, "Done." Verify with sqlcmd:

docker exec scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ScadaBridge_Dev1#' -C -d ScadaBridgeConfig -Q "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='InstanceConnectionBindings' AND COLUMN_NAME='DataSourceReferenceOverride'"

Expected: one row, column present.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/
git commit -m "feat(configdb): migration AddInstanceConnectionBindingOverride"

Task 5: IBrowsableDataConnection interface + BrowseNode types

Classification: small Estimated implement time: ~3 min Parallelizable with: Tasks 1, 2, 3, 4, 6

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs

Step 1: Create the file

namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;

/// <summary>
/// Optional capability for an <see cref="IDataConnection"/> implementation
/// that supports browsing the server's address space. Consumed only by
/// management/UI flows (e.g. the OPC UA tag picker on the instance config
/// page) — never by Instance Actors on the hot path.
/// </summary>
public interface IBrowsableDataConnection
{
    /// <summary>
    /// Returns the immediate children of <paramref name="parentNodeId"/>, or
    /// the server's root-level nodes when null.
    /// </summary>
    /// <param name="parentNodeId">Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder).</param>
    /// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
    Task<BrowseChildrenResult> BrowseChildrenAsync(
        string? parentNodeId,
        CancellationToken cancellationToken = default);
}

/// <param name="Children">Child nodes returned by the server in browse order.</param>
/// <param name="Truncated">True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry.</param>
public record BrowseChildrenResult(
    IReadOnlyList<BrowseNode> Children,
    bool Truncated);

/// <param name="NodeId">Server-issued node identifier (e.g. <c>"ns=2;s=Devices.Pump1.Speed"</c>).</param>
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
/// <param name="NodeClass">Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable).</param>
/// <param name="HasChildren">Hint so the UI can render an expand chevron without a second roundtrip.</param>
public record BrowseNode(
    string NodeId,
    string DisplayName,
    BrowseNodeClass NodeClass,
    bool HasChildren);

public enum BrowseNodeClass { Object, Variable, Method, Other }

/// <summary>
/// Thrown by <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/> when
/// the underlying session is not currently connected. Translated to
/// <c>BrowseFailureKind.ConnectionNotConnected</c> by the site-side handler.
/// </summary>
public sealed class ConnectionNotConnectedException : InvalidOperationException
{
    public ConnectionNotConnectedException(string message) : base(message) { }
}

Step 2: Build

dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj

Expected: 0 errors.

Step 3: Commit

git add src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs
git commit -m "feat(commons): add IBrowsableDataConnection capability interface"

Task 6: BrowseCommands.cs (browse message + result + failure)

Classification: small Estimated implement time: ~3 min Parallelizable with: Tasks 1, 2, 3, 4, 5

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs (new)

Step 1: Create the messages

using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;

namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;

/// <summary>
/// Sent from CentralUI to a specific site to enumerate the immediate children
/// of an OPC UA node on the live server backing the given data connection.
/// </summary>
/// <param name="DataConnectionId">Id of the site-local data connection to browse against.</param>
/// <param name="ParentNodeId">Node to browse, or null to browse from the server root (ObjectsFolder).</param>
public record BrowseOpcUaNodeCommand(
    int DataConnectionId,
    string? ParentNodeId);

public record BrowseOpcUaNodeResult(
    IReadOnlyList<BrowseNode> Children,
    bool Truncated,
    BrowseFailure? Failure);

public record BrowseFailure(
    BrowseFailureKind Kind,
    string Message);

public enum BrowseFailureKind
{
    ConnectionNotFound,
    ConnectionNotConnected,
    NotBrowsable,
    Timeout,
    ServerError
}

Step 2: Write the registry-discovery test

tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs:

using FluentAssertions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using Xunit;

namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;

public class BrowseCommandsRegistryTests
{
    [Fact]
    public void Registry_discovers_BrowseOpcUaNodeCommand()
    {
        var types = ManagementCommandRegistry.GetAllCommandTypes();
        types.Should().Contain(typeof(BrowseOpcUaNodeCommand));
    }
}

(If the registry exposes a different accessor — KnownCommands, AllCommands etc. — adapt. The point: confirm the new command shows up in the auto-discovery surface so Akka serialization treats it as a known management message.)

Step 3: Run + build

dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter "FullyQualifiedName~BrowseCommandsRegistryTests"
dotnet build ZB.MOM.WW.ScadaBridge.slnx

Expected: test passes, build clean.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs \
        tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs
git commit -m "feat(commons): add BrowseOpcUaNodeCommand + result + failure types"

Phase 2 — DCL Browse Capability (depends on Phase 1)

Task 7: Add BrowseChildrenAsync to IOpcUaClient

Classification: small Estimated implement time: ~3 min Parallelizable with: none (Tasks 8 and 9 depend on this signature)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs

Step 1: Append the method to the interface

Add to IOpcUaClient:

/// <summary>
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
/// (or the server's ObjectsFolder when null). Throws
/// <see cref="ConnectionNotConnectedException"/> when the session is not
/// currently up.
/// </summary>
Task<BrowseChildrenResult> BrowseChildrenAsync(
    string? parentNodeId,
    CancellationToken cancellationToken = default);

Add the necessary using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; at the top if it isn't already present.

Step 2: Build (will fail for any test/fake implementations of IOpcUaClient)

dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | grep -E "error|Error" | head -20

Expected: errors in test fakes that implement IOpcUaClient (probably one or two). Note the file paths.

Step 3: Add throwing stubs in each impl that doesn't yet implement it

For every reported missing-member error, add a single stub method to that impl:

public Task<BrowseChildrenResult> BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default)
    => throw new NotImplementedException();

(Tasks 8 and 9 will provide the real implementations on RealOpcUaClient and OpcUaDataConnection. Other test fakes/stubs can stay NotImplementedException until a specific test needs them.)

Step 4: Build again

dotnet build ZB.MOM.WW.ScadaBridge.slnx

Expected: 0 errors.

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs
# plus any test fakes that needed the stub
git commit -m "feat(dcl): add BrowseChildrenAsync to IOpcUaClient (NotImplementedException stubs)"

Task 8: Implement BrowseChildrenAsync on RealOpcUaClient

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 9 (different methods on different types — but Task 9 also adds the wrapping in OpcUaDataConnection; running serially is safer if you're uncertain whether OpcUaDataConnection holds a reference to the IOpcUaClient at construction time)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs (new)

Step 1: Write the failing test (against the live OPC UA infra server)

tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs:

using FluentAssertions;
using Xunit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;

namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;

/// <summary>
/// Live OPC UA browse tests. Requires the infra OPC UA server to be running
/// (cd infra && docker compose up -d opcua). Skipped if the endpoint is
/// unreachable so CI without the infra stack stays green.
/// </summary>
[Trait("Category", "RequiresOpcUa")]
public class RealOpcUaClientBrowseTests
{
    private const string Endpoint = "opc.tcp://localhost:4840";

    [SkippableFact]
    public async Task BrowseChildren_at_root_returns_known_object_folder()
    {
        var client = new RealOpcUaClient(/* construct per existing test helpers */);
        try
        {
            await client.ConnectAsync(new Dictionary<string, string> { ["EndpointUrl"] = Endpoint });
        }
        catch
        {
            Skip.If(true, "OPC UA test server not reachable on " + Endpoint);
        }

        var result = await client.BrowseChildrenAsync(parentNodeId: null);

        result.Children.Should().NotBeEmpty();
        // OPC UA standard: under ObjectsFolder ns=0;i=85 there are at least
        // 'Server' (ns=0;i=2253). Test asserts the server's display name shows
        // up so we know we're browsing the right place.
        result.Children.Should().Contain(n => n.DisplayName == "Server");
    }

    [Fact]
    public async Task BrowseChildren_throws_when_not_connected()
    {
        var client = new RealOpcUaClient(/* same constructor */);

        Func<Task> act = () => client.BrowseChildrenAsync(parentNodeId: null);

        await act.Should().ThrowAsync<ConnectionNotConnectedException>();
    }
}

(Match RealOpcUaClient's actual constructor — the existing test file in this folder will show the pattern; if the test project uses RealOpcUaClientFixture or similar, reuse it.)

Step 2: Run, confirm fail

dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~RealOpcUaClientBrowseTests"

Expected: FAIL — NotImplementedException from the stub added in Task 7.

Step 3: Implement the method

In RealOpcUaClient.cs, replace the stub with:

public async Task<BrowseChildrenResult> BrowseChildrenAsync(
    string? parentNodeId,
    CancellationToken cancellationToken = default)
{
    var session = _session;   // existing field — adapt to the actual name in RealOpcUaClient
    if (session is null || !session.Connected)
        throw new ConnectionNotConnectedException("OPC UA session is not connected.");

    // ObjectsFolder = ns=0;i=85 — the OPC UA standard server root.
    var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
        ? Opc.Ua.ObjectIds.ObjectsFolder
        : Opc.Ua.NodeId.Parse(parentNodeId);

    var browseDescription = new Opc.Ua.BrowseDescription
    {
        NodeId = nodeToBrowse,
        BrowseDirection = Opc.Ua.BrowseDirection.Forward,
        ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HierarchicalReferences,
        IncludeSubtypes = true,
        NodeClassMask = (uint)(Opc.Ua.NodeClass.Object | Opc.Ua.NodeClass.Variable | Opc.Ua.NodeClass.Method),
        ResultMask = (uint)Opc.Ua.BrowseResultMask.All
    };

    var browseDescriptions = new Opc.Ua.BrowseDescriptionCollection { browseDescription };

    var response = await Task.Run(() =>
    {
        session.Browse(
            requestHeader: null,
            view: null,
            requestedMaxReferencesPerNode: 1000u,
            nodesToBrowse: browseDescriptions,
            results: out var results,
            diagnosticInfos: out _);
        return results;
    }, cancellationToken);

    var first = response[0];
    var children = new List<BrowseNode>(first.References.Count);
    foreach (var r in first.References)
    {
        children.Add(new BrowseNode(
            NodeId: r.NodeId.ToString(),
            DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
            NodeClass: MapNodeClass(r.NodeClass),
            HasChildren: r.NodeClass == Opc.Ua.NodeClass.Object));
    }

    var truncated = first.ContinuationPoint != null && first.ContinuationPoint.Length > 0;
    return new BrowseChildrenResult(children, truncated);
}

private static BrowseNodeClass MapNodeClass(Opc.Ua.NodeClass nc) => nc switch
{
    Opc.Ua.NodeClass.Object   => BrowseNodeClass.Object,
    Opc.Ua.NodeClass.Variable => BrowseNodeClass.Variable,
    Opc.Ua.NodeClass.Method   => BrowseNodeClass.Method,
    _                          => BrowseNodeClass.Other
};

If the OPC Foundation SDK in this project uses a slightly different API surface (e.g. session.BrowseAsync(...) exists), prefer the async variant; the structure above is correct for the synchronous-wrap path. Inspect the existing SubscribeAsync / ReadAsync in the same file for the project's preferred wrap idiom.

Step 4: Run the test

dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~RealOpcUaClientBrowseTests"

Expected: both tests pass (or BrowseChildren_at_root_returns_known_object_folder is skipped if cd infra && docker compose up -d opcua hasn't been run).

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs \
        tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs
git commit -m "feat(dcl): implement BrowseChildrenAsync on RealOpcUaClient"

Task 9: Implement IBrowsableDataConnection on OpcUaDataConnection

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 10, Task 11 (different files)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs (new)

Step 1: Implement the capability

OpcUaDataConnection already implements IDataConnection and holds a reference to an IOpcUaClient (the field name in the existing file is likely _client — adapt). Add the interface and a one-line forwarder:

public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection
{
    // ... existing fields & methods ...

    public Task<BrowseChildrenResult> BrowseChildrenAsync(
        string? parentNodeId,
        CancellationToken cancellationToken = default)
        => _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
}

Add using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; if needed.

Step 2: Write the forwarder test

tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs:

using FluentAssertions;
using Moq;
using Xunit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;

namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;

public class OpcUaDataConnectionBrowseTests
{
    [Fact]
    public async Task BrowseChildrenAsync_forwards_to_underlying_client()
    {
        var client = new Mock<IOpcUaClient>();
        var expected = new BrowseChildrenResult(
            new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) },
            Truncated: false);
        client.Setup(c => c.BrowseChildrenAsync("ns=2;s=Parent", It.IsAny<CancellationToken>()))
              .ReturnsAsync(expected);

        var sut = new OpcUaDataConnection(/* construct per existing helpers, injecting client.Object */);

        var actual = await sut.BrowseChildrenAsync("ns=2;s=Parent");

        actual.Should().BeSameAs(expected);
        client.VerifyAll();
    }
}

(Match OpcUaDataConnection's actual constructor. If the project already has a builder/fixture for it in the test project, use that.)

Step 3: Run + build

dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~OpcUaDataConnectionBrowseTests"
dotnet build ZB.MOM.WW.ScadaBridge.slnx

Expected: pass, clean build.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs \
        tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs
git commit -m "feat(dcl): implement IBrowsableDataConnection on OpcUaDataConnection"

Task 10: Handle BrowseOpcUaNodeCommand in DataConnectionManagerActor

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 9 (different file)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs (new)

Step 1: Add the receive handler

Inside the actor's ReceiveAsync/Receive block (find existing receive registrations; the actor uses ReceiveActor), add:

ReceiveAsync<BrowseOpcUaNodeCommand>(HandleBrowse);

Then add the handler method on the actor class:

private async Task HandleBrowse(BrowseOpcUaNodeCommand cmd)
{
    var sender = Sender;
    BrowseOpcUaNodeResult reply;

    try
    {
        // Existing pattern in this actor: connections are keyed by name via
        // an internal dictionary, but central addresses by Id. Look up the
        // IDataConnection instance by DataConnectionId — adapt to the
        // actor's existing lookup (might be _connectionsById, or a
        // _connectionsByName plus an id→name map).
        if (!TryGetConnectionById(cmd.DataConnectionId, out var conn))
        {
            reply = Fail(BrowseFailureKind.ConnectionNotFound, $"No data connection with id {cmd.DataConnectionId} at this site.");
        }
        else if (conn is not IBrowsableDataConnection browsable)
        {
            reply = Fail(BrowseFailureKind.NotBrowsable, "This data connection's protocol does not support browsing.");
        }
        else
        {
            var browseResult = await browsable.BrowseChildrenAsync(cmd.ParentNodeId);
            reply = new BrowseOpcUaNodeResult(browseResult.Children, browseResult.Truncated, Failure: null);
        }
    }
    catch (ConnectionNotConnectedException ex)
    {
        reply = Fail(BrowseFailureKind.ConnectionNotConnected, ex.Message);
    }
    catch (OperationCanceledException)
    {
        reply = Fail(BrowseFailureKind.Timeout, "Browse cancelled.");
    }
    catch (Exception ex)
    {
        // Includes Opc.Ua.ServiceResultException (Bad_*). Carry the SDK
        // message verbatim — the dialog renders it as-is.
        reply = Fail(BrowseFailureKind.ServerError, ex.Message);
    }

    sender.Tell(reply);
}

private static BrowseOpcUaNodeResult Fail(BrowseFailureKind kind, string message)
    => new(Array.Empty<BrowseNode>(), Truncated: false, new BrowseFailure(kind, message));

private bool TryGetConnectionById(int id, out IDataConnection conn)
{
    // Adapt to the existing private state shape. If the actor keeps
    // connections in _connections (keyed by name) and the DataConnection
    // entity table is available via injected EF/repository, look up name
    // by id then return _connections[name]. If the actor already keys by
    // id, this is a one-liner.
    throw new NotImplementedException("Replace with actor's actual id-based connection lookup");
}

Step 2: Wire the lookup correctly

Read the actor's existing receive handlers (e.g., the one that adds/removes connections at DataConnectionManagerActor.cs:121) to see how connections are keyed internally. The Manager almost certainly already needs id→child-actor or id→connection lookups for other commands; use the same store.

If the only existing index is by name and there's a RegisteredDataConnection (or similar) message carrying the id at registration time, hold both indices.

Step 3: Write actor-spec tests

tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs:

using Akka.Actor;
using Akka.TestKit.Xunit2;
using FluentAssertions;
using Moq;
using Xunit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;

namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;

public class DataConnectionManagerBrowseHandlerTests : TestKit
{
    [Fact]
    public void Unknown_connection_id_returns_ConnectionNotFound()
    {
        var manager = SetUpManagerWithNoConnections();
        manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 999, ParentNodeId: null));

        var reply = ExpectMsg<BrowseOpcUaNodeResult>();
        reply.Failure.Should().NotBeNull();
        reply.Failure!.Kind.Should().Be(BrowseFailureKind.ConnectionNotFound);
    }

    [Fact]
    public void Non_browsable_connection_returns_NotBrowsable()
    {
        // Register a connection whose IDataConnection impl does NOT implement IBrowsableDataConnection.
        var manager = SetUpManagerWithBareConnection(id: 7);
        manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 7, ParentNodeId: null));

        var reply = ExpectMsg<BrowseOpcUaNodeResult>();
        reply.Failure!.Kind.Should().Be(BrowseFailureKind.NotBrowsable);
    }

    [Fact]
    public void Success_path_returns_mapped_children()
    {
        var browsable = new Mock<IBrowsableDataConnection>();
        browsable.Setup(b => b.BrowseChildrenAsync(null, It.IsAny<CancellationToken>()))
                 .ReturnsAsync(new BrowseChildrenResult(
                     new[] { new BrowseNode("ns=2;s=A", "A", BrowseNodeClass.Variable, false) },
                     Truncated: false));

        var manager = SetUpManagerWithBrowsableConnection(id: 7, browsable.Object);
        manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 7, ParentNodeId: null));

        var reply = ExpectMsg<BrowseOpcUaNodeResult>();
        reply.Failure.Should().BeNull();
        reply.Children.Should().HaveCount(1);
        reply.Children[0].NodeId.Should().Be("ns=2;s=A");
    }

    [Fact]
    public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
    {
        var browsable = new Mock<IBrowsableDataConnection>();
        browsable.Setup(b => b.BrowseChildrenAsync(null, It.IsAny<CancellationToken>()))
                 .ThrowsAsync(new ConnectionNotConnectedException("session down"));

        var manager = SetUpManagerWithBrowsableConnection(id: 7, browsable.Object);
        manager.Tell(new BrowseOpcUaNodeCommand(DataConnectionId: 7, ParentNodeId: null));

        var reply = ExpectMsg<BrowseOpcUaNodeResult>();
        reply.Failure!.Kind.Should().Be(BrowseFailureKind.ConnectionNotConnected);
    }

    // ... SetUpManager* helpers spin up the actor with a stub connection
    // registry — mirror the existing fixture style in this test project.
}

Step 4: Run

dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj --filter "FullyQualifiedName~DataConnectionManagerBrowseHandlerTests"

Expected: all four tests pass.

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs \
        tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs
git commit -m "feat(dcl): handle BrowseOpcUaNodeCommand in DataConnectionManagerActor"

Task 11: Forward BrowseOpcUaNodeCommand in SiteCommunicationActor

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 9 (different file)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs

Step 1: Find the existing DCL forwarding pattern

Read SiteCommunicationActor.cs around line 94145 to see the existing Receive<...> => _xxxProxy.Forward(msg) pattern. There's likely no _dataConnectionManagerProxy field yet — the manager is reachable through a different path (the DeploymentManagerActor holds connections via children, or the manager itself is a singleton on the site cluster).

Decision: if DataConnectionManagerActor is a site-local singleton with a known path, route directly:

Receive<BrowseOpcUaNodeCommand>(msg =>
{
    Context.ActorSelection("/user/data-connection-manager").Forward(msg);
});

If it's a child of DeploymentManagerActor, forward through that proxy:

Receive<BrowseOpcUaNodeCommand>(msg => _deploymentManagerProxy.Forward(msg));

— and add a Receive<BrowseOpcUaNodeCommand> handler at the top of DeploymentManagerActor that forwards to its child manager. Match the existing site topology.

(Read src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs or the Site Runtime bootstrap to confirm the actor path for DataConnectionManagerActor — there's typically one place where top-level actors are registered.)

Step 2: Build

dotnet build ZB.MOM.WW.ScadaBridge.slnx

Expected: 0 errors.

Step 3: Integration test — central → site browse round-trip

Add a coarse integration test in tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Browse/CentralToSiteBrowseTests.cs:

using FluentAssertions;
using Xunit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;

namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Browse;

[Trait("Category", "RequiresCluster")]
public class CentralToSiteBrowseTests : IClassFixture<ClusterFixture>   // reuse existing fixture
{
    private readonly ClusterFixture _cluster;
    public CentralToSiteBrowseTests(ClusterFixture cluster) => _cluster = cluster;

    [Fact]
    public async Task Browse_round_trips_from_central_to_site_OPC_UA_server()
    {
        var result = await _cluster.CommunicationService.SendCommandToSiteAsync<BrowseOpcUaNodeResult>(
            siteId: "site-a",
            command: new BrowseOpcUaNodeCommand(
                DataConnectionId: _cluster.SiteAOpcUaConnectionId,
                ParentNodeId: null));

        result.Failure.Should().BeNull();
        result.Children.Should().NotBeEmpty();
        result.Children.Should().Contain(n => n.DisplayName == "Server");
    }
}

If ClusterFixture doesn't expose SiteAOpcUaConnectionId, add it by reading the configured connection via the existing repository. If the integration test project doesn't already wire up a docker-backed fixture, mark the test [Trait("Category", "RequiresCluster")] and document that it runs under bash docker/deploy.sh && dotnet test --filter Category=RequiresCluster.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs \
        tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Browse/CentralToSiteBrowseTests.cs
# plus DeploymentManagerActor if you went through the proxy path
git commit -m "feat(comm): route BrowseOpcUaNodeCommand from central to site DCL manager"

Phase 3 — Flattening uses the override (depends on Phase 1)

Task 12: Apply override in FlatteningService.ApplyConnectionBindings

Classification: small Estimated implement time: ~4 min Parallelizable with: Tasks 711, 1422 (different file)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs (ApplyConnectionBindings, lines 348371)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs (new)

Step 1: Write the failing test

tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs:

using FluentAssertions;
using Xunit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
// ... using for the entities your existing flattening tests use

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;

public class ConnectionBindingOverrideTests
{
    [Fact]
    public void Override_replaces_template_DataSourceReference_when_set()
    {
        var template = TestTemplates.WithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault");
        var instance = TestInstances.For(template);
        instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
        {
            DataConnectionId = 1,
            DataSourceReferenceOverride = "ns=2;s=Pump1.Speed"
        });

        var flat = SUT.Flatten(template, instance, dataConnections: TestConnections.One(id: 1));

        flat.Attributes.Should().ContainSingle(a => a.CanonicalName == "Speed")
            .Which.DataSourceReference.Should().Be("ns=2;s=Pump1.Speed");
    }

    [Fact]
    public void Null_override_falls_back_to_template_default()
    {
        var template = TestTemplates.WithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault");
        var instance = TestInstances.For(template);
        instance.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
        {
            DataConnectionId = 1,
            DataSourceReferenceOverride = null
        });

        var flat = SUT.Flatten(template, instance, dataConnections: TestConnections.One(id: 1));

        flat.Attributes.Should().ContainSingle(a => a.CanonicalName == "Speed")
            .Which.DataSourceReference.Should().Be("TemplateDefault");
    }
}

(Reuse whatever test helpers already exist in this test project — TestTemplates, TestInstances, etc. The two existing flattening test files in the project show the conventions; mirror them.)

Step 2: Run, confirm fail

dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj --filter "FullyQualifiedName~ConnectionBindingOverrideTests"

Expected: FAIL on the first test — flat attribute carries "TemplateDefault", not the override.

Step 3: Patch ApplyConnectionBindings

In src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs, lines 364369, add DataSourceReference to the with clause:

attributes[binding.AttributeName] = existing with
{
    BoundDataConnectionId = connection.Id,
    BoundDataConnectionName = connection.Name,
    BoundDataConnectionProtocol = connection.Protocol,
    DataSourceReference = binding.DataSourceReferenceOverride ?? existing.DataSourceReference
};

That's it — one line. RevisionHashService.cs:57 already hashes DataSourceReference on the flattened attribute, so the revision hash naturally captures override changes and deploys roll forward on edit. No change needed there.

Step 4: Run, confirm pass

dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj --filter "FullyQualifiedName~ConnectionBindingOverrideTests"
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj   # full project — regression check

Expected: new tests pass, no regressions.

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs \
        tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs
git commit -m "feat(templates): apply InstanceConnectionBinding override during flattening"

Task 13: Revision-hash regression test (verify override mutates the hash)

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 12 (after T12 lands)

Files:

  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs (extend)

Step 1: Add the test

Append to the file from Task 12:

[Fact]
public void Override_change_changes_revision_hash()
{
    var template = TestTemplates.WithDataSourcedAttribute("Speed", dataSourceReference: "TemplateDefault");
    var connections = TestConnections.One(id: 1);

    var instance1 = TestInstances.For(template);
    instance1.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
    {
        DataConnectionId = 1,
        DataSourceReferenceOverride = "ns=2;s=Pump1.Speed"
    });

    var instance2 = TestInstances.For(template);
    instance2.ConnectionBindings.Add(new InstanceConnectionBinding("Speed")
    {
        DataConnectionId = 1,
        DataSourceReferenceOverride = "ns=2;s=Pump2.Speed"
    });

    var hash1 = SUT.Flatten(template, instance1, connections).RevisionHash;
    var hash2 = SUT.Flatten(template, instance2, connections).RevisionHash;

    hash1.Should().NotBe(hash2);
}

Step 2: Run

dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.csproj --filter "FullyQualifiedName~Override_change_changes_revision_hash"

Expected: pass.

Step 3: Commit

git add tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs
git commit -m "test(templates): override changes drive revision hash forward"

Phase 4 — Central UI (depends on Phases 1 and 2)

Task 14: IOpcUaBrowseService + impl + DI

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 15

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs
  • Create: src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs (DI registration)

Step 1: Create the interface + impl

Services/IOpcUaBrowseService.cs:

using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;

public interface IOpcUaBrowseService
{
    Task<BrowseOpcUaNodeResult> BrowseChildrenAsync(
        string siteId,
        int dataConnectionId,
        string? parentNodeId,
        CancellationToken cancellationToken = default);
}

Services/OpcUaBrowseService.cs:

using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Communication;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;

public class OpcUaBrowseService : IOpcUaBrowseService
{
    private readonly CommunicationService _communication;
    private readonly AuthenticationStateProvider _auth;

    public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth)
    {
        _communication = communication;
        _auth = auth;
    }

    public async Task<BrowseOpcUaNodeResult> BrowseChildrenAsync(
        string siteId,
        int dataConnectionId,
        string? parentNodeId,
        CancellationToken cancellationToken = default)
    {
        // CentralUI-side role guard. The site doesn't unwrap envelopes;
        // central is the trust boundary.
        var state = await _auth.GetAuthenticationStateAsync();
        if (!state.User.IsInRole("Design"))
            return new BrowseOpcUaNodeResult(
                Array.Empty<Commons.Interfaces.Protocol.BrowseNode>(),
                Truncated: false,
                new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized."));

        try
        {
            return await _communication.SendCommandToSiteAsync<BrowseOpcUaNodeResult>(
                siteId,
                new BrowseOpcUaNodeCommand(dataConnectionId, parentNodeId),
                cancellationToken);
        }
        catch (TimeoutException ex)
        {
            return new BrowseOpcUaNodeResult(
                Array.Empty<Commons.Interfaces.Protocol.BrowseNode>(),
                Truncated: false,
                new BrowseFailure(BrowseFailureKind.Timeout, ex.Message));
        }
        catch (Exception ex)
        {
            return new BrowseOpcUaNodeResult(
                Array.Empty<Commons.Interfaces.Protocol.BrowseNode>(),
                Truncated: false,
                new BrowseFailure(BrowseFailureKind.ServerError, ex.Message));
        }
    }
}

Step 2: Register in DI

In src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs, find the scoped service registrations (near other builder.Services.AddScoped<...>() lines) and add:

builder.Services.AddScoped<IOpcUaBrowseService, OpcUaBrowseService>();

Step 3: Build

dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj

Expected: 0 errors.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs \
        src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs \
        src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs
git commit -m "feat(centralui): IOpcUaBrowseService wraps CommunicationService + role guard"

Task 15: <OpcUaBrowserDialog/> scaffold — parameters, modal shell, state

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

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor

Step 1: Create the file

Use the frontend-design skill at this point for the visual polish — the structure below is a starting scaffold. Bootstrap 5 modal, no third-party UI framework.

@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject IOpcUaBrowseService BrowseService

@if (_isVisible)
{
    <div class="modal show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
        <div class="modal-dialog modal-lg" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Browse OPC UA — @ConnectionName</h5>
                    <button type="button" class="btn-close" @onclick="Cancel"></button>
                </div>
                <div class="modal-body">
                    @if (_failure is not null)
                    {
                        <div class="alert alert-danger">
                            @_failureMessage
                            <button class="btn btn-sm btn-outline-danger ms-2" @onclick="RetryRootLoad">Retry</button>
                        </div>
                    }

                    <div class="opcua-browser-tree">
                        <!-- Task 16 fills this in -->
                    </div>

                    <hr />

                    <div class="input-group">
                        <span class="input-group-text">Manual node id:</span>
                        <input class="form-control" @bind="_manualNodeId" placeholder="ns=2;s=..." />
                        <button class="btn btn-outline-secondary" @onclick="UseManual" disabled="@string.IsNullOrWhiteSpace(_manualNodeId)">Use</button>
                    </div>
                </div>
                <div class="modal-footer">
                    <span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
                    <button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
                    <button class="btn btn-primary" @onclick="Confirm" disabled="@string.IsNullOrWhiteSpace(_selectedNodeId)">Select</button>
                </div>
            </div>
        </div>
    </div>
}

@code {
    [Parameter] public string SiteId { get; set; } = "";
    [Parameter] public int DataConnectionId { get; set; }
    [Parameter] public string ConnectionName { get; set; } = "";
    [Parameter] public string? InitialNodeId { get; set; }
    [Parameter] public EventCallback<string> OnSelected { get; set; }
    [Parameter] public EventCallback OnCancelled { get; set; }

    private bool _isVisible;
    private string? _selectedNodeId;
    private string _manualNodeId = "";
    private BrowseFailure? _failure;
    private string _failureMessage = "";

    public async Task ShowAsync()
    {
        _isVisible = true;
        _manualNodeId = InitialNodeId ?? "";
        _selectedNodeId = InitialNodeId;
        await LoadRootAsync();
    }

    private async Task LoadRootAsync()
    {
        // Task 16
    }

    private Task RetryRootLoad() => LoadRootAsync();

    private void UseManual()
    {
        _selectedNodeId = _manualNodeId.Trim();
    }

    private async Task Confirm()
    {
        _isVisible = false;
        if (!string.IsNullOrWhiteSpace(_selectedNodeId))
            await OnSelected.InvokeAsync(_selectedNodeId!);
    }

    private async Task Cancel()
    {
        _isVisible = false;
        await OnCancelled.InvokeAsync();
    }
}

Step 2: Build

dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj

Expected: 0 errors.

Step 3: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor
git commit -m "feat(centralui): scaffold <OpcUaBrowserDialog/> modal"

Task 16: Tree rendering + lazy load + selection in OpcUaBrowserDialog

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (extends Task 15's file)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor

Step 1: Add tree model + node component

Inside @code, add:

private record TreeNode(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren)
{
    public List<TreeNode>? Children { get; set; }   // null = not loaded yet
    public bool Expanded { get; set; }
    public bool Loading { get; set; }
    public bool Truncated { get; set; }
}

private List<TreeNode> _rootNodes = new();

private async Task LoadRootAsync()
{
    _failure = null;
    _rootNodes = new();
    StateHasChanged();

    var result = await BrowseService.BrowseChildrenAsync(SiteId, DataConnectionId, parentNodeId: null);
    if (result.Failure is not null)
    {
        SetFailure(result.Failure);
        return;
    }

    _rootNodes = result.Children.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren)).ToList();
    StateHasChanged();
}

private async Task ToggleAsync(TreeNode node)
{
    if (!node.HasChildren) return;

    if (node.Expanded)
    {
        node.Expanded = false;
        return;
    }

    if (node.Children is null)
    {
        node.Loading = true;
        StateHasChanged();
        var result = await BrowseService.BrowseChildrenAsync(SiteId, DataConnectionId, node.NodeId);
        node.Loading = false;

        if (result.Failure is not null)
        {
            SetFailure(result.Failure);
            return;
        }

        node.Children = result.Children
            .Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
            .ToList();
        node.Truncated = result.Truncated;
    }

    node.Expanded = true;
}

private void Select(TreeNode node)
{
    if (node.NodeClass != BrowseNodeClass.Variable) return;
    _selectedNodeId = node.NodeId;
    _manualNodeId = node.NodeId;
}

Step 2: Render the tree

Replace the <!-- Task 16 fills this in --> line with:

@if (_rootNodes.Count == 0 && _failure is null)
{
    <em class="text-muted">Loading…</em>
}
else
{
    <ul class="list-unstyled mb-0">
        @foreach (var node in _rootNodes)
        {
            <TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" SelectedNodeId="@_selectedNodeId" />
        }
    </ul>
}

Create src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor as a small recursive child component:

@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
<li>
    <span style="cursor: @(Node.HasChildren ? "pointer" : "default");" @onclick="() => OnToggle.InvokeAsync(Node)">
        @if (Node.HasChildren)
        {
            <span>@(Node.Expanded ? "▼" : "▶")</span>
        }
        else
        {
            <span style="display:inline-block; width:1em;">&nbsp;</span>
        }
    </span>

    @if (Node.NodeClass == BrowseNodeClass.Variable)
    {
        <a href="javascript:void(0)"
           class="@(Node.NodeId == SelectedNodeId ? "fw-bold text-primary" : "")"
           @onclick="() => OnSelect.InvokeAsync(Node)"
           @ondblclick="() => OnSelect.InvokeAsync(Node)">
            @Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
        </a>
    }
    else
    {
        <span class="text-muted">@Node.DisplayName</span>
    }

    @if (Node.Loading)
    {
        <em class="ms-2 text-muted">loading…</em>
    }

    @if (Node.Expanded && Node.Children is not null)
    {
        <ul class="list-unstyled ms-4">
            @foreach (var child in Node.Children)
            {
                <TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" SelectedNodeId="@SelectedNodeId" />
            }
            @if (Node.Truncated)
            {
                <li><small class="text-warning">Results truncated — use manual entry if your tag isn't listed.</small></li>
            }
        </ul>
    }
</li>

@code {
    [Parameter] public OpcUaBrowserDialog.TreeNode Node { get; set; } = default!;
    [Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> OnToggle { get; set; }
    [Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> OnSelect { get; set; }
    [Parameter] public string? SelectedNodeId { get; set; }
}

(If TreeNode should not be a nested type — Razor visibility quirks — move it to a top-level OpcUaBrowserTreeNode record in its own file under Components/Dialogs/.)

Step 3: Build

dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj

Expected: 0 errors.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/
git commit -m "feat(centralui): tree rendering + lazy load + selection in OpcUaBrowserDialog"

Task 17: Error banner mapping + final polish on OpcUaBrowserDialog

Classification: small Estimated implement time: ~3 min Parallelizable with: none (extends same file as Task 16)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor

Step 1: Add the failure→message mapping

In @code:

private void SetFailure(BrowseFailure failure)
{
    _failure = failure;
    _failureMessage = failure.Kind switch
    {
        BrowseFailureKind.ConnectionNotFound      => "Connection no longer exists at the site.",
        BrowseFailureKind.ConnectionNotConnected  => "OPC UA session not connected — retry shortly or use manual entry.",
        BrowseFailureKind.NotBrowsable            => "This connection does not support browsing.",
        BrowseFailureKind.Timeout                 => "Browse timed out — the server may be slow. Try again or enter the node id manually.",
        BrowseFailureKind.ServerError             => $"OPC UA server error: {failure.Message}",
        _                                          => failure.Message
    };
    StateHasChanged();
}

Step 2: Build

dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj

Expected: 0 errors.

Step 3: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor
git commit -m "feat(centralui): map BrowseFailureKind to user-facing messages"

Task 18: Add Override column + Browse button to InstanceConfigure.razor

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 17 (after T16 lands)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor

Step 1: Locate the Connection Bindings table

Open the file. Find the table inside the "Connection Bindings" section (the Explore report described columns "Attribute", "Tag Path", "Connection"). Locate the row template (@foreach (var binding in _bindings) or similar).

Step 2: Add two new cells per row

After the existing "Tag Path" column header, add <th>Override</th><th></th>. In the row template, after the existing "Tag Path" cell, add:

<td>
    <input class="form-control form-control-sm"
           @bind="binding.DataSourceReferenceOverride"
           placeholder="@(GetTemplateDefault(binding.AttributeName) ?? "(no default)")" />
</td>
<td>
    @{
        var canBrowse = CanBrowse(binding);
    }
    @if (IsOpcUa(binding))
    {
        <button class="btn btn-sm btn-outline-primary"
                disabled="@(!canBrowse)"
                title="@(canBrowse ? "Browse OPC UA address space" : "Pick a connection first")"
                @onclick="() => OpenBrowser(binding)">
            Browse…
        </button>
    }
</td>

(binding.DataSourceReferenceOverride requires the row's binding to be the InstanceConnectionBinding POCO — make sure the page's edit model holds the override field. If the page uses a separate VM ConnectionBindingRow, add public string? DataSourceReferenceOverride { get; set; } to it and map round-trip in the load/save.)

Step 3: Add the helpers + dialog placeholder in @code

Near the bottom of the @code block:

private string? GetTemplateDefault(string attributeName)
    => _templateAttributes.FirstOrDefault(a => a.CanonicalName == attributeName)?.DataSourceReference;

private bool IsOpcUa(BindingRow row)
    => row.DataConnectionId > 0
       && _siteConnections.FirstOrDefault(c => c.Id == row.DataConnectionId)?.Protocol == DataConnectionProtocol.OpcUa;

private bool CanBrowse(BindingRow row)
    => row.DataConnectionId > 0;

private async Task OpenBrowser(BindingRow row)
{
    _browserRowInEdit = row;
    _browserConnectionId = row.DataConnectionId;
    _browserConnectionName = _siteConnections.First(c => c.Id == row.DataConnectionId).Name;
    _browserInitial = row.DataSourceReferenceOverride
                      ?? GetTemplateDefault(row.AttributeName);
    await _browserRef.ShowAsync();
}

private void OnBrowserSelected(string nodeId)
{
    if (_browserRowInEdit is not null)
        _browserRowInEdit.DataSourceReferenceOverride = nodeId;
}

(BindingRow / _siteConnections / _templateAttributes are placeholders — match the page's actual field names.)

Step 4: Render the dialog at the bottom of the page

<OpcUaBrowserDialog @ref="_browserRef"
                    SiteId="@_siteId"
                    DataConnectionId="@_browserConnectionId"
                    ConnectionName="@_browserConnectionName"
                    InitialNodeId="@_browserInitial"
                    OnSelected="OnBrowserSelected" />

Add private OpcUaBrowserDialog? _browserRef; in @code along with the other fields used above.

Step 5: Build

dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj

Expected: 0 errors.

Step 6: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
git commit -m "feat(centralui): add OPC UA browse button + override column to InstanceConfigure"

Task 19: End-to-end smoke (manual)

Classification: trivial Estimated implement time: ~5 min (manual) Parallelizable with: none (validates Tasks 118 together)

Files: none (manual exercise)

Step 1: Rebuild the cluster

bash docker/deploy.sh
bash docker/seed-sites.sh   # if cluster is fresh

Step 2: Sign in + walk the flow

  1. Open http://localhost:9000, sign in as multi-role / password.
  2. Navigate to a deployed instance's Configure page.
  3. On the Connection Bindings table, pick an OPC UA connection on a data-sourced attribute. Confirm the Browse… button shows up enabled.
  4. Click Browse. Confirm the dialog opens, the root browse populates within a couple of seconds, "Server" is visible under the root.
  5. Expand a folder; expand a device; click a Variable; confirm the footer shows the selected node id; click Select.
  6. Confirm the Override input now shows the picked node id.
  7. Save the bindings. Reload the page; confirm the override persists.

Step 3: Offline-path smoke

  1. docker stop scadabridge-site-a-a scadabridge-site-a-b (whichever site backs the configured connection).
  2. Click Browse. Confirm the error banner appears, manual-paste field is still usable.
  3. Type a node id in manual-paste, click UseSelect. Confirm the override persists despite the site being down.
  4. docker start scadabridge-site-a-a scadabridge-site-a-b to restore.

Step 4: Commit nothing (manual task)

If anything fails, surface it — do not silently patch over. Open a follow-up task instead.


Phase 5 — Docs (parallelizable, can run after Phase 1)

Task 20: Update Component-DataConnectionLayer.md

Classification: trivial Estimated implement time: ~3 min Parallelizable with: Tasks 21, 22

Files:

  • Modify: docs/requirements/Component-DataConnectionLayer.md

Step 1: Add a "Browsing" subsection

Append (or insert near other capability-style sections) a short section:

### Browsing the address space

DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for protocols that support it, exposed via `IBrowsableDataConnection`. Only consumed by management/UI (the OPC UA tag picker on the instance configure page); Instance Actors never call it.

- `OpcUaDataConnection` implements `IBrowsableDataConnection`; custom protocols do not.
- `DataConnectionManagerActor` handles `BrowseOpcUaNodeCommand` (fields: `DataConnectionId`, `ParentNodeId`) and replies with `BrowseOpcUaNodeResult` (children + `Truncated` + structured `BrowseFailure?`).
- Browse runs against the live session; no caching at DCL.

Step 2: List the new actor message

Find any "Actor surface / Messages" table or list and append BrowseOpcUaNodeCommand → BrowseOpcUaNodeResult (handled by DataConnectionManagerActor).

Step 3: Commit

git add docs/requirements/Component-DataConnectionLayer.md
git commit -m "docs(dcl): document browse capability + BrowseOpcUaNodeCommand"

Task 21: Update Component-TemplateEngine.md

Classification: trivial Estimated implement time: ~3 min Parallelizable with: Tasks 20, 22

Files:

  • Modify: docs/requirements/Component-TemplateEngine.md

Step 1: Soften the "fixed at instance level" claim

Find the line around the existing instance-level commentary (the Explore report referenced line 100). Replace text that says DataSourceReference is fixed at instance level with:

`DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used.

Step 2: Note the override participates in the revision hash

Find the section that discusses the revision hash / staleness detection and add one sentence:

The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected.

Step 3: Commit

git add docs/requirements/Component-TemplateEngine.md
git commit -m "docs(templates): document per-instance DataSourceReference override"

Task 22: Update Component-CentralUI.md

Classification: trivial Estimated implement time: ~3 min Parallelizable with: Tasks 20, 21

Files:

  • Modify: docs/requirements/Component-CentralUI.md

Step 1: Extend the Connection Bindings description

Find the Connection Bindings section (the Explore report referenced line 96). Extend it to describe the new Override column and Browse button:

- **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context.
- **Browse…** — opens the OPC UA Tag Browser dialog, populated live from the site's OPC UA server via `BrowseOpcUaNodeCommand`. Visible only when the row's connection uses the OPC UA protocol; disabled until a connection is picked on that row. The dialog lazy-loads the address space, supports manual node-id entry as a fallback, and remains usable when the site or its OPC UA session is offline (the manual-paste field stays active even on error).

Step 2: Commit

git add docs/requirements/Component-CentralUI.md
git commit -m "docs(centralui): document OPC UA browse popup + override column"

Parallelism summary

Phase Tasks Notes
1 1, 2, 5, 6 in parallel; 3 → 4 sequential T3 is mapping; T4 generates migration from T3's mapping
2 7 → 8 → 9; 10, 11 can run in parallel after 9 T7 is interface change, must precede T8/9
3 12 → 13 T13 only adds a test against T12's behavior
4 14 ‖ 15 → 16 → 17 → 18 → 19 UI assembled top-down; T19 is manual smoke
5 20, 21, 22 all in parallel After T12 lands, all three doc updates can run concurrently

Definition of done

  • All tasks committed; git log origin/main..HEAD shows the slice.
  • dotnet build ZB.MOM.WW.ScadaBridge.slnx clean (0 errors, 0 warnings).
  • dotnet test ZB.MOM.WW.ScadaBridge.slnx green (the 5 pre-existing StaleTagMonitor flakes are accepted; other failures must be fixed).
  • Manual smoke (Task 19) passes online + offline paths.
  • Three docs updated (Tasks 2022).