# Akka Hosting Alignment — Gap Closeout Implementation Plan

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

**Goal:** Close the four real/cosmetic gaps identified by the audit of `docs/plans/2026-05-26-akka-hosting-alignment-plan.md` so the v2 implementation matches the plan's literal contract (per-role appsettings overlays, explicit dual-endpoint visibility test, plan-prescribed filenames, removal of empty legacy directories).

**Architecture:** Additive only. No production-runtime semantics change. One small extension to `OpcUaApplicationHost` so the OPC UA server can advertise peer URIs in `Server.ServerArray` — gated on a new option, defaults to old behavior. Everything else is JSON, test code, file moves, and `rm -rf` of stale bin/obj trees.

**Tech Stack:** .NET 10, OPCFoundation .NET Standard SDK (`Opc.Ua.*`), xunit.v3, Shouldly, EF Core 10 (inherited; no schema changes).

**Source plan:** `docs/plans/2026-05-26-akka-hosting-alignment-plan.md`. The audit findings closed by this plan map to Tasks 54, 59, 60, and the post-Task-56 cosmetic cleanup. **Read the source plan's "Conventions for every task" block — those rules still apply here.**

**Branch:** `v2-gap-closeout` off `master`.

---

## Conventions for every task

- **Branch:** Stay on `v2-gap-closeout`. Never commit to `master` while plan is running.
- **Build command:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — must be green before commit.
- **Test command:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build` — relevant new/changed tests must pass.
- **Commit format:** Conventional Commits matching the source plan — `feat(host):`, `test(opcua):`, `chore(cleanup):`, `refactor(test):`, etc.
- **Mac compatibility:** All code must build on macOS. The new dual-endpoint test boots two real OPC UA servers on loopback — works on macOS (no Windows-only APIs needed; PKI is created under a per-test temp dir).

---

## Task 0: Add three role-overlay appsettings files (Task 54 gap)

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

**Files:**
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json`
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json`
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json`

**Background:**
`Program.cs` line 33-35 loads `appsettings.{role-suffix}.json` where the suffix is the roles joined alphabetically with `'-'`. Today the loader passes `optional: true`, so the host boots without these files — but the source plan (Task 54) called them out as required scaffolding so operators have per-role tunable defaults.

Suffix matrix:
| `OTOPCUA_ROLES` env | Loaded file |
|---|---|
| `admin` | `appsettings.admin.json` |
| `driver` | `appsettings.driver.json` |
| `admin,driver` (any order) | `appsettings.admin-driver.json` (joined alphabetical) |

**Step 1: Create `appsettings.admin.json`**

Admin-only nodes don't bind drivers; tighten Serilog and disable the LDAP dev stub by default.

```json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Akka": "Information"
      }
    }
  },
  "Security": {
    "Ldap": {
      "DevStubMode": false
    }
  }
}
```

**Step 2: Create `appsettings.driver.json`**

Driver-only nodes have no Admin UI; raise OPC UA verbosity slightly so per-node diagnostics flow to logs.

```json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Opc.Ua": "Debug",
        "Akka": "Information"
      }
    }
  },
  "Security": {
    "Ldap": {
      "DevStubMode": false
    }
  }
}
```

**Step 3: Create `appsettings.admin-driver.json`**

Combined-role nodes (the docker-dev compose default + the integration test harness) — turn on both surfaces with shared defaults.

```json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Opc.Ua": "Information",
        "Akka": "Information"
      }
    }
  },
  "Security": {
    "Ldap": {
      "DevStubMode": false
    }
  }
}
```

**Step 4: Build green check**

Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
Expected: succeeds. (JSON files do not break the build; this is a smoke check that nothing else regressed.)

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json \
        src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json \
        src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json
git commit -m "feat(host): add per-role appsettings overlays for admin/driver/admin-driver"
```

---

## Task 1: Extend `OpcUaApplicationHost` with `PeerApplicationUris` + populate `Server.ServerArray`

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

**Files:**
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs` (add option + post-start population)
- Test: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs`

**Background:**
The source plan's Task 60 promised a test where "real OPCFoundation client → both endpoints visible in ServerUriArray". That requires production code to populate the peer URIs onto each server's `Server.ServerArray` (NodeId i=2254) property. No such code exists in v2 today — this task adds it as an opt-in option so existing single-node tests keep their current behavior. Task 3 then writes the integration test that drives it across two servers.

**Step 1: Write the failing unit test**

Create `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs`:

```csharp
using System.IO;
using System.Net.Sockets;
using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;

