diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs
new file mode 100644
index 00000000..d3043e0e
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs
@@ -0,0 +1,61 @@
+namespace ZB.MOM.WW.OtOpcUa.Cluster;
+
+///
+/// Selects and configures the transport carrying central→node commands and node→central
+/// deployment acks (per-cluster mesh Phase 2).
+///
+///
+///
+/// is a deliberate dark switch, not a permanent knob: both
+/// transports live in the tree for one phase so the docker-dev rig can run the same
+/// deployment over each and compare, and so a bad Phase 2 rolls back with a config change
+/// rather than a revert. Phase 6 deletes the DistributedPubSub branch along with the single
+/// mesh that made it viable.
+///
+///
+/// Discovery is deliberately asymmetric, matching the sister project. Central
+/// discovers nodes from the database — enabled, non-maintenance ClusterNode
+/// rows, rebuilt every . Nodes discover central from
+/// this section: static, restart to change. The asymmetry follows the rate of change —
+/// operators add and retire nodes, but central's own address is part of the deployment.
+///
+///
+public sealed class MeshTransportOptions
+{
+ /// Configuration section name.
+ public const string SectionName = "MeshTransport";
+
+ /// Every command stays on DistributedPubSub — the pre-Phase-2 behaviour.
+ public const string ModeDps = "Dps";
+
+ /// Commands cross the ClusterClient receptionist boundary.
+ public const string ModeClusterClient = "ClusterClient";
+
+ ///
+ /// (default) or . Any other value fails
+ /// the host at startup rather than silently falling back — a transport that is neither of
+ /// these delivers nothing, and the symptom would be commands that vanish.
+ ///
+ public string Mode { get; set; } = ModeDps;
+
+ ///
+ /// Akka remoting addresses of the central nodes, e.g.
+ /// akka.tcp://otopcua@central-1:4053. Node addresses only — the
+ /// /system/receptionist path is appended at construction time.
+ ///
+ ///
+ /// Each entry must be a central node's remoting endpoint, never this node's own port.
+ /// The sister project shipped a site template whose second contact was the site's own
+ /// remoting port; it is a permanent failure in the initial-contact rotation and stays
+ /// invisible until a command goes missing.
+ /// rejects the malformed shapes at boot instead.
+ ///
+ public string[] CentralContactPoints { get; set; } = [];
+
+ ///
+ /// How often central re-reads ClusterNode rows to rebuild its contact points.
+ /// Refreshing on a timer (rather than only on admin change) means a row edited by any
+ /// process — migration, direct SQL, a second admin node — is picked up without a restart.
+ ///
+ public int ContactRefreshSeconds { get; set; } = 60;
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs
new file mode 100644
index 00000000..afcd8779
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs
@@ -0,0 +1,83 @@
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.OtOpcUa.Cluster;
+
+///
+/// Fails the host at startup on a shape that would leave
+/// commands silently undelivered.
+///
+///
+/// Every fault caught here otherwise surfaces as an absence — a deployment that never
+/// arrives, an alarm acknowledgement that does nothing, a node whose ack is dropped without a
+/// trace. An absence has no stack trace and no failing node to point at, which makes it the
+/// most expensive class of misconfiguration to diagnose in the field. Refusing to start is
+/// cheaper for everyone.
+///
+public sealed class MeshTransportOptionsValidator : IValidateOptions
+{
+ ///
+ public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var errors = new List();
+
+ var isDps = string.Equals(options.Mode, MeshTransportOptions.ModeDps, StringComparison.OrdinalIgnoreCase);
+ var isClusterClient = string.Equals(
+ options.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase);
+
+ if (!isDps && !isClusterClient)
+ {
+ errors.Add(
+ $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is "
+ + $"'{options.Mode}'. Expected '{MeshTransportOptions.ModeDps}' or "
+ + $"'{MeshTransportOptions.ModeClusterClient}'.");
+ }
+
+ if (options.ContactRefreshSeconds <= 0)
+ {
+ errors.Add(
+ $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.ContactRefreshSeconds)} "
+ + $"must be greater than 0 (was {options.ContactRefreshSeconds}). Central rebuilds its "
+ + "ClusterClient contact points on this interval; a non-positive value would leave the "
+ + "contact set frozen at whatever it held when the node booted.");
+ }
+
+ // Contact points are only load-bearing under ClusterClient mode. Requiring them under the
+ // default would make the section mandatory everywhere for a feature that is switched off —
+ // including on admin-only nodes, which have no reason to carry them at all.
+ if (isClusterClient)
+ {
+ if (options.CentralContactPoints.Length == 0)
+ {
+ errors.Add(
+ $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.CentralContactPoints)} "
+ + "is empty. Under ClusterClient mode a driver node cannot reach central without at "
+ + "least one contact point, and its deployment acks would be dropped silently — the "
+ + "deployment would then time out naming a node that is running perfectly.");
+ }
+
+ foreach (var contactPoint in options.CentralContactPoints)
+ {
+ if (!contactPoint.StartsWith("akka.tcp://", StringComparison.Ordinal))
+ {
+ errors.Add(
+ $"Contact point '{contactPoint}' must start with 'akka.tcp://' — it is an Akka "
+ + "remoting address (akka.tcp://@:), not a bare host:port.");
+ }
+ else if (contactPoint.Contains("/user/", StringComparison.Ordinal)
+ || contactPoint.Contains("/system/", StringComparison.Ordinal))
+ {
+ errors.Add(
+ $"Contact point '{contactPoint}' carries an actor-path suffix. Configure the "
+ + "node address only — '/system/receptionist' is appended at construction time, "
+ + "and a doubled suffix produces a path that never resolves.");
+ }
+ }
+ }
+
+ return errors.Count == 0
+ ? ValidateOptionsResult.Success
+ : ValidateOptionsResult.Fail(string.Join(" ", errors));
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs
index 7e5eaa45..1363191e 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs
@@ -34,6 +34,12 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions(
configuration, AkkaClusterOptions.SectionName);
+ // Per-cluster mesh Phase 2: which transport carries central→node commands. Validated at
+ // startup for the same reason the cluster options are — a contact point that cannot resolve
+ // produces silence, not an error, and silence is indistinguishable from "nothing to do".
+ services.AddValidatedOptions(
+ configuration, MeshTransportOptions.SectionName);
+
services.AddSingleton();
return services;
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs
new file mode 100644
index 00000000..af627a9b
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs
@@ -0,0 +1,138 @@
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
+
+///
+/// Every failure this validator catches otherwise surfaces as an absence — a deployment
+/// that never arrives, an alarm ack that does nothing, a node whose ack is dropped without a
+/// trace. Absences are the hardest class of fault to attribute in the field, so the whole point
+/// of these tests is that the host refuses to start instead.
+///
+public class MeshTransportOptionsValidatorTests
+{
+ private static ValidateOptionsResult Validate(MeshTransportOptions o) =>
+ new MeshTransportOptionsValidator().Validate(MeshTransportOptions.SectionName, o);
+
+ [Fact]
+ public void Default_options_are_valid()
+ {
+ // The default is Dps — the pre-Phase-2 transport. A node that never sets this section must
+ // behave exactly as it did before, which is the whole premise of the dark switch.
+ Validate(new MeshTransportOptions()).Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Unknown_mode_fails()
+ {
+ var result = Validate(new MeshTransportOptions { Mode = "grpc" });
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain("grpc");
+ }
+
+ [Fact]
+ public void Mode_matching_is_case_insensitive()
+ {
+ Validate(new MeshTransportOptions
+ {
+ Mode = "clusterclient",
+ CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"],
+ }).Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Non_positive_contact_refresh_fails()
+ {
+ var result = Validate(new MeshTransportOptions { ContactRefreshSeconds = 0 });
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.ContactRefreshSeconds));
+ }
+
+ [Fact]
+ public void ClusterClient_mode_requires_central_contact_points()
+ {
+ var result = Validate(new MeshTransportOptions
+ {
+ Mode = MeshTransportOptions.ModeClusterClient,
+ CentralContactPoints = [],
+ });
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.CentralContactPoints));
+ }
+
+ [Fact]
+ public void Dps_mode_does_not_require_central_contact_points()
+ {
+ // An admin-only node has no reason to carry them, and requiring them under the default mode
+ // would make the section mandatory everywhere for a feature that is switched off.
+ Validate(new MeshTransportOptions
+ {
+ Mode = MeshTransportOptions.ModeDps,
+ CentralContactPoints = [],
+ }).Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void A_contact_point_that_is_not_an_akka_address_fails()
+ {
+ var result = Validate(new MeshTransportOptions
+ {
+ Mode = MeshTransportOptions.ModeClusterClient,
+ CentralContactPoints = ["central-1:4053"],
+ });
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain("akka.tcp://");
+ }
+
+ [Fact]
+ public void A_contact_point_carrying_an_actor_path_suffix_fails()
+ {
+ // The sister project's shipped site template listed a full receptionist path, and separately
+ // listed the site's OWN remoting port as the second contact — "a permanent failure in the
+ // initial-contact rotation". Both are invisible until a command goes missing.
+ var result = Validate(new MeshTransportOptions
+ {
+ Mode = MeshTransportOptions.ModeClusterClient,
+ CentralContactPoints = ["akka.tcp://otopcua@central-1:4053/system/receptionist"],
+ });
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain("/system/receptionist");
+ }
+
+ [Fact]
+ public void One_bad_contact_point_among_good_ones_still_fails()
+ {
+ var result = Validate(new MeshTransportOptions
+ {
+ Mode = MeshTransportOptions.ModeClusterClient,
+ CentralContactPoints =
+ [
+ "akka.tcp://otopcua@central-1:4053",
+ "central-2:4053",
+ ],
+ });
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain("central-2:4053");
+ }
+
+ [Fact]
+ public void Well_formed_ClusterClient_options_succeed()
+ {
+ Validate(new MeshTransportOptions
+ {
+ Mode = MeshTransportOptions.ModeClusterClient,
+ CentralContactPoints =
+ [
+ "akka.tcp://otopcua@central-1:4053",
+ "akka.tcp://otopcua@central-2:4053",
+ ],
+ }).Succeeded.ShouldBeTrue();
+ }
+}