diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs
new file mode 100644
index 00000000..fe192b2d
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs
@@ -0,0 +1,94 @@
+using Akka.Actor;
+using Akka.Cluster.Tools.Client;
+using Akka.Event;
+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;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Communication;
+
+///
+/// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2), registered
+/// with the ClusterClientReceptionist at .
+///
+///
+///
+/// Registered per node, NOT as a cluster singleton — central's ClusterClient rotates
+/// across contact points and must find a live comm actor at whichever node answers.
+///
+///
+/// Inbound commands are re-emitted on the node's , not on their
+/// DistributedPubSub topic. DPS is mesh-wide, and central SendToAlls to every
+/// node's comm actor — so a DPS re-publish would deliver N copies of every command to every
+/// subscriber, where N is the node count. The EventStream is node-local by construction,
+/// which is exactly the needed semantics. It also avoids a registration handshake with
+/// actors spawned later: ScriptedAlarmHostActor is a child of
+/// DriverHostActor and has no registry key, so there is no ref to hand in at wiring
+/// time.
+///
+///
+/// Outbound is only. There is no Ask across this boundary in
+/// Phase 2 — every migrated command is fire-and-forget, and the deploy ack returns as an
+/// independent message the coordinator already subscribes for. Nothing here needs sender
+/// preservation, which is why this actor is far smaller than the sister project's.
+///
+///
+public sealed class NodeCommunicationActor : ReceiveActor
+{
+ private readonly IActorRef? _centralClient;
+ private readonly ILoggingAdapter _log = Context.GetLogger();
+
+ /// Creates the props for this actor.
+ ///
+ /// ClusterClient dialling central's receptionist, or under
+ /// MeshTransport:Mode = Dps, where acks still go out over DistributedPubSub from
+ /// DriverHostActor and this actor never sees them.
+ ///
+ /// The props.
+ public static Props Props(IActorRef? centralClient) =>
+ Akka.Actor.Props.Create(() => new NodeCommunicationActor(centralClient));
+
+ /// Initializes a new instance of the class.
+ /// ClusterClient dialling central, or .
+ public NodeCommunicationActor(IActorRef? centralClient)
+ {
+ _centralClient = centralClient;
+
+ // Inbound from central. Each type is listed explicitly rather than caught by a ReceiveAny:
+ // an unknown type must dead-letter loudly during development rather than be republished
+ // blind onto the node's EventStream, where it would be silently ignored by every subscriber.
+ Receive(PublishLocally);
+ Receive(PublishLocally);
+ Receive(PublishLocally);
+ Receive(PublishLocally);
+
+ // Outbound to central.
+ Receive(HandleApplyAck);
+ }
+
+ private void PublishLocally(object msg)
+ {
+ _log.Debug("Mesh: received {MessageType} from central; publishing node-locally", msg.GetType().Name);
+ Context.System.EventStream.Publish(msg);
+ }
+
+ private void HandleApplyAck(ApplyAck ack)
+ {
+ if (_centralClient is null)
+ {
+ _log.Warning(
+ "No central ClusterClient — dropping ApplyAck for {DeploymentId}. The ack is not "
+ + "buffered and will not be retried, so the deployment will time out at central "
+ + "naming this node even though it applied successfully. Check "
+ + "MeshTransport:CentralContactPoints",
+ ack.DeploymentId);
+ return;
+ }
+
+ // Send, not SendToAll: the deploy coordinator is a single singleton, and the central comm
+ // actor forwards to its proxy. SendToAll would deliver one ack per central node, and the
+ // coordinator would count the same node twice.
+ _centralClient.Tell(new ClusterClient.Send(MeshPaths.CentralCommunication, ack));
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs
new file mode 100644
index 00000000..7cc06aca
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs
@@ -0,0 +1,130 @@
+using Akka.Actor;
+using Akka.Cluster.Tools.Client;
+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.Types;
+using ZB.MOM.WW.OtOpcUa.Runtime.Communication;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Communication;
+
+public class NodeCommunicationActorTests : RuntimeActorTestBase
+{
+ private static DispatchDeployment NewDispatch() => new(
+ new DeploymentId(Guid.NewGuid()),
+ new RevisionHash("rev-1"),
+ new CorrelationId(Guid.NewGuid()));
+
+ [Fact]
+ public void DispatchDeployment_from_central_lands_on_the_node_local_event_stream()
+ {
+ var subscriber = CreateTestProbe();
+ Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
+ var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null));
+
+ var dispatch = NewDispatch();
+ actor.Tell(dispatch);
+
+ subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(dispatch);
+ }
+
+ [Fact]
+ public void A_command_is_published_exactly_once()
+ {
+ // The reason inbound commands do NOT go back onto their DPS topic: central SendToAll-s to
+ // every node's comm actor, so a mesh-wide republish would deliver N copies to every
+ // subscriber. One publish per inbound command is the whole point.
+ var subscriber = CreateTestProbe();
+ Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
+ var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null));
+
+ actor.Tell(NewDispatch());
+
+ subscriber.ExpectMsg(TimeSpan.FromSeconds(5));
+ subscriber.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+
+ [Fact]
+ public void RestartDriver_from_central_lands_on_the_event_stream()
+ {
+ var subscriber = CreateTestProbe();
+ Sys.EventStream.Subscribe(subscriber.Ref, typeof(RestartDriver));
+ var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null));
+
+ var msg = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
+ actor.Tell(msg);
+
+ subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg);
+ }
+
+ [Fact]
+ public void ReconnectDriver_from_central_lands_on_the_event_stream()
+ {
+ var subscriber = CreateTestProbe();
+ Sys.EventStream.Subscribe(subscriber.Ref, typeof(ReconnectDriver));
+ var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null));
+
+ var msg = new ReconnectDriver("C1", "driver-1", "alice", Guid.NewGuid());
+ actor.Tell(msg);
+
+ subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg);
+ }
+
+ [Fact]
+ public void AlarmCommand_from_central_lands_on_the_event_stream()
+ {
+ // ScriptedAlarmHostActor is a CHILD of DriverHostActor with no registry key, so there is no
+ // ref to hand this actor at wiring time — the EventStream is what makes the alarm-command
+ // leg possible without a registration handshake.
+ var subscriber = CreateTestProbe();
+ Sys.EventStream.Subscribe(subscriber.Ref, typeof(AlarmCommand));
+ var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null));
+
+ var msg = new AlarmCommand("alarm-1", "Acknowledge", "alice", "comment", null);
+ actor.Tell(msg);
+
+ subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg);
+ }
+
+ [Fact]
+ public void ApplyAck_goes_to_central_as_a_Send_not_a_SendToAll()
+ {
+ // Send, not SendToAll: the deploy coordinator is one singleton behind the central comm
+ // actor's proxy. SendToAll would deliver one ack per central node and the coordinator would
+ // count this node twice.
+ var client = CreateTestProbe();
+ var actor = Sys.ActorOf(NodeCommunicationActor.Props(client.Ref));
+
+ var ack = new ApplyAck(
+ new DeploymentId(Guid.NewGuid()),
+ new NodeId("host-a:4053"),
+ ApplyAckOutcome.Applied,
+ null,
+ new CorrelationId(Guid.NewGuid()));
+ actor.Tell(ack);
+
+ var sent = client.ExpectMsg(TimeSpan.FromSeconds(5));
+ sent.Path.ShouldBe(MeshPaths.CentralCommunication);
+ sent.Message.ShouldBe(ack);
+ }
+
+ [Fact]
+ public void An_ack_with_no_central_client_is_dropped_with_a_warning()
+ {
+ var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null));
+
+ var ack = new ApplyAck(
+ new DeploymentId(Guid.NewGuid()),
+ new NodeId("host-a:4053"),
+ ApplyAckOutcome.Applied,
+ null,
+ new CorrelationId(Guid.NewGuid()));
+
+ EventFilter.Warning(contains: "dropping ApplyAck").ExpectOne(
+ TimeSpan.FromSeconds(5), () => actor.Tell(ack));
+ }
+}