# 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
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Akka" Version="1.5.62" />
    <PackageVersion Include="Akka.Cluster" Version="1.5.62" />
    <PackageVersion Include="Akka.Cluster.Hosting" Version="1.5.62" />
    <PackageVersion Include="Akka.Cluster.Tools" Version="1.5.62" />
    <PackageVersion Include="Akka.Hosting" Version="1.5.62" />
    <PackageVersion Include="Akka.Remote" Version="1.5.62" />
    <PackageVersion Include="Akka.Remote.Hosting" Version="1.5.62" />
    <PackageVersion Include="Akka.Streams" Version="1.5.62" />
    <PackageVersion Include="Akka.Streams.TestKit" Version="1.5.62" />
    <PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
    <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
    <PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
  </ItemGroup>
</Project>
```

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
<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
</Project>
```

**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
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>ZB.MOM.WW.OtOpcUa.Commons</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Akka" />
  </ItemGroup>
</Project>
```

**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
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>ZB.MOM.WW.OtOpcUa.Cluster</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Akka.Hosting" />
    <PackageReference Include="Akka.Cluster" />
    <PackageReference Include="Akka.Cluster.Hosting" />
    <PackageReference Include="Akka.Cluster.Tools" />
    <PackageReference Include="Akka.Remote.Hosting" />
    <PackageReference Include="Microsoft.Extensions.Hosting" />
    <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
  </ItemGroup>
</Project>
```

**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
<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <RootNamespace>ZB.MOM.WW.OtOpcUa.AdminUI</RootNamespace>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj" />
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj" />
    <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj" />
  </ItemGroup>
</Project>
```

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
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <RootNamespace>ZB.MOM.WW.OtOpcUa.Host</RootNamespace>
    <UserSecretsId>zb-mom-ww-otopcua-host</UserSecretsId>
    <AssemblyName>OtOpcUa.Host</AssemblyName>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true'" />
    <PackageReference Include="Serilog.AspNetCore" />
    <PackageReference Include="Akka.Hosting" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
    <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj" />
    <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj" />
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj" />
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj" />
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj" />
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj" />
    <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj" />
  </ItemGroup>
</Project>
```

