diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs
new file mode 100644
index 00000000..b3932f65
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs
@@ -0,0 +1,95 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.LocalDb;
+using ZB.MOM.WW.LocalDb.Replication;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Single registration point for the node-local LocalDb subsystem: the embedded SQLite store
+/// that caches the deployed-configuration artifact, plus the optional gRPC replication that
+/// mirrors it to the node's redundant pair peer.
+///
+///
+///
+/// Exists as a named extension rather than inline AddZbLocalDb calls in
+/// Program.cs so the wiring 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" defect ships unnoticed. This family has shipped that
+/// defect three times (Secrets 0.2.0, Secrets 0.2.2, ScadaBridge #22).
+///
+///
+/// Driver-role nodes only. Admin-only nodes have no deployed configuration to cache,
+/// and registering here would impose the LocalDb:Path-required-or-no-boot constraint
+/// on them for nothing.
+///
+///
+/// Storage is unconditional; replication is default-OFF. A node with no
+/// PeerAddress and no SyncListenPort is simply a fast local SQLite file —
+/// AddZbLocalDbReplication registers the engine but it stays idle. Replication is
+/// opt-in per pair because there is no production distribution story for the sync
+/// ApiKey yet (the same open question the Secrets KEK has).
+///
+///
+public static class LocalDbRegistration
+{
+ /// Configuration section holding LocalDbOptions.
+ public const string LocalDbSectionPath = "LocalDb";
+
+ /// Configuration section holding ReplicationOptions.
+ public const string ReplicationSectionPath = "LocalDb:Replication";
+
+ ///
+ /// Configuration key carrying the dedicated h2c sync listener's port. Zero or absent means
+ /// no listener is bound and the passive sync endpoint is not mapped.
+ ///
+ public const string SyncListenPortKey = "LocalDb:SyncListenPort";
+
+ ///
+ /// The port the dedicated cleartext-HTTP/2 sync listener should bind, or 0 when the
+ /// node should not listen at all. Defaults to 0 — absent configuration must mean
+ /// "off".
+ ///
+ /// The application configuration.
+ /// The configured sync port, or 0.
+ public static int SyncListenPort(IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ return configuration.GetValue(SyncListenPortKey, defaultValue: 0);
+ }
+
+ ///
+ /// Registers the local database (with the deployment-cache schema applied and registered for
+ /// replication) and the replication engine.
+ ///
+ ///
+ ///
+ /// runs inside AddZbLocalDb's singleton factory,
+ /// before the first caller receives the ILocalDb — so every consumer is guaranteed
+ /// to see the tables created and their capture triggers installed.
+ ///
+ ///
+ /// AddZbLocalDbReplication is called unconditionally rather than behind an
+ /// Enabled flag because the library already models "off" as "no
+ /// PeerAddress": the initiator background service idles and the passive endpoint
+ /// is only reachable if Program.cs maps it, which it does only when
+ /// is set. Adding a second gate on top would give two
+ /// ways to express the same thing and a way for them to disagree.
+ ///
+ ///
+ /// The service collection to add to.
+ /// The application configuration.
+ /// The same instance, for chaining.
+ public static IServiceCollection AddOtOpcUaLocalDb(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ services.AddZbLocalDb(configuration, LocalDbSetup.OnReady);
+ services.AddZbLocalDbReplication(configuration);
+
+ 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 c332916e..30c36854 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -174,6 +174,20 @@ if (hasDriver)
builder.Configuration,
(_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp));
+ // Node-local LocalDb: caches the deployed-configuration artifact so this node can boot from
+ // its last-known-good config when central SQL is unreachable, and optionally replicates that
+ // cache to its redundant pair peer. Driver-role only — admin-only nodes have nothing to cache,
+ // and registering here would impose LocalDb:Path-required-or-no-boot on them for nothing.
+ // Storage ships unconditionally; replication stays inert until LocalDb:Replication:PeerAddress
+ // (initiator) / LocalDb:SyncListenPort (listener) are set. See LocalDbRegistration.
+ builder.Services.AddOtOpcUaLocalDb(builder.Configuration);
+
+ // gRPC server plumbing for the passive sync endpoint mapped below. Registered only under
+ // hasDriver so admin-only nodes expose no sync surface at all. The interceptor is the ONLY
+ // inbound auth on that endpoint — the replication library's LocalDbSyncService verifies
+ // nothing — and it fail-closes when no ApiKey is configured.
+ builder.Services.AddGrpc(o => o.Interceptors.Add());
+
// Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this
// overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with
// a read-only HistorianGateway-backed data source the node manager's HistoryRead overrides
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
index a955665b..e7ca5cd6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
@@ -59,6 +59,16 @@
"Capacity": 1000000,
"DeadLetterRetentionDays": 30
},
+ "LocalDb": {
+ "_comment": "Node-local SQLite store caching the deployed-configuration artifact, so a driver node can boot from its last-known-good config when central SQL is unreachable. Registered on driver-role nodes only (see LocalDbRegistration). Storage is unconditional; replication to the redundant pair peer is default-OFF.",
+ "Path": "./data/otopcua-localdb.db",
+ "_SyncListenPortComment": "Port for the dedicated cleartext-HTTP/2 (h2c) listener serving the passive sync endpoint. 0 = no listener bound and the endpoint is not mapped. Must be a port distinct from the main HTTP listener: prior-knowledge h2c cannot share a cleartext Http1AndHttp2 port.",
+ "SyncListenPort": 0,
+ "Replication": {
+ "_comment": "Empty = passive/off. Set PeerAddress on exactly ONE node of the pair (the stream is bidirectional, so the other side still pushes its own deltas back). ApiKey must be BYTE-IDENTICAL on both nodes — the host interceptor fail-closes, so a typo silently stops convergence rather than degrading to unauthenticated. Supply it via ${secret:...} in production, never committed.",
+ "_MaxBatchSizeComment": "Row count, NOT bytes, against gRPC's 4 MB default message cap. Artifact chunk rows are ~171 KB base64, so keep this at 16 on the pair (16 x 171 KB ~= 2.7 MB worst case)."
+ }
+ },
"Deployment": {
"_comment": "R2-11 (05/CONV-2): deploy-gate TagConfig strictness. Warn (default) = non-blocking warnings logged + appended to the deployment result message; Error = a config with a typo'd enum or unparseable TagConfig is rejected at the draft gate. Running servers are untouched; the gate only sees re-deploys.",
"TagConfigValidationMode": "Warn"