test(mesh): step 7 — replace the boundary test with MeshGrpcBoundaryTests and sweep the comments

MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:

  1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
     point, kept as a test rather than a live-gate note: each cluster stays single-member and
     none ever holds another's address as a member.
  2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
     site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
     would be refused at the far side.
  3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
     it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
     process did". The same node then accepts the right key, so the silence is the auth gate
     and not a dead endpoint.

All three run fully offline — no Docker fixture — and pass.

Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.

The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
This commit is contained in:
Joseph Doherty
2026-08-06 15:58:36 -04:00
parent af3b0c3146
commit 4d2dfb5b50
33 changed files with 505 additions and 86 deletions
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
/// <param name="Topic">
/// The legacy DPS topic. Carried so the router can publish under
/// <c>MeshTransport:Mode = Dps</c> without a second dispatch table; ignored entirely under
/// <c>ClusterClient</c> mode, where the destination is an actor path rather than a topic.
/// <c>Grpc</c> mode, where the destination is a node endpoint rather than a topic.
/// </param>
/// <param name="Message">
/// The command itself, never wrapped. Node-side handlers see exactly the type they see today,
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
/// Declared once, here, rather than as literals on each side. The <c>deployments</c> /
/// <c>deployment-acks</c> topic names are declared twice — once in
/// <c>ConfigPublishCoordinator</c> and again in <c>DriverHostActor</c> — and agree only by
/// convention; renaming one would compile, deploy, and deliver nothing. A ClusterClient path
/// convention; renaming one would compile, deploy, and deliver nothing. A mesh path
/// has the same failure mode and no compiler to catch it, so the constant is shared.
/// </para>
/// <para>
@@ -9,13 +9,13 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
/// <summary>
/// The one translation between the five cross-mesh command records and their
/// <c>mesh_transport.v1</c> wire shapes (ClusterClient→gRPC migration Phase 0). Both directions live
/// <c>mesh_transport.v1</c> wire shapes (mesh gRPC command plane, Phase 0). Both directions live
/// here so a field can only be added to one side without the other by making this file
/// visibly asymmetric.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here guesses.</b> The transport it replaces — an Akka ClusterClient receptionist —
/// <b>Nothing here guesses.</b> The transport it replaced — an Akka ClusterClient receptionist —
/// dropped an undeliverable command silently, and the entire point of the migration is to stop
/// doing that. So every decode that cannot produce a faithful record throws: an unset oneof, an
/// <c>APPLY_OUTCOME_UNSPECIFIED</c> ack, an enum value from a newer peer. A loud failure on one
@@ -6,9 +6,9 @@ option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1";
import "google/protobuf/timestamp.proto";
// ClusterClient→gRPC migration Phase 0: the cross-mesh command plane. The five messages that cross
// an application-Cluster boundary today ride an Akka ClusterClient receptionist; this contract is
// the replacement carrier. Four are central→node commands, one is the node→central deployment ack.
// The cross-mesh command plane (introduced in Phase 0, now the only transport that crosses an
// application-Cluster boundary). Five messages ride it: four central→node commands and one
// node→central deployment ack.
//
// The C# namespace deliberately says "MeshTransport", never "MeshCommand" — the latter is already
// the CLR envelope record ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh.MeshCommand, and a generated type
@@ -20,17 +20,17 @@ import "google/protobuf/timestamp.proto";
// MeshTransportProtoContract + its test in the Commons test project.
// Central → node. Delivered to the node's NodeCommunicationActor, which re-emits the decoded message
// on the node's EventStream. ONE-WAY semantics, preserved verbatim from the ClusterClient transport:
// the ack means "received and re-emitted", nothing more — not applied, not acknowledged by any
// on the node's EventStream. ONE-WAY semantics, matching the node-local DistributedPubSub leg: the
// ack means "received and re-emitted", nothing more — not applied, not acknowledged by any
// subscriber. No retry is layered on a failure; a failed Execute is logged per node and dropped,
// which is an honest upgrade over the receptionist's silent drop rather than a semantic change.
// which is an honest upgrade over a pub/sub publish nobody was listening for.
service NodeCommandService {
rpc Execute (CommandEnvelope) returns (ExecuteAck);
}
// Node → central. Delivered to central's CentralCommunicationActor, which forwards to the
// ConfigPublishCoordinator singleton proxy — so an ack landing on either node of the central pair
// routes identically, exactly as the receptionist made arrival-node-agnostic. ONE-WAY semantics: the
// routes identically — which central node answered is deliberately not observable. ONE-WAY semantics: the
// ack means "handed to the coordinator", not "the deployment is complete".
service CentralCommandService {
rpc ReportApply (ApplyAckMessage) returns (ReportAck);
@@ -22,7 +22,7 @@ public sealed class ClusterNode
/// Akka remoting port (default 4053). <b>This is central's dial target, not the node's own
/// binding configuration</b> — the node binds from <c>Cluster:Port</c> in its own appsettings,
/// and this column duplicates that value for a reader that cannot see the node's config.
/// Per-cluster mesh Phase 2 builds its ClusterClient contact points from
/// Per-cluster mesh Phase 2 built its Akka contact points from
/// <see cref="Host"/> + this port, at which point central and the site node no longer share a
/// gossip ring and central has no other way to learn it.
/// </summary>