# ClusterClient → gRPC-only cross-mesh transport — implementation plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement > this plan task-by-task. Central tracking doc: > `docs/plans/2026-08-06-clusterclient-to-grpc-tracking.md` — **update it at every phase > boundary and defect.** Live gate results go in > `docs/plans/2026-08-06-clusterclient-to-grpc-live-gate.md`. **Goal:** all central↔site mesh traffic rides gRPC with per-cluster PSK auth from `ZB.MOM.WW.Secrets`; `ClusterClient`/`ClusterClientReceptionist` are deleted; Akka remoting never crosses the mesh boundary. **Design:** `2026-08-06-clusterclient-to-grpc-design.md` (read it first — §4 holds the plan-vs-code corrections this plan builds on). **Architecture:** one new proto (`mesh_transport.v1`) with two unary services; `NodeCommandService` hangs off the existing Telemetry listener on driver nodes, `CentralCommandService` off the existing ConfigServe listener on central. The gRPC path is a third `MeshTransport:Mode` branch inside the two existing choke-point actors (`CentralCommunicationActor` / `NodeCommunicationActor`) — no new transport interface, no new ports, no new DB columns. **Tech Stack:** .NET 10, Grpc.Tools 2.76 build-time codegen, Akka.NET 1.5.62, `ZB.MOM.WW.Configuration` validators, `ZB.MOM.WW.Secrets`, xunit + Akka.TestKit.Xunit2. Everything below was code-verified 2026-08-06. If a cited line has drifted, re-locate by the quoted identifier, never by line number. --- ## How to execute this plan (read before starting) - **Phase order:** 0 → (1A ∥ 1B) → 2 → 3 → 4. Phases marked ∥ are parallel-safe **only in separate git worktrees** (`git worktree add ../OtOpcUa-1B feat/grpc-central-ack`) — never run two agents against one working tree (destructive git races are a known family incident). Designated merge order when tracks meet: **1A lands first, 1B rebases** (expected conflicts are confined to `Host/Program.cs` service-map blocks, `docker-dev/docker-compose.yml` env blocks, and `docs/Configuration.md` — resolve as union). - **Subagent model:** dispatch each task to an **Opus subagent** (`model: "opus"`). Within one phase/worktree, run tasks **sequentially** (they share files and git state). Across worktrees, run the 1A and 1B task streams **concurrently** — one Opus agent per worktree. Tasks whose `Parallelizable with:` names a task in the *same* worktree may be batched into a single agent dispatch, never into two agents on one tree. Review each task's diff before the next dispatch (superpowers-extended-cc:subagent-driven-development). - **Branches:** `feat/grpc-phase0-contract-auth`, `feat/grpc-node-command` (1A), `feat/grpc-central-ack` (1B), `feat/grpc-phase3-deletion`. One PR per phase to `master` with its DoD met; record PR number + merge SHA in the tracking doc. **Stop at each phase DoD** — PR + merge is user-owned unless told otherwise. - **Build/test:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 warnings — TreatWarningsAsErrors), `dotnet test ZB.MOM.WW.OtOpcUa.slnx`. TestKit tests inherit the existing harnesses (`ControlPlaneTestHarness`, `RuntimeActorTestBase`) — never hand-roll `ActorSystem.Create`+join. - **Proto codegen is build-time** (`Grpc.Tools`, `` items in `src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj`) — no checked-in generated code. The docker image build is the cross-arch verification; if protoc ever misbehaves in the image, fall back to ScadaBridge's checked-in recipe and record it. - **Rig:** `docker compose -f docker-dev/docker-compose.yml up -d --build` (6 host nodes: central-1/-2 = `admin,driver,cluster-MAIN`; site-a-1/-2, site-b-1/-2 = driver + cluster role; plus sql/traefik/migrator/cluster-seed). AdminUI `http://localhost:9200` (login disabled), headless deploy `POST /api/deployments` with `X-Api-Key: docker-dev-deploy-key`. gRPC ports are container-internal: ConfigServe `:4055` (central), Telemetry `:4056` (all driver nodes). Mesh mode env: `MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"` on all six nodes. - **Coexistence rule:** every migrated path sits behind `MeshTransport:Mode` with `ClusterClient` remaining the rig default until Phase 2 and the compiled default staying `Dps` throughout (it is the single-mesh/in-process default, not this program's concern). Rollback at any point before Phase 3 = flip the mode back. DoD-test flag flips on the rig are **rig-only edits, reverted, never committed** until Phase 2. ## The two choke points (where all transport code changes) - **Central→node:** `CentralCommunicationActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/CentralCommunicationActor.cs`) — `HandleMeshCommand` (`:140-168`) branches on mode; the gRPC branch fans unary `Execute` calls instead of `ClusterClient.SendToAll`. - **Node→central:** `NodeCommunicationActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs`) — `HandleApplyAck` (`:78-92`) branches; the gRPC branch calls `ReportApply` instead of `ClusterClient.Send`. **Publishers and subscribers above these actors are untouched** (`ConfigPublishCoordinator`, `AdminOperationsActor`, `DriverHostActor`, `ScriptedAlarmHostActor`, the EventStream leg, the coordinator `Forward`). --- ## Phase 0 — Contract + auth + config groundwork (standalone; no behavior change) Branch `feat/grpc-phase0-contract-auth`. Everything here is additive and dark. ### Task 0.1: `mesh_transport.proto` + contract lock **Classification:** standard **Estimated implement time:** ~20 min **Parallelizable with:** Task 0.3 (same worktree — batch to one agent, do not split) **Files:** - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto` - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/MeshTransportProtoContract.cs` - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj` (third ``) - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportProtoContractTests.cs` Package `mesh_transport.v1`, `csharp_namespace "ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1"` (NOT `MeshCommand` — collides with `Commons.Messages.Mesh.MeshCommand`). Services + envelope per design §3.2: `NodeCommandService.Execute(CommandEnvelope) returns (ExecuteAck)`; `CentralCommandService.ReportApply(ApplyAckMessage) returns (ReportAck)`. `CommandEnvelope` has `correlation_id` + `oneof command` of 4 messages mirroring `DispatchDeployment` (`Commons/Messages/Deploy/DispatchDeployment.cs`), `RestartDriver`, `ReconnectDriver` (`Commons/Messages/Admin/`), `AlarmCommand` (`Commons/OpcUa/AlarmCommand.cs`). `ApplyAckMessage` mirrors `ApplyAck` (`DeploymentId, NodeId, Outcome, FailureReason, CorrelationId`). **Every enum reserves 0 as `*_UNSPECIFIED`** (`AlarmCommand` verb, `ApplyAck.Outcome`) — proto3 zero must never decode to a meaningful value. `MeshTransportProtoContract.HandledCases` mirrors `TelemetryProtoContract.cs:18-24`; the test mirrors `TelemetryProtoContractTests` (fails when a oneof case is added but unhandled). TDD: write the contract test red (proto absent), add proto + helper, green, commit `feat(mesh): mesh_transport.v1 proto contract`. ### Task 0.2: DTO mapper + round-trip goldens **Classification:** high-risk (data contract — this is where ScadaBridge found five silent-drop bugs) **Estimated implement time:** ~30 min **Parallelizable with:** none (depends on 0.1) **Files:** - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs` Static mapper: `ToProto`/`FromProto` for all five messages + the envelope. Before writing it, **enumerate every member of the five records in the test first** (golden round-trips: construct each record with every property set to a non-default value, map to proto and back, assert record equality; add a null-variant golden for every nullable member — `FailureReason` at minimum). Guard against the known trap classes: init-only props (use object initializers in `FromProto`), nullable strings where `""` is not null (use proto3 `optional`), enum zero (decode unknown → a `Unknown` CLR value or throw, never a meaningful default). Unknown oneof case in `FromProto` ⇒ throw `InvalidOperationException` naming the case — the contract-lock test ensures it's unreachable. Commit `feat(mesh): DTO mapper with round-trip goldens`. ### Task 0.3: `MeshTransportOptions` + validators + SplitTopology relaxation **Classification:** standard **Estimated implement time:** ~25 min **Parallelizable with:** Task 0.1 (same worktree — batch) **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs` - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs` - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs`, `.../SplitTopologyTransportValidatorTests.cs` Add `ModeGrpc = "Grpc"`; new members `string[] CentralGrpcEndpoints`, `string? GrpcPsk`, `int DispatchTimeoutSeconds = 10`, `int AckTimeoutSeconds = 10`, and a config-map override `Dictionary ClusterPsks` (central-side rig/test override; production leaves it empty and the runtime provider falls back to Secrets — mirrors ScadaBridge's `SitePsks__site-*` rig pattern). Validator, following the house rules (roles via `RoleParser.ResolveEffectiveRoles` from `IConfiguration`, keys required only where load-bearing, messages name the exact key + env form `MeshTransport__GrpcPsk`, never echo values): under `Grpc` + driver role ⇒ `GrpcPsk` non-empty, `CentralGrpcEndpoints` non-empty and each `http(s)://`-prefixed, both timeouts > 0. `SplitTopologyTransportValidator:101-109`: accept `ClusterClient` **or** `Grpc` (tighten to `Grpc`-only in Phase 3 — without this relaxation a rig node flipped to `Grpc` refuses to boot). Also correct the three stale "Phase 6 deletes the DPS branch" comments (`MeshTransportOptions.cs:11-13`, `ConfigPublishCoordinator.cs:70-73`, `AdminOperationsActor.cs:90-92`) to match `docs/Configuration.md:150-156`. TDD red-first on both validators. Commit `feat(mesh): Grpc mode value + validation`. ### Task 0.4: node-side `MeshCommandAuthInterceptor` **Classification:** high-risk (security) **Estimated implement time:** ~20 min **Parallelizable with:** Task 0.5 (different files, same worktree — batch) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshCommandAuthInterceptor.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:550-562` (add to the `hasDriver` interceptor block) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/MeshCommandAuthInterceptorTests.cs` Clone `TelemetryStreamAuthInterceptor.cs` byte-for-byte in structure: ServicePrefix `/mesh_transport.v1.NodeCommandService/`, all four `*ServerHandler` overrides → one `Authorize`, case-insensitive `authorization` metadata walk, `Bearer ` prefix, `CryptographicOperations.FixedTimeEquals` over UTF-8, expected token = `IOptions.GrpcPsk`, **fail-closed when unset** (reject all with actionable Warning), deny `PermissionDenied`, non-matching paths pass through. **Exactly ONE public constructor** + a `HasExactlyOnePublicConstructor` reflection test (the `Grpc.AspNetCore` multi-ctor silent-skip trap, `TelemetryStreamAuthInterceptor.cs:38-42`; cost ScadaBridge a live-gate day). No `x-otopcua-cluster` requirement here — header-on-node-side is theatre (design §3.4). Tests: no-token / wrong-token / correct-token / unset-key / non-matching-path, mirroring `TelemetryStreamAuthInterceptorTests`. Commit `feat(mesh): node-side PSK interceptor`. ### Task 0.5: `IMeshPskProvider` + central-side `CentralCommandAuthInterceptor` **Classification:** high-risk (security + the one new stateful component) **Estimated implement time:** ~35 min **Parallelizable with:** Task 0.4 (batch) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshPskProvider.cs` (+ `MeshPskProvider`) - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/CentralCommandAuthInterceptor.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (DI registration; add interceptor to the `hasAdmin` block) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/MeshPskProviderTests.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/CentralCommandAuthInterceptorTests.cs` `IMeshPskProvider.TryGetPsk(string clusterId, out string psk)`: check `MeshTransportOptions.ClusterPsks` config map first, else resolve secret `OT-MESH-PSK-{clusterId}` via `ISecretResolver` (the fail-closed lazy pattern the Galaxy driver uses for `secret:` refs), **cache per cluster, `Invalidate(clusterId)` / `InvalidateAll()` hooks** (wired to contact refresh in 1A). Fail-closed: unresolvable ⇒ `false`, caller denies/skips — never proceeds unauthenticated. Adversarial tests, not just happy-path: unknown cluster, resolver throw, cache serves stale until invalidated, empty secret value = failure. `CentralCommandAuthInterceptor`: ServicePrefix `/mesh_transport.v1.CentralCommandService/`; reads `x-otopcua-cluster`, missing/unknown ⇒ `PermissionDenied`; verifies bearer against that cluster's PSK via the provider, `FixedTimeEquals`; one public ctor + reflection test. Commit `feat(mesh): per-cluster PSK provider + central interceptor`. ### Task 0.6: rig config, issue filing, docs stub **Classification:** small **Estimated implement time:** ~15 min **Parallelizable with:** none (last in phase; touches compose + docs) **Files:** - Modify: `docker-dev/docker-compose.yml` (all six host nodes: `MeshTransport__GrpcPsk` dev literals `dev-mesh-psk-MAIN` / `-SITE-A` / `-SITE-B`; `MeshTransport__CentralGrpcEndpoints__0/1` = `http://central-1:4055`, `http://central-2:4055`; central pair additionally `MeshTransport__ClusterPsks__MAIN` / `SITE-A` / `SITE-B` config-map literals. **Mode stays `${OTOPCUA_MESH_MODE:-ClusterClient}`.**) - Modify: `docs/Configuration.md` (MeshTransport section: document `Grpc` mode + new keys as "shipped dark, cutover pending") - Modify: `docs/plans/2026-08-06-clusterclient-to-grpc-tracking.md` (Phase 0 row) Also: file the Gitea issue for `FleetStatusBroadcaster`'s central-pair-blind fleet view (design §3.8 — pre-existing, transport-independent, NOT fixed here; record the number in the tracking doc). Full-suite run; rig rebuild; confirm all six nodes boot with the new env present and mode unchanged (validators must not fire — keys are only required under `Grpc`). Commit `chore(mesh): rig PSK config + docs stub`. **Phase 0 DoD:** suite green, 0 warnings; rig boots all six nodes on `ClusterClient` with the dark config present; both interceptors + provider + mapper fully unit-tested; FleetStatus issue filed; tracking doc updated. Live-gate doc Phase 0 section recorded (small gate: boot + validator-firing checks, including one deliberate misconfig — `Mode=Grpc` with no `GrpcPsk` on a scratch container refuses boot with the exact-key message). **STOP — PR + merge are user-owned.** --- ## Phase 1A — Central→node command plane (worktree A, branch `feat/grpc-node-command`) ### Task 1A.1: `NodeCommandGrpcService` **Classification:** standard **Estimated implement time:** ~25 min **Parallelizable with:** Task 1B.1 (OTHER worktree only) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/NodeCommandGrpcService.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:707-725` (map under `hasDriver && telemetryListenPort > 0`, beside `TelemetryStreamGrpcService`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Grpc/NodeCommandGrpcServiceTests.cs` Follow the alias convention (`using GeneratedServiceBase = …NodeCommandService.NodeCommandServiceBase;`, class suffixed `GrpcService`). `Execute`: decode via `MeshTransportDtoMapper.FromEnvelope`, Tell the registered `NodeCommunicationActor` (resolve via `NodeCommunicationActorKey` registry, `Runtime/ServiceCollectionExtensions.cs:583`), return `ExecuteAck`. Actor not yet registered (boot window) ⇒ `RpcException(Unavailable)` — the readiness convention. Decode failure ⇒ `InvalidArgument`. Node side re-emits on EventStream exactly as under ClusterClient — the existing `NodeCommunicationActor` receive path handles the four types already; verify `EventStreamCommandDeliveryTests` pass **unmodified** (if they need edits, the seam is wrong). TDD with TestKit probe as the registered actor. Commit `feat(mesh): NodeCommandService on the telemetry listener`. ### Task 1A.2: `MeshGrpcCommandDialer` + the third branch in `CentralCommunicationActor` **Classification:** high-risk (actor + concurrency) **Estimated implement time:** ~40 min **Parallelizable with:** Task 1B.2 (OTHER worktree only) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/MeshGrpcCommandDialer.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/CentralCommunicationActor.cs` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/CentralCommunicationActorTests.cs` (new facts), `.../MeshGrpcCommandDialerTests.cs` Dialer (plain class, injectable fake): `DispatchAsync(nodeEndpoint, CommandEnvelope, ct)` — `GrpcChannel` cached per endpoint (`ConcurrentDictionary`, `IDisposable`, the `GrpcDeploymentArtifactFetcher.cs:138-152` shape), bearer from `IMeshPskProvider` for the target node's cluster + `x-otopcua-cluster` header, deadline via linked CTS (`DispatchTimeoutSeconds`). Actor changes: extend the contact-load query to also select `GrpcPort` (rows already loaded — `LoadContactsFromDb`, `CentralCommunicationActor.cs:207-245`); under `Mode=Grpc`, `HandleMeshCommand` maps the message via `ToProto` and fans `DispatchAsync` to every enabled non-maintenance node across all clusters (the `SendToAll` recipient set), `PipeTo`-ing per-node results back for logging only — **a failure is logged with node id + status and dropped; no retry** (design §3.3). Skip-with-warn a node whose `GrpcPort` is NULL (mirror `TelemetryNodeSource:49-60`). Wire `IMeshPskProvider.Invalidate` to the contact-refresh tick. New facts mirror the existing `SendToAll` facts (`CentralCommunicationActorTests:142,191`) with a fake dialer: fan set, per-node failure isolation (one dead node doesn't stop the fan), NULL-port skip, PSK-miss skip (fail-closed). Commit `feat(mesh): gRPC command fan-out behind Mode=Grpc`. ### Task 1A.3: 1A live check (rig-only, nothing committed but the gate record) **Classification:** small **Estimated implement time:** ~20 min (rig wall-time) **Parallelizable with:** none Rebuild the rig from the branch. Flip **central-1/-2 only** to `MeshTransport__Mode: "Grpc"` (rig-only edit, reverted after). Sites stay `ClusterClient`. Run live-gate Phase 1A checks (deploy dispatch reaches all four site nodes over gRPC while acks return over ClusterClient — coexistence proven; RestartDriver/AlarmCommand from AdminUI; unauthenticated + wrong-key `grpcurl` probes against a site node's `:4056` rejected). Record in the live-gate doc; revert the flip. **Phase 1A DoD:** suite green; gate section PASS; tracking updated. **STOP.** --- ## Phase 1B — Node→central ack plane (worktree B, branch `feat/grpc-central-ack`) ### Task 1B.1: `CentralCommandGrpcService` + admin validator rule **Classification:** standard **Estimated implement time:** ~25 min **Parallelizable with:** Task 1A.1 (OTHER worktree only) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/CentralCommandGrpcService.cs` (beside `DeploymentArtifactService.cs`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:707-725` (map under `hasAdmin && configServeGrpcPort > 0`) - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs` (admin role + `Mode=Grpc` ⇒ `ConfigServe:GrpcListenPort > 0`, cross-read from `IConfiguration` — the SplitTopology pattern) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/CentralCommandGrpcServiceTests.cs`, validator tests `ReportApply`: decode `ApplyAckMessage` → Tell `CentralCommunicationActor` (resolve via `CentralCommunicationActorKey` registry, `ControlPlane/ServiceCollectionExtensions.cs:246`) → its existing `HandleApplyAck` `Forward`s to the coordinator proxy untouched. Not-ready ⇒ `Unavailable`; decode failure ⇒ `InvalidArgument`. Commit `feat(mesh): CentralCommandService on the ConfigServe listener`. ### Task 1B.2: `GrpcApplyAckSender` + node-side wiring **Classification:** high-risk (actor wiring; the `Send`-not-`SendToAll` single-count semantic) **Estimated implement time:** ~35 min **Parallelizable with:** Task 1A.2 (OTHER worktree only) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/GrpcApplyAckSender.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:389-416,536` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs` (new facts), `.../GrpcApplyAckSenderTests.cs` Sender: try `CentralGrpcEndpoints` **in order, stop at first success** (one ack per node — the `Send` semantic; the endpoint loop + per-endpoint channel cache of `GrpcDeploymentArtifactFetcher:67-116`), bearer = own `GrpcPsk` + `x-otopcua-cluster` = own clusterId (from `RoleParser` cluster role), deadline `AckTimeoutSeconds`; all endpoints fail ⇒ log Warning + drop (today's no-client semantic, now visible). `NodeCommunicationActor` gains an **optional** `ackSender` ctor param (null under `Dps`/`ClusterClient` — existing tests pass with ZERO edits; if they need edits, the seam is wrong); under `Mode=Grpc` the registration (`ServiceCollectionExtensions.cs:389-416`) creates no ClusterClient and passes the sender; `_ackRouter` wiring (`:536`) is unchanged (still the actor). New facts: gRPC branch sends exactly once on first-endpoint success, fails over to second, drops after both. Commit `feat(mesh): gRPC ApplyAck path behind Mode=Grpc`. ### Task 1B.3: 1B live check (rig-only) **Classification:** small **Estimated implement time:** ~20 min (rig wall-time) **Parallelizable with:** none Rebuild from branch (after rebasing on merged 1A if it landed — expected union conflicts: `Program.cs` map block, compose, `Configuration.md`). Flip **site-a-1/-2 only** to `Grpc`. Deploy e2e: site-a acks arrive over gRPC (`PersistNodeAck` rows land, deployment seals), site-b still acks over ClusterClient — coexistence proven. Kill central-1 mid-deploy: site-a ack fails over to central-2's endpoint. Record; revert. **Phase 1B DoD:** suite green; gate PASS; tracking updated. **STOP.** --- ## Phase 2 — Full-rig cutover + soak (sequential, after 1A+1B merged) > **Targeted-test policy (phases 2–4):** the full solution suite is large (~3,000+ tests, > 40+ min wall). Per-task verification runs are TARGETED — run only the projects/filters the > task touches (named per task below). ONE full-suite run happens per phase, at the phase DoD > before the merge/STOP — never per task. The mesh-relevant targeted set, for reference: > > ```bash > # mesh transport core (actor, dialer, provider, starter order) > dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ --filter "FullyQualifiedName~Communication" > # node-side actor + ack sender + registration wiring > dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ --filter "FullyQualifiedName~Communication|FullyQualifiedName~ServiceCollectionExtensions" > # interceptors + gRPC services (both sides) > dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/ > # options/validators > dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ --filter "FullyQualifiedName~MeshTransport|FullyQualifiedName~SplitTopology" > # proto contract + mapper goldens > dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/ --filter "FullyQualifiedName~MeshTransport" > # central ack ingress > dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ --filter "FullyQualifiedName~CentralCommandGrpcService" > ``` ### Task 2.0 (MUST FIX, opening task — before the flip and the soak): honest PreStart-race logging **Classification:** small **Estimated implement time:** ~15 min **Parallelizable with:** none (first in phase) **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/CentralCommunicationActor.cs` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/ControlPlaneStarterOrderTests.cs`, `CentralCommunicationActorTests.cs` (string/severity pins only) Both live gates proved the `PreStart` resolve race fires on essentially EVERY admin boot (1A: 2/3; 1B: 4/4) and logs two FALSE Errors — `Resolve`'s "continuing without it" (reads permanent; the memoize heals it at first use) and the I-1 guard's "known-cluster gate will NEVER be published" (it is published moments later — the 1B gate accepted acks and refused `SITE-Z` on every such boot). A healthy central currently fails any error-grep, including this phase's soak. Required behavior after this task: a PreStart-time resolve failure logs at **Debug/Info** ("not yet resolvable — starter ordering; will retry on first use"); the **Error** fires only when a FIRST-USE retry also fails (the genuinely-permanent absence — `Resolve` is already re-invoked by the memoizing properties, so this is a severity/wording split by call site, not new machinery). The I-1 null-guard Errors in `PreStart` move to the same first-use semantics or gain "if this persists past the first contact refresh" wording. Net: **a healthy admin boot produces ZERO Warning/Error lines from this path** (pinned by a test asserting the boot-time severity), while a truly-absent dependency still produces a loud Error at first use (existing pins keep this). **Targeted tests only:** `ControlPlaneStarterOrderTests` + `CentralCommunicationActorTests` filters. Small review chain (code-reviewer). Commit `fix(mesh): PreStart resolve race logs honestly — quiet on heal, loud on permanent absence`. Lands on `master` directly (post-1B-merge, pre-flip). ### The cutover No feature branch; one commit flipping the rig default: `docker-dev/docker-compose.yml` `MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"`. **Before flipping, run the BASELINE on the pre-change build** (live-gate doc has the exact commands): reproduce the two gaps this program closes — (a) a site container holds a live Akka association to central's `:4053` (the unauthenticated cross-WAN surface), (b) stop the site-b pair, dispatch a deployment, observe the silent drop (no per-node error at dispatch; only the 2-min deadline marks it). Reproduced, not assumed. Then: full rebuild all-Grpc; run the Phase 2 check table (deploy e2e all six nodes; Restart/Reconnect/AlarmCommand matrix; kill-central-1 ack failover; kill-site-node mid-dispatch = logged per-node failure + deadline backstop, no retry storm; PSK negatives against both services on running containers; **zero ClusterClient log lines under `Grpc`**; soak ≥ 2 h with 0 `PermissionDenied` noise — PSK drift detector; the soak error-grep must be clean, which Task 2.0 makes possible on a healthy rig). Per-check verification is on the RIG; unit runs during this phase are targeted (see policy above). **Phase 2 DoD:** every check PASS in the live-gate doc; ONE full-suite run green on the cutover SHA; rig default committed; tracking updated. **STOP.** --- ## Phase 3 — Deletion + tightening + docs (branch `feat/grpc-phase3-deletion`) ### Task 3.1: delete ClusterClient (code + HOCON + config) **Classification:** high-risk (deletion ordering) **Estimated implement time:** ~40 min **Parallelizable with:** none **Files:** `Runtime/ServiceCollectionExtensions.cs`, `ControlPlane/ServiceCollectionExtensions.cs`, `ControlPlane/Communication/{CentralCommunicationActor.cs,IMeshClusterClientFactory.cs}`, `Runtime/Communication/NodeCommunicationActor.cs`, `Cluster/Resources/akka.conf`, `Cluster/MeshTransportOptions.cs` + validator, `docker-dev/docker-compose.yml`, affected tests. In order: (1) delete `IMeshClusterClientFactory`/`DefaultMeshClusterClientFactory` + central per-cluster client cache/`RebuildClient` (**keep `LoadContactsFromDb` — it feeds the gRPC dialer**); (2) delete node client creation + `centralClient` param; (3) delete both receptionist registrations; (4) `akka.conf`: remove `ClusterClientReceptionistExtensionProvider` from `akka.extensions` + the whole `akka.cluster.client` block (**keep DPS extension + `maximum-frame-size` canary — pair-local channels live**); (5) delete `CentralContactPoints` + validator rules + all six compose entries; (6) delete the `ClusterClient` mode value; `SplitTopologyTransportValidator` requires `Grpc`; (7) retire/ replace tests: `MeshClusterClientBoundaryTests` → `MeshGrpcBoundaryTests` (two clusters, gRPC between them, **assert no cross-mesh Akka association forms** — the program's point, kept as a test), `MeshClusterClientFactoryTests` deleted, `MeshTransportHoconTests` drops the client-settings asserts (keeps frame-size + DPS pins), `CentralCommunicationActorTests` / `NodeCommunicationActorTests` / `MeshRouterDispatchTests` reshaped to Dps+Grpc. Grep-gates: `rg -i "clusterclient|receptionist" src tests docker-dev docs` → only historical plans/CHANGELOG; `rg "CentralContactPoints"` → empty. Commit sequence per step. **Targeted tests per step, NOT the full suite:** steps 1–3 → the ControlPlane + Runtime `Communication`/`ServiceCollectionExtensions` filters; step 4 → `MeshTransportHoconTests`; steps 5–6 → the Cluster.Tests `MeshTransport|SplitTopology` filter + Host.Tests; step 7 → the replaced/reshaped fixtures by name. The FULL suite runs once, at the Phase 3 DoD. ### Task 3.2: docs sweep **Classification:** small **Estimated implement time:** ~20 min **Parallelizable with:** none `docs/Configuration.md` (MeshTransport = Dps|Grpc, keys, PSK seeding), `docs/Redundancy.md` transport table, `docs/Telemetry.md` cross-refs, `OtOpcUa/CLAUDE.md` transport decisions, `deferment.md` if applicable — and the **scadaproj index entry** (`~/Desktop/scadaproj/CLAUDE.md`, committed on scadaproj `main` and pushed, per its index-edit rule). Update tracking doc. Task 3.2 itself is docs-only — no test runs beyond a build. **Phase 3 DoD:** ONE full-suite run green on the branch tip (the phase's only full run); grep-gates clean; docs merged. **STOP.** --- ## Phase 4 — Terminal live gate (rig; sequential; every check PASS required) Run on `master` AFTER Phase 3 merges, on the **post-deletion image** (verify image build timestamp > merge time — the ScadaBridge provenance guard). Eight checks, recorded in the live-gate doc: (1) PSK negatives both services incl. unset-key fail-closed; (2) command matrix all six nodes; (3) ack matrix + seal; (4) central-kill ack failover + site-node-kill dispatch honesty; (5) mid-deploy central kill → deadline/replay backstops hold, no dupes; (6) **boundary check: no Akka association between any site container and central** (was the baseline gap); (7) restart discipline: full-rig restart clean, **zero receptionist/ClusterClient log lines anywhere**; (8) `MeshGrpcBoundaryTests` (targeted) + the program's TERMINAL full-suite run, green on the merge SHA — this is Phase 4's one full run and the program's last; everything before it in this phase is rig checks + targeted fixtures. Close any carry-forwards named by earlier gates (incl. the I-4 cold-cache measurement if a store-backed rig exists by then — else re-carry with rationale). Tracking doc final row; program CLOSED. --- ## Gotchas for the executor (will bite; read twice) 1. **One public constructor per interceptor** — `Grpc.AspNetCore` silently skips multi-ctor interceptors; correct/wrong/no key then fail identically (`Unknown`), which is indistinguishable from "working" in unit tests that register via DI. Reflection-pin it. 2. **Enum zero is `UNSPECIFIED`, never a meaningful value** — proto3 decodes absent as 0. 3. **Do NOT touch the Kestrel block** (`Program.cs:564-671`) — no new listeners exist in this plan; any deviation that adds one re-imports the explicit-endpoints-suppresses-URLS trap. 4. **Fire-and-forget stays fire-and-forget** — no retry on `DeadlineExceeded` or anything else; the deploy deadline + boot replay are the only recovery. Adding a retry turns one `RestartDriver` into two. 5. **`ConfigPublishCoordinator`'s `deployment-acks` DPS subscription stays** — it is the `Dps`-mode ingress, not dead code. 6. **`SendToAll`/`Send` semantics:** fan = every enabled non-maintenance `ClusterNode`; ack = exactly once, first healthy endpoint. The coordinator counts acks — a double-send double-counts. 7. **Existing tests are the seam oracle:** `EventStreamCommandDeliveryTests` and the pre-existing `NodeCommunicationActorTests`/`CentralCommunicationActorTests` construction paths must pass **unmodified** through 1A/1B (nullable ctor params, never signature breaks). If they need edits before Phase 3, the seam is wrong. 8. **Rig flips before Phase 2 are never committed**; a redeploy from `master` must reset to all-ClusterClient until the Phase 2 commit. 9. **`x-otopcua-cluster` is a central-side routing hint** — do not add it to the node-side interceptor's requirements (theatre), and do not prove isolation with it (wrong-key rejection is the proof). 10. **Secrets on driver-only nodes work** (node-local SQLite + env KEK) — do not add a ConfigDb dependency anywhere on the node path; that constraint is the whole point of mesh Phases 3/4. ## Effort & parallelization | Track | Est. | Parallel with | |---|---|---| | Phase 0 | 2–3 d | — (single worktree; tasks 0.1+0.3 and 0.4+0.5 batchable) | | 1A | 3–4 d | 1B (separate worktrees; 1A merges first, 1B rebases) | | 1B | 2–3 d | 1A | | 2 | 1–2 d | — | | 3 | 1–2 d | — | | 4 | 1 d | — | Critical path ≈ 0 → 1A → 2 → 3 → 4: **~1.5–2.5 weeks.**