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:

ModuleOriginal gateSprint
SignalsAuthority must be enabled; anonymous fallback denied; cache connection string required; production must use RustFS storage.Sprint 20260430-003 SIG-FALLBACK-01
EvidenceLockerCapsule signer, RFC3161 timestamp authority, and Timeline publisher must not resolve to the Null* implementations.Sprint 20260501-051 (audit finding L2)
Notify WebServicenotify:storage:driver=memory and notify:authority:allowAnonymousFallback=true denied outside Development/Testing.Sprint 20260513_015 (extracted)
ExportCenterAll 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:

  1. IRuntimeConfigurationValidator — the contract above.
  2. RuntimeConfigurationValidatorBase — optional helper with a shared IsLocalHarness(env, config, key) method (the four pre-unification validators all had this duplicated).
  3. RuntimeConfigurationValidationHostedService — internal IHostedService that resolves every registered validator and calls Validate in DI registration order at host StartAsync. On any throw the exception propagates, the host fails to start, and operators get a clear log line.
  4. services.AddRuntimeConfigurationValidators() — extension method that wires the hosted service via TryAddEnumerable, 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 / flagEffect
EnvironmentName == "TestingLocalHarness"Always short-circuits, regardless of any flag.
Module:LocalHarness:Enabled=true in Development or TestingShort-circuits.
Module:LocalHarness:Enabled=true in ProductionIgnored. 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 .NET configuration key VexHub:SignatureTrustRoots:<fp>, where : is the configuration hierarchy delimiter. A fingerprint that itself contains a colon (e.g. the common sha256:abcd… form) is parsed as an extra nesting level, so the Dictionary<string,string> SignatureTrustRoots binder cannot bind it and the trust root is silently dropped — the VexHubRuntimeConfigurationValidator then 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 (ICredentialStoreplatform.connector_credentials, AEAD, regional-crypto-backed, re-sealed on KEK rotation) by VexHubTrustRootSealStore; env_settings retains only the non-secret fingerprint index Verification:<profile>:VexHub:TrustRootFingerprints (a JSON string[] of the active sha256-… fingerprints per region). The secret bytes are no longer served over the broadly-reachable GET /platform/verification-settings/{service} endpoint. Instead VexHub fetches the unsealed (fingerprint → secret) map at boot over the dedicated service-token-authenticated channel GET /platform/verification-keys/{profile}/vex-hmac-trust-root/sealed-secrets (VexHubSealedTrustRootConfigurationSource, same X-Stella-Verification-Token scheme) 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 lifecycle introduce (which seals the secret, supplied in secretMaterial) and remove it via invalidate.

7. References