**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<Deployment>` + `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<byte>();
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
    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<Deployment>(b =>
{
    b.ToTable("Deployment");
    b.HasKey(d => d.DeploymentId);
    b.Property(d => d.RevisionHash).HasMaxLength(64).IsRequired();
    b.Property(d => d.Status).HasConversion<int>();
    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<Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey> DataProtectionKeys
    => Set<Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey>();
```

Commit: `feat(configdb): persist DataProtection keys in ConfigDb`.

---

### Tasks 14a-14f: Entity-model rewrite + V2HostingAlignment migration

> **Plan rewrite, 2026-05-26**: the original single Task 14 (5-min EF migration) was
> under-scoped — it only listed the schema drops/adds without addressing the 13+ entities
> whose foreign keys + indexes are keyed on `GenerationId`. The design doc (§ live-edit
> model) requires removing `GenerationId` from `Equipment`, `Driver`, `DriverInstance`,
> `Namespace`, `UnsArea`, `UnsLine`, `Device`, `Tag`, `PollGroup`, `NodeAcl`, `Script`,
> `VirtualTag`, `ScriptedAlarm` and adding `RowVersion` columns for last-write-wins
> stale-write detection. That cascades into `GenerationApplier`/`GenerationDiff`/
> `GenerationSealedCache` and the legacy Server/Admin CRUD services. Policy decision
> (recorded with the user): the legacy `OtOpcUa.Server` + `OtOpcUa.Admin` projects are
> allowed to fail-to-compile between Task 14c and Task 56 — only the new v2 projects need
> to stay green.

#### Task 14a: Add `RowVersion` to live-edit entities

**Classification:** standard
**Estimated implement time:** ~10 min
**Parallelizable with:** none (foundation for 14b)

**Files:** every live-edit entity class — `Equipment`, `DriverInstance`, `Device`, `Tag`,
`PollGroup`, `Namespace`, `UnsArea`, `UnsLine`, `NodeAcl`, `Script`, `VirtualTag`,
`ScriptedAlarm`. Add `public byte[] RowVersion { get; set; } = Array.Empty<byte>();` and a
`e.Property(x => x.RowVersion).IsRowVersion();` mapping in `OtOpcUaConfigDbContext`.

Commit: `feat(configdb): add RowVersion to live-edit entities for last-write-wins detection`.

---

#### Task 14b: Decouple live-edit entities from `ConfigGeneration`

**Classification:** high-risk
**Estimated implement time:** ~30 min
**Parallelizable with:** none

Remove `GenerationId` property, `Generation` navigation property, and the
`HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId)` mapping from each
of the 13 live-edit entities listed above. Rewrite the `UX_<Table>_Generation_LogicalId`
indexes to drop the `GenerationId` column (logical IDs become globally unique). Drop
`UX_*_Generation_*` filtered indexes where the filter referenced generation scope.

Will break `OtOpcUa.Server` + `OtOpcUa.Admin` compilation — that is accepted (Task 56
deletes them).

Commit: `refactor(configdb): drop GenerationId FK from live-edit entities`.

---

#### Task 14c: Mark `GenerationApplier` / `GenerationDiff` / `GenerationSealedCache` obsolete

**Classification:** high-risk
**Estimated implement time:** ~20 min
**Parallelizable with:** none

`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/` contains `GenerationApplier.cs`,
`GenerationDiff.cs`, `ApplyCallbacks.cs`, `ChangeKind.cs`, `IGenerationApplier.cs`. These
implement the v1 draft/publish lifecycle that v2 replaces with `AdminOperationsActor` +
`ConfigComposer`.

Inventory callers via `grep -rln 'GenerationApplier\|GenerationDiff' src tests`. Either:
- Mark types `[Obsolete("Replaced by AdminOperationsActor in v2", error: true)]` so
  surviving call sites become hard build errors (cleaner; surfaces the Server-breakage),
- Or delete the files and accept the Server-side build break.

Sweep `GenerationSealedCache` similarly. Keep the LiteDb cache concept (it's repurposed
in Task 39 for stale-config fallback) but rename references to use `DeploymentArtifact`.

Commit: `refactor(configdb): obsolete GenerationApplier/Diff/SealedCache (replaced by AdminOperationsActor)`.

---

#### Task 14d: Drop `RedundancyRole` from `ClusterNode`

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none

Remove `ClusterNode.RedundancyRole` property + the
`e.Property(x => x.RedundancyRole).HasConversion<string>()` mapping + the
`UX_ClusterNode_Primary_Per_Cluster` filtered unique index from
`OtOpcUaConfigDbContext.ConfigureClusterNode`. Akka cluster leader-of-driver-role becomes
the source of truth (Phase 5, Task 35).

Commit: `refactor(configdb): drop ClusterNode.RedundancyRole (replaced by Akka leader)`.

---

#### Task 14e: Delete `ConfigGeneration` + `ClusterNodeGenerationState`

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on 14b clearing the FKs)

Delete `Entities/ConfigGeneration.cs` and `Entities/ClusterNodeGenerationState.cs`. Remove
the corresponding `DbSet<>` entries and `Configure*` methods from
`OtOpcUaConfigDbContext`. Drop `GenerationStatus` and `NodeApplyStatus` enums.

Commit: `refactor(configdb): delete ConfigGeneration + ClusterNodeGenerationState`.

---

#### Task 14f: Generate `V2HostingAlignment` EF migration

**Classification:** high-risk
**Estimated implement time:** ~15 min
**Parallelizable with:** none (consolidates 14a-14e)

**Files:**
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/<timestamp>_V2HostingAlignment.cs`
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/<timestamp>_V2HostingAlignment.Designer.cs`
- Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs` — update
  the `expected` table list (remove ConfigGeneration + ClusterNodeGenerationState; add
  Deployment + NodeDeploymentState + ConfigEdit + DataProtectionKeys).

**Step 1: Generate migration**

```bash
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")` and `DropTable("ClusterNodeGenerationState")`
- `DropColumn("RedundancyRole", "ClusterNode")`
- For each of the 13 live-edit tables: `DropForeignKey` on `GenerationId`,
  `DropIndex` on `UX_*_Generation_LogicalId` (and any `UX_*_Generation_*`), `DropColumn` on
  `GenerationId`, `AddColumn("RowVersion", "rowversion")`, `CreateIndex` on the new
  globally-unique logical-id pattern.
- `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** (per CLAUDE.md, Docker is on the shared host
`10.100.0.35`, not local).

```bash
# from this Mac dev:
ssh dohertj2@10.100.0.35 '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=10.100.0.35,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
ssh dohertj2@10.100.0.35 '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"'
ssh dohertj2@10.100.0.35 'docker stop v2-migration-test'
```

Expected: migration completes; sys.tables contains the 4 new tables and not the 2 dropped
ones; live-edit tables have `RowVersion` column.

**Step 4: Update `SchemaComplianceTests`** so its `expected` array matches the new schema.

**Step 5: Commit**

```bash
git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ \
        tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
git commit -m "feat(configdb): V2HostingAlignment migration — drop generation lifecycle, add deploy tables"
```

---

### 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<T>`.

**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<string> LocalRoles { get; }
    bool HasRole(string role);
    IReadOnlyList<NodeId> MembersWithRole(string role);
    NodeId? RoleLeader(string role);
    event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
}

