diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/MeshTransportProtoContract.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/MeshTransportProtoContract.cs
new file mode 100644
index 00000000..d49ca315
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/MeshTransportProtoContract.cs
@@ -0,0 +1,33 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Protos;
+
+///
+/// The single source of truth for which variants
+/// the cross-mesh command transport actually handles. Both the contract-lock test (which proves
+/// this list stays in lock-step with the generated oneof) and the oneof↔domain mapper reference
+/// this array, so adding a fifth command to mesh_transport.proto forces a matching entry
+/// here or the build/test goes red.
+///
+///
+/// Mirrors , and for a sharper reason: a telemetry variant
+/// nobody handles loses an observation, whereas a command variant nobody handles loses a
+/// deployment, a driver restart or an alarm acknowledgement — silently, since the sender's ack
+/// only ever meant "received".
+///
+public static class MeshTransportProtoContract
+{
+ ///
+ /// Exactly the four cross-mesh command oneof cases mirrored from the domain records:
+ /// DispatchDeployment / RestartDriver / ReconnectDriver / AlarmCommand. The fifth message on
+ /// this contract, ApplyAckMessage, travels the opposite direction on its own RPC and is
+ /// not a oneof member, so it is deliberately absent from this list.
+ ///
+ public static readonly CommandEnvelope.CommandOneofCase[] HandledCases =
+ [
+ CommandEnvelope.CommandOneofCase.DispatchDeployment,
+ CommandEnvelope.CommandOneofCase.RestartDriver,
+ CommandEnvelope.CommandOneofCase.ReconnectDriver,
+ CommandEnvelope.CommandOneofCase.AlarmCommand,
+ ];
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto
new file mode 100644
index 00000000..d91c52f8
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto
@@ -0,0 +1,118 @@
+syntax = "proto3";
+
+package mesh_transport.v1;
+
+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 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
+// of the same name would collide on every file that touches both sides of the mapper.
+//
+// Additive-only field evolution: never renumber or reuse a tag; a pre-field peer must read a new
+// field's proto3 default correctly (which is why nullable domain members are modelled with `optional`
+// for explicit presence, and why every enum reserves 0 as UNSPECIFIED). The oneof is locked by
+// 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
+// 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.
+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
+// ack means "handed to the coordinator", not "the deployment is complete".
+service CentralCommandService {
+ rpc ReportApply (ApplyAckMessage) returns (ReportAck);
+}
+
+message CommandEnvelope {
+ // Transport-level correlation, carried for every command including AlarmCommandMessage, which is
+ // the one CLR record with no correlation member of its own. The other three ALSO carry their own
+ // domain correlation field-for-field below; the duplication is deliberate, so each command stays
+ // independently round-trippable through the mapper and the envelope stays loggable on its own.
+ string correlation_id = 1;
+ oneof command {
+ DispatchDeploymentCommand dispatch_deployment = 2; // <- Messages.Deploy.DispatchDeployment
+ RestartDriverCommand restart_driver = 3; // <- Messages.Admin.RestartDriver
+ ReconnectDriverCommand reconnect_driver = 4; // <- Messages.Admin.ReconnectDriver
+ AlarmCommandMessage alarm_command = 5; // <- OpcUa.AlarmCommand
+ }
+}
+
+// Deliberately empty. A non-OK gRPC status is the ONLY failure signal on this call — putting a
+// success flag in the body would invite a caller to read OK+false as a distinct outcome the
+// fire-and-forget contract does not have. Extensible later without a version bump.
+message ExecuteAck {
+}
+
+// Deliberately empty, for the same reason as ExecuteAck.
+message ReportAck {
+}
+
+// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.DispatchDeployment.
+message DispatchDeploymentCommand {
+ string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form
+ string revision_hash = 2; // RevisionHash — lowercase 64-char SHA-256 hex
+ string correlation_id = 3; // CorrelationId (Guid) in its "N" hex form
+}
+
+// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.RestartDriver.
+message RestartDriverCommand {
+ string cluster_id = 1;
+ string driver_instance_id = 2;
+ string actor_by_user_name = 3;
+ string correlation_id = 4; // Guid in the record; "N" hex form
+}
+
+// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.ReconnectDriver.
+message ReconnectDriverCommand {
+ string cluster_id = 1;
+ string driver_instance_id = 2;
+ string actor_by_user_name = 3;
+ string correlation_id = 4; // Guid in the record; "N" hex form
+}
+
+// Mirrors ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AlarmCommand.
+message AlarmCommandMessage {
+ string alarm_id = 1;
+ // The Part 9 operation (Acknowledge / Confirm / OneShotShelve / TimedShelve / Unshelve / Enable /
+ // Disable / AddComment). A `string` in the CLR record, and kept as one here — the same call the
+ // telemetry contract makes for transition_kind / state / level. Promoting it to a proto enum would
+ // put a lossy translation in the mapper for a set the CLR side does not constrain: an operation the
+ // enum did not know would decode to UNSPECIFIED and the command would be dropped, which is the
+ // exact silent-drop class this migration exists to remove.
+ string operation = 2;
+ string user = 3;
+ optional string comment = 4; // nullable in the record — presence distinguishes null from ""
+ google.protobuf.Timestamp unshelve_at_utc = 5; // DateTime? — absent Timestamp encodes null
+}
+
+// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAck.
+message ApplyAckMessage {
+ string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form
+ string node_id = 2; // NodeId — a logical host name, already a string
+ ApplyOutcome outcome = 3;
+ optional string failure_reason = 4; // nullable in the record — presence distinguishes null from ""
+ string correlation_id = 5; // CorrelationId (Guid) in its "N" hex form
+}
+
+// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAckOutcome, shifted by one so 0 stays
+// meaningless. proto3 decodes an absent enum field as 0; if 0 were Applied, an ack that lost the
+// field would report a deployment applied when the node said nothing at all — and the deploy path
+// seals on exactly that signal.
+enum ApplyOutcome {
+ APPLY_OUTCOME_UNSPECIFIED = 0;
+ APPLY_OUTCOME_APPLIED = 1;
+ APPLY_OUTCOME_FAILED = 2;
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj
index 5e55544f..bd560b43 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj
@@ -28,6 +28,7 @@
+
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportProtoContractTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportProtoContractTests.cs
new file mode 100644
index 00000000..a7c33c08
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportProtoContractTests.cs
@@ -0,0 +1,49 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Protos;
+using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
+
+///
+/// Contract-lock for the ClusterClient→gRPC command envelope's oneof. If a fifth command is added
+/// to mesh_transport.proto without adding a matching entry to
+/// , this test goes red — the single guard
+/// that keeps the wire contract and the handled-case set from silently drifting apart. A drift
+/// here is exactly the failure the whole transport exists to avoid: a command that crosses the
+/// mesh boundary, decodes to a case nobody handles, and is dropped without a trace.
+///
+public class MeshTransportProtoContractTests
+{
+ [Fact]
+ public void EveryOneofVariant_IsAccountedFor()
+ {
+ var variants = System.Enum.GetValues()
+ .Where(c => c != CommandEnvelope.CommandOneofCase.None).ToArray();
+
+ variants.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true);
+ }
+
+ [Fact]
+ public void The_service_base_and_client_types_generate()
+ {
+ // Referencing these types is the whole assertion — GrpcServices="Both" must emit both halves
+ // of both services: central hosts CentralCommandService and dials NodeCommandService, and a
+ // driver node does the mirror image, so neither service can ship client-only or server-only.
+ typeof(NodeCommandService.NodeCommandServiceBase).ShouldNotBeNull();
+ typeof(NodeCommandService.NodeCommandServiceClient).ShouldNotBeNull();
+ typeof(CentralCommandService.CentralCommandServiceBase).ShouldNotBeNull();
+ typeof(CentralCommandService.CentralCommandServiceClient).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void The_apply_outcome_enum_reserves_zero_as_unspecified()
+ {
+ // proto3 decodes an absent enum field as 0. If 0 meant "Applied", a message that lost the
+ // field in transit — or was written by a peer that predates it — would report a deployment
+ // as successfully applied when nothing was said at all. 0 must stay meaningless.
+ ((int)ApplyOutcome.Unspecified).ShouldBe(0);
+ ((int)ApplyOutcome.Applied).ShouldNotBe(0);
+ ((int)ApplyOutcome.Failed).ShouldNotBe(0);
+ }
+}