diff --git a/CLAUDE.md b/CLAUDE.md
index 0e95818..ad59a73 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -148,7 +148,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed — the "local only / not yet pushed" caveat was stale) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| Audit (event model + writer seam) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed to origin) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | **Adopted** (feed `0.1.0` + `0.2.0`; consumed at `0.2.0` by HistorianGateway **and mxaccessgw**, whose Server wires `AddZbGalaxyRepository` — verified 2026-07-18; the prior "mxaccessgw adoption is a follow-on" claim was stale) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) |
-| Secrets (encrypted store + `${secret:}` resolution) | Built (libs **`0.1.3`**, **published to the Gitea feed**; **HistorianGateway adopted + live-proven** 2026-07-16; **mxaccessgw adopted G-4/G-5/G-6 + merged to `origin/main` @ `e088dfa`** 2026-07-16, box-verified fail-closed + encrypt-at-rest; **ScadaBridge adopted G-3/G-4/G-5/G-6 + merged to `origin/main` @ `128f1596`** 2026-07-16, **G-3 live-proven vs the real production MxGateway gateway** `wonder-app-vd03:5120` (secret-ref ApiKey → Connected + browsed real Galaxy); **OtOpcUa adopted G-2/G-4/G-5/G-6 + merged to `origin/master` @ `872cf7e3`** (lmxopcua) 2026-07-16 — the last app, so **all four now adopted**; Layer-B driver secrets (Galaxy API key + OpcUaClient `Password`/`UserCertificatePassword`) resolve fail-closed at driver session-open, `AddZbSecrets` registered unconditionally so driver-only nodes work; **G-2 live-proven vs the real production MxGateway** `wonder-app-vd03:5120` — a `secret:`-ref Galaxy ApiKey resolved through OtOpcUa's real `GalaxyDriverBrowser` → dummy key `MxGatewayAuthenticationException`, real key → browsed the real Galaxy root (10 nodes); temp key minted+revoked+deleted); **G-8 KEK-rotation BUILT (lib bumped `0.1.2`→`0.1.3`, 2026-07-17):** `Rewrap` DEK primitive + CAS-guarded `ApplyRewrapAsync` + `KekRotationService.RewrapAllAsync` + `secret rewrap-all` CLI + operator runbook (`ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`), full suite green + CLI smoke + adversarial crypto review PASS (TOCTOU closed via compare-and-swap). **G-7 clustered replication BUILT 2026-07-18 (lib `0.2.0`) — BOTH fork options, packaged as two new libs:** **`ZB.MOM.WW.Secrets.Replicator.SqlServer`** (shared SQL-Server `ISecretStore` = the plan's Option A, *plus* a local-store-with-hub mode) and **`ZB.MOM.WW.Secrets.Replicator.AkkaDotNet`** (peer-to-peer over distributed pub/sub = Option B, which the plan had deferred). Core gained `ISecretsStoreMigrator`, `SecretLastWriterWins` (one shared LWW tie-break so stores cannot diverge), `SecretReplicationReconciler`, and `ReplicatingSecretStore` — the last of which fixes a real gap: **nothing had ever called `ISecretReplicator.PublishAsync`**, so the seam was inert. Verified 170 offline / 184 with the live SQL suite / 1 skip / 0 warnings, including **15 live tests vs a real SQL Server 2022** (the SQLite suite ported case-for-case) and a **9-test in-process 2-node Akka cluster** (real remoting; write→peer, delete propagation, both anti-entropy directions, late-joiner catch-up). Hard constraints across all topologies: same KEK on every node, ciphertext-only across trust boundaries, and re-wraps deliberately do NOT replicate (`rewrap-all` runs once per independent store). Operator runbook `ZB.MOM.WW.Secrets/docs/operations/clustered-secrets.md`. **Corrected 2026-07-18:** G-8 + the G-7 plan **ARE committed and pushed** on `main` (`d82d345`) — the "not yet committed/pushed" note was stale. **`0.1.3` PUBLISHED to the feed 2026-07-18** (restore-verified), carrying a **transitive security pin** (`SQLitePCLRaw.lib.e_sqlite3` → `2.1.12`, advisory GHSA-2m69-gcr7-jv3q) that `0.1.2` lacked. **All 4 apps bumped to `0.2.1`** 2026-07-18 (local `feat/secrets-0.2.0` branches, NOT pushed). `0.2.1` fixes a critical defect: `0.2.0`'s Akka replicator was **silently inert** (TryAdd registration order — it published into a no-op sink and never spawned an actor). SQL-Server replication was never affected. Clustered topology is **wired but default-OFF and NOT live-validated** — OtOpcUa on Akka peer-to-peer, ScadaBridge on SQL-hub mode (its central and site clusters are separate Akka clusters, so pub/sub cannot cross them). Separately, a **live** SQLitePCLRaw vulnerability was found and fixed in ScadaBridge, where a NuGetAuditSuppress had been masking vulnerable 2.1.11 in 15 projects. **`0.2.0` PUBLISHED to the feed 2026-07-18** (5 packages, restore-verified from a scratch consumer; merged to `main` @ `15ef1f3`) — no app has adopted a clustered topology yet | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) |
+| Secrets (encrypted store + `${secret:}` resolution) | Built (libs **`0.1.3`**, **published to the Gitea feed**; **HistorianGateway adopted + live-proven** 2026-07-16; **mxaccessgw adopted G-4/G-5/G-6 + merged to `origin/main` @ `e088dfa`** 2026-07-16, box-verified fail-closed + encrypt-at-rest; **ScadaBridge adopted G-3/G-4/G-5/G-6 + merged to `origin/main` @ `128f1596`** 2026-07-16, **G-3 live-proven vs the real production MxGateway gateway** `wonder-app-vd03:5120` (secret-ref ApiKey → Connected + browsed real Galaxy); **OtOpcUa adopted G-2/G-4/G-5/G-6 + merged to `origin/master` @ `872cf7e3`** (lmxopcua) 2026-07-16 — the last app, so **all four now adopted**; Layer-B driver secrets (Galaxy API key + OpcUaClient `Password`/`UserCertificatePassword`) resolve fail-closed at driver session-open, `AddZbSecrets` registered unconditionally so driver-only nodes work; **G-2 live-proven vs the real production MxGateway** `wonder-app-vd03:5120` — a `secret:`-ref Galaxy ApiKey resolved through OtOpcUa's real `GalaxyDriverBrowser` → dummy key `MxGatewayAuthenticationException`, real key → browsed the real Galaxy root (10 nodes); temp key minted+revoked+deleted); **G-8 KEK-rotation BUILT (lib bumped `0.1.2`→`0.1.3`, 2026-07-17):** `Rewrap` DEK primitive + CAS-guarded `ApplyRewrapAsync` + `KekRotationService.RewrapAllAsync` + `secret rewrap-all` CLI + operator runbook (`ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`), full suite green + CLI smoke + adversarial crypto review PASS (TOCTOU closed via compare-and-swap). **G-7 clustered replication BUILT 2026-07-18 (lib `0.2.0`) — BOTH fork options, packaged as two new libs:** **`ZB.MOM.WW.Secrets.Replicator.SqlServer`** (shared SQL-Server `ISecretStore` = the plan's Option A, *plus* a local-store-with-hub mode) and **`ZB.MOM.WW.Secrets.Replicator.AkkaDotNet`** (peer-to-peer over distributed pub/sub = Option B, which the plan had deferred). Core gained `ISecretsStoreMigrator`, `SecretLastWriterWins` (one shared LWW tie-break so stores cannot diverge), `SecretReplicationReconciler`, and `ReplicatingSecretStore` — the last of which fixes a real gap: **nothing had ever called `ISecretReplicator.PublishAsync`**, so the seam was inert. Verified 170 offline / 184 with the live SQL suite / 1 skip / 0 warnings, including **15 live tests vs a real SQL Server 2022** (the SQLite suite ported case-for-case) and a **9-test in-process 2-node Akka cluster** (real remoting; write→peer, delete propagation, both anti-entropy directions, late-joiner catch-up). Hard constraints across all topologies: same KEK on every node, ciphertext-only across trust boundaries, and re-wraps deliberately do NOT replicate (`rewrap-all` runs once per independent store). Operator runbook `ZB.MOM.WW.Secrets/docs/operations/clustered-secrets.md`. **Corrected 2026-07-18:** G-8 + the G-7 plan **ARE committed and pushed** on `main` (`d82d345`) — the "not yet committed/pushed" note was stale. **`0.1.3` PUBLISHED to the feed 2026-07-18** (restore-verified), carrying a **transitive security pin** (`SQLitePCLRaw.lib.e_sqlite3` → `2.1.12`, advisory GHSA-2m69-gcr7-jv3q) that `0.1.2` lacked. **All 4 apps bumped to `0.2.1`** 2026-07-18 (local `feat/secrets-0.2.0` branches, NOT pushed). `0.2.1` fixes a critical defect: `0.2.0`'s Akka replicator was **silently inert** (TryAdd registration order — it published into a no-op sink and never spawned an actor). SQL-Server replication was never affected. Clustered topology is **wired but default-OFF and NOT live-validated** — OtOpcUa on Akka peer-to-peer, ScadaBridge on SQL-hub mode (its central and site clusters are separate Akka clusters, so pub/sub cannot cross them). Separately, a **live** SQLitePCLRaw vulnerability was found and fixed in ScadaBridge, where a NuGetAuditSuppress had been masking vulnerable 2.1.11 in 15 projects. **`0.2.0` PUBLISHED to the feed 2026-07-18** (5 packages, restore-verified from a scratch consumer; merged to `main` @ `15ef1f3`) — no app has adopted a clustered topology yet. **`0.2.2` (2026-07-18, [scadaproj#1](https://gitea.dohertylan.com/dohertj2/scadaproj/issues/1) FIXED): the Akka replicator's hosted-process DI deadlock root-caused** — a circular singleton dependency hidden in factory lambdas (`ISecretStore` decorator → `ISecretReplicator` → actor provider → `ISecretCacheInvalidator` → `DefaultSecretResolver` → `ISecretStore`), which MS.DI's `StackGuard` turns into a silent cross-thread call-site-lock deadlock instead of a stack overflow; NOT `DistributedPubSub`/HOCON (both disproven — the actor was never constructed). Fixed by deferring the one cycle edge (`DeferredSecretCacheInvalidator`, resolves on first eviction); **Akka live gate now 6/6** (was: hang), regression coverage `HostedProcessResolutionTests` (container-built graph + hosted services; red on 0.2.1), suite 180/0/15. Fourth defect class only visible with the DI graph built in a real host. Not yet published to the feed | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) |
| LocalDb (embedded cache + 2-node sync) | Built (3 pkgs `0.1.0`, **published to the Gitea feed** 2026-07-18, restore-verified from a scratch consumer; no app adoption yet) | Shared `ZB.MOM.WW.LocalDb` lib | [`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md) | [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) |
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props
index 0829136..6b68385 100644
--- a/ZB.MOM.WW.Secrets/Directory.Build.props
+++ b/ZB.MOM.WW.Secrets/Directory.Build.props
@@ -5,7 +5,7 @@
enable
enable
latest
- 0.2.1
+ 0.2.2
true
README.md
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs
index 1fd0523..e3aa26f 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs
@@ -69,10 +69,17 @@ public static class AkkaSecretsServiceCollectionExtensions
// The actor is created lazily on first use rather than at registration: the ActorSystem is
// typically registered by the application AFTER this call, and a cluster node may also not be
// ready to join at the moment the container is built.
+ //
+ // The invalidator MUST stay deferred (scadaproj#1): it is the resolver, the resolver reads
+ // ISecretStore, and ISecretStore is the ReplicatingSecretStore decorator registered below —
+ // resolving it here closes a singleton cycle the container cannot detect through factory
+ // lambdas, and every hosted process then deadlocks at startup inside the container's
+ // resolution locks. DeferredSecretCacheInvalidator waits until the first eviction, which
+ // can only happen after the graph has finished resolving.
services.TryAddSingleton(sp => new SecretReplicationActorProvider(
sp.GetRequiredService(),
sp.GetRequiredService(),
- sp.GetService(),
+ new DeferredSecretCacheInvalidator(sp.GetService),
sp.GetRequiredService>().Value));
// MUST be registered BEFORE AddZbSecrets. That call does
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/DeferredSecretCacheInvalidator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/DeferredSecretCacheInvalidator.cs
new file mode 100644
index 0000000..40a717d
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/DeferredSecretCacheInvalidator.cs
@@ -0,0 +1,39 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
+
+///
+/// Resolves the real on first eviction instead of at
+/// construction, so the replication graph can be wired without closing a dependency cycle.
+///
+///
+///
+/// This deferral is load-bearing, not an optimization (scadaproj#1). The invalidator is the
+/// resolver, the resolver reads through ISecretStore, and with replication registered
+/// ISecretStore is the ReplicatingSecretStore decorator whose replicator owns this
+/// node's actor — so resolving the invalidator eagerly inside the actor provider's factory closes
+/// a singleton cycle the container cannot see through factory lambdas. Resolution then recurses
+/// until MS.DI's StackGuard hops it onto another thread, which deadlocks on a call-site
+/// lock the first thread holds: any host resolving the graph hangs at startup, silently and
+/// permanently.
+///
+///
+/// Deferring to first eviction is sufficient because evictions only happen when a replicated row
+/// is applied — strictly after the graph has finished resolving, at which point every singleton on
+/// the former cycle is already materialized and the lookup completes without re-entering the
+/// container's resolution locks.
+///
+///
+///
+/// Lookup for the real invalidator; returns null when the application registered none.
+///
+internal sealed class DeferredSecretCacheInvalidator(Func resolve)
+ : ISecretCacheInvalidator
+{
+ private readonly Lazy _inner = new(
+ resolve ?? throw new ArgumentNullException(nameof(resolve)),
+ LazyThreadSafetyMode.ExecutionAndPublication);
+
+ ///
+ public void Invalidate(SecretName name) => _inner.Value?.Invalidate(name);
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs
index 39151af..dbd812d 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs
@@ -25,12 +25,14 @@ namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection;
/// the other, so the gap is closed here.
///
///
-/// These assert against the rather than a built provider on
-/// purpose. Resolving ISecretReplicator eagerly spawns the replication actor, whose
-/// PreStart calls DistributedPubSub.Get(...); that requires a joined cluster, so a
-/// provider-based test would need a real single-node cluster and would hang without one. The
-/// defect was entirely about which descriptor wins, and the descriptor is where it is visible.
-/// End-to-end actor behaviour is already covered by TwoNodeClusterReplicationTests.
+/// These assert against the because the 0.2.0 defect was entirely
+/// about which descriptor wins, and the descriptor is where it is visible. Resolution through a
+/// BUILT provider — where the scadaproj#1 singleton-cycle deadlock lived — is covered by
+/// , and end-to-end actor behaviour by
+/// TwoNodeClusterReplicationTests. (An earlier revision of this comment claimed a
+/// provider-based test was impossible because DistributedPubSub.Get needs a joined
+/// cluster; the hang it was avoiding was actually that DI deadlock, and a single self-joined
+/// node is all the cluster the actor needs.)
///
///
public sealed class AddZbSecretsAkkaReplicationTests
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/DeferredSecretCacheInvalidatorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/DeferredSecretCacheInvalidatorTests.cs
new file mode 100644
index 0000000..b30e70d
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/DeferredSecretCacheInvalidatorTests.cs
@@ -0,0 +1,75 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
+
+namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection;
+
+///
+/// The deferred invalidator exists to break the scadaproj#1 dependency cycle, which makes it a
+/// pass-through that could silently pass nothing through — the exact failure mode (inert seam,
+/// green suite) this library has shipped before. These tests pin the two halves of its contract:
+/// nothing resolves before the first eviction, and evictions actually reach the real invalidator.
+///
+public sealed class DeferredSecretCacheInvalidatorTests
+{
+ private sealed class RecordingInvalidator : ISecretCacheInvalidator
+ {
+ public List Evicted { get; } = [];
+
+ public void Invalidate(SecretName name) => Evicted.Add(name);
+ }
+
+ [Fact]
+ public void Construction_does_not_resolve_the_inner_invalidator()
+ {
+ // THE point of the type: resolving eagerly is what closed the singleton cycle and hung
+ // every hosted process at startup.
+ bool resolved = false;
+
+ _ = new DeferredSecretCacheInvalidator(() =>
+ {
+ resolved = true;
+ return null;
+ });
+
+ Assert.False(resolved);
+ }
+
+ [Fact]
+ public void Invalidate_forwards_to_the_resolved_invalidator()
+ {
+ var inner = new RecordingInvalidator();
+ var deferred = new DeferredSecretCacheInvalidator(() => inner);
+ var name = new SecretName("gate/alpha");
+
+ deferred.Invalidate(name);
+
+ Assert.Equal([name], inner.Evicted);
+ }
+
+ [Fact]
+ public void The_lookup_runs_once_and_the_instance_is_reused()
+ {
+ int lookups = 0;
+ var inner = new RecordingInvalidator();
+ var deferred = new DeferredSecretCacheInvalidator(() =>
+ {
+ lookups++;
+ return inner;
+ });
+
+ deferred.Invalidate(new SecretName("gate/alpha"));
+ deferred.Invalidate(new SecretName("gate/beta"));
+
+ Assert.Equal(1, lookups);
+ Assert.Equal(2, inner.Evicted.Count);
+ }
+
+ [Fact]
+ public void A_null_resolution_means_evictions_are_no_ops()
+ {
+ // Mirrors the reconciler's own contract: no invalidator registered, nothing to evict.
+ var deferred = new DeferredSecretCacheInvalidator(() => null);
+
+ deferred.Invalidate(new SecretName("gate/alpha"));
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/HostedProcessResolutionTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/HostedProcessResolutionTests.cs
new file mode 100644
index 0000000..cd1b17b
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/HostedProcessResolutionTests.cs
@@ -0,0 +1,153 @@
+using Akka.Actor;
+using Akka.Cluster;
+using Akka.Configuration;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Replication;
+using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
+
+namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection;
+
+///
+/// Builds the replication graph the way a real application host does — container-registered
+/// , hosted services started, services resolved from the built provider —
+/// and asserts resolution actually completes.
+///
+///
+///
+/// This is the regression test for scadaproj#1. The 0.2.x graph had a circular singleton
+/// dependency reachable only through the container: ISecretStore (the
+/// ReplicatingSecretStore decorator) → ISecretReplicator →
+/// SecretReplicationActorProvider → ISecretCacheInvalidator →
+/// DefaultSecretResolver → ISecretStore. Every edge is an opaque factory lambda, so
+/// the container cannot detect the cycle statically; at runtime the resolution recurses until
+/// MS.DI's StackGuard moves it onto a fresh thread-pool thread, which then blocks forever on
+/// a singleton call-site lock the first thread still holds. The symptom was a silent, permanent
+/// hang — not a stack overflow, not an exception — which is why every direct-construction test
+/// passed while any hosted process froze at startup.
+///
+///
+/// The suite's other cluster tests construct the actor with system.ActorOf directly and can
+/// never see this defect. Nothing here may resolve the affected services before the assertions do.
+///
+///
+public sealed class HostedProcessResolutionTests : IAsyncLifetime
+{
+ // Generous next to the milliseconds a healthy resolve takes, tiny next to CI noise: on
+ // regression the resolve NEVER completes, so the only cost of margin is this test's runtime.
+ private static readonly TimeSpan ResolveBudget = TimeSpan.FromSeconds(20);
+
+ private readonly string _dbPath =
+ Path.Combine(Path.GetTempPath(), $"zb-hosted-di-{Guid.NewGuid():N}.db");
+
+ private ActorSystem _system = null!;
+ private ServiceProvider _provider = null!;
+
+ public async Task InitializeAsync()
+ {
+ // A single self-joined cluster node: `actor.provider = cluster` is what the replication
+ // actor's DistributedPubSub mediator needs, and joining makes the rig an honest miniature
+ // of an app node rather than a special case.
+ _system = ActorSystem.Create("zb-secrets-cluster", ConfigurationFactory.ParseString("""
+ akka {
+ loglevel = WARNING
+ actor.provider = cluster
+ remote.dot-netty.tcp {
+ hostname = "127.0.0.1"
+ public-hostname = "127.0.0.1"
+ port = 0
+ }
+ }
+ """).WithFallback(AkkaSecretsReplication.SerializationConfig));
+
+ Cluster cluster = Cluster.Get(_system);
+ cluster.Join(cluster.SelfAddress);
+
+ DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20);
+ while (cluster.State.Members.All(m => m.Status != MemberStatus.Up))
+ {
+ if (DateTime.UtcNow > deadline)
+ {
+ throw new TimeoutException("Single-node cluster never reached Up.");
+ }
+
+ await Task.Delay(100);
+ }
+
+ IConfigurationRoot config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Secrets:SqlitePath"] = _dbPath,
+ ["Secrets:RunMigrationsOnStartup"] = "true",
+ ["Secrets:Replication:AnnounceInterval"] = "00:00:30",
+ })
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(_system);
+ services.AddZbSecretsAkkaReplication(config, "Secrets", "Secrets:Replication");
+ _provider = services.BuildServiceProvider();
+
+ // Started exactly as a host would, BEFORE anything resolves the replicated store — the
+ // order OtOpcUa's startup follows, and the order under which the deadlock was reproduced.
+ foreach (IHostedService hosted in _provider.GetServices())
+ {
+ await hosted.StartAsync(CancellationToken.None);
+ }
+ }
+
+ [Fact]
+ public async Task Resolving_the_replicated_graph_through_the_container_completes()
+ {
+ // One test covers all three entry points IN this order: the store is the edge OtOpcUa's
+ // startup hook actually resolves, the replicator is the narrowest reproduction of the
+ // hang, and the resolver closes the loop from the invalidator's side. Split into separate
+ // facts, each would rebuild the rig only to hit the same three singletons.
+ ISecretStore store = await ResolveWithinBudgetAsync(
+ sp => sp.GetRequiredService(), nameof(ISecretStore));
+ ISecretReplicator replicator = await ResolveWithinBudgetAsync(
+ sp => sp.GetRequiredService(), nameof(ISecretReplicator));
+ ISecretResolver resolver = await ResolveWithinBudgetAsync(
+ sp => sp.GetRequiredService(), nameof(ISecretResolver));
+
+ Assert.IsType(store);
+ Assert.IsType(replicator);
+ Assert.NotNull(resolver);
+ }
+
+ private async Task ResolveWithinBudgetAsync(
+ Func resolve, string label)
+ {
+ // The failure mode under test is an infinite wait inside the container, so the resolve
+ // runs on the pool with a watchdog. On regression the worker thread is unrecoverable —
+ // acceptable in a test process that is about to exit, and vastly better than hanging CI.
+ Task resolution = Task.Run(() => resolve(_provider));
+
+ Task first = await Task.WhenAny(resolution, Task.Delay(ResolveBudget));
+
+ Assert.True(
+ first == resolution,
+ $"Resolving {label} did not complete within {ResolveBudget.TotalSeconds:0}s — " +
+ "the DI singleton cycle deadlock (scadaproj#1) has regressed.");
+
+ return await resolution;
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _provider.DisposeAsync();
+ await _system.Terminate();
+
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+}
diff --git a/components/secrets/GAPS.md b/components/secrets/GAPS.md
index f710e40..b4ef33a 100644
--- a/components/secrets/GAPS.md
+++ b/components/secrets/GAPS.md
@@ -105,28 +105,42 @@ The KEK control fired spontaneously first: a re-run against a hub still holding
run (different KEK) threw `SecretDecryptionException` rather than returning garbage — unplanned but
real evidence of fail-closed.
-### Live gate: Akka peer-to-peer (OtOpcUa topology) — ❌ FAILED, blocking defect
+### Live gate: Akka peer-to-peer (OtOpcUa topology) — ✅ PASSES 6/6 (root-caused + fixed in `0.2.2`, 2026-07-18)
-**Resolving `ISecretReplicator` hangs indefinitely** in a real clustered process. Reproduced twice
-on a healthy 2-node cluster (both members confirmed `Up` before the hang). Isolated by resolving
-`ISecretReplicator` *directly* rather than via `ISecretStore`, so it is **not** nested-resolution
-contention — the hang is in constructing `AkkaSecretReplicator` → `SecretReplicationActorProvider.ActorRef`
-→ `system.ActorOf(SecretReplicationActor.Props(...))`. Native stacks show `Monitor_Wait`.
+The `0.2.1` hang is **root-caused and fixed**. It was never Akka at all: the Akka package's DI
+wiring had a **circular singleton dependency** the container cannot detect through factory lambdas —
+`ISecretStore` (the `ReplicatingSecretStore` decorator) → `ISecretReplicator` →
+`SecretReplicationActorProvider` → `ISecretCacheInvalidator` → `DefaultSecretResolver` →
+`ISecretStore` again. Resolution recurses around that loop (same-thread `Monitor` re-entry keeps it
+alive) until MS.DI's `StackGuard` moves the recursion onto a fresh thread-pool thread to avoid a
+stack overflow — and that thread then blocks forever on a singleton call-site lock the first thread
+still holds. Managed stacks from a `dotnet-dump` of the hung process show the full cycle repeating
+and both threads parked, which is the `Monitor_Wait` the native samples saw. Both earlier candidate
+causes (`DistributedPubSub.Get` vs the `Lazy` lock; missing `Akka.Cluster.Tools` HOCON)
+are **disproven** — the actor's constructor was never even reached, and the deadlock reproduces on a
+single-node rig with no clustering in play.
-**Impact if enabled:** OtOpcUa would hang at startup, since the wiring resolves `ISecretStore` from a
-startup hook. It is currently harmless only because `Secrets:Replication:Enabled` defaults false.
+**Fix (`0.2.2`):** the one cycle-closing edge is deferred. `SecretReplicationActorProvider` now
+receives a `DeferredSecretCacheInvalidator` that resolves the real invalidator on **first
+eviction** — which can only happen when a replicated row is applied, strictly after the graph has
+finished resolving. The SQL-Server package never had the cycle (its replicator chain never touches
+the invalidator), which is exactly why the hub gate passed while Akka hung.
-**Not explained by the library's own suite:** `TwoNodeClusterReplicationTests` creates the same actor
-on a real 2-node cluster and passes (33 tests green). The difference is creation through DI inside a
-real host process. Root cause NOT yet identified — candidates: `DistributedPubSub.Get(...)` in
-`PreStart` interacting with the `Lazy` (`ExecutionAndPublication`) lock, or missing
-`Akka.Cluster.Tools` reference config in the composed HOCON.
+**Verified:** live convergence gate re-run — **6/6 checks** on a real 2-node cluster (write→peer,
+delete→tombstone, tombstoned secret no longer resolves on the peer — the check that proves the
+deferred invalidator actually evicts — reverse direction, wrong-KEK fail-closed). Full Akka test
+project 38/38 green.
-- ⬜ **Do NOT enable `Secrets:Replication:Enabled` in OtOpcUa.** The Akka topology is not adoptable
- until this deadlock is root-caused and fixed, with a regression test that creates the actor
- through DI in a hosted process rather than directly from a test.
- **Tracked:** [`scadaproj#1`](https://gitea.dohertylan.com/dohertj2/scadaproj/issues/1) (the defect,
- where the library code lives) and [`lmxopcua#482`](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/482)
+- ✅ **Regression test now exists at the exact blind spot:** `HostedProcessResolutionTests` builds
+ the graph the way a host does (container-registered `ActorSystem`, hosted services started, then
+ resolves `ISecretStore`/`ISecretReplicator`/`ISecretResolver` under a 20 s watchdog). Verified to
+ discriminate: it deadlocks/fails on `0.2.1` and passes on `0.2.2`. `DeferredSecretCacheInvalidatorTests`
+ pins the wrapper's contract (no eager resolve; evictions forward; lookup runs once). This was the
+ **fourth** defect in this library visible only when the DI graph is built inside a real host.
+- ⬜ OtOpcUa may now adopt the Akka topology: bump to `0.2.2` (once published), re-run its gate,
+ then consider enabling `Secrets:Replication:Enabled`.
+ **Tracked:** [`scadaproj#1`](https://gitea.dohertylan.com/dohertj2/scadaproj/issues/1) (the defect —
+ fixed) and [`lmxopcua#482`](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/482)
(the consumer-side tracker: bump the package, re-run the gate, then consider enabling).
- ✅ ScadaBridge's hub topology **is** live-validated and safe to enable (config + a shared KEK).