diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs
new file mode 100644
index 00000000..1b83ef7d
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs
@@ -0,0 +1,391 @@
+using Google.Protobuf.WellKnownTypes;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
+
+///
+/// The one translation between the five cross-mesh command records and their
+/// mesh_transport.v1 wire shapes (ClusterClient→gRPC migration 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.
+///
+///
+///
+/// Nothing here guesses. The transport it replaces — 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
+/// APPLY_OUTCOME_UNSPECIFIED ack, an enum value from a newer peer. A loud failure on one
+/// command is recoverable; a deployment reported applied because an absent enum field decoded to
+/// 0 is not.
+///
+///
+/// Presence, not emptiness. The two nullable members that cross this contract —
+/// and — are
+/// optional string on the wire, so null maps to an absent field and "" to a
+/// present-but-empty one. Collapsing them would report an operator's deliberately blank comment
+/// as "no comment supplied", and a blank failure reason as "no reason recorded".
+/// uses a message field, whose own absence carries the
+/// null.
+///
+///
+/// Correlation is written twice and read once. Encode stamps the envelope's
+/// correlation_id and the command's own from the same source. Decode takes the command's
+/// own for the three records that declare one — that member is domain state the record must
+/// carry back verbatim — and leaves the envelope's to the transport, which is the only place
+/// 's correlation can live at all since the record has no member for it.
+///
+///
+public static class MeshTransportDtoMapper
+{
+ // ---------------------------------------------------------------- DispatchDeployment
+
+ /// Projects onto its wire form.
+ /// The domain dispatch command.
+ /// The wire .
+ public static DispatchDeploymentCommand ToProto(DispatchDeployment message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return new DispatchDeploymentCommand
+ {
+ DeploymentId = message.DeploymentId.ToString(),
+ RevisionHash = message.RevisionHash.Value ?? "",
+ CorrelationId = message.CorrelationId.ToString(),
+ };
+ }
+
+ /// Rebuilds the domain record from its wire form.
+ /// The wire dispatch command.
+ /// The domain .
+ /// An id field is not a hyphenless GUID.
+ /// The revision hash is absent or blank.
+ public static DispatchDeployment FromProto(DispatchDeploymentCommand proto)
+ {
+ ArgumentNullException.ThrowIfNull(proto);
+
+ return new DispatchDeployment(
+ DeploymentId.Parse(proto.DeploymentId),
+ RevisionHash.Parse(proto.RevisionHash),
+ CorrelationId.Parse(proto.CorrelationId));
+ }
+
+ // ---------------------------------------------------------------- RestartDriver
+
+ /// Projects onto its wire form.
+ /// The domain restart command.
+ /// The wire .
+ public static RestartDriverCommand ToProto(RestartDriver message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ // Required (non-optional) proto string setters throw on null and the record's nullable-ref
+ // annotations are compile-time only, so a null reaching here is already a producer bug. Coalesce
+ // rather than throw out of the mapper: a command with a blank field is diagnosable downstream,
+ // whereas a throw here is the silent drop this transport exists to remove.
+ return new RestartDriverCommand
+ {
+ ClusterId = message.ClusterId ?? "",
+ DriverInstanceId = message.DriverInstanceId ?? "",
+ ActorByUserName = message.ActorByUserName ?? "",
+ CorrelationId = message.CorrelationId.ToString("N"),
+ };
+ }
+
+ /// Rebuilds the domain record from its wire form.
+ /// The wire restart command.
+ /// The domain .
+ /// The correlation id is not a hyphenless GUID.
+ public static RestartDriver FromProto(RestartDriverCommand proto)
+ {
+ ArgumentNullException.ThrowIfNull(proto);
+
+ return new RestartDriver(
+ proto.ClusterId,
+ proto.DriverInstanceId,
+ proto.ActorByUserName,
+ Guid.ParseExact(proto.CorrelationId, "N"));
+ }
+
+ // ---------------------------------------------------------------- ReconnectDriver
+
+ /// Projects onto its wire form.
+ /// The domain reconnect command.
+ /// The wire .
+ public static ReconnectDriverCommand ToProto(ReconnectDriver message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return new ReconnectDriverCommand
+ {
+ ClusterId = message.ClusterId ?? "",
+ DriverInstanceId = message.DriverInstanceId ?? "",
+ ActorByUserName = message.ActorByUserName ?? "",
+ CorrelationId = message.CorrelationId.ToString("N"),
+ };
+ }
+
+ /// Rebuilds the domain record from its wire form.
+ /// The wire reconnect command.
+ /// The domain .
+ /// The correlation id is not a hyphenless GUID.
+ public static ReconnectDriver FromProto(ReconnectDriverCommand proto)
+ {
+ ArgumentNullException.ThrowIfNull(proto);
+
+ return new ReconnectDriver(
+ proto.ClusterId,
+ proto.DriverInstanceId,
+ proto.ActorByUserName,
+ Guid.ParseExact(proto.CorrelationId, "N"));
+ }
+
+ // ---------------------------------------------------------------- AlarmCommand
+
+ /// Projects onto its wire form.
+ /// The domain alarm command.
+ /// The wire .
+ ///
+ /// is copied verbatim. It is an unconstrained string in the
+ /// record and stays one on the wire, so an operation this build does not recognise reaches the
+ /// far side intact instead of collapsing onto an enum's UNSPECIFIED and being dropped.
+ ///
+ public static AlarmCommandMessage ToProto(AlarmCommand message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ var proto = new AlarmCommandMessage
+ {
+ AlarmId = message.AlarmId ?? "",
+ Operation = message.Operation ?? "",
+ User = message.User ?? "",
+ };
+
+ if (message.Comment is not null)
+ proto.Comment = message.Comment;
+ if (message.UnshelveAtUtc is { } unshelveAt)
+ proto.UnshelveAtUtc = ToUtcTimestamp(unshelveAt);
+
+ return proto;
+ }
+
+ /// Rebuilds the domain record from its wire form.
+ /// The wire alarm command.
+ /// The domain .
+ public static AlarmCommand FromProto(AlarmCommandMessage proto)
+ {
+ ArgumentNullException.ThrowIfNull(proto);
+
+ return new AlarmCommand(
+ proto.AlarmId,
+ proto.Operation,
+ proto.User,
+ proto.HasComment ? proto.Comment : null,
+ proto.UnshelveAtUtc?.ToDateTime());
+ }
+
+ // ---------------------------------------------------------------- ApplyAck
+
+ /// Projects onto its wire form.
+ /// The domain per-node apply acknowledgment.
+ /// The wire .
+ ///
+ /// is not a declared .
+ ///
+ public static ApplyAckMessage ToProto(ApplyAck message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ var proto = new ApplyAckMessage
+ {
+ DeploymentId = message.DeploymentId.ToString(),
+ NodeId = message.NodeId.Value ?? "",
+ Outcome = message.Outcome switch
+ {
+ ApplyAckOutcome.Applied => ApplyOutcome.Applied,
+ ApplyAckOutcome.Failed => ApplyOutcome.Failed,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(message), message.Outcome, "Unknown ApplyAckOutcome"),
+ },
+ CorrelationId = message.CorrelationId.ToString(),
+ };
+
+ if (message.FailureReason is not null)
+ proto.FailureReason = message.FailureReason;
+
+ return proto;
+ }
+
+ /// Rebuilds the domain record from its wire form.
+ /// The wire apply acknowledgment.
+ /// The domain .
+ ///
+ /// The outcome is APPLY_OUTCOME_UNSPECIFIED or a value this build does not know.
+ ///
+ /// An id field is not a hyphenless GUID.
+ /// The node id is absent or blank.
+ public static ApplyAck FromProto(ApplyAckMessage proto)
+ {
+ ArgumentNullException.ThrowIfNull(proto);
+
+ // proto3 decodes an absent enum field as 0, and the deploy path seals a revision on Applied.
+ // ApplyAckOutcome has no Unknown member to land on, so an unspecified or unrecognised outcome
+ // must refuse rather than pick one of the two real answers — either choice would be a lie about
+ // what the node said, and the "Applied" lie seals a deployment nobody applied.
+ var outcome = proto.Outcome switch
+ {
+ ApplyOutcome.Applied => ApplyAckOutcome.Applied,
+ ApplyOutcome.Failed => ApplyAckOutcome.Failed,
+ _ => throw new InvalidOperationException(
+ $"ApplyAckMessage carries outcome '{proto.Outcome}', which has no ApplyAckOutcome " +
+ "equivalent. Refusing to decode rather than report an unsaid deployment result."),
+ };
+
+ return new ApplyAck(
+ DeploymentId.Parse(proto.DeploymentId),
+ NodeId.Parse(proto.NodeId),
+ outcome,
+ proto.HasFailureReason ? proto.FailureReason : null,
+ CorrelationId.Parse(proto.CorrelationId));
+ }
+
+ // ---------------------------------------------------------------- Envelope
+
+ /// Wraps a dispatch command in its transport envelope.
+ /// The domain dispatch command.
+ /// The envelope, correlated from the record's own CorrelationId.
+ public static CommandEnvelope ToEnvelope(DispatchDeployment message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return new CommandEnvelope
+ {
+ CorrelationId = message.CorrelationId.ToString(),
+ DispatchDeployment = ToProto(message),
+ };
+ }
+
+ /// Wraps a restart command in its transport envelope.
+ /// The domain restart command.
+ /// The envelope, correlated from the record's own CorrelationId.
+ public static CommandEnvelope ToEnvelope(RestartDriver message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return new CommandEnvelope
+ {
+ CorrelationId = message.CorrelationId.ToString("N"),
+ RestartDriver = ToProto(message),
+ };
+ }
+
+ /// Wraps a reconnect command in its transport envelope.
+ /// The domain reconnect command.
+ /// The envelope, correlated from the record's own CorrelationId.
+ public static CommandEnvelope ToEnvelope(ReconnectDriver message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return new CommandEnvelope
+ {
+ CorrelationId = message.CorrelationId.ToString("N"),
+ ReconnectDriver = ToProto(message),
+ };
+ }
+
+ /// Wraps an alarm command in its transport envelope.
+ /// The domain alarm command.
+ ///
+ /// Transport correlation. Explicit because is the one record with no
+ /// correlation member of its own, so the envelope is the only place it can be carried.
+ ///
+ /// The envelope.
+ public static CommandEnvelope ToEnvelope(AlarmCommand message, CorrelationId correlationId)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return new CommandEnvelope
+ {
+ CorrelationId = correlationId.ToString(),
+ AlarmCommand = ToProto(message),
+ };
+ }
+
+ ///
+ /// Untyped dispatch for the transport router, which holds its payload as
+ /// (an ).
+ ///
+ /// One of the four central→node command records.
+ ///
+ /// Consulted only when is an . The
+ /// other three carry their own correlation and it is authoritative for them, so passing a
+ /// different value here cannot make the envelope disagree with the command inside it.
+ ///
+ /// The envelope produced by the matching typed overload.
+ ///
+ /// is not one of the four handled command records — the transport
+ /// would otherwise carry a payload the far side cannot decode.
+ ///
+ public static CommandEnvelope ToEnvelope(object message, CorrelationId alarmCorrelationId)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return message switch
+ {
+ DispatchDeployment m => ToEnvelope(m),
+ RestartDriver m => ToEnvelope(m),
+ ReconnectDriver m => ToEnvelope(m),
+ AlarmCommand m => ToEnvelope(m, alarmCorrelationId),
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(message), message.GetType().FullName, "Not a cross-mesh command record"),
+ };
+ }
+
+ /// Unwraps a transport envelope into the domain record it carries.
+ /// The received envelope.
+ ///
+ /// One of , ,
+ /// or , boxed — the node-side handlers
+ /// match on the concrete type exactly as they do under the DPS transport.
+ ///
+ ///
+ /// The oneof is unset or names a case this build does not handle. Kept unreachable by
+ /// MeshTransportProtoContract and its contract-lock test; it throws anyway because the
+ /// alternative is a command that crosses the mesh and disappears.
+ ///
+ public static object FromEnvelope(CommandEnvelope envelope)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+
+ return envelope.CommandCase switch
+ {
+ CommandEnvelope.CommandOneofCase.DispatchDeployment => FromProto(envelope.DispatchDeployment),
+ CommandEnvelope.CommandOneofCase.RestartDriver => FromProto(envelope.RestartDriver),
+ CommandEnvelope.CommandOneofCase.ReconnectDriver => FromProto(envelope.ReconnectDriver),
+ CommandEnvelope.CommandOneofCase.AlarmCommand => FromProto(envelope.AlarmCommand),
+ _ => throw new InvalidOperationException(
+ $"CommandEnvelope (correlation '{envelope.CorrelationId}') carries oneof case " +
+ $"'{envelope.CommandCase}', which this build does not handle."),
+ };
+ }
+
+ ///
+ /// Converts a domain to a proto without ever
+ /// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
+ /// already-UTC (the producer contract for every …Utc member here).
+ ///
+ private static Timestamp ToUtcTimestamp(DateTime dt)
+ {
+ var utc = dt.Kind switch
+ {
+ DateTimeKind.Utc => dt,
+ DateTimeKind.Local => dt.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(dt, DateTimeKind.Utc),
+ };
+
+ return Timestamp.FromDateTime(utc);
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs
new file mode 100644
index 00000000..0177a85a
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs
@@ -0,0 +1,451 @@
+using Google.Protobuf;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Commons.Protos;
+using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
+
+///
+/// Round-trip goldens for — the ClusterClient→gRPC command
+/// plane's only data contract. Every golden goes domain → proto → serialized bytes → proto →
+/// domain, because the failure class this suite exists to catch is not a mis-assigned property but a
+/// member that survives an in-memory copy and vanishes on the wire: proto3 explicit presence is a
+/// serialization-level property, so a null-vs-"" or absent-vs-epoch mistake is invisible
+/// unless the bytes are actually round-tripped.
+///
+///
+/// Each record gets one all-members-non-default golden plus a null-variant golden for every
+/// nullable member, since a nullable member is exactly where a silent drop hides — the record still
+/// compares equal to a fresh instance built with the default, so nothing else in the system notices.
+///
+public class MeshTransportDtoMapperTests
+{
+ // Deliberately non-default in every position: distinct GUIDs (so a swapped assignment cannot pass),
+ // a real 64-char lowercase SHA-256 hex, and strings that are neither empty nor equal to each other.
+ private static readonly Guid DeploymentGuid = Guid.ParseExact("11111111111111111111111111111111", "N");
+ private static readonly Guid CorrelationGuid = Guid.ParseExact("22222222222222222222222222222222", "N");
+ private static readonly Guid EnvelopeGuid = Guid.ParseExact("33333333333333333333333333333333", "N");
+
+ private const string Sha256Hex = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
+
+ /// Serializes and re-parses so presence is proven on the wire.
+ private static T Wire(T message)
+ where T : IMessage, new()
+ => new MessageParser(() => new T()).ParseFrom(message.ToByteArray());
+
+ // ---------------------------------------------------------------- DispatchDeployment
+
+ [Fact]
+ public void DispatchDeployment_RoundTrips_WithEveryMemberSet()
+ {
+ var domain = new DispatchDeployment(
+ new DeploymentId(DeploymentGuid),
+ new RevisionHash(Sha256Hex),
+ new CorrelationId(CorrelationGuid));
+
+ MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void DispatchDeployment_EncodesIdsInTheirHexNForm()
+ {
+ var proto = MeshTransportDtoMapper.ToProto(new DispatchDeployment(
+ new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)));
+
+ proto.DeploymentId.ShouldBe(DeploymentGuid.ToString("N"));
+ proto.RevisionHash.ShouldBe(Sha256Hex);
+ proto.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
+ }
+
+ // ---------------------------------------------------------------- RestartDriver / ReconnectDriver
+
+ [Fact]
+ public void RestartDriver_RoundTrips_WithEveryMemberSet()
+ {
+ var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe@zb.local", CorrelationGuid);
+
+ MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void ReconnectDriver_RoundTrips_WithEveryMemberSet()
+ {
+ var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "aoperator@zb.local", CorrelationGuid);
+
+ MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
+ }
+
+ // ---------------------------------------------------------------- AlarmCommand
+
+ [Fact]
+ public void AlarmCommand_RoundTrips_WithEveryMemberSet()
+ {
+ var domain = new AlarmCommand(
+ "alarm-42",
+ "TimedShelve",
+ "jdoe@zb.local",
+ "shelving for maintenance",
+ new DateTime(2026, 8, 6, 13, 45, 12, 345, DateTimeKind.Utc).AddTicks(6789));
+
+ MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void AlarmCommand_RoundTrips_WithNullComment()
+ {
+ var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", null, null);
+
+ var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
+
+ back.ShouldBe(domain);
+ back.Comment.ShouldBeNull();
+ back.UnshelveAtUtc.ShouldBeNull();
+ }
+
+ [Fact]
+ public void AlarmCommand_EmptyComment_StaysEmpty_AndIsNotNull()
+ {
+ // The trap: proto3 without explicit presence decodes an absent string as "", collapsing
+ // null and "" onto one value. `optional string comment` keeps them distinct — an operator who
+ // acknowledged with a deliberately blank comment must not be reported as having supplied none.
+ var domain = new AlarmCommand("alarm-42", "AddComment", "jdoe", "", null);
+
+ var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
+
+ back.ShouldBe(domain);
+ back.Comment.ShouldNotBeNull();
+ back.Comment.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void AlarmCommand_UnshelveAt_RoundTripsAsUtc()
+ {
+ // DateTime equality compares ticks and ignores Kind, so record equality alone would pass on a
+ // Local-Kind result whose instant is hours off once anything re-interprets it. Assert Kind.
+ var when = new DateTime(2026, 8, 6, 13, 45, 12, DateTimeKind.Utc).AddTicks(1234567);
+ var domain = new AlarmCommand("alarm-42", "TimedShelve", "jdoe", null, when);
+
+ var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
+
+ back.UnshelveAtUtc.ShouldNotBeNull();
+ back.UnshelveAtUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
+ back.UnshelveAtUtc.Value.Ticks.ShouldBe(when.Ticks);
+ }
+
+ [Fact]
+ public void AlarmCommand_UnrecognizedOperation_RoundTripsVerbatim()
+ {
+ // Controller decision: `operation` stays a proto string precisely so an operation this build
+ // does not know travels intact rather than decoding to an enum's UNSPECIFIED and being dropped.
+ var domain = new AlarmCommand("alarm-42", "SomeFutureOperation", "jdoe", null, null);
+
+ var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
+
+ back.ShouldBe(domain);
+ back.Operation.ShouldBe("SomeFutureOperation");
+ }
+
+ // ---------------------------------------------------------------- ApplyAck
+
+ [Theory]
+ [InlineData(ApplyAckOutcome.Applied)]
+ [InlineData(ApplyAckOutcome.Failed)]
+ public void ApplyAck_RoundTrips_WithEveryMemberSet(ApplyAckOutcome outcome)
+ {
+ var domain = new ApplyAck(
+ new DeploymentId(DeploymentGuid),
+ new NodeId("site-a-1"),
+ outcome,
+ "artifact fetch failed: sha mismatch",
+ new CorrelationId(CorrelationGuid));
+
+ MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void ApplyAck_RoundTrips_WithNullFailureReason()
+ {
+ var domain = new ApplyAck(
+ new DeploymentId(DeploymentGuid),
+ new NodeId("site-a-1"),
+ ApplyAckOutcome.Applied,
+ null,
+ new CorrelationId(CorrelationGuid));
+
+ var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
+
+ back.ShouldBe(domain);
+ back.FailureReason.ShouldBeNull();
+ }
+
+ [Fact]
+ public void ApplyAck_EmptyFailureReason_StaysEmpty_AndIsNotNull()
+ {
+ var domain = new ApplyAck(
+ new DeploymentId(DeploymentGuid),
+ new NodeId("site-a-1"),
+ ApplyAckOutcome.Failed,
+ "",
+ new CorrelationId(CorrelationGuid));
+
+ var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
+
+ back.ShouldBe(domain);
+ back.FailureReason.ShouldNotBeNull();
+ back.FailureReason.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ApplyAck_UnspecifiedOutcome_Throws_RatherThanDecodingToAMeaningfulValue()
+ {
+ // The single highest-stakes decode in this contract. proto3 hands back 0 for an absent enum
+ // field, and the deploy path seals on Applied — so 0 must never resolve to Applied (or to
+ // Failed, which would fail a deployment that actually succeeded). ApplyAckOutcome has no
+ // Unknown member to land on, so refusal is the only honest answer.
+ var proto = new ApplyAckMessage
+ {
+ DeploymentId = DeploymentGuid.ToString("N"),
+ NodeId = "site-a-1",
+ Outcome = ApplyOutcome.Unspecified,
+ CorrelationId = CorrelationGuid.ToString("N"),
+ };
+
+ var ex = Should.Throw(() => MeshTransportDtoMapper.FromProto(proto));
+ ex.Message.ShouldContain(nameof(ApplyOutcome.Unspecified));
+ }
+
+ [Fact]
+ public void ApplyAck_UnknownOutcomeValue_Throws()
+ {
+ // A peer built against a newer contract with a third outcome. Same rule: refuse, do not guess.
+ var proto = new ApplyAckMessage
+ {
+ DeploymentId = DeploymentGuid.ToString("N"),
+ NodeId = "site-a-1",
+ Outcome = (ApplyOutcome)99,
+ CorrelationId = CorrelationGuid.ToString("N"),
+ };
+
+ Should.Throw(() => MeshTransportDtoMapper.FromProto(proto))
+ .Message.ShouldContain("99");
+ }
+
+ [Fact]
+ public void ApplyAck_UnknownClrOutcome_ThrowsOnEncode()
+ {
+ var domain = new ApplyAck(
+ new DeploymentId(DeploymentGuid),
+ new NodeId("site-a-1"),
+ (ApplyAckOutcome)99,
+ null,
+ new CorrelationId(CorrelationGuid));
+
+ Should.Throw(() => MeshTransportDtoMapper.ToProto(domain));
+ }
+
+ // ---------------------------------------------------------------- Envelope
+
+ [Fact]
+ public void Envelope_RoundTrips_DispatchDeployment()
+ {
+ var domain = new DispatchDeployment(
+ new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid));
+
+ var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
+
+ envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.DispatchDeployment);
+ MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void Envelope_RoundTrips_RestartDriver()
+ {
+ var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe", CorrelationGuid);
+
+ var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
+
+ envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.RestartDriver);
+ MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void Envelope_RoundTrips_ReconnectDriver()
+ {
+ var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "jdoe", CorrelationGuid);
+
+ var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
+
+ envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.ReconnectDriver);
+ MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void Envelope_RoundTrips_AlarmCommand()
+ {
+ var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", "ack'd", null);
+
+ var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain, new CorrelationId(EnvelopeGuid)));
+
+ envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.AlarmCommand);
+ envelope.CorrelationId.ShouldBe(EnvelopeGuid.ToString("N"));
+ MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
+ }
+
+ [Fact]
+ public void Envelope_SetsBothCorrelationIds_ForTheThreeSelfCorrelatingCommands()
+ {
+ // Encode writes envelope and inner correlation from the record's own member, so the two can
+ // only diverge if a peer produced them apart — see the decode-precedence golden below.
+ var dispatch = MeshTransportDtoMapper.ToEnvelope(new DispatchDeployment(
+ new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)));
+ dispatch.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
+ dispatch.DispatchDeployment.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
+
+ var restart = MeshTransportDtoMapper.ToEnvelope(
+ new RestartDriver("c", "d", "u", CorrelationGuid));
+ restart.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
+ restart.RestartDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
+
+ var reconnect = MeshTransportDtoMapper.ToEnvelope(
+ new ReconnectDriver("c", "d", "u", CorrelationGuid));
+ reconnect.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
+ reconnect.ReconnectDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
+ }
+
+ [Fact]
+ public void Envelope_InnerCorrelationWins_OnDecode()
+ {
+ // Controller decision: the inner field is the domain member, the envelope's is transport
+ // metadata. If a peer ever writes them apart, the record must carry what the record declared.
+ var envelope = Wire(new CommandEnvelope
+ {
+ CorrelationId = EnvelopeGuid.ToString("N"),
+ DispatchDeployment = new DispatchDeploymentCommand
+ {
+ DeploymentId = DeploymentGuid.ToString("N"),
+ RevisionHash = Sha256Hex,
+ CorrelationId = CorrelationGuid.ToString("N"),
+ },
+ });
+
+ var decoded = MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBeOfType();
+ decoded.CorrelationId.Value.ShouldBe(CorrelationGuid);
+ decoded.CorrelationId.Value.ShouldNotBe(EnvelopeGuid);
+ }
+
+ [Fact]
+ public void Envelope_InnerCorrelationWins_OnDecode_ForDriverCommands()
+ {
+ var restart = Wire(new CommandEnvelope
+ {
+ CorrelationId = EnvelopeGuid.ToString("N"),
+ RestartDriver = new RestartDriverCommand
+ {
+ ClusterId = "c",
+ DriverInstanceId = "d",
+ ActorByUserName = "u",
+ CorrelationId = CorrelationGuid.ToString("N"),
+ },
+ });
+ MeshTransportDtoMapper.FromEnvelope(restart).ShouldBeOfType()
+ .CorrelationId.ShouldBe(CorrelationGuid);
+
+ var reconnect = Wire(new CommandEnvelope
+ {
+ CorrelationId = EnvelopeGuid.ToString("N"),
+ ReconnectDriver = new ReconnectDriverCommand
+ {
+ ClusterId = "c",
+ DriverInstanceId = "d",
+ ActorByUserName = "u",
+ CorrelationId = CorrelationGuid.ToString("N"),
+ },
+ });
+ MeshTransportDtoMapper.FromEnvelope(reconnect).ShouldBeOfType()
+ .CorrelationId.ShouldBe(CorrelationGuid);
+ }
+
+ [Fact]
+ public void FromEnvelope_UnsetOneof_Throws()
+ {
+ // Unreachable while MeshTransportProtoContractTests holds the oneof locked, which is exactly
+ // why it must throw rather than return null: the alternative is a command that crosses the
+ // mesh, decodes to nothing and is dropped without a trace.
+ var ex = Should.Throw(
+ () => MeshTransportDtoMapper.FromEnvelope(new CommandEnvelope { CorrelationId = "x" }));
+
+ ex.Message.ShouldContain(nameof(CommandEnvelope.CommandOneofCase.None));
+ }
+
+ [Fact]
+ public void ToEnvelope_UnknownMessageType_Throws()
+ {
+ Should.Throw(
+ () => MeshTransportDtoMapper.ToEnvelope("not a command", new CorrelationId(EnvelopeGuid)));
+ }
+
+ [Fact]
+ public void EveryHandledOneofCase_DecodesToADomainRecord()
+ {
+ // Ties the mapper to the contract-lock list: a fifth oneof case added to the proto and to
+ // HandledCases without a mapper arm fails here rather than at runtime on the far side.
+ var samples = new Dictionary
+ {
+ [CommandEnvelope.CommandOneofCase.DispatchDeployment] = MeshTransportDtoMapper.ToEnvelope(
+ new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex),
+ new CorrelationId(CorrelationGuid))),
+ [CommandEnvelope.CommandOneofCase.RestartDriver] = MeshTransportDtoMapper.ToEnvelope(
+ new RestartDriver("c", "d", "u", CorrelationGuid)),
+ [CommandEnvelope.CommandOneofCase.ReconnectDriver] = MeshTransportDtoMapper.ToEnvelope(
+ new ReconnectDriver("c", "d", "u", CorrelationGuid)),
+ [CommandEnvelope.CommandOneofCase.AlarmCommand] = MeshTransportDtoMapper.ToEnvelope(
+ new AlarmCommand("a", "Acknowledge", "u", null, null), new CorrelationId(EnvelopeGuid)),
+ };
+
+ samples.Keys.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true);
+
+ foreach (var (expectedCase, envelope) in samples)
+ {
+ envelope.CommandCase.ShouldBe(expectedCase);
+ MeshTransportDtoMapper.FromEnvelope(Wire(envelope)).ShouldNotBeNull();
+ }
+ }
+
+ // ---------------------------------------------------------------- ToEnvelope(object …) dispatch
+
+ [Fact]
+ public void ToEnvelope_ObjectDispatch_MatchesTheTypedOverloads()
+ {
+ // The router holds its command as MeshCommand.Message (an object), so the untyped entry point
+ // must produce byte-identical envelopes to the typed ones it delegates to.
+ var alarmCorrelation = new CorrelationId(EnvelopeGuid);
+
+ object[] commands =
+ [
+ new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex),
+ new CorrelationId(CorrelationGuid)),
+ new RestartDriver("c", "d", "u", CorrelationGuid),
+ new ReconnectDriver("c", "d", "u", CorrelationGuid),
+ new AlarmCommand("a", "Acknowledge", "u", "note", null),
+ ];
+
+ CommandEnvelope[] typed =
+ [
+ MeshTransportDtoMapper.ToEnvelope((DispatchDeployment)commands[0]),
+ MeshTransportDtoMapper.ToEnvelope((RestartDriver)commands[1]),
+ MeshTransportDtoMapper.ToEnvelope((ReconnectDriver)commands[2]),
+ MeshTransportDtoMapper.ToEnvelope((AlarmCommand)commands[3], alarmCorrelation),
+ ];
+
+ for (var i = 0; i < commands.Length; i++)
+ {
+ MeshTransportDtoMapper.ToEnvelope(commands[i], alarmCorrelation).ShouldBe(typed[i]);
+ }
+ }
+}