# OtOpcUa v2 Architecture

Single-page tour of the v2 layout. For decision history + tradeoffs, see [`2026-05-26-akka-hosting-alignment-design.md`](../plans/2026-05-26-akka-hosting-alignment-design.md).

## Big picture

```
                    ┌─────────────────────────────────────────────┐
                    │                  OtOpcUa.Host               │  (fused binary)
                    │                                             │
                    │  reads OTOPCUA_ROLES env, mounts:           │
                    │  ┌─────────────────────────────────────┐    │
                    │  │  admin → Blazor + auth + control-  │    │
                    │  │          plane singletons          │    │
                    │  │  driver → OPC UA endpoint +        │    │
                    │  │           per-node actors          │    │
                    │  └─────────────────────────────────────┘    │
                    └─────────────────────────────────────────────┘
                                          │
                                          │  joins
                                          ▼
                    ┌─────────────────────────────────────────────┐
                    │              Akka.NET cluster               │
                    │  (split-brain resolver: keep-oldest, 15s)   │
                    └─────────────────────────────────────────────┘

shared by every node:  ┌─────────────────┐
                       │  ConfigDb (SQL) │   live-edit + Deployment artifacts + audit
                       └─────────────────┘
```

The v1 setup was two separate Windows services (`OtOpcUa.Server` + `OtOpcUa.Admin`) talking through the DB. v2 collapses them into one binary with role gating, and adds an Akka cluster so admin singletons can drive deploys and the redundancy story is automatic.

## Project layout

```
src/Core/                       shared abstractions, no Server deps
  ZB.MOM.WW.OtOpcUa.Commons             types + Akka message contracts + interfaces
  ZB.MOM.WW.OtOpcUa.Cluster             HOCON, AkkaClusterOptions, IClusterRoleInfo
  ZB.MOM.WW.OtOpcUa.Configuration       EF Core DbContext + entities

src/Server/                     server-side projects
  ZB.MOM.WW.OtOpcUa.Security            cookie+JWT auth, LDAP, JwtTokenService
  ZB.MOM.WW.OtOpcUa.ControlPlane        admin-role cluster singletons
  ZB.MOM.WW.OtOpcUa.Runtime             driver-role per-node actors
  ZB.MOM.WW.OtOpcUa.OpcUaServer         OPC UA endpoint facade + Phase7Composer
  ZB.MOM.WW.OtOpcUa.AdminUI             Blazor Razor class library
  ZB.MOM.WW.OtOpcUa.Host                fused binary (Program.cs)
```

| Project | Role | Doc |
|---|---|---|
| Cluster | Bootstrap + cluster topology view | [Cluster.md](Cluster.md) |
| ControlPlane | Admin singletons (deploy, audit, fleet, redundancy) | [ControlPlane.md](ControlPlane.md) |
| Runtime | Driver-role actor tree | [Runtime.md](Runtime.md) |
| Security | Cookie+JWT auth, LDAP, /auth/* endpoints | [../security.md](../security.md) |
| OpcUaServer | OPC UA endpoint host + composer | [../OpcUaServer.md](../OpcUaServer.md) |
| Host | Role-gated DI graph + Program.cs | [../ServiceHosting.md](../ServiceHosting.md) |

## Role gating

`Program.cs` reads `OTOPCUA_ROLES` once (per process) and decides what to wire:

```csharp
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
var hasAdmin = roles.Contains("admin");
var hasDriver = roles.Contains("driver");

builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
builder.Services.AddOtOpcUaCluster(builder.Configuration);

builder.Services.AddAkka("otopcua", (ab, sp) =>
{
    ab.WithOtOpcUaClusterBootstrap(sp);            // HOCON + remote + cluster options
    if (hasAdmin)  ab.WithOtOpcUaControlPlaneSingletons();
    if (hasDriver) ab.WithOtOpcUaRuntimeActors();
});

if (hasAdmin)
{
    builder.Services.AddOtOpcUaAuth(builder.Configuration);
    builder.Services.AddAdminUI();
    // SignalR, AdminOpsClient, etc.
}

builder.Services.AddOtOpcUaHealth();
```

There is a **single** ActorSystem. Cluster singletons + per-node actors share it via the `Akka.Hosting` registry. This was a v2 fix (the initial Phase 9 wiring ran two ActorSystems by mistake; see commit `d6fac2d`).

## Live-edit vs draft/publish

v1 had `ConfigGeneration(Draft|Published)` with every live-edit entity FK'd to a generation. Edits accumulated in a Draft until Publish promoted them.

v2 removes that entirely:

- No `ConfigGeneration` table, no `GenerationId` columns.
- Every live-edit entity has a `RowVersion` (`IsRowVersion()`) for last-write-wins.
- Audit goes to `ConfigEdit` (per-row delta) and `ConfigAuditLog` (event-level).
- Deploys snapshot the *current* DB state into an immutable `Deployment.ArtifactBlob` + its `RevisionHash`. That artifact is what driver nodes apply.

See [ControlPlane.md § Deploy flow](ControlPlane.md#deploy-flow) for the end-to-end dispatch + ACK + seal sequence.

## NodeId

Each cluster member has a `NodeId` derived as `{PublicHostname}:{Port}` of the Akka remote endpoint. `ClusterRoleInfo.LocalNode` + `ConfigPublishCoordinator.DiscoverDriverNodes()` use the same formula so they always agree. The port suffix makes loopback test deployments distinguishable (commit `5cfbe8b`); in production the hostname alone is already unique.

## Health endpoints

| Path | Returns 200 when… |
|---|---|
| `/healthz` | Process is alive (no checks). |
| `/health/ready` | DB reachable + this node is `Up` in the cluster. |
| `/health/active` | This node is the admin role-leader (used by Traefik/HA-LB to pin traffic). |

## What lives where (quick map)

| Concern | Project | Entry point |
|---|---|---|
| Read OTOPCUA_ROLES | `Cluster.RoleParser` | static `Parse(string?)` |
| Cluster lifecycle | `Cluster.WithOtOpcUaClusterBootstrap` | extension on `AkkaConfigurationBuilder` |
| Local node identity | `Cluster.IClusterRoleInfo.LocalNode` | DI singleton |
| Admin singletons | `ControlPlane.WithOtOpcUaControlPlaneSingletons` | extension on `AkkaConfigurationBuilder` |
| Driver actors | `Runtime.WithOtOpcUaRuntimeActors` | extension on `AkkaConfigurationBuilder` |
| Auth pipeline | `Security.AddOtOpcUaAuth` + `MapOtOpcUaAuth` | extensions on `IServiceCollection` / `IEndpointRouteBuilder` |
| OPC UA facade | `OpcUaServer.OpcUaApplicationHost` | runtime host, started by driver-role startup |
| Partner-URI advertising | `OpcUaServer.OpcUaApplicationHost.PopulateServerArray` | runs after `_application.Start`, appends `PeerApplicationUris` to the SDK `ServerUris` `StringTable` so `Server.ServerArray` (i=2254) returns self + peers |
| Health endpoints | `Host.Health.AddOtOpcUaHealth` + `MapOtOpcUaHealth` | extensions on `IServiceCollection` / `IEndpointRouteBuilder` |