/// <summary>
/// Audit gap closeout — verifies <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>
/// is reflected in <c>Server.ServerArray</c> after start. Single-server in-process check; the
/// cross-server visibility check lives in <c>OtOpcUa.OpcUaServer.IntegrationTests</c>.
/// </summary>
public sealed class OpcUaApplicationHostServerArrayTests
{
    [Fact]
    public async Task ServerArray_contains_local_uri_and_configured_peers_after_start()
    {
        var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}");
        try
        {
            var options = new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.UnitTest",
                ApplicationUri = "urn:OtOpcUa.UnitTest.NodeA",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "127.0.0.1",
                PkiStoreRoot = pkiRoot,
                PeerApplicationUris = new[] { "urn:OtOpcUa.UnitTest.NodeB" },
            };

            var server = new StandardServer();
            await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
            await host.StartAsync(server, CancellationToken.None);

            var serverArray = server.CurrentInstance.ServerObject.ServerArray.Value;
            serverArray.ShouldNotBeNull();
            serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeA");
            serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeB");
        }
        finally
        {
            if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
        }
    }

    private static int AllocateFreePort()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }
}
```

**Step 2: Run the test — confirm it fails**

Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"`
Expected: FAIL with `PeerApplicationUris` not found (compile error) — the option doesn't exist yet.

**Step 3: Add the option**

Edit `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`. Add to `OpcUaApplicationHostOptions` (after `AutoAcceptUntrustedClientCertificates`, around line 65):

```csharp
/// <summary>
/// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
/// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
/// deployments so OPC UA clients can discover the partner endpoint via the standard
/// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
/// is always element 0.
/// </summary>
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
```

**Step 4: Populate `Server.ServerArray` after start**

Edit `OpcUaApplicationHost.StartAsync` (around line 100-118). After the `_application.Start(server)` call and before the log line, insert:

```csharp
PopulateServerArray();
```

Then add the private method below `AttachUserAuthenticator`:

```csharp
/// <summary>
/// Writes the union of <see cref="OpcUaApplicationHostOptions.ApplicationUri"/> and
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> to the OPC UA standard
/// <c>Server.ServerArray</c> property (NodeId i=2254). Clients in a warm-redundancy
/// deployment discover the partner endpoint by reading this property.
/// </summary>
private void PopulateServerArray()
{
    var serverObject = _server?.CurrentInstance?.ServerObject;
    if (serverObject is null) return;

    var uris = new List<string> { _options.ApplicationUri };
    foreach (var peer in _options.PeerApplicationUris)
    {
        if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
            uris.Add(peer);
    }
    serverObject.ServerArray.Value = uris.ToArray();
}
```

**Step 5: Run the test — confirm it passes**

Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"`
Expected: PASS. If `ServerObject.ServerArray.Value` is read-only (some SDK versions guard it), fall back to writing through `ServerArrayNode.Value` via the address-space accessor — but try the direct write first; the SDK exposes it as a settable BaseDataVariableState on `ServerObjectState`.

**Step 6: Run full OpcUaServer.Tests suite to confirm no regression**

Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests`
Expected: all tests pass — `PopulateServerArray` is additive when `PeerApplicationUris` is empty (default), so existing tests don't change behavior.

**Step 7: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs
git commit -m "feat(opcua): OpcUaApplicationHost publishes peer URIs in Server.ServerArray"
```

---

## Task 2: Create `OtOpcUa.OpcUaServer.IntegrationTests` project

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 6 (file moves elsewhere)
**Depends on:** none (csproj is self-contained)

**Files:**
- Create: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj`
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx` (add the project)

**Background:**
The source plan's Task 60 named this exact project. Audit found ServiceLevel coverage relocated to other test projects but no `OpcUaServer.IntegrationTests` project exists. Creating the project skeleton in its own task keeps Task 3's commit focused on the test code.

**Step 1: Create the csproj**

