diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptions.cs
new file mode 100644
index 00000000..066c17ae
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptions.cs
@@ -0,0 +1,67 @@
+namespace ZB.MOM.WW.OtOpcUa.Cluster;
+
+///
+/// Node-side selection of where a driver reads its deployed configuration (per-cluster mesh
+/// Phase 3). The dark switch that lets a driver node stop reading Deployment.ArtifactBlob
+/// from central SQL and instead fetch the artifact from central over gRPC and read it from the
+/// pair-local LocalDb cache.
+///
+public sealed class ConfigSourceOptions
+{
+ /// Configuration section name.
+ public const string SectionName = "ConfigSource";
+
+ /// Read config directly from central SQL — today's behaviour, and the default.
+ public const string ModeDirect = "Direct";
+
+ /// Fetch the artifact from central over gRPC, cache it in LocalDb, and read from there.
+ public const string ModeFetchAndCache = "FetchAndCache";
+
+ ///
+ /// (default) or . Any other value
+ /// fails host start.
+ ///
+ public string Mode { get; set; } = ModeDirect;
+
+ ///
+ /// Central artifact-gRPC base addresses, e.g. http://central-1:4055 (h2c ⇒ the
+ /// http scheme). Tried in order for failover. Required under
+ /// ; ignored under .
+ ///
+ public string[] CentralFetchEndpoints { get; set; } = [];
+
+ ///
+ /// Shared bearer key; must equal central's . Supply via
+ /// the environment (ConfigSource__ApiKey) — never commit it. Required under
+ /// .
+ ///
+ public string ApiKey { get; set; } = string.Empty;
+
+ /// Per-fetch deadline in seconds. Must be positive under .
+ public int FetchTimeoutSeconds { get; set; } = 30;
+}
+
+///
+/// Central-side artifact-serve surface (per-cluster mesh Phase 3). Configures the dedicated h2c
+/// listener + shared bearer key the DeploymentArtifactService is exposed behind. Only
+/// admin-role nodes (which hold the central SQL connection) serve.
+///
+public sealed class ConfigServeOptions
+{
+ /// Configuration section name.
+ public const string SectionName = "ConfigServe";
+
+ ///
+ /// Dedicated HTTP/2-only (h2c) listener port for the artifact gRPC service. 0 (the
+ /// default) disables it — nothing is bound. Must differ from the main HTTP port and from
+ /// LocalDb:SyncListenPort (h2c cannot share a cleartext HTTP/1 port).
+ ///
+ public int GrpcListenPort { get; set; }
+
+ ///
+ /// Shared bearer key the serve-side interceptor checks (constant-time, fail-closed: an unset
+ /// key rejects every call). Supply via the environment (ConfigServe__ApiKey) — never
+ /// commit it.
+ ///
+ public string ApiKey { get; set; } = string.Empty;
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs
new file mode 100644
index 00000000..9519a622
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.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 a
+/// driver node unable to fetch its configuration.
+///
+///
+/// Like , every fault caught here otherwise surfaces as
+/// an absence — a deployment that never applies because the node has no endpoint to fetch
+/// from or no key to authenticate with. An absence has no stack trace and no failing node to point
+/// at; refusing to start is cheaper to diagnose.
+///
+public sealed class ConfigSourceOptionsValidator : IValidateOptions
+{
+ ///
+ public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var errors = new List();
+
+ var isDirect = string.Equals(options.Mode, ConfigSourceOptions.ModeDirect, StringComparison.OrdinalIgnoreCase);
+ var isFetch = string.Equals(
+ options.Mode, ConfigSourceOptions.ModeFetchAndCache, StringComparison.OrdinalIgnoreCase);
+
+ if (!isDirect && !isFetch)
+ {
+ errors.Add(
+ $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)} is "
+ + $"'{options.Mode}'. Expected '{ConfigSourceOptions.ModeDirect}' or "
+ + $"'{ConfigSourceOptions.ModeFetchAndCache}'.");
+ }
+
+ // The fetch surface is only load-bearing under FetchAndCache. Requiring it under the default
+ // would make the section mandatory everywhere for a feature that is switched off — including on
+ // admin-only nodes, which never fetch.
+ if (isFetch)
+ {
+ if (options.CentralFetchEndpoints.Length == 0)
+ {
+ errors.Add(
+ $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.CentralFetchEndpoints)} "
+ + "is empty. Under FetchAndCache a driver node cannot reach central to fetch its "
+ + "configuration, and every deployment would time out naming a node that is running "
+ + "perfectly.");
+ }
+
+ foreach (var endpoint in options.CentralFetchEndpoints)
+ {
+ if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ errors.Add(
+ $"Central fetch endpoint '{endpoint}' must start with 'http://' (h2c) or "
+ + "'https://' — it is a gRPC base address (scheme://host:port), not a bare "
+ + "host:port.");
+ }
+ }
+
+ if (string.IsNullOrEmpty(options.ApiKey))
+ {
+ errors.Add(
+ $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.ApiKey)} is empty. "
+ + "Under FetchAndCache the shared bearer key is the whole authentication boundary to "
+ + "central's artifact service; without it every fetch is rejected. Supply it via the "
+ + "environment (ConfigSource__ApiKey).");
+ }
+
+ if (options.FetchTimeoutSeconds <= 0)
+ {
+ errors.Add(
+ $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.FetchTimeoutSeconds)} "
+ + $"must be greater than 0 (was {options.FetchTimeoutSeconds}).");
+ }
+ }
+
+ 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 1363191e..4c9e567e 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs
@@ -40,6 +40,14 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions(
configuration, MeshTransportOptions.SectionName);
+ // Per-cluster mesh Phase 3: where a driver reads its config (Direct SQL, or fetch-and-cache
+ // over gRPC). Validated at startup for the same reason — a FetchAndCache node with no endpoint
+ // or no key produces silence, not an error. ConfigServe (the central serve surface) needs no
+ // cross-field validation: a 0 port is a legitimate "disabled", so a plain Configure suffices.
+ services.AddValidatedOptions(
+ configuration, ConfigSourceOptions.SectionName);
+ services.Configure(configuration.GetSection(ConfigServeOptions.SectionName));
+
services.AddSingleton();
return services;
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs
new file mode 100644
index 00000000..064e68e8
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs
@@ -0,0 +1,120 @@
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
+
+///
+/// A FetchAndCache node with no endpoint or no key does not error — it simply never
+/// applies a deployment, and the deploy times out naming a node that is otherwise healthy. These
+/// tests make that misconfiguration a loud host-start failure instead.
+///
+public class ConfigSourceOptionsValidatorTests
+{
+ private static ValidateOptionsResult Validate(ConfigSourceOptions o) =>
+ new ConfigSourceOptionsValidator().Validate(ConfigSourceOptions.SectionName, o);
+
+ private static ConfigSourceOptions ValidFetch() => new()
+ {
+ Mode = ConfigSourceOptions.ModeFetchAndCache,
+ CentralFetchEndpoints = ["http://central-1:4055", "http://central-2:4055"],
+ ApiKey = "shared-key",
+ FetchTimeoutSeconds = 30,
+ };
+
+ [Fact]
+ public void Default_options_are_valid()
+ {
+ // The default is Direct with everything empty — the pre-Phase-3 behaviour. A node that never
+ // sets this section must validate, which is the premise of the dark switch.
+ Validate(new ConfigSourceOptions()).Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Unknown_mode_fails()
+ {
+ var result = Validate(new ConfigSourceOptions { Mode = "cache" });
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain("cache");
+ }
+
+ [Fact]
+ public void Mode_matching_is_case_insensitive()
+ {
+ Validate(new ConfigSourceOptions
+ {
+ Mode = "fetchandcache",
+ CentralFetchEndpoints = ["http://central-1:4055"],
+ ApiKey = "k",
+ }).Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void FetchAndCache_with_no_endpoints_fails()
+ {
+ var o = ValidFetch();
+ o.CentralFetchEndpoints = [];
+
+ var result = Validate(o);
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.CentralFetchEndpoints));
+ }
+
+ [Fact]
+ public void FetchAndCache_with_a_non_http_endpoint_fails()
+ {
+ var o = ValidFetch();
+ o.CentralFetchEndpoints = ["central-1:4055"];
+
+ var result = Validate(o);
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain("central-1:4055");
+ }
+
+ [Fact]
+ public void FetchAndCache_with_an_empty_key_fails()
+ {
+ var o = ValidFetch();
+ o.ApiKey = "";
+
+ var result = Validate(o);
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.ApiKey));
+ }
+
+ [Fact]
+ public void FetchAndCache_with_a_non_positive_timeout_fails()
+ {
+ var o = ValidFetch();
+ o.FetchTimeoutSeconds = 0;
+
+ var result = Validate(o);
+
+ result.Failed.ShouldBeTrue();
+ result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.FetchTimeoutSeconds));
+ }
+
+ [Fact]
+ public void A_fully_populated_FetchAndCache_config_is_valid()
+ {
+ Validate(ValidFetch()).Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Direct_ignores_the_empty_fetch_surface()
+ {
+ // Under Direct the endpoints/key are never read, so their emptiness must not fail validation —
+ // otherwise every admin-only and every Direct node would be forced to carry fetch config.
+ Validate(new ConfigSourceOptions
+ {
+ Mode = ConfigSourceOptions.ModeDirect,
+ CentralFetchEndpoints = [],
+ ApiKey = "",
+ FetchTimeoutSeconds = 0,
+ }).Succeeded.ShouldBeTrue();
+ }
+}