public interface IAdminOperationsClient
{
    Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
    // … other mutating ops added in later tasks
}

public interface IFleetDiagnosticsClient
{
    Task<NodeDiagnosticsSnapshot> 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
<ItemGroup>
  <EmbeddedResource Include="Resources\akka.conf" />
</ItemGroup>
```

**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<string>();
    public string[] Roles { get; set; } = Array.Empty<string>();
}
```

**`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<string>();
        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<OtOpcUaConfigDbContext>().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<Program>` 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<OtOpcUaConfigDbContext> dbFactory) =>
        Akka.Actor.Props.Create(() => new ConfigPublishCoordinator(dbFactory));

    private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
    private readonly HashSet<NodeId> _expectedAcks = new();
    private DeploymentId _current;
    private readonly Dictionary<NodeId, ApplyAckOutcome> _acks = new();

    public ConfigPublishCoordinator(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
    {
        _dbFactory = dbFactory;
        Receive<DispatchDeployment>(HandleDispatch);
        Receive<ApplyAck>(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<DeadlineElapsed>` 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<FleetStatusHub>` and `IHubContext<AlertHub>`.

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<TActor>` 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<IControlPlaneStartup, ControlPlaneStartup>();
    return services;
}

internal sealed class ControlPlaneStartup : IControlPlaneStartup
{
    public void Configure(AkkaConfigurationBuilder cb)
    {
        cb.WithClusterSingleton<ConfigPublishCoordinator>("config-publish", new ClusterSingletonOptions { Role = "admin" });
        cb.WithClusterSingleton<AdminOperationsActor>("admin-ops",         new ClusterSingletonOptions { Role = "admin" });
        cb.WithClusterSingleton<AuditWriterActor>("audit-writer",          new ClusterSingletonOptions { Role = "admin" });
        cb.WithClusterSingleton<FleetStatusBroadcaster>("fleet-status",    new ClusterSingletonOptions { Role = "admin" });
        cb.WithClusterSingleton<RedundancyStateActor>("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<DispatchDeployment>`:
  - 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<WriteAttribute>` → 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<TApp>(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<TApp>` 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<FleetStatusHub>`, 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<T>` — 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<TResult>(message, timeout)` with 10s timeout + propagated cancellation.

Register in DI: `services.AddScoped<IAdminOperationsClient, AdminOperationsClient>()` (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<IControlPlaneStartup>().Configure(ab);
    if (roles.Contains("driver")) sp.GetRequiredService<IRuntimeStartup>().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>();
    app.MapHub<FleetStatusHub>("/hubs/fleet");
    app.MapHub<AlertHub>("/hubs/alerts");
    app.MapHub<ScriptLogHub>("/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<Program>` 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 |
| 14a | RowVersion on live-edit entities | standard | 10m | — |
| 14b | Drop GenerationId FK from entities | high-risk | 30m | — |
| 14c | Obsolete GenerationApplier/Diff/Cache | high-risk | 20m | — |
| 14d | Drop ClusterNode.RedundancyRole | standard | 5m | — |
| 14e | Delete ConfigGeneration + ClusterNodeGenerationState | small | 5m | — |
| 14f | V2HostingAlignment migration (consolidator) | high-risk | 15m | — |
| 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.
