diff --git a/docs/Configuration.md b/docs/Configuration.md
index 6e0bb3fb..e188d552 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -22,14 +22,30 @@ Two related concerns get their own dedicated pages and are **only summarised + l
The Host (`Program.cs`) loads `appsettings.json`, then overlays a **per-role** file chosen from the cluster roles:
-- A single role → `appsettings.{role}.json` (e.g. `appsettings.driver.json`, `appsettings.admin.json`).
-- Both roles → `appsettings.admin-driver.json` (roles joined with `-`, ordinal-sorted).
+- A single plain role → `appsettings.{role}.json` (e.g. `appsettings.driver.json`, `appsettings.admin.json`).
+- Both roles → `appsettings.admin-driver.json` (plain roles joined with `-`, ordinal-sorted).
+- `cluster-{ClusterId}` roles are **ignored** when naming the overlay (#678.6) — `[driver, cluster-SITE-A]` loads `appsettings.driver.json`.
- `appsettings.{ASPNETCORE_ENVIRONMENT}.json` (e.g. `appsettings.Development.json`) is layered on by the host builder.
All role overlays are **optional** — the base `appsettings.json` plus the Options-class C# defaults are enough to boot. The roles themselves come from `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`) — `Cluster:Roles`, falling back to `OTOPCUA_ROLES` when that is empty (see [`ServiceHosting.md`](ServiceHosting.md) and the table below).
The checked-in `appsettings*.json` files are deliberately thin: they carry only `Serilog` and the `Security:Ldap` overlay. Everything else (`OpcUa`, `Cluster`, `ConnectionStrings`/`ConfigDb`) binds from the Options-class defaults documented below unless an operator adds the section explicitly or supplies the corresponding environment variable.
+
+#### Role overlay audit (#678.6)
+
+Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the cluster role leaked into the filename). Fixing that **activates** the shipped overlays on those nodes (the docker-dev rig and any per-cluster-mesh production node), so each key was audited against base `appsettings.json` / the Options defaults. Env vars and command-line args are re-appended after the overlay, so any value a deployment sets explicitly (the rig sets every `Security__Ldap__*` key) still wins.
+
+| Key | Overlay(s) | Base / default | Effect on activation |
+|---|---|---|---|
+| `Serilog:MinimumLevel:Default` = `Information` | all three | Serilog default `Information` | no-op (in `Development` it outranks `appsettings.Development.json`'s `Debug`, by design) |
+| `…Override:Akka` = `Information` | all three | inherits `Default` (`Information`) | no-op |
+| `…Override:Microsoft.AspNetCore` = `Warning` | `admin`, `admin-driver` | unset (`Information`) | quieter ASP.NET Core logs; log-level only, matches what the rig already pins |
+| `…Override:Opc.Ua` = `Information` | `admin-driver`, `driver` | inherits `Default` | no-op. **`driver` shipped `Debug` — changed to `Information` in #678.6**: activating a Debug OPC UA stack trace on every production driver node would flood Serilog (the same pipeline starvation the rig's EF/AspNetCore pins guard against). Raise it per node with `Serilog__MinimumLevel__Override__Opc.Ua=Debug`. |
+| `Security:Ldap:DevStubMode` = `false` | all three | `false` | no-op; the overlay's point is that it outranks a developer's `appsettings.Development.json` (`true`) |
+| `Security:Ldap:Transport` = `Ldaps` | all three | `LdapOptions.Transport` default `None` | **behaviour-changing, kept deliberately** (documented secure production default). A cluster-role node that configures `Security:Ldap:Server`/`Port` for a plaintext LDAP but never sets `Transport` would now dial LDAPS and fail AdminUI sign-in; set `Security__Ldap__Transport` (the rig already sets `None` explicitly). Data-plane auth is API-key based and unaffected; driver-only nodes have no AdminUI. |
+
+
---
## `appsettings` sections
@@ -38,7 +54,7 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
- **Purpose:** logging. Console + rolling daily file sink, layered with the shared `ZB.MOM.WW.Telemetry` enrichers (`AddZbSerilog` in `Program.cs`).
- **Where bound:** `builder.AddZbSerilog(...)` reads `Serilog` from configuration (`ReadFrom.Configuration`).
-- **Checked-in shape** (`appsettings.json`): `Using` = `[ "Serilog.Sinks.Console", "Serilog.Sinks.File" ]`, `WriteTo` = a `Console` sink and a `File` sink (`path: logs/otopcua-.log`, `rollingInterval: Day`). Role overlays add `MinimumLevel` / `Override` blocks (e.g. `Opc.Ua: Debug`, `Akka: Information`).
+- **Checked-in shape** (`appsettings.json`): `Using` = `[ "Serilog.Sinks.Console", "Serilog.Sinks.File" ]`, `WriteTo` = a `Console` sink and a `File` sink (`path: logs/otopcua-.log`, `rollingInterval: Day`). Role overlays add `MinimumLevel` / `Override` blocks (e.g. `Opc.Ua: Information`, `Akka: Information`).
### `OpcUa`
diff --git a/docs/ServiceHosting.md b/docs/ServiceHosting.md
index e5e8aa42..48b13b9b 100644
--- a/docs/ServiceHosting.md
+++ b/docs/ServiceHosting.md
@@ -32,13 +32,13 @@ Single-node dev: `OTOPCUA_ROLES=admin,driver` (or the `Cluster:Roles` equivalent
### Per-role configuration overlays
-`Program.cs` builds a role suffix by joining the resolved `EffectiveRoles.Gossiped` roles **alphabetically** with `-` and loads `appsettings.{roleSuffix}.json` as an optional overlay on top of base `appsettings.json`. A plain role (`admin`/`driver`/`dev`) is always lower-case in `Gossiped` regardless of how the operator spelled it, so the overlay filename for those is stable (`appsettings.driver.json`); if a `cluster-{ClusterId}` role is also configured, its suffix carries into the filename **verbatim** (e.g. `Cluster:Roles: ["driver", "cluster-SITE-A"]` loads `appsettings.cluster-SITE-A-driver.json`), since `Gossiped` preserves that spelling by design (see [Configuration.md § `Roles`](Configuration.md#appsettings-sections)). Three overlays ship in `src/Server/ZB.MOM.WW.OtOpcUa.Host/`:
+`Program.cs` builds a role suffix (`RoleOverlay.Suffix`) by joining the resolved `EffectiveRoles.Gossiped` **plain** roles (`admin`/`driver`/`dev`) alphabetically with `-` and loads `appsettings.{roleSuffix}.json` as an optional overlay on top of base `appsettings.json`. A plain role is always lower-case in `Gossiped` regardless of how the operator spelled it, so the overlay filename is stable (`appsettings.driver.json`). **`cluster-{ClusterId}` roles are NOT part of the filename** (#678.6): they are mesh-membership tags, so `Cluster:Roles: ["driver", "cluster-SITE-A"]` loads `appsettings.driver.json` and `["admin", "driver", "cluster-MAIN"]` loads `appsettings.admin-driver.json`. (Before #678.6 the cluster role was folded into the name, producing e.g. `appsettings.admin-cluster-MAIN-driver.json`, which never exists — so on every node with a cluster role no shipped overlay ever loaded.) Three overlays ship in `src/Server/ZB.MOM.WW.OtOpcUa.Host/`:
- `appsettings.admin.json` — admin-only nodes
- `appsettings.driver.json` — driver-only nodes
- `appsettings.admin-driver.json` — fused single-node dev / small deployments
-All three carry Serilog log-level overrides + `Security:Ldap:DevStubMode = false`. Configuration loading order (lowest to highest precedence) is:
+All three carry Serilog log-level overrides + `Security:Ldap:DevStubMode = false` + `Security:Ldap:Transport = Ldaps` (audit table: [Configuration.md § Role overlays](Configuration.md#role-overlay-audit)). Configuration loading order (lowest to highest precedence) is:
`appsettings.json` < `appsettings.{Environment}.json` < `appsettings.{role}.json` < environment variables < command-line args
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/RoleOverlay.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/RoleOverlay.cs
new file mode 100644
index 00000000..b7ce2047
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/RoleOverlay.cs
@@ -0,0 +1,28 @@
+using ZB.MOM.WW.OtOpcUa.Cluster;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Names the per-role appsettings overlay (appsettings.{suffix}.json) for a node.
+///
+///
+/// The suffix is built from the node's PLAIN roles only. cluster-{ClusterId} roles are mesh-membership
+/// tags (and carry a verbatim, operator-chosen ClusterId), not configuration profiles — folding one into the
+/// filename produced names like appsettings.admin-cluster-MAIN-driver.json that never exist, so on every
+/// node with a cluster role the shipped overlays silently never loaded.
+///
+internal static class RoleOverlay
+{
+ ///
+ /// The overlay filename suffix: the plain (non-cluster) roles, ordinal-sorted and joined with -, or
+ /// when the node has none.
+ ///
+ public static string? Suffix(IReadOnlyList gossipedRoles)
+ {
+ var plain = gossipedRoles
+ .Where(r => !string.IsNullOrWhiteSpace(r) && !RoleParser.IsClusterRole(r))
+ .OrderBy(r => r, StringComparer.Ordinal)
+ .ToList();
+ return plain.Count == 0 ? null : string.Join('-', plain);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 29dd14a3..ee5e79d7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -90,8 +90,9 @@ builder.WebHost.UseStaticWebAssets();
builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost");
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
-// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
-var roleSuffix = roles.Count == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
+// (both). Optional — base appsettings.json carries enough to boot if these don't exist. Built from the
+// PLAIN roles only (#678.6): a cluster-{ClusterId} role in the name pointed at a file that never exists.
+var roleSuffix = RoleOverlay.Suffix(roles);
if (roleSuffix is not null)
{
builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json
index 1db8bd01..89cf7f6f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json
@@ -3,7 +3,7 @@
"MinimumLevel": {
"Default": "Information",
"Override": {
- "Opc.Ua": "Debug",
+ "Opc.Ua": "Information",
"Akka": "Information"
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/RoleOverlayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/RoleOverlayTests.cs
new file mode 100644
index 00000000..80622a4d
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/RoleOverlayTests.cs
@@ -0,0 +1,78 @@
+using System.Text.Json;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
+
+///
+/// #678.6 — the per-role appsettings overlay name is built from plain roles only. A cluster-* role in the
+/// name produced a file that never exists, so no overlay ever loaded on a node with a cluster role.
+///
+public sealed class RoleOverlayTests
+{
+ [Fact]
+ public void A_driver_site_node_loads_the_driver_overlay()
+ => RoleOverlay.Suffix(["driver", "cluster-SITE-A"]).ShouldBe("driver");
+
+ [Fact]
+ public void A_central_node_loads_the_admin_driver_overlay()
+ => RoleOverlay.Suffix(["admin", "driver", "cluster-MAIN"]).ShouldBe("admin-driver");
+
+ [Fact]
+ public void Plain_roles_are_ordinal_sorted_regardless_of_input_order()
+ => RoleOverlay.Suffix(["driver", "cluster-MAIN", "admin"]).ShouldBe("admin-driver");
+
+ [Fact]
+ public void Roles_without_a_cluster_role_keep_the_existing_names()
+ {
+ RoleOverlay.Suffix(["admin"]).ShouldBe("admin");
+ RoleOverlay.Suffix(["driver"]).ShouldBe("driver");
+ RoleOverlay.Suffix(["admin", "driver"]).ShouldBe("admin-driver");
+ }
+
+ [Fact]
+ public void A_cluster_role_prefix_is_matched_case_insensitively()
+ => RoleOverlay.Suffix(["driver", "CLUSTER-SITE-A"]).ShouldBe("driver");
+
+ [Fact]
+ public void No_plain_role_means_no_overlay()
+ {
+ RoleOverlay.Suffix([]).ShouldBeNull();
+ RoleOverlay.Suffix(["cluster-SITE-A"]).ShouldBeNull();
+ }
+
+ // ---- The shipped overlays: activating them on cluster-role nodes must not change production semantics. ----
+
+ private static string HostDir()
+ {
+ for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var candidate = Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host");
+ if (File.Exists(Path.Combine(candidate, "appsettings.json"))) return candidate;
+ }
+
+ throw new InvalidOperationException("Host project directory not found above " + AppContext.BaseDirectory);
+ }
+
+ [Theory]
+ [InlineData("admin")]
+ [InlineData("driver")]
+ [InlineData("admin-driver")]
+ public void Shipped_overlays_carry_only_the_audited_keys(string suffix)
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(HostDir(), $"appsettings.{suffix}.json")));
+ var root = doc.RootElement;
+
+ // Only Serilog + Security:Ldap, and in Security:Ldap only the two audited keys.
+ root.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["Security", "Serilog"]);
+ var ldap = root.GetProperty("Security").GetProperty("Ldap");
+ ldap.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["DevStubMode", "Transport"]);
+ ldap.GetProperty("DevStubMode").GetBoolean().ShouldBeFalse();
+
+ // No Debug-level (or lower) category override: activating an overlay must not raise log volume on a node.
+ foreach (var o in root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Override").EnumerateObject())
+ o.Value.GetString().ShouldNotBeOneOf("Debug", "Verbose");
+ root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Default").GetString().ShouldBe("Information");
+ }
+}