Mirror the conventions in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj`. The integration project needs the `Opc.Ua.Client` package (vs. only `Opc.Ua.Server` in the unit tests) — confirm the version against the existing client CLI's csproj: `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj`.

```xml
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <IsPackable>false</IsPackable>
        <IsTestProject>true</IsTestProject>
        <RootNamespace>ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests</RootNamespace>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="xunit.v3"/>
        <PackageReference Include="Shouldly"/>
        <PackageReference Include="Microsoft.NET.Test.Sdk"/>
        <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
        <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
        <PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
        <PackageReference Include="xunit.runner.visualstudio">
            <PrivateAssets>all</PrivateAssets>
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
        </PackageReference>
    </ItemGroup>

    <ItemGroup>
        <ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
    </ItemGroup>

</Project>
```

If `OPCFoundation.NetStandard.Opc.Ua.Client` isn't in `Directory.Packages.props`, add it there (mirror the existing `OPCFoundation.NetStandard.Opc.Ua.Server` version exactly).

**Step 2: Add project to the solution**

Run: `dotnet sln ZB.MOM.WW.OtOpcUa.slnx add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj`
Expected: "Project added to the solution."

**Step 3: Build green check**

Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
Expected: builds. (Empty project, so no test discovery yet — `dotnet test` would say "no tests".)

**Step 4: Commit**

```bash
git add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ \
        ZB.MOM.WW.OtOpcUa.slnx \
        Directory.Packages.props  # only if the Opc.Ua.Client version was added there
git commit -m "test(opcua): scaffold OtOpcUa.OpcUaServer.IntegrationTests project"
```

---

## Task 3: `DualEndpointTests` — real OPC UA client reads both URIs from `Server.ServerArray`

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 6
**Depends on:** Task 1 (PeerApplicationUris wiring), Task 2 (IT project exists)

**Files:**
- Create: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs`

**Background:**
This is the explicit Task 60 deliverable: a real OPC UA client connects to one server and confirms it can discover the partner via `Server.ServerArray`. Single-server unit-side coverage exists in Task 1; this test exercises the wire path with both servers up.

**Step 1: Write the test**

```csharp
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;

/// <summary>
/// Source plan Task 60 — closes the audit gap. Boots two real <see cref="StandardServer"/>
/// instances on loopback, each configured with the other's <c>ApplicationUri</c> in
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>. A real OPC UA client connects
/// to Node A, reads <c>Server.ServerArray</c>, and asserts both URIs are visible — the
/// warm-redundancy discovery contract clients depend on.
/// </summary>
public sealed class DualEndpointTests
{
    private const string NodeAUri = "urn:OtOpcUa.DualEndpoint.NodeA";
    private const string NodeBUri = "urn:OtOpcUa.DualEndpoint.NodeB";

    [Fact]
    public async Task Client_reads_both_ApplicationUris_from_NodeA_ServerArray()
    {
        var pkiRootA = Path.Combine(Path.GetTempPath(), $"otopcua-pki-a-{Guid.NewGuid():N}");
        var pkiRootB = Path.Combine(Path.GetTempPath(), $"otopcua-pki-b-{Guid.NewGuid():N}");
        var portA = AllocateFreePort();
        var portB = AllocateFreePort();

        try
        {
            await using var nodeA = await StartNodeAsync(NodeAUri, portA, pkiRootA, peers: new[] { NodeBUri });
            await using var nodeB = await StartNodeAsync(NodeBUri, portB, pkiRootB, peers: new[] { NodeAUri });

            var serverArray = await ReadServerArrayAsync($"opc.tcp://127.0.0.1:{portA}/OtOpcUa");
            serverArray.ShouldContain(NodeAUri);
            serverArray.ShouldContain(NodeBUri);
        }
        finally
        {
            if (Directory.Exists(pkiRootA)) Directory.Delete(pkiRootA, recursive: true);
            if (Directory.Exists(pkiRootB)) Directory.Delete(pkiRootB, recursive: true);
        }
    }

    private static async Task<OpcUaApplicationHost> StartNodeAsync(
        string applicationUri, int port, string pkiRoot, string[] peers)
    {
        var options = new OpcUaApplicationHostOptions
        {
            ApplicationName = applicationUri,           // unique per node — SDK uses it for cert CN
            ApplicationUri = applicationUri,
            OpcUaPort = port,
            PublicHostname = "127.0.0.1",
            PkiStoreRoot = pkiRoot,
            EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
            AutoAcceptUntrustedClientCertificates = true,
            PeerApplicationUris = peers,
        };
        var server = new StandardServer();
        var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
        await host.StartAsync(server, CancellationToken.None);
        return host;
    }

    private static async Task<string[]> ReadServerArrayAsync(string endpointUrl)
    {
        var appConfig = new ApplicationConfiguration
        {
            ApplicationName = "OtOpcUa.DualEndpointClient",
            ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
            ApplicationType = ApplicationType.Client,
            SecurityConfiguration = new SecurityConfiguration
            {
                ApplicationCertificate = new CertificateIdentifier(),
                AutoAcceptUntrustedCertificates = true,
            },
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
            CertificateValidator = new CertificateValidator(),
        };
        await appConfig.Validate(ApplicationType.Client);
        appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;

        var endpoint = CoreClientUtils.SelectEndpoint(appConfig, endpointUrl, useSecurity: false);
        var endpointConfiguration = EndpointConfiguration.Create(appConfig);
        var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);

        using var session = await Session.Create(
            appConfig, configuredEndpoint, updateBeforeConnect: false,
            sessionName: "DualEndpointTests", sessionTimeout: 60_000,
            identity: new UserIdentity(new AnonymousIdentityToken()),
            preferredLocales: null);

        var value = session.ReadValue(VariableIds.Server_ServerArray);
        return (string[])value.Value;
    }

    private static int AllocateFreePort()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }
}
```

