Files
lmxopcua/docs/plans/2026-08-06-clusterclient-to-grpc-design.md
T

406 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OtOpcUa: migrating cross-mesh ClusterClient traffic to gRPC-only
**Date:** 2026-08-06. Code-verified inventory of every ClusterClient touchpoint in
`~/Desktop/OtOpcUa`, then the migration assessment. **Reference implementation:** ScadaBridge
shipped the identical migration 2026-07-22→08-01 (design
`~/Desktop/scadaproj/scadabridge_clusterclient_to_grpc.md`, plan + live gate under
`~/Desktop/ScadaBridge/docs/plans/2026-07-22-clusterclient-to-grpc-*`) — live-gated end to end,
ClusterClient deleted. OtOpcUa runs on the same cluster boxes, so this migration converges both
apps on one WAN boundary posture: gRPC + PSK, no Akka remoting between site and central.
Companion docs: `docs/plans/2026-07-21-per-cluster-mesh-design.md` (why the boundary is
per-cluster meshes), `2026-07-22-mesh-phase2-clusterclient-transport.md` (how ClusterClient got
here), `2026-07-24-mesh-phase6-partition-and-colocation.md` (the mesh split this completes).
Everything below was code-verified 2026-08-06. If a cited line has drifted, re-locate by the
quoted identifier, never by line number.
---
## 1. What ClusterClient actually carries today
Everything crossing the central↔site mesh boundary over Akka funnels through **two actors** —
`CentralCommunicationActor` (central) and `NodeCommunicationActor` (node) — so the migration
surface is even better-bounded than ScadaBridge's was.
**Receptionist registrations (2):**
| Role | Actor | Path | File:line | Note |
|---|---|---|---|---|
| admin | `CentralCommunicationActor` | `/user/central-communication` | `ControlPlane/ServiceCollectionExtensions.cs:85` | registered unconditionally in both modes (deliberate) |
| driver | `NodeCommunicationActor` | `/user/node-communication` | `Runtime/ServiceCollectionExtensions.cs:416` | same |
Path constants: `Commons/Messages/Mesh/MeshPaths.cs`. Receptionist + DPS extensions are
force-started via `Cluster/Resources/akka.conf:16-24`; receptionist role scoping deliberately
empty (`akka.conf:113-119`).
**Client creation (2 sites, asymmetric discovery — documented `Cluster/MeshTransportOptions.cs:16-21`):**
- **Central:** one `ClusterClient` **per application Cluster**, contacts loaded **from the
database** (`ClusterNode` rows `WHERE Enabled AND NOT MaintenanceMode`, grouped by
`ClusterId`, `akka.tcp://…/system/receptionist` built from `Host`+`AkkaPort`) —
`CentralCommunicationActor.cs:207-245`, refreshed every `MeshTransport:ContactRefreshSeconds`
(default 60). Factory seam `IMeshClusterClientFactory` /
`DefaultMeshClusterClientFactory` (`ControlPlane/Communication/IMeshClusterClientFactory.cs`).
- **Node:** one `central-cluster-client`, contacts from **static config**
`MeshTransport:CentralContactPoints[]` — `Runtime/ServiceCollectionExtensions.cs:394-407`,
created only under `MeshTransport:Mode=ClusterClient`.
**Central → node (4 message types), all `ClusterClient.SendToAll("/user/node-communication", …)`
fanned across every per-cluster client** (`CentralCommunicationActor.cs:167-168`; `SendToAll`
not `Send` is load-bearing — `Send` would deploy to one node only). Node side re-emits on the
local `EventStream` (`NodeCommunicationActor.cs:70-74`); subscribers (`DriverHostActor`,
`ScriptedAlarmHostActor`) are transport-blind.
| Message | Purpose | Sender | Backstop layered on top |
|---|---|---|---|
| `DispatchDeployment` | "revision exists — fetch + apply" (payload-free: id + `RevisionHash` + correlation; artifact bytes ride gRPC config-serve) | `ConfigPublishCoordinator` (admin singleton), `:198` | 2-min apply deadline (`DefaultApplyDeadline`, `:60`, handling `:283-300`) + boot replay of orphan `Applying` rows in `DriverHostActor.Bootstrap()` |
| `RestartDriver` | AdminUI restart of one driver instance | `AdminOperationsActor.cs:431` | none — local `RestartDriverResult` answers the UI *before/independent of* node delivery |
| `ReconnectDriver` | AdminUI reconnect | `AdminOperationsActor.cs:461` | none |
| `AlarmCommand` | operator Ack/Shelve/Unshelve into the scripted-alarm engine | `AdminOperationsActor.cs:122,169` | none |
**Node → central (1 message type), `ClusterClient.Send("/user/central-communication", …)`**
(`Send` not `SendToAll` is load-bearing — `SendToAll` would double-count the node at the
coordinator, `NodeCommunicationActor.cs:89-92`):
| Message | Purpose | Path |
|---|---|---|
| `ApplyAck` (`DeploymentId, NodeId, Outcome, FailureReason, CorrelationId`) | per-node deploy acknowledgement | `DriverHostActor.SendAck` (`:2488-2507`) → `_ackRouter` = `NodeCommunicationActor` → central `HandleApplyAck` (`:171-187`) → `Forward` to the `ConfigPublishCoordinator` singleton proxy → `PersistNodeAck` (`:247-260`) |
**The invariant that sizes this whole migration: there is NO Ask across this boundary.** Every
message is a one-way Tell; no sender preservation, no reply futures, no Ask timeouts
(`NodeCommunicationActor.cs:30-35` states it; code confirms it). The per-cluster mesh design
doc's line about "sender preservation is the whole Ask idiom" describes ScadaBridge, not this
codebase — do not size off it.
**Transport guarantees today: none.** `akka.cluster.client.buffer-size = 0` (no buffering, no
replay), `reconnect-timeout = off` (`akka.conf:106,111`). No client for a cluster ⇒ the command
is logged and dropped (`CentralCommunicationActor.cs:146-158`); no central client ⇒ the ack is
dropped (`NodeCommunicationActor.cs:78-87`). Serialization is Akka's default Newtonsoft JSON —
**no `serialization-bindings` exist for any mesh message** — under a 128 KB
`maximum-frame-size` with drop-don't-teardown semantics (`akka.conf:35-47`). There is no proto
contract and no serializer discipline to inherit; the wire surface must be authored fresh.
**Not ClusterClient (stays untouched):** gRPC config-serve (Phase 3), gRPC telemetry dial
(Phase 5), LocalDb sync gRPC, Akka secrets replication (pair-local), the pair-local DPS
channels (`alarm-commands`' OPC-UA-originated leg published by `OtOpcUaServerHostedService`,
`redundancy-state`, `fleet-status`), and the node-local EventStream delivery leg.
---
## 2. Why gRPC-only is a natural completion, not a rewrite
1. **ClusterClient is the last Akka hop crossing the WAN.** Config artifacts (Phase 3),
telemetry (Phase 5) and LocalDb sync already ride gRPC across the boundary. After this
migration, Akka remoting is pair-internal only — the exact end state ScadaBridge reached.
2. **The surface is tiny.** 5 one-way message types, 2 choke-point actors, no Ask/reply
semantics, no ordering or delivery guarantees to preserve beyond "fan to all driver nodes"
and "one ack per node." ScadaBridge migrated 35 message types with Ask semantics in ~5
weeks; this is a fraction of that.
3. **The playbook is proven on the same clusters.** ScadaBridge's design, PSK pattern,
coexistence flags, deletion sequencing and live-gate format all shipped and survived their
gates. OtOpcUa co-deploys on the same boxes; converging both apps on one boundary posture
(gRPC + per-relationship PSK, no cross-WAN Akka) halves the firewall/audit story.
4. **Auth is a real gap, not hygiene.** Cross-mesh commands (`RestartDriver`, `AlarmCommand`,
`DispatchDeployment`) ride unauthenticated Akka remoting today — anyone who can reach a
node's `:4053` and speak the protocol can reach `/user/node-communication`. The gRPC path
inherits the family's fail-closed bearer/PSK discipline that already gates the other three
channels.
5. **Silent drop becomes visible failure.** `buffer-size = 0` means an undeliverable command
vanishes with one central-side log line. A unary gRPC call fails *per node, with a status
code* — the deploy path's 2-min deadline stops being the only witness.
---
## 3. What the migration takes
### 3.1 Topology — both directions already have a listener to hang off
This is the OtOpcUa-shaped economy (ScadaBridge had to build central's listener from scratch;
OtOpcUa does not):
- **`NodeCommandService`** (central→node commands) is mapped on the **existing Telemetry
listener** (`Telemetry:GrpcListenPort`, h2c, rig `:4056`) that every driver node already
hosts. On a split-topology node, `SplitTopologyTransportValidator` already forces
`Telemetry:Mode=Grpc`, whose validator forces `GrpcListenPort > 0` — the listener is
guaranteed up.
- **`CentralCommandService`** (node→central acks) is mapped on the **existing ConfigServe
listener** (`ConfigServe:GrpcListenPort`, rig `:4055`) that admin nodes host. A new validator
rule ties `MeshTransport:Mode=Grpc` + admin role to `ConfigServe:GrpcListenPort > 0`.
**Rejected: a dedicated mesh-command port.** It would need a new `ClusterNode` column + EF
migration + the seed-backfill `UPDATE` dance (`docker-dev/seed/seed-clusters.sql:126-128`
exists precisely because Phase 5's column landed NULL on persisted volumes) + compose churn on
six services — for no isolation gain, because the interceptors path-scope shared listeners
anyway (three services already coexist in one `AddGrpc` pipeline).
**Rejected: a node-held long-lived command stream** (node subscribes, central pushes). It
re-implements the connection/reconnect management the receptionist gave for free, inverts the
existing dial direction convention (central already dials nodes for telemetry), and buys
nothing for 4 command types. Same reasoning ScadaBridge recorded for the mirror-image option.
**Either-pair-node delivery is already safe.** Acks land on whichever central answers; the
receiving `CentralCommandService` Tells `CentralCommunicationActor`, which `Forward`s to the
`ConfigPublishCoordinator` **singleton proxy** — same intra-pair routing that makes today's
receptionist arrival-node-agnostic. Commands land on the *specific* node dialed, which is the
required semantic (each node must apply its own deployment).
### 3.2 Contract
One new proto: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto`, package
`mesh_transport.v1`, csharp_namespace `ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1`
(deliberately NOT `MeshCommand` — that C# name is taken by the existing envelope record
`Commons.Messages.Mesh.MeshCommand`).
```proto
service NodeCommandService {
// Central → node. One-way semantics: the ack means "received and re-emitted on the
// node's EventStream", nothing more. No retry is layered on a failure.
rpc Execute (CommandEnvelope) returns (ExecuteAck);
}
service CentralCommandService {
// Node → central. One-way semantics: the ack means "handed to the coordinator proxy".
rpc ReportApply (ApplyAckMessage) returns (ReportAck);
}
message CommandEnvelope {
string correlation_id = 1;
oneof command {
DispatchDeploymentCommand dispatch_deployment = 2;
RestartDriverCommand restart_driver = 3;
ReconnectDriverCommand reconnect_driver = 4;
AlarmCommandMessage alarm_command = 5;
}
}
```
Codegen is **build-time `Grpc.Tools`** exactly like the two existing protos
(`Commons.csproj:28-31` `<Protobuf … GrpcServices="Both"/>`; generated code is NOT checked in —
OtOpcUa does not have ScadaBridge's linux_arm64 protoc segfault; the rig image build is the
verification). Add a `MeshTransportProtoContract` contract-lock helper + tests mirroring
`TelemetryProtoContract.cs` / `TelemetryProtoContractTests.cs` so a new oneof case cannot ship
unhandled. Mapper: `MeshTransportDtoMapper` in Commons with **round-trip golden tests** for all
five messages (the ScadaBridge T1A.1/T1B.1 lesson: nullable members, init-only props and
enum-zero placement are where mappers silently drop data — reserve proto enum 0 as
`UNSPECIFIED`, never a meaningful value).
### 3.3 Semantics to preserve (all transport-agnostic, all carry over)
- **Fire-and-forget stays fire-and-forget.** No retries, no queues, no new delivery
guarantees. A failed `Execute` is logged per node with its status code and dropped — an
honest upgrade over the silent drop, not a semantic change. The deploy path's backstops
(2-min deadline, orphan-`Applying` boot replay) remain the only recovery mechanisms.
- **`SendToAll` fan ⇒ dial every enabled, non-maintenance `ClusterNode`.** The recipient set is
already DB-derived (`DiscoverDriverNodes` derives the expected-ack set from the same rows) —
the gRPC dispatcher uses the same query shape, so dispatch set == expected-ack set by
construction.
- **`Send` singleton-counting ⇒ one `ReportApply` per node per dispatch.** Unary gives this
naturally; the node tries central endpoints in order and stops at the first success.
- **Node-side delivery stays the EventStream.** The gRPC service Tells
`NodeCommunicationActor` the decoded message; the actor's existing re-emit (`:70-74`) is
untouched, so `EventStreamCommandDeliveryTests` and every subscriber remain valid.
- **Central-side ack handling stays `HandleApplyAck`.** The gRPC service Tells
`CentralCommunicationActor`; the `Forward`-to-proxy and `PersistNodeAck` paths are untouched.
- **Fused nodes work unchanged.** central-1/-2 are `admin,driver,cluster-MAIN`: they serve
both services, dial their own pair with the MAIN key, and ack to themselves. Nothing special.
### 3.4 Work you must do anyway: auth — per-cluster PSK from the Secrets store (decided 2026-08-06)
The command plane is unauthenticated today. The gRPC migration adopts ScadaBridge's
live-gate-proven shape, adapted to OtOpcUa's conventions:
- **Key scope: one PSK per application Cluster** — `OT-MESH-PSK-<clusterId>` (e.g.
`OT-MESH-PSK-MAIN`, `OT-MESH-PSK-SITE-A`), covering both directions of that cluster's
traffic. **Steelmanned alternative — one fleet-wide shared key** (what the other three
OtOpcUa channels do): simpler seeding, but a compromised site node could then issue
`RestartDriver`/`AlarmCommand` to *every other site* and forge acks fleet-wide. ScadaBridge
rejected the shared key on exactly this blast-radius argument; the command plane is where it
bites hardest, so we do too. (The three existing shared-bearer channels are read/telemetry
planes; retrofitting them is out of scope.)
- **Node side (one key):** new option `MeshTransport:GrpcPsk`, production value
`${secret:OT-MESH-PSK-<clusterId>}`, resolved by the **existing pre-host
`SecretReferenceExpander`** (`Host/Program.cs:93-112`) — zero new resolution code. Works on
driver-only nodes (node-local SQLite secrets store + env KEK; no ConfigDb involved).
- **Central dial + serve sides (many keys, cluster set is DB-dynamic):** new
`IMeshPskProvider` (ControlPlane) over the runtime `ISecretResolver`, resolving
`OT-MESH-PSK-{clusterId}` at channel-build/verify time, **cached per cluster, invalidated on
contact refresh, fail-closed** (unresolvable ⇒ deny/skip, never proceed unauthenticated).
A `MeshTransport:ClusterPsks:{clusterId}` config map is checked before the Secrets store —
the rig/test override, mirroring ScadaBridge's `SitePsks__site-*` env pattern; production
leaves it empty.
- **Node-side server interceptor** `MeshCommandAuthInterceptor`, cloned from
`TelemetryStreamAuthInterceptor`: path prefix `/mesh_transport.v1.NodeCommandService/`,
`authorization: Bearer` extraction, `CryptographicOperations.FixedTimeEquals` over UTF-8,
**fail-closed when `GrpcPsk` unset**, reject `PermissionDenied` (the 2-of-3 majority
convention; `ConfigServeAuthInterceptor`'s `Unauthenticated` is the outlier — don't copy it).
**Exactly ONE public constructor** — `Grpc.AspNetCore` silently stops invoking an
interceptor with two (documented `TelemetryStreamAuthInterceptor.cs:38-42`, pinned by
`HasExactlyOnePublicConstructor`; ScadaBridge lost a live-gate day to this).
- **Central-side server interceptor** `CentralCommandAuthInterceptor`: gates
`/mesh_transport.v1.CentralCommandService/`; callers send `x-otopcua-cluster: <clusterId>`
so central picks which key to verify; missing/unknown cluster ⇒ `PermissionDenied`
(fail-closed). **The header is a central-side routing hint only** — per-cluster isolation is
proven by wrong-key rejection, not by the header; do not add a header requirement to the
node-side interceptor (ScadaBridge's gate recorded that as theatre).
- **Client attach:** raw `Metadata` `authorization: Bearer <psk>` per call, the exact pattern
of `GrpcDeploymentArtifactFetcher.cs:122` / `TelemetryStreamClient.cs:134`.
- **Boot validation:** `MeshTransportOptionsValidator` extended per the house rules (roles from
`IConfiguration` via `RoleParser.ResolveEffectiveRoles` — never `IClusterRoleInfo`; require
keys only where the mode makes them load-bearing; error messages name the exact key and env
form, never echo the value). Under `Mode=Grpc`: driver role ⇒ `GrpcPsk` non-empty +
`CentralGrpcEndpoints` non-empty and `http(s)://`-prefixed.
- **Transport honesty:** bearer PSK over plaintext h2c is readable/replayable on-path — same
accepted posture (network isolation) as the three existing channels, and strictly better
than today's unauthenticated remoting. TLS on the internal listeners is follow-on hardening;
the PSK design doesn't change if it lands.
- **Seeding/rotation:** the `secret` CLI's *Reference audit & seed* flow per store; rotation =
set new value both sides, restart the pair (pairs restart together by discipline). Rig uses
committed dev literals per its zero-operator-step convention: `dev-mesh-psk-MAIN`,
`dev-mesh-psk-SITE-A`, `dev-mesh-psk-SITE-B`.
### 3.5 Endpoint discovery — both patterns already exist
- **Central → node:** `ClusterNode.Host` + `GrpcPort` ⇒ `http://{host}:{port}` — the exact
`TelemetryNodeSource` pattern, same `Enabled && !MaintenanceMode` filter, refreshed on the
existing `MeshTransport:ContactRefreshSeconds` timer that today refreshes Akka contacts. **No
new column, no migration, no seed change.**
- **Node → central:** new `MeshTransport:CentralGrpcEndpoints[]` (e.g.
`["http://central-1:4055", "http://central-2:4055"]`), tried in order per ack — the
`GrpcDeploymentArtifactFetcher` endpoint-failover loop shape, channels cached per endpoint.
It replaces `CentralContactPoints` (deleted in Phase 3) rather than reusing
`ConfigSource:CentralFetchEndpoints`, because a `Direct`-mode fused node legitimately has no
ConfigSource section but still acks deployments.
- **Deadlines are net-new** (no Ask timeouts existed): `MeshTransport:DispatchTimeoutSeconds`
(default 10) on `Execute`, `MeshTransport:AckTimeoutSeconds` (default 10) on `ReportApply`,
enforced client-side via linked CTS like the fetcher. Never retry `DeadlineExceeded` — with
one-way semantics a late-delivered command is worse than a dropped one only if we retry it
into a duplicate.
### 3.6 The mode switch — third value in, one value out
`MeshTransport:Mode` gains **`Grpc`**. End state: `Dps` (compiled default; in-mesh/single-mesh
topologies where admin and driver share one gossip ring) | `Grpc` (split topologies).
`ClusterClient` is **deleted**. The DPS branch is deliberately **retained**: the
"Phase 6 deletes the DistributedPubSub branch" comments
(`MeshTransportOptions.cs:11-13`, `ConfigPublishCoordinator.cs:70-73`,
`AdminOperationsActor.cs:90-92`) describe a deletion that never happened and contradict
`docs/Configuration.md:150-156`, which records the deliberate reversal — the comments get
corrected in Phase 0. `SplitTopologyTransportValidator` accepts `{ClusterClient, Grpc}` during
the migration (Phase 0 relaxation — without it, flipping any rig node to `Grpc` fails boot) and
requires `Grpc` from Phase 3.
The coexistence granularity falls out of where the switch is read: **central's mode governs the
command direction, each node's mode governs its ack direction** — so a single site pair can be
flipped to gRPC acks while everything else stays on ClusterClient, and vice versa. (Better than
ScadaBridge's central-wide `SiteTransport`, whose per-site coexistence gate had to be recorded
as unachievable.)
### 3.7 Deletions at the end
`DefaultMeshClusterClientFactory` + `IMeshClusterClientFactory`, both receptionist
registrations, node client creation (`Runtime/ServiceCollectionExtensions.cs:394-407`) and
central `RebuildClient`/per-cluster client cache (**keep `LoadContactsFromDb` — it now feeds
the gRPC dialer's node list**), `akka.conf`'s `ClusterClientReceptionistExtensionProvider`
line + the whole `akka.cluster.client` block (**keep the DPS extension** — pair-local channels
live), `MeshTransport:CentralContactPoints` + its validator rules + rig env, the
`ClusterClient` mode value, and the ClusterClient-shaped tests
(`MeshClusterClientBoundaryTests` is *replaced* by a gRPC-boundary equivalent — the "no Akka
association crosses the boundary" assertion is the point of the whole program and must survive
as a test). `Akka.Cluster.Tools` package refs **stay** (ClusterSingleton + DPS).
### 3.8 Deliberately out of scope
- **`FleetStatusBroadcaster` is silently central-pair-blind post-Phase-6** (derives "fleet"
from central's own gossip ring). Real, pre-existing, transport-independent — filed as its own
issue in Phase 0, not folded in.
- **Cross-mesh `redundancy-state` / `fleet-status` visibility** — deferred by Phase 5, stays
deferred; this migration adds no new cross-mesh channels.
- **`alarm-commands`' pair-local DPS leg** (OPC UA Part 9 method calls published node-locally
by `OtOpcUaServerHostedService:158-159`) — untouched; it never crosses meshes.
- **Delivery-guarantee upgrades** (queuing, retry, dedup) — explicitly not this program.
- **Retrofitting per-cluster PSKs onto the three existing shared-bearer channels** — follow-on.
---
## 4. Code-verified findings this design is sized on (plan-vs-code corrections)
Baked in at authoring time rather than discovered mid-implementation (the ScadaBridge §7
lesson, run as a three-track inventory before writing this doc):
1. **No Ask exists across the boundary** — the per-cluster mesh design doc's sender-preservation
paragraph describes ScadaBridge. OtOpcUa's surface is 4+1 one-way Tells. (Also: its line
listing "queries, subscribe handshakes" over ClusterClient — none exist.)
2. **"Phase 6 deletes the DPS branch" never happened** and the compiled default is still `Dps`;
three source comments assert the opposite of `docs/Configuration.md:150-156`. This design
keeps DPS for in-mesh topologies and deletes only `ClusterClient`; the comments get fixed.
3. **There is no `ICommandTransport` seam.** The seam is the `MeshCommand` envelope + the
nullable `meshRouter`/`centralClient`/`ackRouter` ctor params. The gRPC branch lands inside
the two choke-point actors (a third mode branch), NOT as a new polymorphic interface — the
same "no interface extraction" economy ScadaBridge's §7.1 landed on.
4. **`ConfigPublishCoordinator` subscribes to the `deployment-acks` DPS topic unconditionally**
(`:132`) in both modes — it is the `Dps`-mode ack ingress and must survive; do not "clean it
up" while deleting ClusterClient.
5. **`FleetStatusBroadcaster` was left behind by Phase 6** — see §3.8; issue, not scope.
6. **Interceptor multi-constructor trap and enum-zero mapping traps** are pre-known from
ScadaBridge's live gate and OtOpcUa's own `TelemetryStreamAuthInterceptor.cs:38-42` — both
are encoded as tests in the plan, not left to be rediscovered.
---
## 5. The other knob: keep ClusterClient (the do-nothing steelman)
ClusterClient works today: Phase 7 ran 6/6 failover drills through it, `SendToAll` fan-out and
DB-driven contacts are debugged, and the 5-message surface is small enough that "it's a lot of
transport" is not an argument. Keeping it costs nothing *now*.
What it leaves on the table: (1) the command plane stays **unauthenticated** across the WAN —
the one boundary path with no auth story, in the app whose sister already closed the identical
gap; (2) two transport postures on one set of boxes — every firewall rule, packet capture and
incident runbook must reason about Akka remoting *and* gRPC crossing the same boundary;
(3) `buffer-size=0` silent drops stay silent; (4) Akka.NET upstream has signalled the
ClusterClient/receptionist pair as legacy — ScadaBridge's design recorded the same
deprecation-direction argument and it has only aged one way.
**Verdict:** the auth gap alone justifies Phase 0-style hardening, and once auth + contract
exist, the remaining migration is small. Do the whole thing.
---
## 6. Options
- **A — full migration (recommended):** contract + PSK + both directions behind
`MeshTransport:Mode=Grpc`, coexistence-flagged, then delete ClusterClient. ~2 weeks of
focused work given the 5-message surface and the existing listeners/patterns.
- **B — auth-only:** bolt a PSK story onto ClusterClient (there isn't a natural one — Akka
remoting auth means mTLS on the transport, a different and larger program) or accept the gap.
Rejected: the "auth" half of the work has no cheap ClusterClient-shaped form.
- **C — do nothing:** see §5.
**Recommendation: A.**
---
## 7. Phasing and effort
| Phase | Content | Est. | Parallel with |
|---|---|---|---|
| 0 | Contract (proto + mappers + goldens), auth interceptors + `IMeshPskProvider`, options/validators (+`Grpc` value accepted, SplitTopology relaxation), rig PSK seeding, stale-comment fixes, FleetStatus issue filed | 2–3 d | 1A/1B prep |
| 1A | Central→node command plane behind central's `Mode=Grpc` (worktree A) | 3–4 d | 1B |
| 1B | Node→central ack plane behind node's `Mode=Grpc` (worktree B) | 2–3 d | 1A |
| 2 | Full-rig flip + soak + kill drills (live gate) | 1–2 d | — |
| 3 | Deletion + validator tightening + docs sweep | 1–2 d | — |
| 4 | Terminal live gate on the post-deletion build | 1 d | — |
Critical path ≈ 0 → 1A → 2 → 3 → 4: **~1.5–2.5 weeks**, one-third of ScadaBridge's, which is
what a 5-message no-Ask surface with pre-built listeners should cost.
**Risks, honestly:** the mapper/enum-zero class of bug (mitigated by goldens + contract-lock
tests, but ScadaBridge still found five of these in implementation); the Kestrel
explicit-endpoints trap does NOT apply (no new listeners — but any future "just add a port"
deviation re-imports it, see `Program.cs:564-583` before touching anything there); PSK cache
invalidation on cluster add/remove is the one genuinely new stateful component
(`IMeshPskProvider`) — its fail-closed behavior needs adversarial tests, not just happy-path.
Implementation plan: `2026-08-06-clusterclient-to-grpc.md`. Live gate:
`2026-08-06-clusterclient-to-grpc-live-gate.md`. Central tracking:
`2026-08-06-clusterclient-to-grpc-tracking.md`.