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