**Step 2: Run the test**

Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests`
Expected: PASS. Wall-time ~3-5 s (two cert-creation cycles + session handshake).

If the test hangs on the session handshake on first run, it's the SDK reading the trusted-cert store — bumping `AutoAcceptUntrustedClientCertificates = true` on both server hosts (already set above) should resolve it. If `CoreClientUtils.SelectEndpoint` throws because the SDK version uses a different overload, fall back to constructing the `EndpointDescription` directly with `EndpointUrl = endpointUrl, SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None` and skipping `SelectEndpoint`.

**Step 3: Commit**

```bash
git add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs
git commit -m "test(opcua): DualEndpointTests — real client reads peer URIs from Server.ServerArray"
```

---

## Task 4: Wire `OtOpcUa.OpcUaServer.IntegrationTests` into v2-ci.yml

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 5, Task 6
**Depends on:** Task 3 (project must exist + have a real test before CI runs it)

**Files:**
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/.github/workflows/v2-ci.yml`

**Step 1: Add the project to the `integration` job**

Either extend the existing `integration` job to run a second `dotnet test` step, or convert it to a matrix. Prefer a matrix for symmetry with `unit-tests`:

Open `.github/workflows/v2-ci.yml`, locate the `integration:` job. Replace it with:

```yaml
  integration:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        project:
          - tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests
          - tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      - name: dotnet test ${{ matrix.project }}
        run: dotnet test ${{ matrix.project }} --configuration Release --filter "Category!=E2E"
```

**Step 2: Build green check**

Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests --configuration Release --filter "Category!=E2E"`
Expected: matches the exact CI command — passes locally so CI will pass too.

**Step 3: Commit**

```bash
git add .github/workflows/v2-ci.yml
git commit -m "ci(v2): include OpcUaServer.IntegrationTests in integration matrix"
```

---

## Task 5: Rename `FailoverScenarioTests` → `FailoverDuringDeployTests` (Task 59 cosmetic)

**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 0, Task 1, Task 2, Task 6 (different files)

**Files:**
- Rename: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs` → `FailoverDuringDeployTests.cs`
- Modify: class name + namespace-internal references

**Step 1: Rename the file and the class**

```bash
git mv tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs \
       tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs
```

Then edit `FailoverDuringDeployTests.cs` and replace the single class declaration `public sealed class FailoverScenarioTests` with `public sealed class FailoverDuringDeployTests`. Use Edit, not sed — the file only declares this class once (`grep -c "FailoverScenario" .` ≤ 2).

**Step 2: Sweep for any stale references**

Run: `grep -rln "FailoverScenarioTests" .`
Expected: zero matches after Step 1. If anything appears (e.g., a CI filter, a doc), fix the reference.

**Step 3: Build + run test**

Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~FailoverDuringDeployTests"`
Expected: same tests pass that previously passed under the old name.

**Step 4: Commit**

```bash
git add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs
git commit -m "refactor(test): rename FailoverScenarioTests → FailoverDuringDeployTests for plan parity"
```

---

## Task 6: Delete empty bin/obj-only legacy directories

**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 0, Task 1, Task 2, Task 5

