diff --git a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md
new file mode 100644
index 0000000..1359be5
--- /dev/null
+++ b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md
@@ -0,0 +1,1944 @@
+# OtOpcUa v2 — Akka.NET + Fused Hosting Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` to implement this plan task-by-task.
+
+**Goal:** Fuse `OtOpcUa.Server` and `OtOpcUa.Admin` into a single role-gated binary (`OtOpcUa.Host`), introduce an Akka.NET cluster (admin/driver roles) for control-plane singletons and per-node runtime actors, replace the draft/publish `ConfigGeneration` lifecycle with a live-edit + snapshot-deploy model, and drive OPC UA `ServiceLevel` from Akka cluster leadership while preserving the dual-endpoint warm-redundancy client behavior.
+
+**Architecture:** Single solution with new component libraries (`Cluster`, `Security`, `ControlPlane`, `Runtime`, `OpcUaServer`, `AdminUI`, `Commons`) reused by one `Host` web binary. Akka 1.5.62 with `Akka.Hosting` + `Akka.Cluster.Hosting` + `Akka.Cluster.Tools`. Cluster singletons pinned to `admin` role; per-node actor trees on `driver`-role nodes. Existing `ZB.MOM.WW.OtOpcUa.Configuration` project keeps the EF Core `DbContext` (renamed-in-place, no project rename) and grows new tables for `Deployment`, `NodeDeploymentState`, `ConfigEdit`, `DataProtectionKeys`. EF migrations executed via auto-migration on dev + idempotent SQL script `Migrate-To-V2.ps1` for prod.
+
+**Tech Stack:** .NET 10, Akka.NET 1.5.62 (`Akka.Hosting`, `Akka.Cluster.Hosting`, `Akka.Cluster.Tools`, `Akka.Remote.Hosting`, `Akka.Streams`), EF Core 10.0.7 (SQL Server), Blazor Server, SignalR, OPCFoundation .NET Standard stack, LDAP (`Novell.Directory.Ldap.NETStandard`), Bootstrap 5 (vendored).
+
+**Design source:** `docs/plans/2026-05-26-akka-hosting-alignment-design.md`. Always read it before starting a task; it is the spec.
+
+**Branch:** `v2-akka-fuse` off `master`.
+
+**Reference project:** Sister repo `~/Desktop/scadalink-design` — copy patterns, not code (different domain). Pattern files to copy from:
+- ScadaLink HOCON: `src/ScadaLink.Host/Akka/akka.conf`
+- ScadaLink Security setup: `src/ScadaLink.Security/ServiceCollectionExtensions.cs`
+- ScadaLink Cluster bootstrap: `src/ScadaLink.Host/Program.cs:60-228`
+- ScadaLink ClusterSingleton pattern: `src/ScadaLink.ManagementService/`
+
+---
+
+## Conventions for every task
+
+- **Branch:** Stay on `v2-akka-fuse`. Never commit to `master` while plan is running.
+- **TDD where it makes sense:** New actors, new domain logic — write the test first. Pure refactors / file moves — verify-by-build is enough.
+- **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 — `feat(scope):`, `refactor(scope):`, `chore(scope):`, `test(scope):`. Scope examples: `host`, `cluster`, `runtime`, `controlplane`, `security`, `adminui`, `configdb`.
+- **Mac compatibility:** All code must build on macOS. Windows-only APIs (`AddWindowsService`, Galaxy/Wonderware drivers) must be gated by `OperatingSystem.IsWindows()` or `[SupportedOSPlatform]`.
+
+---
+
+## Phase 0 — Branch & scaffolding
+
+### Task 0: Create branch and central package management
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** none (first task)
+
+**Files:**
+- Create: `/Users/dohertj2/Desktop/OtOpcUa/Directory.Packages.props`
+- Create: `/Users/dohertj2/Desktop/OtOpcUa/Directory.Build.props`
+
+**Step 1: Create branch**
+
+```bash
+cd ~/Desktop/OtOpcUa
+git checkout -b v2-akka-fuse
+```
+
+**Step 2: Create `Directory.Packages.props`** with central package management for Akka + EF Core + ASP.NET Core. Source versions from `~/Desktop/scadalink-design/Directory.Packages.props`. At minimum include:
+
+```xml
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Audit the existing `.csproj` files for any package not listed; add it to `Directory.Packages.props` and strip the `Version` attribute from the csprojs.
+
+**Step 3: Create minimal `Directory.Build.props`:**
+
+```xml
+
+
+ net10.0
+ enable
+ enable
+ true
+ latest
+
+
+```
+
+**Step 4: Build green check**
+
+Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
+Expected: Build succeeded. If any csproj has a duplicate `Version` after centralization, fix.
+
+**Step 5: Commit**
+
+```bash
+git add Directory.Packages.props Directory.Build.props
+git commit -m "chore(build): introduce central package management for v2"
+```
+
+---
+
+### Task 1: Create `OtOpcUa.Commons` project
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 2, 3, 4, 5, 6, 7, 8
+
+**Files:**
+- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj`
+- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/.gitkeep`
+- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/.gitkeep`
+- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/.gitkeep`
+- Modify: `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx` (add Commons project)
+
+**Step 1: Create csproj**
+
+```xml
+
+
+ ZB.MOM.WW.OtOpcUa.Commons
+
+
+
+
+
+```
+
+**Step 2: Add to solution**
+
+Run: `dotnet sln ZB.MOM.WW.OtOpcUa.slnx add src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj`
+
+**Step 3: Build green**
+
+Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
+Expected: Build succeeded.
+
+**Step 4: Commit**
+
+```bash
+git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/ ZB.MOM.WW.OtOpcUa.slnx
+git commit -m "feat(commons): scaffold OtOpcUa.Commons project"
+```
+
+---
+
+### Task 2: Create `OtOpcUa.Cluster` project
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 1, 3, 4, 5, 6, 7, 8
+
+**Files:**
+- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ZB.MOM.WW.OtOpcUa.Cluster.csproj`
+- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
+
+**Step 1: Create csproj**
+
+```xml
+
+
+ ZB.MOM.WW.OtOpcUa.Cluster
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Step 2-4:** add to solution, build, commit (`feat(cluster): scaffold OtOpcUa.Cluster project`).
+
+---
+
+### Task 3: Create `OtOpcUa.Security` project
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 1, 2, 4, 5, 6, 7, 8
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`
+- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
+
+**csproj:** classlib targeting `net10.0`, references `OtOpcUa.Commons`, `OtOpcUa.Configuration`. Packages: `Microsoft.AspNetCore.Authentication.Cookies`, `Microsoft.AspNetCore.Authentication.JwtBearer`, `Microsoft.IdentityModel.Tokens`, `System.IdentityModel.Tokens.Jwt`, `Novell.Directory.Ldap.NETStandard`.
+
+Commit: `feat(security): scaffold OtOpcUa.Security project`.
+
+---
+
+### Task 4: Create `OtOpcUa.ControlPlane` project
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 1, 2, 3, 5, 6, 7, 8
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj`
+
+**csproj:** classlib, references `OtOpcUa.Commons`, `OtOpcUa.Cluster`, `OtOpcUa.Configuration`. Packages: `Akka.Hosting`, `Akka.Cluster.Tools`, `Microsoft.AspNetCore.SignalR.Core`.
+
+Commit: `feat(controlplane): scaffold OtOpcUa.ControlPlane project`.
+
+---
+
+### Task 5: Create `OtOpcUa.Runtime` project
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 1, 2, 3, 4, 6, 7, 8
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj`
+
+**csproj:** classlib, references `OtOpcUa.Commons`, `OtOpcUa.Cluster`, `OtOpcUa.Configuration`, `OtOpcUa.OpcUaServer`, all `OtOpcUa.Driver.*` abstraction projects (NOT concrete driver implementations — those are loaded reflectively). Packages: `Akka.Hosting`, `Akka.Cluster.Tools`.
+
+Commit: `feat(runtime): scaffold OtOpcUa.Runtime project`.
+
+---
+
+### Task 6: Create `OtOpcUa.OpcUaServer` project
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 1, 2, 3, 4, 5, 7, 8
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj`
+
+**csproj:** classlib, references `OtOpcUa.Commons`, `OtOpcUa.Configuration`. Packages: `OPCFoundation.NetStandard.Opc.Ua.Server`, `OPCFoundation.NetStandard.Opc.Ua.Configuration`. Copy exact versions from current `ZB.MOM.WW.OtOpcUa.Server.csproj`.
+
+Commit: `feat(opcua): scaffold OtOpcUa.OpcUaServer project`.
+
+---
+
+### Task 7: Create `OtOpcUa.AdminUI` Razor class library
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 1, 2, 3, 4, 5, 6, 8
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj`
+
+**csproj:**
+
+```xml
+
+
+ ZB.MOM.WW.OtOpcUa.AdminUI
+ true
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Commit: `feat(adminui): scaffold OtOpcUa.AdminUI Razor class library`.
+
+---
+
+### Task 8: Create `OtOpcUa.Host` Web SDK project
+
+**Classification:** small
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 1, 2, 3, 4, 5, 6, 7
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (minimal "Hello, host" stub)
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Properties/launchSettings.json`
+
+**csproj:**
+
+```xml
+
+
+ ZB.MOM.WW.OtOpcUa.Host
+ zb-mom-ww-otopcua-host
+ OtOpcUa.Host
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Stub Program.cs:**
+
+```csharp
+var builder = WebApplication.CreateBuilder(args);
+var app = builder.Build();
+app.MapGet("/", () => "OtOpcUa.Host scaffold");
+await app.RunAsync();
+```
+
+**appsettings.json:** empty `{}` for now.
+
+**launchSettings.json:** profile `OtOpcUa.Host` with `applicationUrl=http://localhost:9000`.
+
+Commit: `feat(host): scaffold OtOpcUa.Host web project`.
+
+---
+
+### Task 9: Build green smoke
+
+**Classification:** trivial
+**Estimated implement time:** ~2 min
+**Parallelizable with:** none (depends on Tasks 0-8)
+
+**Step 1:** Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx`. Expected: succeeded, no warnings-as-errors. Fix anything that broke. No commit (verification only).
+
+---
+
+## Phase 1 — ConfigDb schema (live-edit + deploy model)
+
+### Task 10: Add `Deployment` entity
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 11, 12, 13
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs`
+- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs` (add `DbSet` + `OnModelCreating` mapping)
+
+**Step 1: Create `Deployment.cs`:**
+
+```csharp
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+
+public sealed class Deployment
+{
+ public Guid DeploymentId { get; init; } = Guid.NewGuid();
+ public required string RevisionHash { get; init; }
+ public DeploymentStatus Status { get; set; } = DeploymentStatus.Dispatching;
+ public required string CreatedBy { get; init; }
+ public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
+ public byte[] ArtifactBlob { get; init; } = Array.Empty();
+ public byte[] RowVersion { get; set; } = Array.Empty();
+ public string? FailureReason { get; set; }
+ public DateTime? SealedAtUtc { get; set; }
+}
+
+public enum DeploymentStatus
+{
+ Dispatching = 0,
+ AwaitingApplyAcks = 1,
+ Sealed = 2,
+ PartiallyFailed = 3,
+ TimedOut = 4
+}
+```
+
+**Step 2: Add mapping in `OtOpcUaConfigDbContext.OnModelCreating`:**
+
+```csharp
+modelBuilder.Entity(b =>
+{
+ b.ToTable("Deployment");
+ b.HasKey(d => d.DeploymentId);
+ b.Property(d => d.RevisionHash).HasMaxLength(64).IsRequired();
+ b.Property(d => d.Status).HasConversion();
+ b.Property(d => d.CreatedBy).HasMaxLength(128).IsRequired();
+ b.Property(d => d.FailureReason).HasMaxLength(2048);
+ b.Property(d => d.RowVersion).IsRowVersion();
+ b.HasIndex(d => d.Status);
+ b.HasIndex(d => d.CreatedAtUtc);
+});
+```
+
+**Step 3:** Build green. Commit: `feat(configdb): add Deployment entity`.
+
+---
+
+### Task 11: Add `NodeDeploymentState` entity (replaces ClusterNodeGenerationState)
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 10, 12, 13
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeDeploymentState.cs`
+- Modify: `OtOpcUaConfigDbContext.cs` (add DbSet + mapping)
+
+**Schema:** `(NodeId, DeploymentId)` composite key; `Status` enum `Applying|Applied|Failed`; `StartedAtUtc`, `AppliedAtUtc?`, `FailureReason?`, `RowVersion`.
+
+Do NOT delete `ClusterNodeGenerationState.cs` yet — keep it for the migration step in Task 14.
+
+Commit: `feat(configdb): add NodeDeploymentState entity`.
+
+---
+
+### Task 12: Add `ConfigEdit` audit entity
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 10, 11, 13
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigEdit.cs`
+- Modify: `OtOpcUaConfigDbContext.cs`
+
+**Schema:** `(EditId GUID PK, EntityType string, EntityId GUID, FieldsJson nvarchar(max), ExecutionId GUID NULL, EditedBy, EditedAtUtc, SourceNode)`.
+
+Captures per-row edits to `Equipment`, `Driver`, `DriverInstance`, `Script`, etc. Inserted by `AdminOperationsActor` on every mutating op.
+
+Commit: `feat(configdb): add ConfigEdit audit entity`.
+
+---
+
+### Task 13: Add DataProtection keys table
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 10, 11, 12
+
+**Files:**
+- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj` — add `Microsoft.AspNetCore.DataProtection.EntityFrameworkCore` package
+- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs` — implement `IDataProtectionKeyContext`:
+
+```csharp
+public DbSet DataProtectionKeys
+ => Set();
+```
+
+Commit: `feat(configdb): persist DataProtection keys in ConfigDb`.
+
+---
+
+### Task 14: EF migration — drop `ConfigGeneration` and `ClusterNode.RedundancyRole`, add new tables
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (depends on Tasks 10-13)
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/_V2HostingAlignment.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/_V2HostingAlignment.Designer.cs`
+- Modify: `OtOpcUaConfigDbContext.cs` — remove `DbSet` and `DbSet`; remove `ClusterNode.RedundancyRole` property
+- Delete: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs`
+- Delete: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs`
+
+**Step 1: Generate migration**
+
+Run: `dotnet ef migrations add V2HostingAlignment --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host`
+
+If `dotnet-ef` isn't installed: `dotnet tool install --global dotnet-ef --version 10.0.7`.
+
+**Step 2: Audit the generated migration** — it should:
+- `DropTable("ConfigGeneration")`
+- `DropTable("ClusterNodeGenerationState")`
+- `DropColumn("RedundancyRole", "ClusterNode")`
+- `CreateTable("Deployment", ...)`
+- `CreateTable("NodeDeploymentState", ...)`
+- `CreateTable("ConfigEdit", ...)`
+- `CreateTable("DataProtectionKeys", ...)`
+
+If extra changes appear (e.g., column-type drift), reconcile by editing the entity classes — do not edit the migration directly.
+
+**Step 3: Verify on a scratch SQL Server**
+
+```bash
+docker run --rm -d --name v2-migration-test -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Pass@word123" -p 14333:1433 mcr.microsoft.com/mssql/server:2022-latest
+# Wait ~10s for SQL Server to start
+ConnectionStrings__ConfigDb="Server=localhost,14333;Database=OtOpcUaV2Test;User Id=sa;Password=Pass@word123;TrustServerCertificate=true" \
+ dotnet ef database update --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host
+```
+
+Expected: completes without error. Verify with `docker exec v2-migration-test /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Pass@word123 -d OtOpcUaV2Test -Q "SELECT name FROM sys.tables ORDER BY name"`.
+
+**Step 4: Tear down**
+
+`docker stop v2-migration-test`.
+
+**Step 5: Commit**
+
+```bash
+git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/
+git commit -m "feat(configdb): V2HostingAlignment migration — drop ConfigGeneration, add Deployment+NodeDeploymentState+ConfigEdit"
+```
+
+---
+
+### Task 15: `Migrate-To-V2.ps1` idempotent prod migration script
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 16, 17, 18 (Phase 2)
+
+**Files:**
+- Create: `scripts/migration/Migrate-To-V2.ps1`
+- Create: `scripts/migration/Migrate-To-V2.sql` (the idempotent SQL output)
+
+**Step 1: Generate idempotent SQL from EF**
+
+Run: `dotnet ef migrations script --idempotent --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host --output scripts/migration/Migrate-To-V2.sql`
+
+**Step 2: PowerShell wrapper:**
+
+```powershell
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string] $ConnectionString,
+ [string] $BackupPath = "$env:TEMP\OtOpcUa-V1-Backup-$(Get-Date -Format yyyyMMddHHmmss).bak"
+)
+
+Write-Host "Step 1/4 — Backup ConfigDb to $BackupPath"
+Invoke-Sqlcmd -ConnectionString $ConnectionString -Query "BACKUP DATABASE [OtOpcUaConfigDb] TO DISK = '$BackupPath' WITH FORMAT, COMPRESSION"
+
+Write-Host "Step 2/4 — Row counts before"
+$beforeCounts = Invoke-Sqlcmd -ConnectionString $ConnectionString -InputFile "$PSScriptRoot\count-rows.sql"
+$beforeCounts | Format-Table
+
+Write-Host "Step 3/4 — Apply Migrate-To-V2.sql"
+Invoke-Sqlcmd -ConnectionString $ConnectionString -InputFile "$PSScriptRoot\Migrate-To-V2.sql"
+
+Write-Host "Step 4/4 — Row counts after + validation"
+$afterCounts = Invoke-Sqlcmd -ConnectionString $ConnectionString -InputFile "$PSScriptRoot\count-rows.sql"
+$afterCounts | Format-Table
+
+# Validation gates
+$tablesNow = (Invoke-Sqlcmd -ConnectionString $ConnectionString -Query "SELECT name FROM sys.tables ORDER BY name").name
+foreach ($t in 'Deployment','NodeDeploymentState','ConfigEdit','DataProtectionKeys') {
+ if ($tablesNow -notcontains $t) { throw "Expected table $t missing." }
+}
+foreach ($t in 'ConfigGeneration','ClusterNodeGenerationState') {
+ if ($tablesNow -contains $t) { throw "Legacy table $t still present." }
+}
+Write-Host "Migration complete. Backup at $BackupPath"
+```
+
+Also create `scripts/migration/count-rows.sql` listing per-table row counts for the audit.
+
+Commit: `feat(migration): add Migrate-To-V2.ps1 idempotent migration runner`.
+
+---
+
+## Phase 2 — Commons types and contracts
+
+### Task 16: Common types
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 17, 18
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/CorrelationId.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/ExecutionId.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/NodeId.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeploymentId.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RevisionHash.cs`
+
+Each is a readonly record struct wrapping a `Guid` (IDs) or `string` (hash). Implement `ToString()`, parse, `IEquatable`.
+
+**Example (`CorrelationId.cs`):**
+
+```csharp
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+public readonly record struct CorrelationId(Guid Value)
+{
+ public static CorrelationId NewId() => new(Guid.NewGuid());
+ public override string ToString() => Value.ToString("N");
+ public static CorrelationId Parse(string s) => new(Guid.ParseExact(s, "N"));
+}
+```
+
+Same pattern for `ExecutionId`, `DeploymentId`, `NodeId` (string), `RevisionHash` (string).
+
+Commit: `feat(commons): add correlation/execution/node/deployment/revisionhash types`.
+
+---
+
+### Task 17: Akka message contracts
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 16, 18
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DispatchDeployment.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentSealed.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentFailed.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeployment.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/RedundancyStateChanged.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs`
+
+All as `sealed record` with `CorrelationId` field. Example:
+
+```csharp
+namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
+
+public sealed record DispatchDeployment(
+ DeploymentId DeploymentId,
+ RevisionHash RevisionHash,
+ CorrelationId CorrelationId);
+
+public sealed record ApplyAck(
+ DeploymentId DeploymentId,
+ NodeId NodeId,
+ ApplyAckOutcome Outcome,
+ string? FailureReason,
+ CorrelationId CorrelationId);
+
+public enum ApplyAckOutcome { Applied, Failed }
+```
+
+Commit: `feat(commons): add deploy/admin/audit/redundancy/fleet message contracts`.
+
+---
+
+### Task 18: Common interfaces
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 16, 17
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IFleetDiagnosticsClient.cs`
+
+```csharp
+public interface IClusterRoleInfo
+{
+ NodeId LocalNode { get; }
+ IReadOnlySet LocalRoles { get; }
+ bool HasRole(string role);
+ IReadOnlyList MembersWithRole(string role);
+ NodeId? RoleLeader(string role);
+ event EventHandler? RoleLeaderChanged;
+}
+
+public interface IAdminOperationsClient
+{
+ Task StartDeploymentAsync(string createdBy, CancellationToken ct);
+ // … other mutating ops added in later tasks
+}
+
+public interface IFleetDiagnosticsClient
+{
+ Task GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct);
+}
+```
+
+Commit: `feat(commons): add cluster/admin/diagnostics client interfaces`.
+
+---
+
+## Phase 3 — Cluster library
+
+### Task 19: HOCON config
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 20, 21, 22
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf`
+- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ZB.MOM.WW.OtOpcUa.Cluster.csproj` (embed resource)
+
+**Step 1:** Copy `~/Desktop/scadalink-design/src/ScadaLink.Host/Akka/akka.conf` (or equivalent path — check what ScadaLink actually has) as a starting template, then adapt:
+- `actor.provider = cluster`
+- `remote.dot-netty.tcp { hostname = "0.0.0.0", port = 4053 }`
+- `cluster.roles = []` (populated dynamically by Task 21)
+- `cluster.split-brain-resolver.active-strategy = keep-oldest`
+- `cluster.split-brain-resolver.stable-after = 15s`
+- `cluster.down-removal-margin = 15s`
+- `cluster.failure-detector.heartbeat-interval = 2s`
+- `cluster.failure-detector.threshold = 10.0`
+- `cluster.singleton.singleton-name = "singleton"`
+- `cluster.singleton-proxy.singleton-identification-interval = 1s`
+- Synchronized dispatcher for OPC UA actors (Task 44):
+ ```hocon
+ opcua-synchronized-dispatcher {
+ type = "PinnedDispatcher"
+ executor = "thread-pool-executor"
+ }
+ ```
+
+If ScadaLink puts HOCON inline in Program.cs rather than a .conf file, embed it the same way — but a separate .conf file is preferred for editability.
+
+**Step 2:** Mark as embedded resource in csproj:
+
+```xml
+
+
+
+```
+
+**Step 3:** Add a loader helper `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/HoconLoader.cs`:
+
+```csharp
+public static class HoconLoader
+{
+ public static string LoadBaseConfig()
+ {
+ using var stream = typeof(HoconLoader).Assembly
+ .GetManifestResourceStream("ZB.MOM.WW.OtOpcUa.Cluster.Resources.akka.conf")
+ ?? throw new InvalidOperationException("akka.conf resource not found");
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+}
+```
+
+Commit: `feat(cluster): embed Akka HOCON config matching ScadaLink tuning`.
+
+---
+
+### Task 20: `AkkaHostedService` implementation
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 19, 21, 22
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaHostedService.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaClusterOptions.cs`
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs`
+
+**`AkkaClusterOptions.cs`:**
+
+```csharp
+public sealed class AkkaClusterOptions
+{
+ public string SystemName { get; set; } = "otopcua";
+ public string Hostname { get; set; } = "0.0.0.0";
+ public int Port { get; set; } = 4053;
+ public string PublicHostname { get; set; } = "127.0.0.1";
+ public string[] SeedNodes { get; set; } = Array.Empty();
+ public string[] Roles { get; set; } = Array.Empty();
+}
+```
+
+**`AkkaHostedService.cs`:** Implements `IHostedService`. On Start, builds `ActorSystem` from `HoconLoader.LoadBaseConfig()` + overlay from `AkkaClusterOptions`. Joins cluster (`Cluster.Get(system).Join` against seed nodes). On Stop, calls `CoordinatedShutdown.Get(system).Run(CoordinatedShutdown.ClusterLeavingReason.Instance)` with a 30s timeout.
+
+**`ServiceCollectionExtensions.AddOtOpcUaCluster(IConfiguration)`:** binds `AkkaClusterOptions`, registers `AkkaHostedService` as `IHostedService`, registers `ActorSystem` as a singleton resolved from the hosted service.
+
+Mirror the wiring in `~/Desktop/scadalink-design/src/ScadaLink.Host/Program.cs` Akka block. Don't deviate on tuning.
+
+Commit: `feat(cluster): AkkaHostedService and DI extension`.
+
+---
+
+### Task 21: Role parsing from `OTOPCUA_ROLES` env
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 19, 20, 22
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs` (also creates the test project — see Task 23 for the csproj)
+
+```csharp
+public static class RoleParser
+{
+ public static string[] Parse(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) return Array.Empty();
+ var roles = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(r => r.ToLowerInvariant())
+ .Distinct()
+ .ToArray();
+ foreach (var r in roles)
+ if (r is not ("admin" or "driver" or "dev"))
+ throw new ArgumentException($"Unknown role '{r}'. Allowed: admin, driver, dev.");
+ return roles;
+ }
+}
+```
+
+Tests cover: empty input → empty; `"admin"` → `["admin"]`; `"admin,driver"` → both; whitespace tolerant; case-insensitive; throws on unknown role.
+
+Commit: `feat(cluster): parse OTOPCUA_ROLES env var with validation`.
+
+---
+
+### Task 22: `IClusterRoleInfo` implementation
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 19, 20, 21
+
+**Files:**
+- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs`
+
+Implements `IClusterRoleInfo` (from Task 18). Wraps `Akka.Cluster.Cluster.Get(ActorSystem)`. Subscribes to `ClusterEvent.LeaderChanged`, `ClusterEvent.RoleLeaderChanged`, `ClusterEvent.IMemberEvent` via an internal subscriber actor, raises CLR event.
+
+Commit: `feat(cluster): ClusterRoleInfo wraps Akka.Cluster for app-facing role queries`.
+
+---
+
+### Task 23: Cluster test project + initial tests
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (verification task — depends on Tasks 19-22)
+
+**Files:**
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests.csproj`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests/HoconLoaderTests.cs` — asserts HOCON parses and key values present
+- Move: `tests/.../RoleParserTests.cs` if Task 21 dropped it elsewhere
+
+**csproj:** xUnit test project, references `OtOpcUa.Cluster`, `OtOpcUa.Commons`. Packages: `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `FluentAssertions`.
+
+**`HoconLoaderTests.cs`:** parses HOCON via `Akka.Configuration.ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig())`, asserts `actor.provider == "cluster"`, `cluster.split-brain-resolver.active-strategy == "keep-oldest"`, etc.
+
+Run: `dotnet test tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests/`. Expected: all green.
+
+Add to solution: `dotnet sln ZB.MOM.WW.OtOpcUa.slnx add tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests.csproj`.
+
+Commit: `test(cluster): HOCON parses, role parser truth table`.
+
+---
+
+## Phase 4 — Security library
+
+### Task 24: Move `LdapAuthService` into `OtOpcUa.Security`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 25 (different file)
+
+**Files:**
+- Move: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs` → `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs`
+- Rename namespace: `ZB.MOM.WW.OtOpcUa.Admin.Security` → `ZB.MOM.WW.OtOpcUa.Security.Ldap`
+- Update all callers (use `grep -rl 'OtOpcUa.Admin.Security'` to find them; update with `sed` or by hand)
+
+Commit: `refactor(security): move LdapAuthService into OtOpcUa.Security library`.
+
+---
+
+### Task 25: `JwtTokenService`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 24
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtTokenServiceTests.cs` (also creates test csproj)
+
+Mirror `~/Desktop/scadalink-design/src/ScadaLink.Security/JwtTokenService.cs`. Options: `SigningKey` (HS256, ≥32 bytes), `Issuer`, `Audience`, `ExpiryMinutes` (default 15). `Issue(claims)` → string. `TryValidate(token, out principal)` → bool.
+
+Tests cover: valid token roundtrip; expired token rejected; tampered token rejected; missing required claim rejected.
+
+Commit: `feat(security): JwtTokenService with HS256 + 15-min expiry`.
+
+---
+
+### Task 26: Cookie+JWT hybrid registration extension
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 27, 28
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`
+
+`AddOtOpcUaAuth(IConfiguration)`:
+1. Bind `JwtOptions` from `Security:Jwt`, bind `CookieOptions` from `Security:Cookie`.
+2. `services.AddDataProtection().PersistKeysToDbContext().SetApplicationName("OtOpcUa")`.
+3. `services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)`
+ - `.AddCookie(o => { o.Cookie.Name = "OtOpcUa.Auth"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; o.SlidingExpiration = true; o.ExpireTimeSpan = TimeSpan.FromMinutes(30); })`
+ - `.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o => { /* HS256 with JwtOptions.SigningKey */ })`.
+4. `services.AddAuthorization()` + fallback policy requiring authenticated user.
+5. Register `LdapAuthService`, `JwtTokenService`, `RoleMapper`.
+
+Mirror the wiring in `~/Desktop/scadalink-design/src/ScadaLink.Security/ServiceCollectionExtensions.cs` exactly for the cookie/JWT/DataProtection plumbing.
+
+Commit: `feat(security): cookie+JWT hybrid auth via AddOtOpcUaAuth`.
+
+---
+
+### Task 27: `/auth/login`, `/auth/ping`, `/auth/token` endpoints
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 26, 28
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs`
+
+Mirror `~/Desktop/scadalink-design/src/ScadaLink.Security/Endpoints/AuthEndpoints.cs`. Three minimal-API endpoints:
+
+- `POST /auth/login` — accepts `{username, password}`, calls `LdapAuthService.AuthenticateAsync`, builds claims (sub, roles), issues cookie via `HttpContext.SignInAsync` AND embeds JWT in cookie. Returns 204 on success / 401 on bad creds / 503 on LDAP unreachable.
+- `GET /auth/ping` — `[AllowAnonymous]`, returns 200 if `User.Identity.IsAuthenticated`, 401 otherwise.
+- `POST /auth/token` — authenticated, returns `{token: "..."}` JWT bearer for external clients.
+
+Extension method `MapOtOpcUaAuth(this IEndpointRouteBuilder)`. Wire in Host Program.cs at Task 53.
+
+Commit: `feat(security): /auth/login, /auth/ping, /auth/token endpoints`.
+
+---
+
+### Task 28: `CookieAuthenticationStateProvider` for Blazor circuits
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 26, 27
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Blazor/CookieAuthenticationStateProvider.cs`
+
+Standard pattern: snapshots `HttpContext.User` at circuit construction, polls `/auth/ping` every 60s to detect expiry, calls `NotifyAuthenticationStateChanged` on transition. Mirror ScadaLink's equivalent — search `~/Desktop/scadalink-design/src/ScadaLink.CentralUI/` for the `*AuthenticationStateProvider*` file.
+
+Commit: `feat(security): CookieAuthenticationStateProvider for Blazor circuit expiry detection`.
+
+---
+
+### Task 29: Security test project + tests
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (verification — depends on Tasks 24-28)
+
+**Files:**
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtTokenServiceTests.cs` (moved from Task 25 if dropped elsewhere)
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsTests.cs` — uses `Microsoft.AspNetCore.Mvc.Testing` with a `WebApplicationFactory` against a stubbed LDAP
+
+Tests cover: login happy path issues cookie+JWT; login bad password returns 401; login with LDAP outage returns 503; `/auth/ping` after expired cookie returns 401; `/auth/token` issues a valid JWT for authenticated user.
+
+Add to solution. Run: `dotnet test tests/ZB.MOM.WW.OtOpcUa.Security.Tests/`. Expected: all green.
+
+Commit: `test(security): cookie+JWT roundtrip, login/ping/token endpoint tests`.
+
+---
+
+## Phase 5 — ControlPlane cluster singletons
+
+### Task 30: `ConfigPublishCoordinator` — happy path
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 32 (different files; sibling singletons)
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorTests.cs` (also creates test csproj)
+
+**Step 1: Write failing test (`Akka.TestKit.Xunit2`)**
+
+```csharp
+[Fact]
+public async Task HappyPath_AllNodesAck_SealsDeployment()
+{
+ using var harness = new ControlPlaneHarness();
+ var coord = harness.Sys.ActorOf(ConfigPublishCoordinator.Props(harness.DbFactory));
+
+ var ack1 = new ApplyAck(harness.DeploymentId, NodeId.Of("node-a"), ApplyAckOutcome.Applied, null, CorrelationId.NewId());
+ var ack2 = new ApplyAck(harness.DeploymentId, NodeId.Of("node-b"), ApplyAckOutcome.Applied, null, CorrelationId.NewId());
+
+ coord.Tell(new DispatchDeployment(harness.DeploymentId, harness.RevisionHash, CorrelationId.NewId()));
+ coord.Tell(ack1);
+ coord.Tell(ack2);
+
+ await harness.WaitUntil(() => harness.LoadDeploymentStatus() == DeploymentStatus.Sealed, TimeSpan.FromSeconds(5));
+}
+```
+
+`ControlPlaneHarness` is a helper that spins up Akka TestKit + in-memory EF Core ConfigDb seeded with a Deployment row in `Dispatching` and two `ClusterNode` rows.
+
+**Step 2: Run test, expect FAIL (class doesn't exist).**
+
+**Step 3: Implement `ConfigPublishCoordinator` minimal:**
+
+```csharp
+public sealed class ConfigPublishCoordinator : ReceiveActor
+{
+ public static Props Props(IDbContextFactory dbFactory) =>
+ Akka.Actor.Props.Create(() => new ConfigPublishCoordinator(dbFactory));
+
+ private readonly IDbContextFactory _dbFactory;
+ private readonly HashSet _expectedAcks = new();
+ private DeploymentId _current;
+ private readonly Dictionary _acks = new();
+
+ public ConfigPublishCoordinator(IDbContextFactory dbFactory)
+ {
+ _dbFactory = dbFactory;
+ Receive(HandleDispatch);
+ Receive(HandleAck);
+ }
+
+ private void HandleDispatch(DispatchDeployment msg)
+ {
+ _current = msg.DeploymentId;
+ using var ctx = _dbFactory.CreateDbContext();
+ _expectedAcks.UnionWith(ctx.ClusterNodes.Where(n => n.RolesCsv.Contains("driver")).Select(n => NodeId.Of(n.NodeId)).ToList());
+ DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish("deployments", msg));
+ }
+
+ private void HandleAck(ApplyAck msg)
+ {
+ if (msg.DeploymentId != _current) return; // stale
+ _acks[msg.NodeId] = msg.Outcome;
+ if (_acks.Count == _expectedAcks.Count && _acks.Values.All(o => o == ApplyAckOutcome.Applied))
+ SealDeployment();
+ }
+
+ private void SealDeployment()
+ {
+ using var ctx = _dbFactory.CreateDbContext();
+ var d = ctx.Deployments.Single(x => x.DeploymentId == _current.Value);
+ d.Status = DeploymentStatus.Sealed;
+ d.SealedAtUtc = DateTime.UtcNow;
+ ctx.SaveChanges();
+ }
+}
+```
+
+**Step 4: Run test, expect PASS.**
+
+**Step 5: Commit:** `feat(controlplane): ConfigPublishCoordinator happy path`.
+
+---
+
+### Task 31: `ConfigPublishCoordinator` — timeout + failover recovery
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 32
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorTimeoutTests.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorRecoveryTests.cs`
+
+**Step 1: Add tests** for:
+- Deadline elapses with one node unacked → `Deployment.Status = TimedOut`.
+- New Coordinator started with in-flight `Dispatching` deployment recovers state via `PreStart` (queries `Deployment` + `NodeDeploymentState`).
+
+**Step 2: Extend Coordinator** with:
+- `Context.System.Scheduler.ScheduleTellOnce(applyMaxDuration, Self, new DeadlineElapsed(_current))` after dispatch.
+- `Receive` handler that marks `TimedOut` if any node unacked.
+- `protected override void PreStart()`: read `Deployment` rows where `Status` ∈ `{Dispatching, AwaitingApplyAcks}`; for each, repopulate `_current`, `_expectedAcks`, `_acks` from `NodeDeploymentState`; schedule remaining deadline.
+
+**Step 3: Run all `ConfigPublishCoordinatorTests`.** Expected: all green.
+
+Commit: `feat(controlplane): ConfigPublishCoordinator deadline timeout + failover recovery`.
+
+---
+
+### Task 32: `AdminOperationsActor` + `StartDeployment` handler
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 30, 31, 33, 34, 35
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs`
+
+**Responsibilities:**
+1. Receive `StartDeployment(createdBy, correlationId)`.
+2. `ConfigComposer.SnapshotAndFlatten(dbContext)` → byte[] `ArtifactBlob` (DataContract-serialized or `System.Text.Json` over the flat artifact). Pure function.
+3. Compute `RevisionHash = SHA256(artifactBlob).ToHexString()`.
+4. Insert `Deployment` row (`Status = Dispatching`).
+5. Insert one `ConfigEdit` audit row marking the deployment snapshot.
+6. `coordinator.Tell(new DispatchDeployment(deploymentId, revisionHash, correlationId))`.
+7. Reply `StartDeploymentResult(deploymentId, revisionHash)` to sender.
+
+For now stub CRUD ops as TODO comments — they'll be filled in Task 51 (UI wiring).
+
+Tests: snapshot is deterministic given a fixed seed of equipment rows; hash matches; Deployment row inserted; DispatchDeployment dispatched to mocked coordinator.
+
+Commit: `feat(controlplane): AdminOperationsActor + ConfigComposer + StartDeployment flow`.
+
+---
+
+### Task 33: `AuditWriterActor`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 30, 31, 32, 34, 35
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs`
+
+Receives `AuditEvent` messages, batches into in-memory buffer (cap 500 events / 5s flush window), bulk-inserts to `ConfigAuditLog`. Idempotent on `EventId` (INSERT IF NOT EXISTS or `MERGE`). On `PreRestart` flushes buffer.
+
+Tests: 1000 events with random duplicates → ConfigAuditLog has correct count, no duplicates; PreRestart simulates supervisor restart and verifies buffer is flushed before death.
+
+Commit: `feat(controlplane): AuditWriterActor with batched idempotent insert`.
+
+---
+
+### Task 34: `FleetStatusBroadcaster`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 30, 31, 32, 33, 35
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/FleetStatusBroadcaster.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/FleetStatusBroadcasterTests.cs`
+
+Subscribes to `ClusterEvent.MemberUp`, `MemberRemoved`, `UnreachableMember`, `ReachableMember`, `LeaderChanged`, `RoleLeaderChanged`. Receives per-node `DriverHostStatusHeartbeat` Tells. Maintains in-memory `FleetSnapshot`. Pushes diffs via injected `IHubContext` and `IHubContext`.
+
+Hubs themselves are not built yet — at this stage inject mock `IHubContext` for tests. UI rewiring happens in Task 50.
+
+Tests: cluster member up → diff broadcast; heartbeat staleness → unreachable broadcast; full snapshot on `OnConnectedAsync` request.
+
+Commit: `feat(controlplane): FleetStatusBroadcaster push-driven from Akka cluster events`.
+
+---
+
+### Task 35: `RedundancyStateActor`
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 30, 31, 32, 33, 34
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs` (pure function)
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs`
+
+**`ServiceLevelCalculator`:** pure static function per design §6:
+
+```csharp
+public static byte Compute(NodeHealthInputs h)
+{
+ if (h.MemberState is not (MemberStatus.Up or MemberStatus.Joining))
+ return 0;
+ byte basis = (h.DbReachable, h.OpcUaProbeOk, h.Stale) switch
+ {
+ (true, true, false) => 240,
+ (true, _, true) => 200,
+ (false, _, true) => 100,
+ _ => 0
+ };
+ return (byte)Math.Clamp(basis + (h.IsDriverRoleLeader ? 10 : 0), 0, 255);
+}
+```
+
+**Tests:** every combination of inputs → expected byte (FsCheck or table-driven).
+
+**`RedundancyStateActor`:** subscribes to cluster events, debounces 250ms, recomputes per-node `NodeRedundancyState`, publishes `RedundancyStateChanged` via `DistributedPubSub` topic `redundancy-state`.
+
+Commit: `feat(controlplane): RedundancyStateActor + pure ServiceLevelCalculator`.
+
+---
+
+### Task 36: Singleton registration extension
+
+**Classification:** standard
+**Estimated implement time:** ~4 min
+**Parallelizable with:** none (depends on Tasks 30-35)
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs`
+
+`AddOtOpcUaControlPlane()`: registers all five singletons via `Akka.Cluster.Hosting` `WithClusterSingletonProxy` extension methods, all pinned to `admin` role.
+
+Pattern (mirror `~/Desktop/scadalink-design/src/ScadaLink.ManagementService/ServiceCollectionExtensions.cs`):
+
+```csharp
+public static IServiceCollection AddOtOpcUaControlPlane(this IServiceCollection services)
+{
+ services.AddSingleton();
+ return services;
+}
+
+internal sealed class ControlPlaneStartup : IControlPlaneStartup
+{
+ public void Configure(AkkaConfigurationBuilder cb)
+ {
+ cb.WithClusterSingleton("config-publish", new ClusterSingletonOptions { Role = "admin" });
+ cb.WithClusterSingleton("admin-ops", new ClusterSingletonOptions { Role = "admin" });
+ cb.WithClusterSingleton("audit-writer", new ClusterSingletonOptions { Role = "admin" });
+ cb.WithClusterSingleton("fleet-status", new ClusterSingletonOptions { Role = "admin" });
+ cb.WithClusterSingleton("redundancy-state", new ClusterSingletonOptions { Role = "admin" });
+ }
+}
+```
+
+Verify against ScadaLink's actual API surface — `Akka.Hosting` syntax may differ slightly across versions.
+
+Commit: `feat(controlplane): singleton registration extension pinned to admin role`.
+
+---
+
+## Phase 6 — Runtime per-node actors
+
+### Task 37: `DriverHostActor` scaffolding + bootstrap
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 41, 42, 43, 44 (different actors)
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/DriverHostActorBootstrapTests.cs` (also creates test csproj)
+
+**`DriverHostActor` responsibilities (this task):**
+- `PreStart`: read `NodeDeploymentState` for self; if `Applied` → Become `Steady(currentDeployment)`; if `Applying` (orphan) → discard, replay; if no row + ConfigDb unreachable → fall back to LiteDb cache → Become `Stale`.
+- Subscribe to `DistributedPubSub` topic `deployments`.
+
+State machine via Become: `Bootstrapping → Steady | Applying(id) | Stale`.
+
+Tests: orphan `Applying` row → re-runs apply on PreStart; missing row + DB unreachable → Stale state.
+
+Commit: `feat(runtime): DriverHostActor scaffolding + PreStart recovery`.
+
+---
+
+### Task 38: `DriverHostActor` `DispatchDeployment` handler
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 41, 42, 43, 44 (different actors)
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/DriverHostActorDispatchTests.cs`
+
+Add:
+- `Receive`:
+ - If `currentRevisionHash == msg.RevisionHash` → reply `ApplyAck(Applied)` immediately.
+ - Else → write `NodeDeploymentState(Applying)`, Become `Applying(msg.DeploymentId)`, fetch artifact, compute delta, dispatch `ApplyDelta` to children, collect acks, write `NodeDeploymentState(Applied|Failed)`, reply `ApplyAck` to coordinator, Become `Steady`.
+
+For now children dispatch is mocked — actual `DriverInstanceActor` integration in Task 41.
+
+Tests: idempotent dispatch (same hash → ack, no work); successful apply → ack `Applied`; child failure → ack `Failed`.
+
+Commit: `feat(runtime): DriverHostActor handles DispatchDeployment idempotently`.
+
+---
+
+### Task 39: `DriverHostActor` stale-config fallback
+
+**Classification:** standard
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 41, 42, 43, 44
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (background reconnect)
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/DriverHostActorStaleTests.cs`
+
+Background `Context.System.Scheduler.ScheduleTellRepeatedly(30s, 30s, Self, RetryConfigDbConnection.Instance)`. On `RetryConfigDbConnection`: try ConfigDb; on success and current state is `Stale`, pull latest sealed deployment, apply, Become `Steady`; publish `NodeRedundancyState(Stale=false)` to `redundancy-state` topic.
+
+Tests: simulated DB outage → Stale published; DB recovery → state advances + Stale=false published.
+
+Commit: `feat(runtime): DriverHostActor stale-config fallback + reconnect`.
+
+---
+
+### Task 40: Runtime test project bootstrap
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** none (depends on Tasks 37-39)
+
+**Files:**
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/RuntimeHarness.cs` — TestKit base with EF in-memory + driver mocks
+
+Confirm all DriverHostActor tests from Tasks 37-39 pass. Add to solution.
+
+Commit: `test(runtime): test project scaffold + DriverHostActor tests passing`.
+
+---
+
+### Task 41: `DriverInstanceActor` state machine
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 37-39 already done; parallel with Task 42, 43, 44
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/DriverInstanceActorTests.cs`
+
+States via Become: `Connecting → Connected → Reconnecting → Failed`.
+- `PreStart` → enter Connecting; call `IDriver.InitializeAsync`.
+- On connect success → Become Connected; subscribe tags; publish `OpcUaPublishActor.AttributeValueUpdate`.
+- On disconnect → Become Reconnecting; publish bad quality to all subscribed tags; schedule retry at fixed interval (driver.ReconnectIntervalSeconds, default 10).
+- On `ApplyDelta(plan)` → idempotent diff against current state; only changed attributes update; reply `ApplyResult` to parent.
+- On write request via `Ask` → synchronous; failure returned to caller.
+- Restart with exponential backoff supervises via parent.
+
+Reuse existing `IDriver` interface (from current `OtOpcUa.Driver.*` projects).
+
+Tests: connecting transitions to Connected on success; disconnect triggers bad-quality publish + Reconnecting; write failure returned to Ask caller; ApplyDelta diffs correctly.
+
+Commit: `feat(runtime): DriverInstanceActor with Connecting/Connected/Reconnecting/Failed`.
+
+---
+
+### Task 42: `VirtualTagActor`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 41, 43, 44
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTagActorTests.cs`
+
+Wraps existing `VirtualTagEngine` from `~/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/`. On subscribe-to-dependencies value update, recomputes expression, publishes result to OpcUaPublishActor.
+
+Restart with backoff; expression compile errors fail the actor (parent restarts with backoff).
+
+Commit: `feat(runtime): VirtualTagActor wrapping VirtualTagEngine`.
+
+---
+
+### Task 43: `ScriptedAlarmActor`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 41, 42, 44
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarmActorTests.cs`
+
+Wraps existing `AlarmConditionService`. State machine `Inactive → Active → Acknowledged → Inactive`. On state change, emits history row to `HistorianAdapterActor`. `PreRestart` hook serializes current alarm state to `ScriptedAlarmState` ConfigDb table; `PostStop`/`PreStart` rehydrates from it.
+
+Commit: `feat(runtime): ScriptedAlarmActor with state preservation across restart`.
+
+---
+
+### Task 44: `OpcUaPublishActor` on synchronized dispatcher
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 41, 42, 43
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUaPublishActorTests.cs`
+
+Bridge between Akka messages and OPCFoundation address space. Pinned dispatcher: `opcua-synchronized-dispatcher` (from HOCON, Task 19) — `Props.WithDispatcher("opcua-synchronized-dispatcher")`.
+
+Responsibilities:
+- Receive `AttributeValueUpdate(nodeId, value, quality, timestampUtc)` → write to OPC UA address space.
+- Receive `AlarmStateUpdate(...)` → write alarm node.
+- Subscribe to DistributedPubSub topic `redundancy-state` → on `NodeRedundancyState` for this node, write `ServiceLevel` byte + `ServerUriArray` nodes.
+- Receive `RebuildAddressSpace` → marshal address-space rebuild via OPC UA SDK API; bump sequence number.
+
+OPC UA SDK objects are NEVER exposed in message payloads — actor owns them internally.
+
+Tests: receive update → SDK write invoked; ServiceLevel update → ServiceLevel node written with correct byte.
+
+Commit: `feat(runtime): OpcUaPublishActor bridges Akka and OPCFoundation address space`.
+
+---
+
+### Task 45: `HistorianAdapterActor`, `PeerOpcUaProbeActor`, `DbHealthProbeActor`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (last Phase 6 task — combines three small actors)
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Health/PeerOpcUaProbeActor.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Health/DbHealthProbeActor.cs`
+- Test: `tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests/HealthProbeActorTests.cs`
+
+- **`HistorianAdapterActor`**: wraps existing named-pipe IPC to Wonderware sidecar. Buffers writes to SQLite store-and-forward on pipe disconnect. Reuses existing `SqliteStoreAndForwardSink` from current OtOpcUa code (find via `grep -rln SqliteStoreAndForwardSink ~/Desktop/OtOpcUa/src`).
+- **`PeerOpcUaProbeActor`**: per-peer-node periodic OPC UA `opc.tcp://peer:4840` ping. Publishes `OpcUaProbeResult(nodeId, ok)` to `redundancy-state` topic input.
+- **`DbHealthProbeActor`**: cached DB probe (single-flight) feeding `/health/ready` + `RedundancyStateActor`. Reuses `DbHealthCache` if present.
+
+Wrap all three actors as children under `DriverHostActor`.
+
+Commit: `feat(runtime): HistorianAdapter + PeerOpcUaProbe + DbHealthProbe actors`.
+
+---
+
+## Phase 7 — OpcUaServer extraction
+
+### Task 46: Move `OpcUaApplicationHost` + `Phase7Composer`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (large file move with namespace rename)
+
+**Files:**
+- Move: `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs` → `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`
+- Move: `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs` → `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`
+- Update all callers (namespace rename to `ZB.MOM.WW.OtOpcUa.OpcUaServer`)
+
+Use `grep -rln 'ZB.MOM.WW.OtOpcUa.Server.OpcUa' ~/Desktop/OtOpcUa/src` and `grep -rln 'ZB.MOM.WW.OtOpcUa.Server.Phase7' ~/Desktop/OtOpcUa/src` to find imports; update them.
+
+Build green check.
+
+Commit: `refactor(opcua): extract OpcUaApplicationHost and Phase7Composer to OpcUaServer library`.
+
+---
+
+### Task 47: Make `Phase7Composer` pure + property test
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 48-52 (Phase 8)
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (remove side effects; take inputs as parameters)
+- Test: `tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs`
+
+Refactor: remove static state, remove logging side effects (or make logging optional via injected `ILogger?`), return a `Phase7CompositionResult` record. Same inputs must always produce identical output.
+
+Property test (FsCheck or hand-rolled): generate random `EquipmentRow[]`, `DriverInstanceRow[]`, `ScriptRow[]` arrays; call `ComposeAsync` twice; assert results structurally equal.
+
+Commit: `refactor(opcua): make Phase7Composer pure + property tests`.
+
+---
+
+## Phase 8 — AdminUI library migration
+
+### Task 48: Move Blazor components into AdminUI library
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 47
+
+**Files:**
+- Move: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/*` → `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/*`
+- Move: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/*` → `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/*`
+
+Namespace rename across all .razor + .razor.cs: `ZB.MOM.WW.OtOpcUa.Admin.Components` → `ZB.MOM.WW.OtOpcUa.AdminUI.Components`.
+
+`MapAdminUI(this IEndpointRouteBuilder, IServiceCollection)` extension method in `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` that maps Razor components and static assets. Mirror ScadaLink's `MapCentralUI` exactly.
+
+Build green check.
+
+Commit: `refactor(adminui): move Blazor components from Admin into AdminUI Razor class library`.
+
+---
+
+### Task 49: Move SignalR hubs into AdminUI; rewire to FleetStatusBroadcaster
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 50, 51, 52
+
+**Files:**
+- Move: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/*.cs` → `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/*.cs`
+- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs` (replaced by FleetStatusBroadcaster)
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/FleetStatusBroadcaster.cs` — inject `IHubContext`, push diffs to it; do same for `AlertHub`, `ScriptLogHub`
+
+Note: hubs in AdminUI reference `ControlPlane` only for telemetry types; `ControlPlane` references hub interfaces via DI'd `IHubContext` — no project-reference cycle.
+
+Build green check.
+
+Commit: `refactor(adminui): SignalR hubs fed by FleetStatusBroadcaster push, no polling`.
+
+---
+
+### Task 50: `IAdminOperationsClient` wrapper
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 49, 51, 52
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs`
+
+Implements `IAdminOperationsClient` (from Task 18) via `ClusterSingletonProxy` to `admin-ops`. Each method does `proxy.Ask(message, timeout)` with 10s timeout + propagated cancellation.
+
+Register in DI: `services.AddScoped()` (scoped because per-circuit `HttpContext.User` flows in claims).
+
+Commit: `feat(adminui): IAdminOperationsClient backed by ClusterSingletonProxy`.
+
+---
+
+### Task 51: Replace `DriverDiagnosticsClient` with `IFleetDiagnosticsClient`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 49, 50, 52
+
+**Files:**
+- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverDiagnosticsClient.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsClient.cs`
+- Modify: any Blazor pages that referenced `DriverDiagnosticsClient` (use `grep -rln DriverDiagnosticsClient ~/Desktop/OtOpcUa/src`)
+
+`FleetDiagnosticsClient` uses `ClusterClient` (or `ActorSelection` if same cluster) to send `GetDiagnosticsRequest(nodeId)` to `/user/driver-host` at the target node and await response.
+
+Pages updated to inject `IFleetDiagnosticsClient` instead.
+
+Commit: `refactor(adminui): replace HTTP DriverDiagnosticsClient with actor-based IFleetDiagnosticsClient`.
+
+---
+
+### Task 52: Drift indicator + Deploy button
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 49, 50, 51
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor`
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor` (add drift badge if applicable)
+
+**`Deployments.razor`:**
+- Table of `Deployment` rows (most recent first), columns: DeploymentId (short), RevisionHash (short), Status, CreatedBy, CreatedAtUtc, SealedAtUtc.
+- "Deploy current configuration" button (requires `FleetAdmin` or `ConfigEditor` role) → calls `IAdminOperationsClient.StartDeploymentAsync(User.Identity.Name, ct)` → toast + auto-refresh table.
+- Drift badge: green "in sync" if latest sealed Deployment's revision hash matches `ConfigComposer.SnapshotAndFlatten()` of current ConfigDb state; yellow "drift" otherwise.
+
+Use frontend-design skill aesthetic: clean corporate Bootstrap, vertical stacking per `feedback_form_layout.md`.
+
+Commit: `feat(adminui): Deployments page with drift indicator and Deploy button`.
+
+---
+
+## Phase 9 — Host entry point
+
+### Task 53: `Host/Program.cs` role-gated startup
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 54, 55
+
+**Files:**
+- Replace: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`
+
+Mirror `~/Desktop/scadalink-design/src/ScadaLink.Host/Program.cs` structure. Pseudocode:
+
+```csharp
+var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
+var builder = WebApplication.CreateBuilder(args);
+builder.Configuration.AddJsonFile($"appsettings.{string.Join('-', roles.OrderBy(r=>r))}.json", optional: true);
+builder.Host.UseSerilog(...);
+if (OperatingSystem.IsWindows()) builder.Host.UseWindowsService();
+
+builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
+builder.Services.AddOtOpcUaCluster(builder.Configuration);
+builder.Services.AddOtOpcUaSecurity(builder.Configuration);
+builder.Services.AddAkka("otopcua", (ab, sp) => {
+ ab.AddOtOpcUaClusterConfig(roles);
+ if (roles.Contains("admin")) sp.GetRequiredService().Configure(ab);
+ if (roles.Contains("driver")) sp.GetRequiredService().Configure(ab);
+});
+if (roles.Contains("admin"))
+{
+ builder.Services.AddRazorComponents().AddInteractiveServerComponents();
+ builder.Services.AddSignalR();
+ builder.Services.AddOtOpcUaAdminUI();
+}
+
+var app = builder.Build();
+app.UseSerilogRequestLogging();
+if (roles.Contains("admin"))
+{
+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.UseAntiforgery();
+ app.MapOtOpcUaAuth();
+ app.MapAdminUI();
+ app.MapHub("/hubs/fleet");
+ app.MapHub("/hubs/alerts");
+ app.MapHub("/hubs/script-log");
+}
+app.MapHealthEndpoints();
+await app.RunAsync();
+```
+
+Reads Roles from env; binds Akka cluster config; conditionally maps Blazor + hubs only if `admin` role.
+
+Commit: `feat(host): role-gated Program.cs composes all components`.
+
+---
+
+### Task 54: Health endpoints + appsettings layout
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 53, 55
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/DatabaseHealthCheck.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AkkaClusterHealthCheck.cs`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AdminRoleLeaderHealthCheck.cs`
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` (full Cluster/Security/ConfigDb/OpcUa/Drivers/Historian sections)
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json` (combined-role default)
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json`
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json`
+
+Three endpoints (mirror ScadaLink's pattern):
+- `MapHealthChecks("/health/ready", new { Predicate = c => c.Tags.Contains("ready") })`
+- `MapHealthChecks("/health/active", new { Predicate = c => c.Tags.Contains("active") })`
+- `/healthz` on port 4841 — preserve current OPC UA stack health probe semantics
+
+Commit: `feat(host): health endpoints + per-role appsettings split`.
+
+---
+
+### Task 55: Mac dev mode + dev-stub drivers
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 53, 54
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (add `Stubbed` Become state)
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.Development.json`
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs` (already allows "dev" role per Task 21)
+
+`DriverInstanceActor`: at PreStart, if any of:
+- `roles.Contains("dev")` AND `driverType is "Galaxy" or "Historian.Wonderware"`
+- `!OperatingSystem.IsWindows()` AND `driverType` is Windows-only
+
+→ Become `Stubbed` immediately; log `INFO [DEV-STUB] driver={Name} reason={dev-role|non-windows}`. Stubbed state returns deterministic test values for read; no-op for write.
+
+`appsettings.Development.json` sets `Security:Ldap:DevStubMode = true`.
+
+Commit: `feat(runtime): DEV-STUB mode for Galaxy/Wonderware on non-Windows or dev role`.
+
+---
+
+## Phase 10 — Cleanup & deletions
+
+### Task 56: Delete `OtOpcUa.Server` and `OtOpcUa.Admin` projects
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (depends on Tasks 0-55)
+
+**Files:**
+- Delete (directory): `src/Server/ZB.MOM.WW.OtOpcUa.Server/`
+- Delete (directory): `src/Server/ZB.MOM.WW.OtOpcUa.Admin/`
+- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (remove the two project entries)
+- Sweep & delete files referenced in design §10 step 12:
+ - `DriverInstanceBootstrapper.cs` (should be in Server, already deleted)
+ - `Redundancy/RedundancyCoordinator.cs`
+ - `Redundancy/RedundancyStatePublisher.cs`
+ - `Redundancy/ApplyLeaseRegistry.cs`
+ - `Hosting/PeerHttpProbeLoop.cs`
+ - `Hosting/PeerUaProbeLoop.cs` — if not yet ported to `PeerOpcUaProbeActor`, port it now
+ - `Hubs/FleetStatusPoller.cs` (should be moved/deleted in Task 49)
+ - `Security/HubTokenService.cs`
+- Grep sweep: `grep -rln 'RedundancyRole\|ConfigGeneration\|ApplyLeaseRegistry\|PeerHttpProbeLoop\|FleetStatusPoller\|HubTokenService' ~/Desktop/OtOpcUa/src` — if any reference survives, fix it.
+- Delete corresponding `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/` and `tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/` (or keep and gut, depending on what's salvageable — recommend full delete and rebuild from Phase 11)
+
+Build green:
+```bash
+dotnet build ZB.MOM.WW.OtOpcUa.slnx
+```
+
+Run all surviving tests:
+```bash
+dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build
+```
+
+Commit: `chore(cleanup): delete OtOpcUa.Server, OtOpcUa.Admin, and obsoleted v1 services`.
+
+---
+
+### Task 57: Build & test green check
+
+**Classification:** trivial
+**Estimated implement time:** ~3 min
+**Parallelizable with:** none
+
+Verify. No commit unless cleanup needed.
+
+---
+
+## Phase 11 — Integration & E2E tests
+
+### Task 58: Host integration test harness (2-node in-process cluster)
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (foundational)
+
+**Files:**
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml` (SQL Server + OpenLDAP for Mac-friendly local runs)
+
+`TwoNodeClusterHarness` spins up two `WebApplicationFactory` instances on different ports + different Akka ports + shared SQL Server. Forms a 2-member cluster (both admin+driver). Exposes `AdminA`, `AdminB`, `DriverA`, `DriverB` references (in this harness, A==A and B==B since both roles on both nodes).
+
+Commit: `test(host): 2-node integration test harness`.
+
+---
+
+### Task 59: Deploy happy path + failover integration tests
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 60
+
+**Files:**
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployHappyPathTests.cs`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs`
+
+Test cases mirror design §8 "Failover-specific test cases" 1-7. Each test spins up the 2-node harness, performs the scenario, asserts final ConfigDb + actor state.
+
+Commit: `test(host): deploy happy path + failover-during-deploy integration tests`.
+
+---
+
+### Task 60: OPC UA integration tests
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 59
+
+**Files:**
+- Create: `tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelTests.cs`
+
+Tests: real OPCFoundation client → both endpoints visible in ServerUriArray; `ServiceLevel` byte = 250 on leader, 240 on follower (with the +10 leader bonus); write through OpcUaPublishActor returns synchronous failure on driver write error.
+
+Commit: `test(opcua): dual-endpoint visibility + ServiceLevel leader-bonus tests`.
+
+---
+
+### Task 61: E2E test infrastructure + CI
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none
+
+**Files:**
+- Create: `tests/ZB.MOM.WW.OtOpcUa.E2ETests/ZB.MOM.WW.OtOpcUa.E2ETests.csproj`
+- Create: `tests/ZB.MOM.WW.OtOpcUa.E2ETests/docker-compose.yml` (4 Host processes — 2 admin+driver + 2 driver-only + Traefik + SQL + LDAP)
+- Create: `.github/workflows/v2-ci.yml` — unit + integration jobs; nightly E2E job
+
+CI runs `dotnet build`, `dotnet test --filter Category!=E2E`, `dotnet test --filter Category=E2E` nightly only.
+
+Commit: `ci(v2): integration test workflow + nightly E2E`.
+
+---
+
+## Phase 12 — Deploy scripts & docs
+
+### Task 62: Rewrite `Install-Services.ps1`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 63, 64, 65
+
+**Files:**
+- Replace: `scripts/install/Install-Services.ps1`
+
+New script installs a single Windows Service `OtOpcUaHost` per node; takes `-Roles` parameter, writes `OTOPCUA_ROLES` to service env; binds to a configurable port (default 9000). Uses `sc.exe create` with restart-on-failure.
+
+Update `Refresh-Services.ps1` and `Uninstall-Services.ps1` to match.
+
+Commit: `feat(install): single-service Install-Services.ps1 with -Roles parameter`.
+
+---
+
+### Task 63: Traefik config + `docker-dev/`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 62, 64, 65
+
+**Files:**
+- Create: `scripts/install/Install-Traefik.ps1`
+- Create: `scripts/install/traefik.toml` (or `traefik.yml`)
+- Create: `docker-dev/docker-compose.yml`
+- Create: `docker-dev/README.md`
+- Create: `docker-dev/Dockerfile`
+
+`traefik.toml`: one entrypoint `:80`, one router `host=otopcua.*`, one service load-balancing `admin-a:9000` + `admin-b:9000` with `/health/active` health check (interval 5s, timeout 2s, expected 200).
+
+`docker-dev/` runs four Host containers (admin-a, admin-b, driver-a, driver-b) + SQL Server + OpenLDAP + Traefik. Mac-friendly. README walks through `docker compose up -d` and access at `http://localhost`.
+
+Commit: `feat(deploy): Traefik config + docker-dev Mac dev compose`.
+
+---
+
+### Task 64: Update existing docs
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 62, 63, 65
+
+**Files:**
+- Rewrite: `docs/Redundancy.md`
+- Rewrite: `docs/ServiceHosting.md`
+- Update: `docs/security.md`
+- Update: `docs/README.md`
+
+`Redundancy.md`: replace operator-managed `RedundancyRole` story with Akka-leader-driven `ServiceLevel`. Document the `ServiceLevelCalculator` truth table.
+`ServiceHosting.md`: single fused service, role gating, Traefik, health endpoints.
+`security.md`: cookie+JWT hybrid, DataProtection keys in ConfigDb, `/auth/ping` polling.
+
+Commit: `docs: rewrite Redundancy + ServiceHosting + security for v2`.
+
+---
+
+### Task 65: New v2 architecture docs
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 62, 63, 64
+
+**Files:**
+- Create: `docs/Architecture-v2.md` (high-level summary, references design doc)
+- Create: `docs/Cluster.md` (Akka HOCON, roles, split-brain, failure detector)
+- Create: `docs/ControlPlane.md` (singletons, their state machines, ConfigDb tables)
+- Create: `docs/Runtime.md` (per-node actor tree, OPC UA bridge, dev-stub mode)
+
+Each ~1-2 pages. Link to design doc as source of truth.
+
+Commit: `docs: v2 architecture overview + Cluster/ControlPlane/Runtime guides`.
+
+---
+
+## Final verification
+
+After Task 65:
+
+1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green
+2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — all green (unit + integration)
+3. `cd docker-dev && docker compose up -d` — manual smoke: login at `http://localhost`, deploy from UI, verify OPC UA dual endpoint via UaExpert
+4. Run `scripts/migration/Migrate-To-V2.ps1` against a copy of a real ConfigDb backup; verify row counts match expectations.
+5. Tag `v1.x.x-final` on `master` for backport-only fixes.
+6. Open PR `v2-akka-fuse` → `master` titled "v2: Akka.NET cluster + fused hosting alignment".
+
+---
+
+## Task index
+
+| # | Title | Class | Time | Parallel with |
+|---|---|---|---|---|
+| 0 | Branch + Directory.Packages.props | small | 3m | — |
+| 1 | Commons project | small | 3m | 2-8 |
+| 2 | Cluster project | small | 3m | 1,3-8 |
+| 3 | Security project | small | 3m | 1,2,4-8 |
+| 4 | ControlPlane project | small | 3m | 1-3,5-8 |
+| 5 | Runtime project | small | 3m | 1-4,6-8 |
+| 6 | OpcUaServer project | small | 3m | 1-5,7,8 |
+| 7 | AdminUI project | small | 3m | 1-6,8 |
+| 8 | Host project | small | 5m | 1-7 |
+| 9 | Build green | trivial | 2m | — |
+| 10 | Deployment entity | standard | 5m | 11-13 |
+| 11 | NodeDeploymentState entity | standard | 5m | 10,12,13 |
+| 12 | ConfigEdit entity | small | 4m | 10,11,13 |
+| 13 | DataProtection keys | small | 3m | 10-12 |
+| 14 | V2 migration | high-risk | 5m | — |
+| 15 | Migrate-To-V2.ps1 | standard | 5m | 16-18 |
+| 16 | Common types | standard | 5m | 17,18 |
+| 17 | Message contracts | standard | 5m | 16,18 |
+| 18 | Common interfaces | small | 4m | 16,17 |
+| 19 | HOCON | standard | 5m | 20-22 |
+| 20 | AkkaHostedService | standard | 5m | 19,21,22 |
+| 21 | Role parser | small | 3m | 19,20,22 |
+| 22 | ClusterRoleInfo | standard | 5m | 19-21 |
+| 23 | Cluster tests | standard | 5m | — |
+| 24 | Move LdapAuthService | standard | 5m | 25 |
+| 25 | JwtTokenService | standard | 5m | 24 |
+| 26 | AddOtOpcUaAuth | standard | 5m | 27,28 |
+| 27 | Auth endpoints | standard | 5m | 26,28 |
+| 28 | CookieAuthStateProvider | small | 4m | 26,27 |
+| 29 | Security tests | standard | 5m | — |
+| 30 | ConfigPublishCoordinator happy | high-risk | 5m | 32-35 |
+| 31 | Coordinator timeout/recovery | high-risk | 5m | 32-35 |
+| 32 | AdminOperationsActor | standard | 5m | 30,31,33-35 |
+| 33 | AuditWriterActor | standard | 5m | 30-32,34,35 |
+| 34 | FleetStatusBroadcaster | standard | 5m | 30-33,35 |
+| 35 | RedundancyStateActor | high-risk | 5m | 30-34 |
+| 36 | Singleton registration | standard | 4m | — |
+| 37 | DriverHostActor bootstrap | high-risk | 5m | 41-44 |
+| 38 | DriverHostActor dispatch | high-risk | 5m | 41-44 |
+| 39 | DriverHostActor stale | standard | 4m | 41-44 |
+| 40 | Runtime test scaffold | small | 3m | — |
+| 41 | DriverInstanceActor | high-risk | 5m | 42-44 |
+| 42 | VirtualTagActor | standard | 5m | 41,43,44 |
+| 43 | ScriptedAlarmActor | standard | 5m | 41,42,44 |
+| 44 | OpcUaPublishActor | high-risk | 5m | 41-43 |
+| 45 | Health probe actors | standard | 5m | — |
+| 46 | Extract OpcUaApplicationHost | standard | 5m | — |
+| 47 | Phase7Composer purity | standard | 5m | 48-52 |
+| 48 | Move Blazor → AdminUI | standard | 5m | 47 |
+| 49 | Move hubs, rewire | standard | 5m | 50-52 |
+| 50 | IAdminOperationsClient | standard | 5m | 49,51,52 |
+| 51 | IFleetDiagnosticsClient | standard | 5m | 49,50,52 |
+| 52 | Drift + Deploy UI | standard | 5m | 49-51 |
+| 53 | Host Program.cs | high-risk | 5m | 54,55 |
+| 54 | Health + appsettings | standard | 5m | 53,55 |
+| 55 | DEV-STUB drivers | standard | 5m | 53,54 |
+| 56 | Delete Server + Admin | high-risk | 5m | — |
+| 57 | Build & test green | trivial | 3m | — |
+| 58 | Integration harness | standard | 5m | — |
+| 59 | Deploy + failover IT | standard | 5m | 60 |
+| 60 | OPC UA IT | standard | 5m | 59 |
+| 61 | E2E + CI | standard | 5m | — |
+| 62 | Install-Services.ps1 | standard | 5m | 63-65 |
+| 63 | Traefik + docker-dev | standard | 5m | 62,64,65 |
+| 64 | Update existing docs | standard | 5m | 62,63,65 |
+| 65 | New v2 docs | standard | 5m | 62-64 |
+
+**Total estimated subagent time:** ~5 hours of focused execution, well-suited to subagent-driven dispatch with parallel scheduling on independent tasks.
diff --git a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json
new file mode 100644
index 0000000..f5f6a1e
--- /dev/null
+++ b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json
@@ -0,0 +1,74 @@
+{
+ "planPath": "docs/plans/2026-05-26-akka-hosting-alignment-plan.md",
+ "branch": "v2-akka-fuse",
+ "designDoc": "docs/plans/2026-05-26-akka-hosting-alignment-design.md",
+ "lastUpdated": "2026-05-26T00:00:00Z",
+ "tasks": [
+ {"id": 0, "subject": "Task 0: Create branch and central package management", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": []},
+ {"id": 1, "subject": "Task 1: Create OtOpcUa.Commons project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [2,3,4,5,6,7,8], "blockedBy": [0]},
+ {"id": 2, "subject": "Task 2: Create OtOpcUa.Cluster project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,3,4,5,6,7,8], "blockedBy": [0]},
+ {"id": 3, "subject": "Task 3: Create OtOpcUa.Security project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,4,5,6,7,8], "blockedBy": [0]},
+ {"id": 4, "subject": "Task 4: Create OtOpcUa.ControlPlane project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,5,6,7,8], "blockedBy": [0]},
+ {"id": 5, "subject": "Task 5: Create OtOpcUa.Runtime project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,6,7,8], "blockedBy": [0]},
+ {"id": 6, "subject": "Task 6: Create OtOpcUa.OpcUaServer project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,5,7,8], "blockedBy": [0]},
+ {"id": 7, "subject": "Task 7: Create OtOpcUa.AdminUI Razor class library", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,5,6,8], "blockedBy": [0]},
+ {"id": 8, "subject": "Task 8: Create OtOpcUa.Host Web SDK project", "status": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": [1,2,3,4,5,6,7], "blockedBy": [0]},
+ {"id": 9, "subject": "Task 9: Build green smoke check", "status": "pending", "classification": "trivial", "estMinutes": 2, "parallelizableWith": [], "blockedBy": [1,2,3,4,5,6,7,8]},
+ {"id": 10, "subject": "Task 10: Add Deployment entity", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [11,12,13], "blockedBy": [9]},
+ {"id": 11, "subject": "Task 11: Add NodeDeploymentState entity", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [10,12,13], "blockedBy": [9]},
+ {"id": 12, "subject": "Task 12: Add ConfigEdit audit entity", "status": "pending", "classification": "small", "estMinutes": 4, "parallelizableWith": [10,11,13], "blockedBy": [9]},
+ {"id": 13, "subject": "Task 13: Add DataProtection keys table", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [10,11,12], "blockedBy": [9]},
+ {"id": 14, "subject": "Task 14: EF migration V2HostingAlignment", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [10,11,12,13]},
+ {"id": 15, "subject": "Task 15: Migrate-To-V2.ps1 idempotent script", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [16,17,18], "blockedBy": [14]},
+ {"id": 16, "subject": "Task 16: Common types (CorrelationId, ExecutionId, NodeId, ...)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [17,18], "blockedBy": [9]},
+ {"id": 17, "subject": "Task 17: Akka message contracts", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [16,18], "blockedBy": [16]},
+ {"id": 18, "subject": "Task 18: Common interfaces", "status": "pending", "classification": "small", "estMinutes": 4, "parallelizableWith": [16,17], "blockedBy": [16]},
+ {"id": 19, "subject": "Task 19: HOCON config", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [20,21,22], "blockedBy": [2]},
+ {"id": 20, "subject": "Task 20: AkkaHostedService implementation", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [19,21,22], "blockedBy": [2,18]},
+ {"id": 21, "subject": "Task 21: Role parser from OTOPCUA_ROLES env", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [19,20,22], "blockedBy": [2]},
+ {"id": 22, "subject": "Task 22: ClusterRoleInfo implementation", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [19,20,21], "blockedBy": [18,20]},
+ {"id": 23, "subject": "Task 23: Cluster test project + tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [19,20,21,22]},
+ {"id": 24, "subject": "Task 24: Move LdapAuthService into OtOpcUa.Security", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [25], "blockedBy": [3]},
+ {"id": 25, "subject": "Task 25: JwtTokenService", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [24], "blockedBy": [3]},
+ {"id": 26, "subject": "Task 26: Cookie+JWT hybrid AddOtOpcUaAuth extension", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [27,28], "blockedBy": [13,24,25]},
+ {"id": 27, "subject": "Task 27: /auth/login, /auth/ping, /auth/token endpoints", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [26,28], "blockedBy": [24,25]},
+ {"id": 28, "subject": "Task 28: CookieAuthenticationStateProvider for Blazor", "status": "pending", "classification": "small", "estMinutes": 4, "parallelizableWith": [26,27], "blockedBy": [25]},
+ {"id": 29, "subject": "Task 29: Security test project + tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [24,25,26,27,28]},
+ {"id": 30, "subject": "Task 30: ConfigPublishCoordinator happy path", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [32,33,34,35], "blockedBy": [4,17,18,10,11]},
+ {"id": 31, "subject": "Task 31: Coordinator timeout + failover recovery", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [32,33,34,35], "blockedBy": [30]},
+ {"id": 32, "subject": "Task 32: AdminOperationsActor + StartDeployment", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,33,34,35], "blockedBy": [4,17,18,10,12]},
+ {"id": 33, "subject": "Task 33: AuditWriterActor batched idempotent insert", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,32,34,35], "blockedBy": [4,17]},
+ {"id": 34, "subject": "Task 34: FleetStatusBroadcaster", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,32,33,35], "blockedBy": [4,17]},
+ {"id": 35, "subject": "Task 35: RedundancyStateActor + ServiceLevelCalculator", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [30,31,32,33,34], "blockedBy": [4,17,18]},
+ {"id": 36, "subject": "Task 36: Singleton registration extension (admin role)", "status": "pending", "classification": "standard", "estMinutes": 4, "parallelizableWith": [], "blockedBy": [30,31,32,33,34,35]},
+ {"id": 37, "subject": "Task 37: DriverHostActor scaffolding + PreStart recovery", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43,44], "blockedBy": [5,17,18,11]},
+ {"id": 38, "subject": "Task 38: DriverHostActor DispatchDeployment handler", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43,44], "blockedBy": [37]},
+ {"id": 39, "subject": "Task 39: DriverHostActor stale-config fallback", "status": "pending", "classification": "standard", "estMinutes": 4, "parallelizableWith": [41,42,43,44], "blockedBy": [38]},
+ {"id": 40, "subject": "Task 40: Runtime test project bootstrap", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [], "blockedBy": [37,38,39]},
+ {"id": 41, "subject": "Task 41: DriverInstanceActor state machine", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [42,43,44], "blockedBy": [5,17,40]},
+ {"id": 42, "subject": "Task 42: VirtualTagActor", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [41,43,44], "blockedBy": [5,17,40]},
+ {"id": 43, "subject": "Task 43: ScriptedAlarmActor", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [41,42,44], "blockedBy": [5,17,40]},
+ {"id": 44, "subject": "Task 44: OpcUaPublishActor on synchronized dispatcher", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43], "blockedBy": [5,6,17,19,40]},
+ {"id": 45, "subject": "Task 45: HistorianAdapter + PeerOpcUaProbe + DbHealthProbe actors", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [37,40]},
+ {"id": 46, "subject": "Task 46: Extract OpcUaApplicationHost + Phase7Composer", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [6]},
+ {"id": 47, "subject": "Task 47: Phase7Composer purity + property tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [48,49,50,51,52], "blockedBy": [46]},
+ {"id": 48, "subject": "Task 48: Move Blazor components into AdminUI library", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [47], "blockedBy": [7]},
+ {"id": 49, "subject": "Task 49: Move SignalR hubs and rewire to FleetStatusBroadcaster", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [50,51,52], "blockedBy": [34,48]},
+ {"id": 50, "subject": "Task 50: IAdminOperationsClient via ClusterSingletonProxy", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,51,52], "blockedBy": [18,32,48]},
+ {"id": 51, "subject": "Task 51: Replace DriverDiagnosticsClient with IFleetDiagnosticsClient", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,52], "blockedBy": [18,48]},
+ {"id": 52, "subject": "Task 52: Drift indicator + Deploy button UI", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,51], "blockedBy": [50,48]},
+ {"id": 53, "subject": "Task 53: Host Program.cs role-gated startup", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [54,55], "blockedBy": [8,15,20,21,22,26,36,40,45,46,48,49]},
+ {"id": 54, "subject": "Task 54: Health endpoints + appsettings layout", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,55], "blockedBy": [8,22]},
+ {"id": 55, "subject": "Task 55: Mac dev mode + DEV-STUB drivers", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,54], "blockedBy": [41]},
+ {"id": 56, "subject": "Task 56: Delete OtOpcUa.Server + OtOpcUa.Admin projects", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [53,54,55]},
+ {"id": 57, "subject": "Task 57: Build & test green check", "status": "pending", "classification": "trivial", "estMinutes": 3, "parallelizableWith": [], "blockedBy": [56]},
+ {"id": 58, "subject": "Task 58: 2-node integration test harness", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [57]},
+ {"id": 59, "subject": "Task 59: Deploy + failover integration tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [60], "blockedBy": [58]},
+ {"id": 60, "subject": "Task 60: OPC UA dual-endpoint + ServiceLevel tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [59], "blockedBy": [58]},
+ {"id": 61, "subject": "Task 61: E2E test infrastructure + GitHub Actions CI", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [59,60]},
+ {"id": 62, "subject": "Task 62: Rewrite Install-Services.ps1", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [63,64,65], "blockedBy": [53]},
+ {"id": 63, "subject": "Task 63: Traefik config + docker-dev compose", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,64,65], "blockedBy": [53]},
+ {"id": 64, "subject": "Task 64: Update existing docs (Redundancy, ServiceHosting, security)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,65], "blockedBy": [57]},
+ {"id": 65, "subject": "Task 65: New v2 docs (Architecture-v2, Cluster, ControlPlane, Runtime)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,64], "blockedBy": [57]}
+ ]
+}