26 KiB
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
ClusterClientper application Cluster, contacts loaded from the database (ClusterNoderowsWHERE Enabled AND NOT MaintenanceMode, grouped byClusterId,akka.tcp://…/system/receptionistbuilt fromHost+AkkaPort) —CentralCommunicationActor.cs:207-245, refreshed everyMeshTransport:ContactRefreshSeconds(default 60). Factory seamIMeshClusterClientFactory/DefaultMeshClusterClientFactory(ControlPlane/Communication/IMeshClusterClientFactory.cs). - Node: one
central-cluster-client, contacts from static configMeshTransport:CentralContactPoints[]—Runtime/ServiceCollectionExtensions.cs:394-407, created only underMeshTransport: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
- 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.
- 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.
- 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.
- 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:4053and 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. - Silent drop becomes visible failure.
buffer-size = 0means 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,SplitTopologyTransportValidatoralready forcesTelemetry:Mode=Grpc, whose validator forcesGrpcListenPort > 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 tiesMeshTransport:Mode=Grpc+ admin role toConfigServe: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 Forwards 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).
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
Executeis 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-Applyingboot replay) remain the only recovery mechanisms. SendToAllfan ⇒ dial every enabled, non-maintenanceClusterNode. The recipient set is already DB-derived (DiscoverDriverNodesderives the expected-ack set from the same rows) — the gRPC dispatcher uses the same query shape, so dispatch set == expected-ack set by construction.Sendsingleton-counting ⇒ oneReportApplyper 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
NodeCommunicationActorthe decoded message; the actor's existing re-emit (:70-74) is untouched, soEventStreamCommandDeliveryTestsand every subscriber remain valid. - Central-side ack handling stays
HandleApplyAck. The gRPC service TellsCentralCommunicationActor; theForward-to-proxy andPersistNodeAckpaths 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 issueRestartDriver/AlarmCommandto 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-hostSecretReferenceExpander(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 runtimeISecretResolver, resolvingOT-MESH-PSK-{clusterId}at channel-build/verify time, cached per cluster, invalidated on contact refresh, fail-closed (unresolvable ⇒ deny/skip, never proceed unauthenticated). AMeshTransport:ClusterPsks:{clusterId}config map is checked before the Secrets store — the rig/test override, mirroring ScadaBridge'sSitePsks__site-*env pattern; production leaves it empty. - Node-side server interceptor
MeshCommandAuthInterceptor, cloned fromTelemetryStreamAuthInterceptor: path prefix/mesh_transport.v1.NodeCommandService/,authorization: Bearerextraction,CryptographicOperations.FixedTimeEqualsover UTF-8, fail-closed whenGrpcPskunset, rejectPermissionDenied(the 2-of-3 majority convention;ConfigServeAuthInterceptor'sUnauthenticatedis the outlier — don't copy it). Exactly ONE public constructor —Grpc.AspNetCoresilently stops invoking an interceptor with two (documentedTelemetryStreamAuthInterceptor.cs:38-42, pinned byHasExactlyOnePublicConstructor; ScadaBridge lost a live-gate day to this). - Central-side server interceptor
CentralCommandAuthInterceptor: gates/mesh_transport.v1.CentralCommandService/; callers sendx-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
Metadataauthorization: Bearer <psk>per call, the exact pattern ofGrpcDeploymentArtifactFetcher.cs:122/TelemetryStreamClient.cs:134. - Boot validation:
MeshTransportOptionsValidatorextended per the house rules (roles fromIConfigurationviaRoleParser.ResolveEffectiveRoles— neverIClusterRoleInfo; require keys only where the mode makes them load-bearing; error messages name the exact key and env form, never echo the value). UnderMode=Grpc: driver role ⇒GrpcPsknon-empty +CentralGrpcEndpointsnon-empty andhttp(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
secretCLI'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 exactTelemetryNodeSourcepattern, sameEnabled && !MaintenanceModefilter, refreshed on the existingMeshTransport:ContactRefreshSecondstimer 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 — theGrpcDeploymentArtifactFetcherendpoint-failover loop shape, channels cached per endpoint. It replacesCentralContactPoints(deleted in Phase 3) rather than reusingConfigSource:CentralFetchEndpoints, because aDirect-mode fused node legitimately has no ConfigSource section but still acks deployments. - Deadlines are net-new (no Ask timeouts existed):
MeshTransport:DispatchTimeoutSeconds(default 10) onExecute,MeshTransport:AckTimeoutSeconds(default 10) onReportApply, enforced client-side via linked CTS like the fetcher. Never retryDeadlineExceeded— 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
FleetStatusBroadcasteris 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-statusvisibility — 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 byOtOpcUaServerHostedService: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):
- 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.)
- "Phase 6 deletes the DPS branch" never happened and the compiled default is still
Dps; three source comments assert the opposite ofdocs/Configuration.md:150-156. This design keeps DPS for in-mesh topologies and deletes onlyClusterClient; the comments get fixed. - There is no
ICommandTransportseam. The seam is theMeshCommandenvelope + the nullablemeshRouter/centralClient/ackRouterctor 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. ConfigPublishCoordinatorsubscribes to thedeployment-acksDPS topic unconditionally (:132) in both modes — it is theDps-mode ack ingress and must survive; do not "clean it up" while deleting ClusterClient.FleetStatusBroadcasterwas left behind by Phase 6 — see §3.8; issue, not scope.- 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.