Files
lmxopcua/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md
2026-05-26 11:15:59 -04:00

28 KiB

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.

{
  "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.

{
  "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.

{
  "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

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:

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):

/// <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:

PopulateServerArray();

Then add the private method below AttachUserAuthenticator:

/// <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

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.

<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

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

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

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:

  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

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

Task 5: Rename FailoverScenarioTestsFailoverDuringDeployTests (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.csFailoverDuringDeployTests.cs
  • Modify: class name + namespace-internal references

Step 1: Rename the file and the class

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

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

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

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

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:

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-closeoutmaster 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