diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
index c9d2cdd..d5415c8 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
@@ -78,9 +78,19 @@ public static class GatewayApplication
// here (SecretNotFoundException); config with no tokens is untouched (no-op), so this is safe
// to always run. CreateBuilder is synchronous and single-shot at bootstrap, so the two awaits
// are driven via GetAwaiter().GetResult() (no sync-context deadlock risk during host startup).
+ // The content root is passed explicitly because this container is a throwaway
+ // ServiceCollection with no IHostEnvironment in it. Without it the library cannot tell
+ // "no content root exists" from "no host is registered", so it skips the
+ // under-content-root rule — and the migrator below CREATES the store before the real host
+ // ever validates. The boot then fails a moment later, having already left an empty
+ // database with its -wal/-shm siblings at the very path the rule rejects. That artifact is
+ // what made the 2026-08-09 outage read as "the database is there, it's just empty".
+ // DO NOT simplify this to the 3-argument overload: it still compiles, the app still boots
+ // when the path is correct, and the guard silently stops running at the one moment that
+ // matters.
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
using (var secretsProvider = new ServiceCollection()
- .AddZbSecrets(builder.Configuration, "Secrets")
+ .AddZbSecrets(builder.Configuration, "Secrets", builder.Environment.ContentRootPath)
.BuildServiceProvider())
#pragma warning restore ASP0000
{
diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
index e9258b2..93162cf 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
+++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
@@ -21,9 +21,9 @@
-
-
-
+
+
+
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/SecretsStorePathGuardTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/SecretsStorePathGuardTests.cs
new file mode 100644
index 0000000..d5aabcd
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/SecretsStorePathGuardTests.cs
@@ -0,0 +1,111 @@
+using Microsoft.AspNetCore.Builder;
+using ZB.MOM.WW.MxGateway.Server;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
+
+// Mutates the process-global Secrets__SqlitePath that GatewayApplication.CreateBuilder reads;
+// serialized against every other collection so a parallel host-building test cannot inherit the
+// deliberately-rejected path. See GlobalEnvironmentCollection.
+[Collection(TestSupport.GlobalEnvironmentCollection.Name)]
+public sealed class SecretsStorePathGuardTests
+{
+ private const string SqlitePathVariable = "Secrets__SqlitePath";
+
+ ///
+ /// Verifies the store-path guard runs in the pre-host secrets container, which is the
+ /// only place it matters.
+ ///
+ ///
+ ///
+ /// CreateBuilder resolves ${secret:} references before the host exists, using a
+ /// throwaway that
+ /// contains no IHostEnvironment — and it runs the store migrator, which creates the
+ /// database. A library that infers the content root from IHostEnvironment alone
+ /// cannot distinguish "no content root" from "no host registered" and skips the rule here, so
+ /// the store is created at the rejected path and only then does the real host refuse to start.
+ /// The leftover empty database with its -wal/-shm siblings is precisely the
+ /// artifact that made the 2026-08-09 credential loss read as "the database is there, it's just
+ /// empty". The gateway therefore passes the content root explicitly.
+ ///
+ ///
+ /// The assertion that no file was created is the load-bearing one. A test that merely observed
+ /// a failed boot would pass even while the store was being written, because the failure arrives
+ /// afterwards either way — which is exactly how this defect survived its first release.
+ ///
+ ///
+ [Fact]
+ public void CreateBuilder_RejectsSecretsStoreUnderContentRoot_WithoutCreatingIt()
+ {
+ string? original = Environment.GetEnvironmentVariable(SqlitePathVariable);
+ string contentRoot = ResolveContentRoot();
+ string rejected = Path.Combine(contentRoot, $"probe-secrets-{Guid.NewGuid():N}.db");
+
+ try
+ {
+ Environment.SetEnvironmentVariable(SqlitePathVariable, rejected);
+
+ // Capture rather than Assert.ThrowsAny, so the store-creation assertions below are
+ // reported first. Ordering matters here: "it threw" is the weaker claim, and asserting
+ // it first would mask the stronger one — that nothing was written before it threw.
+ Exception? thrown = Record.Exception(() => GatewayApplication.CreateBuilder([]));
+
+ Assert.False(File.Exists(rejected), $"the rejected store was created at {rejected}");
+ Assert.False(File.Exists(rejected + "-wal"), "a write-ahead log was created for the rejected store");
+ Assert.False(File.Exists(rejected + "-shm"), "a shared-memory file was created for the rejected store");
+ Assert.NotNull(thrown);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(SqlitePathVariable, original);
+
+ // Delete defensively: if the guard ever regresses this test writes a database into the
+ // content root, which on a dev machine is the source tree.
+ foreach (string leftover in new[] { rejected, rejected + "-wal", rejected + "-shm" })
+ {
+ if (File.Exists(leftover))
+ {
+ File.Delete(leftover);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Verifies a store path outside the content root is accepted and actually used.
+ ///
+ ///
+ /// The assertion is that the database exists afterwards, not merely that nothing threw. A
+ /// not-null builder is very close to a tautology once no exception escaped, so it would pass
+ /// even if the pre-host container had stopped opening the store altogether — which would also
+ /// silently void the negative test above, since that one can only observe a file the migration
+ /// would otherwise have written. Proving the accepted path gets a real database is what keeps
+ /// the rejected-path assertion meaningful.
+ ///
+ [Fact]
+ public void CreateBuilder_AcceptsSecretsStoreOutsideContentRoot_AndCreatesIt()
+ {
+ string? original = Environment.GetEnvironmentVariable(SqlitePathVariable);
+ string directory = Directory.CreateTempSubdirectory("mxgw-secrets-ok").FullName;
+ string accepted = Path.Combine(directory, "secrets.db");
+
+ try
+ {
+ Environment.SetEnvironmentVariable(SqlitePathVariable, accepted);
+
+ WebApplicationBuilder builder = GatewayApplication.CreateBuilder([]);
+
+ Assert.NotNull(builder);
+ Assert.True(File.Exists(accepted), $"the accepted store was not created at {accepted}");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(SqlitePathVariable, original);
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ // The content root CreateBuilder will use, taken from a builder created with the suite's normal
+ // (valid) store path rather than assumed from the test's working directory.
+ private static string ResolveContentRoot() =>
+ GatewayApplication.CreateBuilder([]).Environment.ContentRootPath;
+}