**Files:**
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Server/`
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/`
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/`
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/`
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/`

**Background:**
Source plan Task 56 deleted the projects from `ZB.MOM.WW.OtOpcUa.slnx` (confirmed by the audit) but left `bin/`+`obj/` shells on disk. These confuse new contributors and skew directory listings. None of them are referenced anywhere.

**Step 1: Sanity-check that each directory is bin/obj-only**

```bash
for dir in \
  src/Server/ZB.MOM.WW.OtOpcUa.Server \
  src/Server/ZB.MOM.WW.OtOpcUa.Admin \
  tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests \
  tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests \
  tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests; do
  echo "--- $dir ---"
  find "$dir" -maxdepth 2 -type f | grep -v "/bin/\|/obj/"
done
```

Expected: every section is empty (no source files leak out). If any source file shows, STOP and surface it — don't delete blindly.

**Step 2: Verify slnx doesn't reference them**

Run: `grep -nE 'ZB\.MOM\.WW\.OtOpcUa\.(Server|Admin)(/|\.Tests|\.E2ETests)' ZB.MOM.WW.OtOpcUa.slnx`
Expected: zero matches.

**Step 3: Delete the directories**

```bash
rm -rf src/Server/ZB.MOM.WW.OtOpcUa.Server \
       src/Server/ZB.MOM.WW.OtOpcUa.Admin \
       tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests \
       tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests \
       tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests
```

**Step 4: Build green check**

Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
Expected: succeeds (these directories were already out of the solution).

**Step 5: Commit**

```bash
git add -A
git commit -m "chore(cleanup): remove stale bin/obj shells for deleted v1 Server/Admin projects"
```

---

## Task 7: Final build + test green check

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (verification, depends on all prior tasks)

**Step 1: Restore + build**

Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
Expected: 0 errors, 0 warnings (TreatWarningsAsErrors is on across the solution).

**Step 2: Run the full test suite**

Run: `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build`
Expected: all tests green. Specifically confirm:
- `OpcUaApplicationHostServerArrayTests` (Task 1) — pass
- `DualEndpointTests` (Task 3) — pass
- `FailoverDuringDeployTests` (Task 5) — same count of tests pass as before the rename

**Step 3: Smoke check the audit assertions**

Run:
```bash
ls src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.*.json
find tests/Server -iname "DualEndpointTests.cs" -o -iname "FailoverDuringDeployTests.cs"
ls -la src/Server/ZB.MOM.WW.OtOpcUa.{Server,Admin} 2>/dev/null
```

Expected:
- 4 appsettings files: `.json`, `.Development.json`, `.admin.json`, `.admin-driver.json`, `.driver.json`
- Both renamed/new test files exist
- The two `ls -la` calls return errors (directories gone)

**Step 4: No commit unless cleanup turned up**

If anything failed in Steps 1-3, fix it as a follow-up task — do not paper over with a `--no-verify` commit.

---

## Final verification

After Task 7:

1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build` — green (incl. 2 new tests)
3. `git log --oneline master..HEAD` — exactly 6 commits, Conventional-Commits style
4. Open PR `v2-gap-closeout` → `master` titled "v2: close audit gaps — appsettings overlays, DualEndpointTests, cleanup"

---

## Task index

| # | Title | Class | Time | Parallel with |
|---|---|---|---|---|
| 0 | Per-role appsettings overlays | small | 3m | 1, 5, 6 |
| 1 | OpcUaApplicationHost.PeerApplicationUris + ServerArray | standard | 5m | 0, 5, 6 |
| 2 | OpcUaServer.IntegrationTests project skeleton | small | 4m | 5, 6 |
| 3 | DualEndpointTests | standard | 5m | 5, 6 |
| 4 | CI matrix entry for new IT project | small | 3m | 5, 6 |
| 5 | Rename FailoverScenarioTests → FailoverDuringDeployTests | trivial | 2m | 0, 1, 2, 6 |
| 6 | Delete stale bin/obj-only directories | trivial | 2m | 0, 1, 2, 5 |
| 7 | Final build + test green check | trivial | 3m | none |

**Total estimated subagent time:** ~27 min.

**Dependency graph (non-parallel pairs):**
- Task 3 depends on Task 1 (option must exist) and Task 2 (project must exist)
- Task 4 depends on Task 3 (CI runs the project's tests)
- Task 7 depends on all prior tasks
