diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshPskProvider.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshPskProvider.cs
new file mode 100644
index 00000000..cbdbc987
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshPskProvider.cs
@@ -0,0 +1,202 @@
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.OtOpcUa.Cluster;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
+
+///
+/// Central's per-cluster pre-shared key lookup for the gRPC command plane. A node holds exactly
+/// one key (MeshTransport:GrpcPsk, its own cluster's); central talks to every cluster, so
+/// it needs one key per Cluster — both to authenticate itself when dialling a node and to
+/// verify an inbound deployment ack.
+///
+///
+///
+/// Fail-closed by contract. Every failure — unknown cluster, absent secret, blank
+/// value, a store that throws — is reported as "no key" (false), and the caller
+/// denies the inbound call or skips the outbound dial. There is no arm on which a caller may
+/// proceed unauthenticated; a missing key must cost delivery, not authentication.
+///
+///
+/// The Invalidate hooks exist because central's cluster set is DB-dynamic. They are
+/// deliberately unconsumed at Phase 0 — Phase 1A wires them to the contact refresh, which is
+/// the point at which central learns a cluster's membership (and so possibly its key) has
+/// changed.
+///
+///
+public interface IMeshPskProvider
+{
+ /// Gets the pre-shared key for one application Cluster.
+ /// The application Cluster id, matched case-insensitively.
+ /// The key, or null when none could be established.
+ /// true when a usable key was found; otherwise false.
+ bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk);
+
+ /// Drops any cached key for one cluster, so the next lookup re-reads the store.
+ /// The application Cluster id, matched case-insensitively.
+ void Invalidate(string clusterId);
+
+ /// Drops every cached key.
+ void InvalidateAll();
+}
+
+///
+public sealed class MeshPskProvider : IMeshPskProvider
+{
+ ///
+ /// Prefix of the secret holding a cluster's key — the full name is
+ /// OT-MESH-PSK-{clusterId}. The same name a node's
+ /// ${secret:OT-MESH-PSK-<clusterId>} reference expands, so central and the node
+ /// read one row rather than two conventions that drift.
+ ///
+ public const string SecretNamePrefix = "OT-MESH-PSK-";
+
+ private readonly IOptions _options;
+ private readonly ISecretResolver _secretResolver;
+ private readonly ILogger _logger;
+
+ ///
+ /// Cached store resolutions, keyed by cluster id. Holds only values that came from the
+ /// Secrets store: the config map is re-read on every lookup (it is an in-memory dictionary),
+ /// which is what keeps the override unconditional rather than merely first-past-the-post.
+ ///
+ ///
+ /// under so a
+ /// burst of concurrent inbound acks for one cluster resolves the secret once instead of
+ /// stampeding the store. Negatives are never retained — see .
+ ///
+ private readonly ConcurrentDictionary> _cache =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ /// Creates the provider.
+ /// Mesh transport options; ClusterPsks is the override map.
+ /// The shared secret store read seam.
+ /// Logger for lookup-failure diagnostics.
+ public MeshPskProvider(
+ IOptions options,
+ ISecretResolver secretResolver,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(secretResolver);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ _options = options;
+ _secretResolver = secretResolver;
+ _logger = logger;
+ }
+
+ ///
+ public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
+ {
+ psk = null;
+
+ if (string.IsNullOrWhiteSpace(clusterId))
+ {
+ _logger.LogWarning(
+ "Mesh PSK lookup was asked for a blank cluster id; refusing rather than resolving a "
+ + "key that would belong to no cluster.");
+ return false;
+ }
+
+ // Config map first, on every call. Blank counts as absent: an empty
+ // MeshTransport__ClusterPsks__ is an unset environment variable, not an operator
+ // deciding this cluster has an empty key.
+ if (_options.Value.ClusterPsks.TryGetValue(clusterId, out var configured)
+ && !string.IsNullOrWhiteSpace(configured))
+ {
+ psk = configured;
+ return true;
+ }
+
+ var entry = _cache.GetOrAdd(
+ clusterId,
+ static (id, self) => new Lazy(
+ () => self.ResolveFromStore(id), LazyThreadSafetyMode.ExecutionAndPublication),
+ this);
+
+ var resolved = entry.Value;
+ if (string.IsNullOrWhiteSpace(resolved))
+ {
+ // Never retain a negative. A store that was briefly unreachable would otherwise stay
+ // "no key" for this cluster until something called Invalidate — and at Phase 0 nothing
+ // does, so a transient fault would present as a permanent, silent command outage.
+ _cache.TryRemove(new KeyValuePair>(clusterId, entry));
+ return false;
+ }
+
+ psk = resolved;
+ return true;
+ }
+
+ ///
+ public void Invalidate(string clusterId)
+ {
+ if (string.IsNullOrWhiteSpace(clusterId))
+ return;
+
+ _cache.TryRemove(clusterId, out _);
+ }
+
+ ///
+ public void InvalidateAll() => _cache.Clear();
+
+ ///
+ /// Reads OT-MESH-PSK-{clusterId} from the Secrets store, converting every failure mode
+ /// into null.
+ ///
+ /// The application Cluster id.
+ /// The stored key, or null when it could not be established.
+ ///
+ ///
+ /// Blocking on the async resolver is deliberate. The consumer is a gRPC server
+ /// interceptor, whose Authorize is synchronous in all four sibling interceptors,
+ /// and making this seam async would push that shape onto every one of them. The block
+ /// happens at most once per cluster per process — every later lookup is a dictionary hit
+ /// — and the store is a node-local SQLite file, not a network call.
+ ///
+ ///
+ /// The catch is broad on purpose: this is an authentication boundary, and the set of
+ /// exceptions a secret store can raise (missing KEK, corrupt row, IO) is neither closed
+ /// nor interesting here. Anything other than a usable key is "no key".
+ ///
+ ///
+ private string? ResolveFromStore(string clusterId)
+ {
+ SecretName name;
+ try
+ {
+ name = new SecretName(SecretNamePrefix + clusterId);
+ }
+ catch (ArgumentException ex)
+ {
+ // Cluster ids are operator-authored and may carry characters SecretName's allow-list
+ // rejects. Log the id — it is not sensitive — and never the key.
+ _logger.LogWarning(
+ ex,
+ "Cluster {ClusterId} cannot be expressed as a secret name, so its mesh PSK cannot be "
+ + "resolved from the store. Supply it through MeshTransport:ClusterPsks instead, or "
+ + "rename the cluster to [a-z0-9._/-].",
+ clusterId);
+ return null;
+ }
+
+ try
+ {
+ return _secretResolver.GetAsync(name, CancellationToken.None).GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(
+ ex,
+ "Failed to resolve the mesh PSK for cluster {ClusterId} from secret {SecretName}. "
+ + "Commands to that cluster and acks from it are refused until it resolves.",
+ clusterId,
+ name.Value);
+ return null;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
index 89d2622c..0ffdd314 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
@@ -19,6 +19,10 @@
Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). -->
+
+