Runtime Configuration Validation
Status: Active (introduced Sprint 20260513_018 URCV-001…006) Owners: Platform Audience: Service authors adding new fail-fast startup gates.
1. Purpose
Several Stella Ops services need to refuse to start when configuration would silently route production traffic to null fallbacks, in-memory stores, or anonymous-fallback authentication. Examples already in production:
| Module | Original gate | Sprint |
|---|---|---|
| Signals | Authority must be enabled; anonymous fallback denied; cache connection string required; production must use RustFS storage. | Sprint 20260430-003 SIG-FALLBACK-01 |
| EvidenceLocker | Capsule signer, RFC3161 timestamp authority, and Timeline publisher must not resolve to the Null* implementations. | Sprint 20260501-051 (audit finding L2) |
| Notify WebService | notify:storage:driver=memory and notify:authority:allowAnonymousFallback=true denied outside Development/Testing. | Sprint 20260513_015 (extracted) |
| ExportCenter | All Export:UseInMemory* switches and Export:AllowInMemoryRepositories are Testing-only. | Sprint #109/1 truthful runtime |
Before Sprint 20260513_018 each gate was an ad-hoc class or in-line static function with its own invocation pattern. Sprint 20260513_018 unifies them behind a single contract so future fail-fast checks land via one canonical extension point.
2. The contract
The interface lives in src/__Libraries/StellaOps.Hosting/RuntimeConfiguration/:
namespace StellaOps.Hosting.RuntimeConfiguration;
public interface IRuntimeConfigurationValidator
{
/// <summary>
/// Throws <see cref="InvalidOperationException"/> on a gate failure.
/// </summary>
void Validate(IHostEnvironment environment, IConfiguration configuration);
}
Synchronous, side-effect free. All four existing validators are sync. Lock to this signature to keep the gate cheap and deterministic. If you need network I/O, prefer an IHealthCheck instead.
Throw on failure. All four existing validators throw InvalidOperationException. Keep that contract — the host’s StartAsync failure path propagates the message cleanly to operators.
3. The execution pipeline
StellaOps.Hosting provides:
IRuntimeConfigurationValidator— the contract above.RuntimeConfigurationValidatorBase— optional helper with a sharedIsLocalHarness(env, config, key)method (the four pre-unification validators all had this duplicated).RuntimeConfigurationValidationHostedService— internalIHostedServicethat resolves every registered validator and callsValidatein DI registration order at hostStartAsync. On any throw the exception propagates, the host fails to start, and operators get a clear log line.services.AddRuntimeConfigurationValidators()— extension method that wires the hosted service viaTryAddEnumerable, so repeated calls do not produce duplicate gate invocations.
The hosted service runs before background workloads (queue drainers, listeners, schedulers) because IHostedService.StartAsync completes in registration order. Register the gate before any other hosted service.
4. Adding a new validator (worked example)
Suppose you are hardening the Foo module. Production must not start when Foo:Storage:Provider=Memory.
1. Create the validator (in your module — typically the WebService project’s Hosting/ folder, or an Infrastructure/Hosting/ library if shared between WebService and Worker):
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using StellaOps.Hosting.RuntimeConfiguration;
namespace StellaOps.Foo.WebService.Hosting;
public sealed class FooRuntimeConfigurationValidator : IRuntimeConfigurationValidator
{
private readonly IOptions<FooOptions> _options;
public FooRuntimeConfigurationValidator(IOptions<FooOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public void Validate(IHostEnvironment environment, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(configuration);
// Short-circuit local harness using the shared helper.
if (RuntimeConfigurationValidatorBase.IsLocalHarness(
environment, configuration, "Foo:LocalHarness:Enabled"))
{
return;
}
var provider = configuration["Foo:Storage:Provider"]
?? _options.Value.Storage.Provider;
if (string.Equals(provider, "Memory", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Foo:Storage:Provider=Memory is allowed only in Development/Testing. "
+ "Production hosts must configure a durable provider.");
}
}
}
2. Register with DI (in Program.cs):
builder.Services.AddSingleton<IRuntimeConfigurationValidator, FooRuntimeConfigurationValidator>();
builder.Services.AddRuntimeConfigurationValidators();
3. Add the project reference to StellaOps.Hosting:
<ProjectReference Include="../../__Libraries/StellaOps.Hosting/StellaOps.Hosting.csproj" />
4. Add regression tests that construct the static Validate(env, config, options) overload directly (if you exposed one for tests) or wire the validator through a stub host. The contract tests in src/__Libraries/__Tests/StellaOps.Hosting.Tests/ demonstrate the host-level path.
5. Local harness conventions
All four canonical validators use the same harness semantics:
| Environment / flag | Effect |
|---|---|
EnvironmentName == "TestingLocalHarness" | Always short-circuits, regardless of any flag. |
Module:LocalHarness:Enabled=true in Development or Testing | Short-circuits. |
Module:LocalHarness:Enabled=true in Production | Ignored. Defense-in-depth: a Dev image deployed to Prod by mistake still trips production guards. |
RuntimeConfigurationValidatorBase.IsLocalHarness(env, config, configKey) implements these semantics. Use it instead of rolling your own short-circuit.
6. Pre-build versus DI invocation
Some services (e.g., Notify, Signals) call their validator synchronously before builder.Build()because subsequent host setup consumes the options snapshot directly and would otherwise observe an invalid state. That call is preserved as a fail-fast pre-build check; the DI registration runs the same gate again at host StartAsync and catches late PostConfigure mutations to the options object.
This is acceptable because the validator is synchronous and side-effect free. A double-call on a valid configuration is a no-op.
6a. Sourcing verification material from the Platform
Some gates require operator-entered, region-scoped signature-verification material (e.g., PacksRegistry’s PacksRegistry:Verification:PublicKeyPem, VexHub’s VexHub:SignatureTrustRoots:<fp>). Rather than baking these keys into compose env, they are stored at the platform level (platform.environment_settings under the Verification:<profile>:* namespace) and pulled at startup by PlatformVerificationSettingsConfigurationSource (same library as the validator contract).
A service adds the source to builder.Configuration before binding its verification options:
builder.Configuration.AddPlatformVerificationSettings("packsregistry");
The source performs ONE HTTP GET to GET {STELLAOPS_PLATFORM_URL}/platform/verification-settings/{service}?profile=<active> (authenticated with the shared Router:IdentityEnvelopeSigningKey in the X-Stella-Verification-Token header) and overlays the returned flat keys onto the service’s IConfiguration. The validator then reads the populated configuration unchanged — no validator code change. The source is fail-soft: if the platform is unreachable it contributes nothing and the existing fail-loud guard fires (correct fail-closed behaviour). It never weakens a guard. Because configuration is read once at boot, changing a key requires a service restart to take effect.
Operator note — VexHub trust-root fingerprints must be delimiter-safe. The trust-root fingerprint is used verbatim as the final segment of the
.NETconfiguration keyVexHub:SignatureTrustRoots:<fp>, where:is the configuration hierarchy delimiter. A fingerprint that itself contains a colon (e.g. the commonsha256:abcd…form) is parsed as an extra nesting level, so theDictionary<string,string> SignatureTrustRootsbinder cannot bind it and the trust root is silently dropped — theVexHubRuntimeConfigurationValidatorthen aborts startup with “SignatureTrustRoots is empty” even though a row exists. Store the fingerprint/key-id with a delimiter-safe separator instead (e.g.sha256-abcd…), matching the key-id the DSSE signer presents.
D1 — VexHub HMAC trust roots are SECRETS, never cleartext
env_settings(ADR-028, SPRINT_20260610_002 / VKR-005). VexHub’s HMAC-SHA256 trust roots are symmetric secrets. They are sealed in the platform encrypted credential store (ICredentialStore→platform.connector_credentials, AEAD, regional-crypto-backed, re-sealed on KEK rotation) byVexHubTrustRootSealStore;env_settingsretains only the non-secret fingerprint indexVerification:<profile>:VexHub:TrustRootFingerprints(a JSONstring[]of the activesha256-…fingerprints per region). The secret bytes are no longer served over the broadly-reachableGET /platform/verification-settings/{service}endpoint. Instead VexHub fetches the unsealed (fingerprint → secret) map at boot over the dedicated service-token-authenticated channelGET /platform/verification-keys/{profile}/vex-hmac-trust-root/sealed-secrets(VexHubSealedTrustRootConfigurationSource, sameX-Stella-Verification-Tokenscheme) and holds it only in the in-process configuration overlay (VexHub:SignatureTrustRoots:<fp>) — never written to disk. During a rotation grace window the platform’s active index carries both the retiring and the active fingerprints, so the pull returns both (dual-trust); invalidation drops the retiring fingerprint from the index and revokes its sealed row, after which the next boot pull returns only the survivors and material signed under the dropped root fails verification by design (fail-closed). Operators set/rotate a trust root via the lifecycleintroduce(which seals the secret, supplied insecretMaterial) and remove it viainvalidate.
7. References
- Interface + helper:
src/__Libraries/StellaOps.Hosting/RuntimeConfiguration/ - Contract tests:
src/__Libraries/__Tests/StellaOps.Hosting.Tests/RuntimeConfigurationValidationHostedServiceTests.cs - Platform verification config source:
src/__Libraries/StellaOps.Hosting/RuntimeConfiguration/PlatformVerificationSettingsConfigurationSource.cs(tests:PlatformVerificationSettingsConfigurationSourceTests.cs) - Platform resolver + endpoint:
src/Platform/StellaOps.Platform.WebService/Services/VerificationSettingsResolver.cs,Endpoints/VerificationSettingsEndpoints.cs(tests:VerificationSettingsResolverTests.cs) - Signals:
src/Signals/StellaOps.Signals/Hosting/SignalsRuntimeConfigurationValidator.cs - EvidenceLocker:
src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/Hosting/EvidenceLockerRuntimeConfigurationValidator.cs - Notify:
src/Notify/StellaOps.Notify.WebService/Hosting/NotifyRuntimeConfigurationValidator.cs - ExportCenter:
src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/Runtime/ExportCenterRuntimeConfigurationValidator.cs
