# OtOpcUa.ControlPlane Five admin-role cluster singletons that drive the v2 deploy, audit, fleet, and redundancy stories. Path: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/`. ## Singletons | Actor | File | Marker key | Role | |---|---|---|---| | `ConfigPublishCoordinator` | `Coordinators/ConfigPublishCoordinator.cs` | `ConfigPublishCoordinatorKey` | Dispatches `DispatchDeployment`, collects `ApplyAck`s, seals/fails/times-out. | | `AdminOperationsActor` | `AdminOperations/AdminOperationsActor.cs` | `AdminOperationsActorKey` | Receives `StartDeployment` from the UI, snapshots ConfigDb via `ConfigComposer`, persists `Deployment` row + `ConfigEdit` marker, tells the coordinator to dispatch. | | `AuditWriterActor` | `Audit/AuditWriterActor.cs` | `AuditWriterActorKey` | Batched `ConfigAuditLog` writer. Flushes every 500 events or 5 s. In-buffer dedup; cross-restart dedup tracked as F3. | | `FleetStatusBroadcaster` | `Fleet/FleetStatusBroadcaster.cs` | `FleetStatusBroadcasterKey` | Aggregates per-node `FleetNodeStatus` heartbeats; publishes `FleetStatusChanged` on the `fleet-status` DPS topic (SignalR bridge tracked as F16). | | `RedundancyStateActor` | `Redundancy/RedundancyStateActor.cs` | `RedundancyStateActorKey` | Cluster-event subscriber; debounces 250 ms; publishes `RedundancyStateChanged` on the `redundancy-state` DPS topic. | All five register via `WithOtOpcUaControlPlaneSingletons()` (extension on `AkkaConfigurationBuilder`). Each uses `ClusterSingletonOptions { Role = "admin" }` so the singleton runs on the admin role-leader and migrates to the next admin node on failover. ```csharp // Program.cs (admin role only) builder.Services.AddAkka("otopcua", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons(); if (hasDriver) ab.WithOtOpcUaRuntimeActors(); }); ``` Resolve from anywhere via `IRequiredActor` or the `ActorRegistry`: ```csharp public sealed class AdminOperationsClient(ActorRegistry registry) : IAdminOperationsClient { private readonly IActorRef _proxy = registry.Get(); // ... } ``` ## Deploy flow ``` UI → IAdminOperationsClient.StartDeploymentAsync(createdBy) │ Ask the AdminOperationsActor singleton proxy ▼ AdminOperationsActor │ ConfigComposer.SnapshotAndFlattenAsync(db) → ConfigArtifact(blob, revHash) │ insert Deployment(Dispatching) + ConfigEdit marker │ Tell coordinator → DispatchDeployment ▼ ConfigPublishCoordinator │ DiscoverDriverNodes() → expected ACK set (host:port per member) │ insert NodeDeploymentState(Applying) per driver │ Publish DispatchDeployment on "deployments" topic │ Start apply-deadline timer (2 min default) ▼ DistributedPubSub DriverHostActor (on each driver node — subscribed to "deployments") │ PreStart subscribed; current state Steady(rev) │ if currentRev == msg.rev → immediate ApplyAck(Applied) (idempotent) │ else Become(Applying) → write NodeDeploymentStatus → ApplyAck ▼ via "deployment-acks" topic ConfigPublishCoordinator (subscribed to "deployment-acks" in PreStart) │ PersistNodeAck + collect │ all-Applied → Sealed │ any-Failed → PartiallyFailed │ deadline → TimedOut ``` The dedicated `deployment-acks` topic + coordinator subscription was added in commit `5cfbe8b`. Before that, ACKs were published back on `deployments` and the coordinator (not subscribed) silently dropped them — deployments hung at `AwaitingApplyAcks` forever in multi-node tests. ### Failover recovery If the admin singleton fails over mid-deploy, the new instance's `PreStart` queries `NodeDeploymentState` for any `Dispatching`/`AwaitingApplyAcks` row, rebuilds `_expectedAcks` + `_acks` from persisted state, and resumes the deadline timer. See `Coordinators/ConfigPublishCoordinator.cs::PreStart`. ## ConfigComposer Pure function `SnapshotAndFlattenAsync(db) → ConfigArtifact(byte[], string)`: 1. Reads every live-edit table. 2. Serialises to a stable byte[] (deterministic ordering). 3. Computes SHA-256 over the bytes → 64-hex `RevisionHash`. Same DB state → same artifact + same hash. That's what makes the `NoChanges` outcome work (AdminOperations compares the proposed hash to the last sealed deployment's hash). ## ServiceLevelCalculator Pure function exposed at `Redundancy/ServiceLevelCalculator.Compute(NodeHealthInputs)`. Returns the OPC UA `ServiceLevel` byte per the truth table in [Redundancy.md](../Redundancy.md#servicelevel-tiers-part-5-65). No side effects; trivially unit-testable. ## DPS topics | Topic | Publisher | Subscribers | |---|---|---| | `deployments` | ConfigPublishCoordinator | DriverHostActor (per-node) | | `deployment-acks` | DriverHostActor | ConfigPublishCoordinator | | `fleet-status` | FleetStatusBroadcaster | (SignalR bridge — F16) | | `redundancy-state` | RedundancyStateActor | (per-node ServiceLevel calc — F10) | ## Tests `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/` — 29 tests covering coordinator (happy path, timeout, failover recovery), AdminOps (StartDeployment outcomes), AuditWriter (batching, dedup), FleetStatusBroadcaster (heartbeat staleness), RedundancyStateActor (debounce, snapshot), ConfigComposer (purity), ServiceLevelCalculator (truth table). Multi-node tests (cross-ActorSystem) are in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/`.