diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretReplicationStarter.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretReplicationStarter.cs
new file mode 100644
index 00000000..b6d1a2cd
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretReplicationStarter.cs
@@ -0,0 +1,55 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Forces this node's secret-replication actor into existence at startup.
+///
+///
+///
+/// The replication actor is created lazily, on the first resolution of
+/// — resolving the store builds ReplicatingSecretStore,
+/// which takes ISecretReplicator, whose factory touches
+/// SecretReplicationActorProvider.ActorRef and thereby spawns the actor.
+///
+///
+/// Laziness is correct for the library (the ActorSystem is usually registered after
+/// the replication call), but it makes participation in anti-entropy accidental: a node that
+/// happens never to read or write a secret — a plausible steady state for a driver-role node
+/// whose driver configs carry no ${secret:} references — would never create the actor,
+/// never announce its manifest, and never converge. Nothing would fail; it would just
+/// silently not replicate. Resolving the store once at startup makes participation
+/// unconditional.
+///
+///
+/// Known ineffective against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0. That
+/// version never binds its own ISecretReplicator:
+/// AddZbSecretsAkkaReplication calls AddZbSecrets first, which
+/// TryAdds NoOpSecretReplicator, making the package's own subsequent
+/// TryAddSingleton<ISecretReplicator> a no-op. Resolving the store therefore
+/// builds a ReplicatingSecretStore around a no-op replicator and spawns no actor.
+/// This hook is correct and stays in place for when the library is fixed, but replication
+/// must be treated as non-functional until then — see the skipped test
+/// SecretsReplicationRegistrationTests.The_startup_hook_actually_creates_the_replication_actor.
+///
+///
+/// Root provider used to resolve the (decorated) secret store exactly once.
+public sealed class SecretReplicationStarter(IServiceProvider services) : IHostedService
+{
+ /// Resolves , which spawns the replication actor.
+ /// Unused — resolution is synchronous and non-blocking.
+ /// A completed task.
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ // The resolution itself is the side effect; the instance is deliberately unused.
+ _ = services.GetRequiredService();
+ return Task.CompletedTask;
+ }
+
+ /// No-op: the actor's lifetime is the actor system's.
+ /// Unused.
+ /// A completed task.
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs
new file mode 100644
index 00000000..c1cadc33
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs
@@ -0,0 +1,82 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ZB.MOM.WW.Secrets.DependencyInjection;
+using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Single registration point for the secrets subsystem on every OtOpcUa node, admin- and
+/// driver-role alike. Exists as a named extension rather than an inline AddZbSecrets call
+/// in Program.cs so the choice of ISecretStore implementation is covered by a DI
+/// resolution test — Program.cs is top-level statements and cannot be exercised directly,
+/// which is exactly how a "registered but never resolvable" wiring defect ships unnoticed.
+///
+public static class SecretsRegistration
+{
+ /// Configuration key gating cluster secret replication. Absent or false = off.
+ public const string ReplicationEnabledKey = "Secrets:Replication:Enabled";
+
+ /// Configuration section holding AkkaSecretsReplicationOptions.
+ public const string ReplicationSectionPath = "Secrets:Replication";
+
+ /// Configuration section holding the core secrets options.
+ public const string SecretsSectionPath = "Secrets";
+
+ ///
+ /// True when cluster secret replication is switched on in configuration. Defaults to false —
+ /// absent configuration must mean "off".
+ ///
+ /// The application configuration.
+ /// true when replication is enabled.
+ public static bool IsReplicationEnabled(IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ return configuration.GetValue(ReplicationEnabledKey, defaultValue: false);
+ }
+
+ ///
+ /// Registers the secrets subsystem. Replication is opt-in: unless
+ /// is true this is exactly the plain local SQLite wiring
+ /// the host has always used.
+ ///
+ ///
+ ///
+ /// The gate is deliberately default-deny. This call decides which ISecretStore the
+ /// container hands to every node, including driver-role nodes with no auth or
+ /// AdminUI surface, where a bad store surfaces as drivers failing to open sessions rather
+ /// than as a failing test. Enabling replication by default would change secret resolution
+ /// across a production fleet with nobody having asked for it.
+ ///
+ ///
+ /// AddZbSecretsAkkaReplication calls AddZbSecrets internally, so the two
+ /// branches are alternatives, never additive — calling both would double-register.
+ ///
+ ///
+ /// The service collection to add to.
+ /// The application configuration.
+ /// The same instance, for chaining.
+ public static IServiceCollection AddOtOpcUaSecrets(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ if (!IsReplicationEnabled(configuration))
+ {
+ services.AddZbSecrets(configuration, SecretsSectionPath);
+ return services;
+ }
+
+ services.AddZbSecretsAkkaReplication(configuration, SecretsSectionPath, ReplicationSectionPath);
+
+ // Anti-entropy participation must not hinge on this node happening to touch a secret — see
+ // SecretReplicationStarter for why the library's lazy actor creation is insufficient here.
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+
+ return services;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index dc9ad1c4..c332916e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -41,6 +41,7 @@ using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection;
+using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
using ZB.MOM.WW.Secrets.Sqlite;
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
@@ -313,6 +314,14 @@ if (hasDriver)
builder.Services.AddAkka("otopcua", (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp);
+ // Secret-replication wire protocol → its own serializer, merged only when replication is on so a
+ // non-replicating node carries no serializer bindings for messages it will never see. Appended
+ // (Akka.Hosting's fallback merge — the same mode WithOtOpcUaClusterBootstrap uses for the base
+ // config) rather than a raw Config.WithFallback, which would fight the builder's own assembly.
+ // Without this the protocol DTOs would round-trip on Akka's default JSON serializer, leaving the
+ // serializer that carries secret ciphertext an inherited default rather than an explicit choice.
+ if (SecretsRegistration.IsReplicationEnabled(builder.Configuration))
+ ab.AddHocon(AkkaSecretsReplication.SerializationConfig, HoconAddMode.Append);
if (hasAdmin)
{
ab.WithOtOpcUaControlPlaneSingletons();
@@ -350,7 +359,8 @@ if (hasAdmin)
}
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
-builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
+// Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration.
+builder.Services.AddOtOpcUaSecrets(builder.Configuration);
builder.Services.AddOtOpcUaHealth();
builder.Services.AddOtOpcUaObservability(builder.Configuration);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
index eea476b0..a955665b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
@@ -18,7 +18,13 @@
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
},
"RunMigrationsOnStartup": true,
- "ResolveCacheTtl": "00:00:30"
+ "ResolveCacheTtl": "00:00:30",
+ "Replication": {
+ "_comment": "Peer-to-peer secret replication over the Akka cluster. OPT-IN: Enabled=false keeps the plain local SQLite store on every node. KNOWN NON-FUNCTIONAL against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 — that version's ISecretReplicator registration is shadowed by the NoOp one AddZbSecrets registers first, so enabling this decorates the store but replicates nothing and starts no actor. Do not enable until the library is fixed. All nodes must also share the same KEK.",
+ "Enabled": false,
+ "AnnounceInterval": "00:00:30",
+ "ActorName": "zb-secret-replication"
+ }
},
"ServerHistorian": {
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsReplicationRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsReplicationRegistrationTests.cs
new file mode 100644
index 00000000..a8c75153
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsReplicationRegistrationTests.cs
@@ -0,0 +1,168 @@
+using Akka.Actor;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Replication;
+using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// Guards the production DI wiring for cluster secret replication (AddOtOpcUaSecrets).
+/// This is the registration half of a defect class that has already shipped once in this library
+/// family: every unit test passed because nothing ever built a container, so a decorator that
+/// could not actually be constructed went unnoticed.
+///
+/// Two properties matter here, and both are load-bearing on driver-role nodes that have no
+/// auth/AdminUI surface and whose only symptom of a broken registration is drivers failing to
+/// open sessions:
+///
+/// -
+/// With replication off (the default), must resolve to the
+/// plain — byte-identical to the pre-replication host.
+///
+/// -
+/// With replication on, must resolve to
+/// and the undecorated concrete
+/// must still resolve, because the decorator is
+/// constructed from it. A missing concrete registration is the exact gap that shipped.
+///
+///
+///
+public sealed class SecretsReplicationRegistrationTests
+{
+ ///
+ /// Builds a container mirroring the host's registration order: Akka first (the host calls
+ /// AddAkka before AddOtOpcUaSecrets), then secrets.
+ ///
+ private static ServiceProvider BuildProvider(bool replicationEnabled, ActorSystem? actorSystem = null)
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ // A per-test SQLite path keeps these hermetic; nothing here opens the file, because
+ // resolving the store does not touch the database.
+ ["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"),
+ ["Secrets:MasterKey:Source"] = "Environment",
+ ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
+ ["Secrets:RunMigrationsOnStartup"] = "false",
+ ["Secrets:Replication:Enabled"] = replicationEnabled ? "true" : "false",
+ ["Secrets:Replication:AnnounceInterval"] = "00:00:30",
+ ["Secrets:Replication:ActorName"] = "zb-secret-replication",
+ })
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ // The replicating store reaches the ActorSystem through SecretReplicationActorProvider.
+ // The host supplies one via Akka.Hosting; a plain system is equivalent for registration.
+ if (actorSystem is not null)
+ services.AddSingleton(actorSystem);
+
+ services.AddOtOpcUaSecrets(configuration);
+
+ return services.BuildServiceProvider();
+ }
+
+ [Fact]
+ public void Replication_disabled_resolves_the_plain_sqlite_store()
+ {
+ using var sp = BuildProvider(replicationEnabled: false);
+
+ var store = sp.GetRequiredService();
+
+ store.ShouldBeOfType(
+ "replication is opt-in — with Secrets:Replication:Enabled false the host must resolve the "
+ + "plain local store, so existing nodes are behaviourally unchanged");
+ }
+
+ [Fact]
+ public void Replication_disabled_does_not_require_an_actor_system()
+ {
+ // A driver-role node builds its container before the ActorSystem is reachable for secret
+ // resolution; the default path must not have taken a dependency on it.
+ using var sp = BuildProvider(replicationEnabled: false);
+
+ Should.NotThrow(() => sp.GetRequiredService());
+ }
+
+ [Fact]
+ public void Replication_enabled_resolves_the_replicating_store()
+ {
+ using var system = ActorSystem.Create("secrets-registration-test-enabled");
+ using var sp = BuildProvider(replicationEnabled: true, system);
+
+ var store = sp.GetRequiredService();
+
+ store.ShouldBeOfType(
+ "with replication enabled the local store must be decorated so writes publish to peers");
+ }
+
+ [Fact]
+ public void Replication_enabled_still_resolves_the_undecorated_concrete_store()
+ {
+ using var system = ActorSystem.Create("secrets-registration-test-undecorated");
+ using var sp = BuildProvider(replicationEnabled: true, system);
+
+ // The decorator is constructed from the concrete store, not from ISecretStore (which would
+ // recurse). If this registration is ever lost the decorator cannot be built at all.
+ var concrete = sp.GetRequiredService();
+
+ concrete.ShouldNotBeNull();
+ sp.GetRequiredService().ShouldNotBeSameAs(concrete);
+ }
+
+ [Fact]
+ public void Replication_enabled_registers_a_startup_hook_that_creates_the_replication_actor()
+ {
+ using var system = ActorSystem.Create("secrets-registration-test-actor");
+ using var sp = BuildProvider(replicationEnabled: true, system);
+
+ // The replication actor is created LAZILY on first ISecretStore resolution. A node that never
+ // reads or writes a secret would therefore never join anti-entropy and would silently never
+ // converge — so the host must register a startup hook that forces the resolution.
+ var starter = sp.GetServices().OfType().SingleOrDefault();
+
+ starter.ShouldNotBeNull(
+ "replication must not depend on something happening to resolve ISecretStore later");
+ }
+
+ [Fact(Skip = "BLOCKED upstream: ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 never binds its own "
+ + "ISecretReplicator, so the replication actor is never created. AddZbSecretsAkkaReplication "
+ + "calls AddZbSecrets FIRST, which does TryAddSingleton(); "
+ + "the package's own TryAddSingleton(AkkaSecretReplicator) that follows is "
+ + "therefore a no-op. Verified empirically: with Secrets:Replication:Enabled=true, "
+ + "ISecretReplicator resolves to ZB.MOM.WW.Secrets.DependencyInjection.NoOpSecretReplicator, so "
+ + "ReplicatingSecretStore publishes into a sink and no actor is spawned. Un-skip once the library "
+ + "registers its replicator with AddSingleton (or registers it before calling AddZbSecrets).")]
+ public async Task The_startup_hook_actually_creates_the_replication_actor()
+ {
+ using var system = ActorSystem.Create("secrets-registration-test-actor-created");
+ using var sp = BuildProvider(replicationEnabled: true, system);
+
+ var starter = sp.GetServices().OfType().Single();
+ await starter.StartAsync(TestContext.Current.CancellationToken);
+
+ // Verify the actor exists rather than assuming resolution created it: ActorSelection
+ // resolution succeeds only if a live actor occupies the configured name under /user.
+ var actorName = new AkkaSecretsReplicationOptions().ActorName;
+ var selection = system.ActorSelection($"/user/{actorName}");
+ var actorRef = await selection.ResolveOne(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+
+ actorRef.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Replication_disabled_registers_no_startup_hook()
+ {
+ using var sp = BuildProvider(replicationEnabled: false);
+
+ sp.GetServices().OfType().ShouldBeEmpty();
+ }
+}