diff --git a/ZB.MOM.WW.Secrets/CHANGELOG.md b/ZB.MOM.WW.Secrets/CHANGELOG.md
index 54c36e8..a0bcf6d 100644
--- a/ZB.MOM.WW.Secrets/CHANGELOG.md
+++ b/ZB.MOM.WW.Secrets/CHANGELOG.md
@@ -18,6 +18,14 @@ Fixes six scadaproj#15 code-review findings (`code-reviews/Secrets/findings.md`)
the SQLite store (shared-store mode) or the `NoOpSecretReplicator` (hub and Akka modes). They now
replace those registrations after the core call; a later `AddZbSecrets` only `TryAdd`s, so either
order yields the same graph, and exactly one `ISecretStore` is registered.
+- **The SQL-Server and Akka extensions take a `contentRootPath`** (scadaproj#31, Secrets-002), and the
+ store-path validator's content root is now **the last explicitly supplied one** across all
+ `AddZbSecrets` calls (a `null` root never erases an earlier explicit one). Before, the first call to
+ register the validator captured the root, so a package's internal 3-arg call that ran first silently
+ dropped the application's 4-arg root and the pre-host under-content-root guard was skipped — in hub
+ mode no registration order applied it. New trailing optional parameter on
+ `AddZbSecretsSqlServerStore`, `AddZbSecretsSqlServerReplication` and `AddZbSecretsAkkaReplication`
+ (source-compatible; callers recompile). Pass it from every pre-host composition.
## 0.6.5 — unreleased (NOT published to the feed)
diff --git a/ZB.MOM.WW.Secrets/README.md b/ZB.MOM.WW.Secrets/README.md
index cc36558..d4a023e 100644
--- a/ZB.MOM.WW.Secrets/README.md
+++ b/ZB.MOM.WW.Secrets/README.md
@@ -182,7 +182,10 @@ node can always reach a shared database, because you pay real distributed-system
Since `0.6.6` all three extensions are **order-independent** with respect to `AddZbSecrets`: call
it before or after them (or not at all — each calls it internally) and the same store, migrator and
-replicator are bound.
+replicator are bound. Each also takes an optional `contentRootPath`, forwarded to `AddZbSecrets`:
+pass it from any **pre-host** composition (e.g. the container a `${secret:}` expander builds), just as
+you would the 4-argument `AddZbSecrets`, so the store-path guard can reject a store inside the content
+root. The last explicitly supplied root wins across calls.
### What replication costs you (2 and 3)
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 c0aa260..ab3db92 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
@@ -46,12 +46,19 @@ public static class AkkaSecretsServiceCollectionExtensions
/// The application configuration.
/// Configuration section holding the core secrets options.
/// Configuration section holding the replication options.
+ ///
+ /// The application content root, forwarded to AddZbSecrets so the store-path guard can
+ /// reject a Secrets:SqlitePath inside it. Pass it from every pre-host composition (a
+ /// container with no IHostEnvironment, such as a ${secret:} expander's), exactly as
+ /// for the 4-argument AddZbSecrets. The last explicitly supplied root wins across calls.
+ ///
/// The same instance, for chaining.
public static IServiceCollection AddZbSecretsAkkaReplication(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
- string replicationSectionPath = "Secrets:Replication")
+ string replicationSectionPath = "Secrets:Replication",
+ string? contentRootPath = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
@@ -83,7 +90,7 @@ public static class AkkaSecretsServiceCollectionExtensions
sp.GetRequiredService>().Value));
// The local store + migrator come from AddZbSecrets (SQLite by default).
- services.AddZbSecrets(config, secretsSectionPath);
+ services.AddZbSecrets(config, secretsSectionPath, contentRootPath);
// REPLACED after the core call rather than TryAdd-ed before it (scadaproj#30). AddZbSecrets
// does TryAddSingleton(); a TryAdd here only won when
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs
index 4d01243..82aec6d 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs
@@ -39,16 +39,23 @@ public static class SqlServerSecretsServiceCollectionExtensions
/// The application configuration.
/// Configuration section holding the core secrets options.
/// Configuration section holding the SQL-Server options.
+ ///
+ /// The application content root, forwarded to AddZbSecrets so the store-path guard can
+ /// reject a Secrets:SqlitePath inside it. Pass it from every pre-host composition (a
+ /// container with no IHostEnvironment, such as a ${secret:} expander's), exactly as
+ /// for the 4-argument AddZbSecrets. The last explicitly supplied root wins across calls.
+ ///
/// The same instance, for chaining.
public static IServiceCollection AddZbSecretsSqlServerStore(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
- string sqlServerSectionPath = "Secrets:SqlServer")
+ string sqlServerSectionPath = "Secrets:SqlServer",
+ string? contentRootPath = null)
{
RegisterSqlServerCore(services, config, sqlServerSectionPath);
- services.AddZbSecrets(config, secretsSectionPath);
+ services.AddZbSecrets(config, secretsSectionPath, contentRootPath);
// REPLACE, not TryAdd (scadaproj#30): the application may already have called AddZbSecrets,
// whose SQLite ISecretStore / ISecretsStoreMigrator a TryAdd here would silently leave in
@@ -81,19 +88,26 @@ public static class SqlServerSecretsServiceCollectionExtensions
/// The application configuration.
/// Configuration section holding the core secrets options.
/// Configuration section holding the SQL-Server options.
+ ///
+ /// The application content root, forwarded to AddZbSecrets so the store-path guard can
+ /// reject a Secrets:SqlitePath inside it. Pass it from every pre-host composition (a
+ /// container with no IHostEnvironment, such as a ${secret:} expander's), exactly as
+ /// for the 4-argument AddZbSecrets. The last explicitly supplied root wins across calls.
+ ///
/// The same instance, for chaining.
public static IServiceCollection AddZbSecretsSqlServerReplication(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
- string sqlServerSectionPath = "Secrets:SqlServer")
+ string sqlServerSectionPath = "Secrets:SqlServer",
+ string? contentRootPath = null)
{
RegisterSqlServerCore(services, config, sqlServerSectionPath);
services.TryAddSingleton(sp => sp.GetRequiredService());
// The local store + its migrator come from AddZbSecrets (SQLite by default).
- services.AddZbSecrets(config, secretsSectionPath);
+ services.AddZbSecrets(config, secretsSectionPath, contentRootPath);
// REPLACE, not TryAdd (scadaproj#30): if the application called AddZbSecrets first, its
// NoOpSecretReplicator is already registered and a TryAdd would silently lose to it — live
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs
index 3451622..70a83f6 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs
@@ -69,16 +69,29 @@ public static class SecretsServiceCollectionExtensions
// composed without a host, which the CLI legitimately does.
//
// But AddZbSecrets is called more than once in real compositions (a replication package
- // calls it internally after the application already has), so registration has to be
+ // calls it internally before or after the application does), so registration has to be
// idempotent — and TryAddEnumerable cannot do it here: it de-duplicates on implementation
// TYPE, which a factory descriptor does not carry, so it rejects the second call outright
// rather than skipping it. Guarding on the concrete registration gives both properties.
+ //
+ // The content root is NOT captured by whichever call registers the validator (scadaproj#31).
+ // It lives in one shared holder that every call may set, and the LAST explicitly supplied
+ // root wins; a call that passes null leaves an earlier explicit root alone. Before 0.6.6 the
+ // first call captured it, so a package's internal 3-arg call that ran first discarded the
+ // application's later 4-arg root, and the pre-host store-path guard was silently skipped.
+ SecretsContentRoot holder = GetOrAddContentRootHolder(services);
+ if (contentRootPath is not null)
+ {
+ holder.Path = contentRootPath;
+ }
+
if (services.All(d => d.ServiceType != typeof(SecretsOptionsValidator)))
{
// An explicitly supplied content root wins; otherwise fall back to IHostEnvironment,
// which is present in a real host and absent in a pre-host container.
services.AddSingleton(sp => new SecretsOptionsValidator(
- contentRootPath ?? sp.GetService()?.ContentRootPath));
+ sp.GetRequiredService().Path
+ ?? sp.GetService()?.ContentRootPath));
services.AddSingleton>(
sp => sp.GetRequiredService());
}
@@ -153,4 +166,30 @@ public static class SecretsServiceCollectionExtensions
return services;
}
+
+ // The one content-root holder for this service collection, registered as an INSTANCE so every
+ // AddZbSecrets call (and the validator factory) sees the same object.
+ private static SecretsContentRoot GetOrAddContentRootHolder(IServiceCollection services)
+ {
+ if (services.FirstOrDefault(d => d.ServiceType == typeof(SecretsContentRoot))
+ ?.ImplementationInstance is SecretsContentRoot existing)
+ {
+ return existing;
+ }
+
+ var holder = new SecretsContentRoot();
+ services.AddSingleton(holder);
+ return holder;
+ }
+}
+
+///
+/// The content root the store-path validator checks against, as explicitly supplied to
+/// AddZbSecrets (or a replication package's contentRootPath argument). The last
+/// explicit value wins; means "fall back to ".
+///
+internal sealed class SecretsContentRoot
+{
+ /// The explicitly supplied content root, or when none was.
+ public string? Path { get; set; }
}
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 dbd812d..15f5590 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
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Replication;
@@ -108,4 +109,26 @@ public sealed class AddZbSecretsAkkaReplicationTests
Assert.Contains(services, d => d.ServiceType == typeof(ISecretsStoreMigrator));
}
+
+ [Fact]
+ public void The_content_root_argument_reaches_the_store_path_guard()
+ {
+ // scadaproj#31: the Akka node's local SQLite store is migrated by a pre-host expander too,
+ // so the extension must be able to hand the guard a content root (no IHostEnvironment here).
+ string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-akka-root-{Guid.NewGuid():N}");
+ IConfigurationRoot config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Secrets:SqlitePath"] = Path.Combine(contentRoot, "s.db"),
+ })
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddZbSecretsAkkaReplication(config, contentRootPath: contentRoot);
+ using ServiceProvider provider = services.BuildServiceProvider();
+
+ Assert.Throws(
+ () => provider.GetRequiredService>().Value);
+ }
}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests.cs
new file mode 100644
index 0000000..891a6b3
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests.cs
@@ -0,0 +1,114 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.DependencyInjection;
+using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
+
+namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.DependencyInjection;
+
+///
+/// scadaproj#31 (Secrets-002): in hub-replication mode the node's local SQLite store is migrated by
+/// a pre-host ${secret:} expander, so the under-content-root guard must be reachable — either
+/// through the extension's own contentRootPath or through a 4-arg AddZbSecrets in
+/// either order. Before the fix no registration order applied it: the extension's internal 3-arg
+/// call captured a null root first, and the core-first order lost the replicator (scadaproj#30).
+///
+///
+/// Every container here is pre-host — no IHostEnvironment — which is the case the guard
+/// exists for. The store file is asserted absent BEFORE the exception is asserted, so a regression
+/// that stops the throw but still creates the database fails on the artifact.
+///
+public sealed class ContentRootTests : IDisposable
+{
+ private readonly string _contentRoot =
+ Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-root-{Guid.NewGuid():N}");
+
+ public ContentRootTests() => Directory.CreateDirectory(_contentRoot);
+
+ public enum Composition
+ {
+ ExtensionArgument,
+ ExtensionThenCore,
+ CoreThenExtension,
+ }
+
+ private IConfiguration Config(string sqlitePath) =>
+ new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["Secrets:SqlitePath"] = sqlitePath,
+ ["Secrets:RunMigrationsOnStartup"] = "false",
+ ["Secrets:SqlServer:ConnectionString"] = "Server=unused;Database=x;",
+ }).Build();
+
+ private ServiceProvider Compose(Composition composition, IConfiguration config)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ switch (composition)
+ {
+ case Composition.ExtensionArgument:
+ services.AddZbSecretsSqlServerReplication(config, contentRootPath: _contentRoot);
+ break;
+ case Composition.ExtensionThenCore:
+ services.AddZbSecretsSqlServerReplication(config);
+ services.AddZbSecrets(config, "Secrets", _contentRoot);
+ break;
+ case Composition.CoreThenExtension:
+ services.AddZbSecrets(config, "Secrets", _contentRoot);
+ services.AddZbSecretsSqlServerReplication(config);
+ break;
+ }
+
+ return services.BuildServiceProvider();
+ }
+
+ [Theory]
+ [InlineData(Composition.ExtensionArgument)]
+ [InlineData(Composition.ExtensionThenCore)]
+ [InlineData(Composition.CoreThenExtension)]
+ public async Task Hub_mode_refuses_a_local_store_inside_the_content_root(Composition composition)
+ {
+ string dbPath = Path.Combine(_contentRoot, "data", "s.db");
+ using ServiceProvider provider = Compose(composition, Config(dbPath));
+
+ Exception? thrown = await Record.ExceptionAsync(
+ () => provider.GetRequiredService().MigrateAsync(default));
+
+ Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file");
+ Assert.IsType(thrown);
+ }
+
+ [Theory]
+ [InlineData(Composition.ExtensionArgument)]
+ [InlineData(Composition.ExtensionThenCore)]
+ [InlineData(Composition.CoreThenExtension)]
+ public void Hub_mode_accepts_a_store_outside_the_content_root(Composition composition)
+ {
+ // Positive control: the same compositions with a legal path resolve cleanly, so the refusal
+ // above is the content-root rule and not some other failure of the composition.
+ string dbPath = Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-outside-{Guid.NewGuid():N}.db");
+ using ServiceProvider provider = Compose(composition, Config(dbPath));
+
+ Assert.Equal(dbPath, provider.GetRequiredService>().Value.SqlitePath);
+ }
+
+ [Fact]
+ public void Shared_store_mode_forwards_the_content_root_too()
+ {
+ string dbPath = Path.Combine(_contentRoot, "s.db");
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddZbSecretsSqlServerStore(Config(dbPath), contentRootPath: _contentRoot);
+ using ServiceProvider provider = services.BuildServiceProvider();
+
+ Assert.Throws(
+ () => provider.GetRequiredService>().Value);
+ }
+
+ public void Dispose()
+ {
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ Directory.Delete(_contentRoot, recursive: true);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs
index b5d9126..6ad7433 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs
@@ -313,6 +313,53 @@ public sealed class SecretsStorePathRulesTests
}
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task The_last_explicit_content_root_wins_whichever_call_registered_the_validator(
+ bool threeArgCallFirst)
+ {
+ // REGRESSION (scadaproj#31). The validator used to capture the content root of whichever
+ // AddZbSecrets call registered it FIRST. A replication package calls the 3-arg overload
+ // internally; when that ran before the application's 4-arg call, the explicit root was
+ // discarded and the pre-host guard silently skipped — the 0.6.0 hole, reopened. A null root
+ // must also never erase an explicit one supplied earlier.
+ string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-prehost-order-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(contentRoot);
+ string dbPath = Path.Combine(contentRoot, "store.db");
+
+ try
+ {
+ IConfiguration config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = dbPath })
+ .Build();
+
+ var services = new ServiceCollection();
+ if (threeArgCallFirst)
+ {
+ services.AddZbSecrets(config, "Secrets");
+ services.AddZbSecrets(config, "Secrets", contentRoot);
+ }
+ else
+ {
+ services.AddZbSecrets(config, "Secrets", contentRoot);
+ services.AddZbSecrets(config, "Secrets");
+ }
+
+ using ServiceProvider provider = services.BuildServiceProvider();
+ Exception? thrown = await Record.ExceptionAsync(
+ () => provider.GetRequiredService().MigrateAsync(default));
+
+ Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file");
+ Assert.IsType(thrown);
+ }
+ finally
+ {
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ Directory.Delete(contentRoot, recursive: true);
+ }
+ }
+
[Fact]
public async Task Without_a_content_root_the_pre_host_container_still_cannot_be_told_it_is_fine()
{
diff --git a/code-reviews/README.md b/code-reviews/README.md
index 18619d6..2d30401 100644
--- a/code-reviews/README.md
+++ b/code-reviews/README.md
@@ -40,7 +40,7 @@ code-reviews/
## Summary
-10 of 10 libraries reviewed. 29 pending findings across all libraries.
+10 of 10 libraries reviewed. 28 pending findings across all libraries.
Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health,
Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also
@@ -50,9 +50,9 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
|----------|---------------|
| Critical | 0 |
| High | 0 |
-| Medium | 10 |
+| Medium | 9 |
| Low | 19 |
-| **Total** | **29** |
+| **Total** | **28** |
## Library Status
@@ -65,7 +65,7 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
| [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 7 |
| [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/3 | 7 | 7 |
| [Overview](Overview/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 5 |
-| [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/5/13 | 18 | 19 |
+| [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/13 | 17 | 19 |
| [Telemetry](Telemetry/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 11 |
| [Theme](Theme/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 8 |
@@ -84,7 +84,7 @@ _None open._
_None open._
-### Medium (10)
+### Medium (9)
| ID | Library | Title |
|----|---------|-------|
@@ -93,7 +93,6 @@ _None open._
| LocalDb-002 | [LocalDb](LocalDb/findings.md) | Snapshot streams tables by name and rows by `pk_json` text, so FK children land before parents and are dead-lettered for good |
| LocalDb-003 | [LocalDb](LocalDb/findings.md) | `INSERT OR REPLACE` apply silently deletes a different row on a secondary `UNIQUE` conflict, so the pair diverges |
| LocalDb-004 | [LocalDb](LocalDb/findings.md) | `FailClosedOnDrift` is bypassed on the snapshot path, so a future-stamped snapshot row moves the local HLC forward permanently |
-| Secrets-002 | [Secrets](Secrets/findings.md) | The SQL-Server extensions cannot pass the content root, so the pre-host store-path hole reopens in hub mode |
| Secrets-003 | [Secrets](Secrets/findings.md) | `${secret:}` expansion is undone by configuration reload |
| Secrets-010 | [Secrets](Secrets/findings.md) | Akka replication actor is created only when `ISecretStore` is first resolved; a node that never resolves it never replicates |
| Secrets-013 | [Secrets](Secrets/findings.md) | UI add/rotate/delete: if the audit write throws after the store write succeeded, the change is reported and audited as a failure and the resolver cache is not evicted |
diff --git a/code-reviews/Secrets/findings.md b/code-reviews/Secrets/findings.md
index b3fbf25..42096c1 100644
--- a/code-reviews/Secrets/findings.md
+++ b/code-reviews/Secrets/findings.md
@@ -10,7 +10,7 @@
| Last reviewed | 2026-11-09 |
| Reviewer | Claude (scadaproj#15) |
| Commit reviewed | `91fb2b4` |
-| Open findings | 18 |
+| Open findings | 17 |
## Summary
@@ -98,7 +98,7 @@ Resolved 2026-11-09 in the commit tagged `(scadaproj#30)` (Secrets `0.6.6`, unpu
| Severity | Medium |
| Priority | P2 |
| Category | Security & secret handling |
-| Status | Open |
+| Status | Resolved |
| Location | `ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs:94` (with `ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs:76`) |
**Description**
@@ -119,7 +119,7 @@ Add a `string? contentRootPath` parameter to both SQL-Server extensions and forw
**Resolution**
-_Unresolved._
+Resolved 2026-11-09 in the commit tagged `(scadaproj#31)` (Secrets `0.6.6`, unpublished). Both recommendations done: (1) `AddZbSecretsSqlServerStore`, `AddZbSecretsSqlServerReplication` and `AddZbSecretsAkkaReplication` gained a trailing optional `string? contentRootPath = null`, forwarded to the 4-arg `AddZbSecrets`; (2) the content root moved out of the first-registering call's closure into one shared `SecretsContentRoot` instance holder in the service collection, which every `AddZbSecrets` call may set — the last explicit root wins and a `null` never erases an earlier one; the validator factory still falls back to `IHostEnvironment` only when no explicit root was given. Regression tests: `Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests` (hub mode refuses an in-root store via the extension argument, extension-then-core and core-then-extension, artifact asserted before the throw, with an outside-root positive control; shared-store mode forwards too), `SecretsStorePathRulesTests.The_last_explicit_content_root_wins_whichever_call_registered_the_validator`, and `AddZbSecretsAkkaReplicationTests.The_content_root_argument_reaches_the_store_path_guard`. Without the holder change the extension-then-core and 3-arg-first cases create the store and do not throw.
### Secrets-003 — `${secret:}` expansion is undone by configuration reload