diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs
index 4ad7385e..903fb92e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs
@@ -17,21 +17,37 @@ public static class HealthEndpoints
{
///
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
- /// ready+active; admin-leader on active only.
+ /// ready+active; admin-leader on active only. The configdb probe is admin-only (per-cluster mesh
+ /// Phase 4): a driver-only node holds no ConfigDb, so it is registered iff .
///
/// The service collection to register the health checks on.
+ ///
+ /// Whether this node carries the admin role. When false (a driver-only node) the
+ /// configdb probe is skipped — the node registers no
+ /// factory, so probing it would throw when the readiness endpoint resolves the factory. Defaults to
+ /// true so admin/fused callers (including the test harnesses) keep the probe unchanged.
+ ///
/// The same service collection, for chaining.
- public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services)
+ public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services, bool hasAdmin = true)
{
- services.AddHealthChecks()
- .AddTypeActivatedCheck>(
+ var checks = services.AddHealthChecks();
+
+ // configdb probe is admin-only (per-cluster mesh Phase 4). A driver-only node gets its config
+ // from central via ConfigSource:Mode=FetchAndCache and never registers the ConfigDb factory, so
+ // the type-activated DatabaseHealthCheck would throw resolving it. Admin + fused nodes: unchanged.
+ if (hasAdmin)
+ {
+ checks.AddTypeActivatedCheck>(
"configdb",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
args: new DatabaseHealthCheckOptions
{
ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct),
- })
+ });
+ }
+
+ checks
.AddTypeActivatedCheck(
"akka",
failureStatus: null,
@@ -42,11 +58,10 @@ public static class HealthEndpoints
failureStatus: null,
tags: new[] { ZbHealthTags.Active },
args: "admin")
- // Registered unconditionally, not driver-gated. AddOtOpcUaHealth takes no role argument
- // and runs on every node; the check itself resolves ISyncStatus optionally and reports
- // Healthy when LocalDb is absent (admin-only graphs) or replication is default-OFF, so a
- // plain node is never degraded by it. A factory registration keeps this no-arg signature
- // while still reading ISyncStatus + options + the sync port from the container.
+ // Registered on every node regardless of role (unlike the admin-only configdb probe above):
+ // the check itself resolves ISyncStatus optionally and reports Healthy when LocalDb is absent
+ // (admin-only graphs) or replication is default-OFF, so a plain node is never degraded by it.
+ // A factory registration reads ISyncStatus + options + the sync port from the container.
.Add(new HealthCheckRegistration(
"localdb-replication",
sp => new LocalDbReplicationHealthCheck(
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 74fb072b..0ed23bda 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -122,8 +122,16 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
// Windows-service registration is handled at install time by scripts/install/Install-Services.ps1
// rather than in-process, so the binary stays cross-platform-compilable.
-// Shared services — always registered regardless of role. ConfigDb is required for everything.
-builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
+// ConfigDb is admin-only now (per-cluster mesh Phase 4). Only the admin role owns a ConfigDb
+// connection string; a driver-only node holds none at all and gets its deployed configuration from
+// central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
+// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string.
+// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected.
+if (hasAdmin)
+ builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
+else
+ Log.Information("Driver-only node — no ConfigDb registered; configuration via ConfigSource:Mode=FetchAndCache");
+
builder.Services.AddOtOpcUaCluster(builder.Configuration);
// Validate LdapOptions unconditionally so ANY role node (admin-only, driver-only, or fused)
@@ -392,7 +400,9 @@ if (hasAdmin)
// Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration.
builder.Services.AddOtOpcUaSecrets(builder.Configuration);
-builder.Services.AddOtOpcUaHealth();
+// hasAdmin gates the configdb probe: a driver-only node registers no ConfigDb factory (see above), so
+// probing it would throw when the readiness endpoint resolves the factory.
+builder.Services.AddOtOpcUaHealth(hasAdmin);
builder.Services.AddOtOpcUaObservability(builder.Configuration);
// gRPC server plumbing shared by two endpoints: the LocalDb passive sync endpoint (driver-role,
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs
new file mode 100644
index 00000000..954ddadc
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs
@@ -0,0 +1,95 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// Per-cluster mesh Phase 4 — ConfigDb is registered iff the node has the admin role.
+/// A driver-only node (Akka role driver, NOT admin) boots with NO ConfigDb
+/// connection string at all; its configuration arrives from central via the Phase-3
+/// ConfigSource:Mode=FetchAndCache path.
+///
+///
+///
+/// These tests replicate the exact ConfigDb gating decision Program.cs makes —
+/// if (hasAdmin) services.AddOtOpcUaConfigDb(configuration) — over the real
+/// method, for both role shapes.
+/// They deliberately do NOT boot the full host: Program.cs reads roles from the
+/// OTOPCUA_ROLES process env var and starting the host triggers the Akka cluster join
+/// (see TwoNodeClusterHarness on why WebApplicationFactory<Program> is not
+/// driven here, and LocalDbWiringTests for the same partial-graph harness model). The
+/// full driver-only boot is proven by the Phase-4 live gate + the later DI-graph sweep task.
+///
+///
+public sealed class DriverOnlyNoConfigDbBootTests : IDisposable
+{
+ private readonly List _apps = [];
+
+ ///
+ /// Builds the shared-services graph the way Program.cs gates ConfigDb: it is registered iff
+ /// . A driver-only node passes hasAdmin: false and supplies
+ /// no ConnectionStrings:ConfigDb at all.
+ ///
+ private IServiceProvider BuildSharedServicesGraph(bool hasAdmin, string? configDbConnectionString)
+ {
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
+ if (configDbConnectionString is not null)
+ {
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:ConfigDb"] = configDbConnectionString,
+ });
+ }
+
+ if (hasAdmin)
+ builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
+
+ var app = builder.Build();
+ _apps.Add(app);
+ return app.Services;
+ }
+
+ [Fact]
+ public void DriverOnlyNode_NoConnectionString_BuildsWithoutThrowing_AndRegistersNoConfigDbFactory()
+ {
+ // The core Phase-4 guarantee: a driver-only node holds no ConfigDb connection string and must
+ // still build its service graph without throwing — nothing in the shared registrations may
+ // reach for the ConfigDb factory.
+ IServiceProvider sp = null!;
+ Should.NotThrow(() => sp = BuildSharedServicesGraph(hasAdmin: false, configDbConnectionString: null));
+
+ sp.GetService>().ShouldBeNull();
+ }
+
+ [Fact]
+ public void AdminNode_WithConnectionString_RegistersTheConfigDbFactory()
+ {
+ // The fused/admin path is byte-for-byte unaffected: ConfigDb is registered exactly as before.
+ var sp = BuildSharedServicesGraph(
+ hasAdmin: true,
+ configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True");
+
+ sp.GetService>().ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void AddOtOpcUaConfigDb_WithoutConnectionString_Throws()
+ {
+ // Why the gate exists: the unconditional call Program.cs made throws on a driver-only node,
+ // which has no ConfigDb connection string. This pins the exact failure the hasAdmin gate avoids.
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
+
+ Should.Throw(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration));
+ }
+
+ public void Dispose()
+ {
+ foreach (var app in _apps)
+ ((IDisposable)app).Dispose();
+ }
+}