# 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 `BrowseOpcUaNodeCommand` → `SiteCommunicationActor` → `DataConnectionManagerActor` → new `IBrowsableDataConnection` capability on `OpcUaDataConnection` → `RealOpcUaClient.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`](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`:

```csharp
/// <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**

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

```csharp
public record ConnectionBinding(string AttributeName, int DataConnectionId);
```

with:

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

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

```csharp
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**

```bash
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**

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

```csharp
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**

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

Expected: 0 errors.

**Step 4: Commit**

```bash
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**

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

```csharp
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**

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

```bash
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**

```bash
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**

```csharp
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**

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

Expected: 0 errors.

**Step 3: Commit**

```bash
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**

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

```csharp
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**

```bash
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**

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

```csharp
/// <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)**

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

```csharp
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**

```bash
dotnet build ZB.MOM.WW.ScadaBridge.slnx
```

Expected: 0 errors.

**Step 5: Commit**

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

```csharp
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**

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

```csharp
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**

```bash
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**

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

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

```csharp
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**

```bash
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**

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

```csharp
ReceiveAsync<BrowseOpcUaNodeCommand>(HandleBrowse);
```

Then add the handler method on the actor class:

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

```csharp
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**

```bash
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**

```bash
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 94–145 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:

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

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

```csharp
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**

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

```csharp
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**

```bash
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 7–11, 14–22 (different file)

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs` (`ApplyConnectionBindings`, lines 348–371)
- 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`:

```csharp
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**

```bash
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 364–369, add `DataSourceReference` to the `with` clause:

```csharp
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**

```bash
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**

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

```csharp
[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**

```bash
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**

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

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

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

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

**Step 3: Build**

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

Expected: 0 errors.

**Step 4: Commit**

```bash
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.

```razor
@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**

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

Expected: 0 errors.

**Step 3: Commit**

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

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

```razor
@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:

```razor
@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**

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

Expected: 0 errors.

**Step 4: Commit**

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

```csharp
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**

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

Expected: 0 errors.

**Step 3: Commit**

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

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

```csharp
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**

```razor
<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**

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

Expected: 0 errors.

**Step 6: Commit**

```bash
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)

> **PASS 2026-08-01 (online + offline)** — run live on the docker rig against a purpose-built
> deployed instance (data-sourced Float attribute bound to site-a's "OPC PLC Simulator";
> artifacts deleted after). Online: Browse dialog opened, root populated (Server visible),
> expanded OpcPlc → Telemetry → Basic, selected `StepUp` — footer + override stored the durable
> `nsu=http://microsoft.com/Opc/OpcPlc/;s=StepUp` form and it persisted across save + reload.
> Offline: with BOTH site-a nodes stopped, Browse showed the error banner
> (`StatusCode="Unavailable"` + Retry) with the manual-paste field still usable; a hand-typed
> `ns=3;s=TestChildObject.TestInt` (bare-index advisory shown as designed) went Use → Select →
> Save and persisted across reload while the site was down. Pair restarted staggered and rejoined.

**Classification:** trivial
**Estimated implement time:** ~5 min (manual)
**Parallelizable with:** none (validates Tasks 1–18 together)

**Files:** none (manual exercise)

**Step 1: Rebuild the cluster**

```bash
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 **Use** → **Select**. 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:

```markdown
### 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**

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

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

```markdown
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**

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

```markdown
- **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**

```bash
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 20–22).
