# 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; /// /// Audit gap closeout — verifies /// is reflected in Server.ServerArray after start. Single-server in-process check; the /// cross-server visibility check lives in OtOpcUa.OpcUaServer.IntegrationTests. /// 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.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 /// /// Peer server URIs published in Server.ServerArray after start, in addition to /// the local . 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. /// public IList PeerApplicationUris { get; set; } = new List(); ``` **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 /// /// Writes the union of and /// to the OPC UA standard /// Server.ServerArray property (NodeId i=2254). Clients in a warm-redundancy /// deployment discover the partner endpoint by reading this property. /// private void PopulateServerArray() { var serverObject = _server?.CurrentInstance?.ServerObject; if (serverObject is null) return; var uris = new List { _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 false true ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests true all runtime; build; native; contentfiles; analyzers; buildtransitive ``` 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; /// /// Source plan Task 60 — closes the audit gap. Boots two real /// instances on loopback, each configured with the other's ApplicationUri in /// . A real OPC UA client connects /// to Node A, reads Server.ServerArray, and asserts both URIs are visible — the /// warm-redundancy discovery contract clients depend on. /// 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 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.None }, AutoAcceptUntrustedClientCertificates = true, PeerApplicationUris = peers, }; var server = new StandardServer(); var host = new OpcUaApplicationHost(options, NullLogger.Instance); await host.StartAsync(server, CancellationToken.None); return host; } private static async Task 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