Files
scadaproj/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md
T
Joseph Doherty 80e4d59209 plan(config): correct git layout — library committed to outer repo, no nested .git
The sibling libs (Auth/Theme/Health/Telemetry) are tracked as regular files in
the outer scadaproj repo, not separate git repos. Remove the git-init/nested-repo
instructions; all commits target the outer repo on feat/zb-mom-ww-configuration.
2026-06-01 09:23:08 -04:00

37 KiB
Raw Blame History

ZB.MOM.WW.Configuration Shared Library Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Author the components/configuration/ normalization docs and build the ZB.MOM.WW.Configuration shared library (1 NuGet package) that gives the fleet one startup-options-validation toolkit — a failure-accumulating IValidateOptions<T> base, reusable rule primitives, a bind+validate+ValidateOnStart DI helper, and a pre-host ConfigPreflight aggregator — removing the duplicated validation plumbing the three sister apps each hand-roll.

Architecture: A new self-contained solution directory committed into the outer scadaproj repo at ~/Desktop/scadaproj/ZB.MOM.WW.Configuration (same layout as the sibling ZB.MOM.WW.Telemetry/Health/Auth/Theme — regular tracked files, not a submodule and not a separate .git; build output bin//obj//artifacts/ is gitignored). .NET 10, one library project ZB.MOM.WW.Configuration with four public types (OptionsValidatorBase<TOptions>, ValidationBuilder, ServiceCollectionExtensions, ConfigPreflight) over one internal Checks helper that keeps rule wording identical across the two front-ends (options-object validation vs raw-IConfiguration preflight). Scope is startup options validation only — OtOpcUa's runtime draft/snapshot validation stays per-project. No app is modified in this pass; adoption is a tracked GAPS.md follow-on, matching Auth/Health/Theme.

Tech Stack: .NET 10, C#; xUnit + coverlet; central package management; .slnx; Version 0.1.0. Library deps are minimal and framework-aligned: Microsoft.Extensions.Options (carries IValidateOptions, OptionsBuilder, AddOptions, ValidateOnStart — verified in net10's Microsoft.Extensions.Options assembly), Microsoft.Extensions.Options.ConfigurationExtensions (Bind), Microsoft.Extensions.Configuration.Abstractions, Microsoft.Extensions.DependencyInjection.Abstractions — all already referenced by all three apps, which is why this is a single package (no Akka/EF/Serilog-style heavy dep to isolate).

Version note: Pin the Microsoft.Extensions.* packages at 10.0.0 (the floor that matches the installed net10 shared framework); the executor may bump to the latest restorable 10.0.x patch. Consumers' own central package management governs the final version at adoption.


Design summary (approved via brainstorming)

Decision Choice Rationale
Deliverable Full pass (normalization docs + built library) Match Auth/Theme/Health/Telemetry
Scope Startup options validation only Genuinely common across all 3; YAGNI
Package name ZB.MOM.WW.Configuration Full-word style; aligns with Microsoft.Extensions.Configuration/Options
API style Approach A — lightweight base + rule primitives + DI/startup helpers Mirrors what all 3 already do; no new fleet-wide dependency; single package

Out of scope (YAGNI): runtime draft/snapshot validation (stays in OtOpcUa's DraftValidator); any FluentValidation/DataAnnotations dependency; editing the three apps' code.

Public API (namespace ZB.MOM.WW.Configuration; everything else internal):

  • OptionsValidatorBase<TOptions> — abstract IValidateOptions<TOptions>; override one Validate(ValidationBuilder, TOptions); base aggregates ALL failures, Success only when clean.
  • ValidationBuilder — failure accumulator with primitives (Required, Port, HostPort, PositiveTimeSpan, OneOf, MinCount) + RequireThat/Add escape hatch for cross-field rules.
  • ServiceCollectionExtensions.AddValidatedOptions<TOptions,TValidator>(config, sectionPath) — bind + register validator + ValidateOnStart(); returns OptionsBuilder<TOptions>.
  • ConfigPreflight — fluent aggregator over raw IConfiguration for pre-host checks (For/Require/RequireValue/RequirePort/When/ThrowIfInvalid).

Error-handling contract: accumulate (never fail-fast-on-first); options validators surface via ValidateOnStart() → host throws OptionsValidationException; ConfigPreflight.ThrowIfInvalid() throws one InvalidOperationException with a "Configuration validation failed:\n - …" body — byte-compatible with ScadaBridge's current StartupValidator so its swap is a drop-in. Message format is "<field> <reason>".

Conventions for every task: TDD — failing test first, minimal impl, green, commit. File-scoped namespaces, sealed by default, XML doc comments on public members (match the sibling libs). All work — library and docs — is committed to the outer scadaproj repo on branch feat/zb-mom-ww-configuration (the library is tracked files inside it, like the sibling libs; there is no separate nested .git). dotnet build/test may cd into the library dir, but every git commit targets the outer repo. The Files: block IS the files_to_edit contract.

Source references (read-only, to verify current-state against — do NOT modify):

  • OtOpcUa: ~/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/{DraftValidator,DraftSnapshot}.cs (draft validation stays per-project)
  • MxAccessGateway: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/{GatewayOptionsValidator,GatewayConfigurationServiceCollectionExtensions}.cs
  • ScadaBridge: per-module *OptionsValidator.cs (ClusterInfrastructure, Security, HealthMonitoring, AuditLog) + ~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs + each module's ServiceCollectionExtensions.cs
  • Design + conventions to mirror: ~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/ (props, .slnx, csproj, code/test style)

Phase 0 — Normalization docs (spec drives the API)

Task 1: components/configuration spec + shared-contract

Classification: small Estimated implement time: ~5 min Parallelizable with: Task 2

Files:

  • Create: components/configuration/spec/SPEC.md
  • Create: components/configuration/shared-contract/ZB.MOM.WW.Configuration.md

Steps:

  1. spec/SPEC.mdScope section: normalized = the IValidateOptions<T> failure-accumulation convention, the reusable rule primitives, the AddValidatedOptions bind+validate+ValidateOnStart wiring, and the pre-host ConfigPreflight aggregator (generalizes ScadaBridge's StartupValidator). NOT normalized = each app's options classes + their domain rules (cluster/security/gateway), and OtOpcUa's runtime draft/snapshot validation. Document the error-handling contract (accumulate; two surfacing paths; "<field> <reason>" message format).
  2. shared-contract/ZB.MOM.WW.Configuration.md — paper API of the single package: the four public types with signatures (copy from the Design summary above), the internal Checks seam, and the consumer matrix (all three apps; ScadaBridge heaviest — StartupValidatorConfigPreflight).

Acceptance: both files exist; SPEC explicitly lists normalized vs per-project; shared-contract signatures match the code built in Phase 1.

Commit (outer repo): git -C ~/Desktop/scadaproj add components/configuration && git -C ~/Desktop/scadaproj commit -m "docs(config): normalization spec + shared-contract"


Task 2: components/configuration current-state ×3 + GAPS + README

Classification: small Estimated implement time: ~5 min Parallelizable with: Task 1

Files:

  • Create: components/configuration/current-state/otopcua/CURRENT-STATE.md
  • Create: components/configuration/current-state/mxaccessgw/CURRENT-STATE.md
  • Create: components/configuration/current-state/scadabridge/CURRENT-STATE.md
  • Create: components/configuration/GAPS.md
  • Create: components/configuration/README.md

Steps:

  1. For each app, read the validators under the Source references and write CURRENT-STATE.md with file:line refs describing how it validates config today + an Adoption plan (what it deletes/replaces to reach the spec; what stays bespoke). Emphasize ScadaBridge's StartupValidator (raw-config, pre-Akka) → ConfigPreflight, and that OtOpcUa's DraftValidator stays put.
  2. GAPS.md — per-project divergence vs SPEC + a prioritized extraction/adoption backlog (each app adopts the base + AddValidatedOptions; ScadaBridge additionally swaps StartupValidator).
  3. README.md — overview + per-project status table linking the docs above (mirror components/observability/README.md).

Acceptance: all five files exist; each current-state has real file:line refs and an Adoption plan; README status table links resolve.

Commit (outer repo): git -C ~/Desktop/scadaproj add components/configuration && git -C ~/Desktop/scadaproj commit -m "docs(config): current-state x3 + GAPS + README"


Phase 1 — Build the library (TDD)

Task 3: Scaffold nested repo, solution, library + test projects

Classification: small Estimated implement time: ~5 min Parallelizable with: none

Files:

  • Create: ZB.MOM.WW.Configuration/.gitignore (copy from ~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/.gitignore)
  • Create: ZB.MOM.WW.Configuration/Directory.Build.props
  • Create: ZB.MOM.WW.Configuration/Directory.Packages.props
  • Create: ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx
  • Create: ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj
  • Create: ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj

Step 1: Directory.Build.props (identical to Telemetry's)

<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <LangVersion>latest</LangVersion>
    <Version>0.1.0</Version>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
</Project>

Step 2: Directory.Packages.props

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <!-- Library -->
    <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
    <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
    <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
    <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
    <!-- Test only -->
    <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
    <PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageVersion Include="xunit" Version="2.9.3" />
    <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
    <PackageVersion Include="coverlet.collector" Version="6.0.4" />
  </ItemGroup>
</Project>

Step 3: src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <IsPackable>true</IsPackable>
    <PackageId>ZB.MOM.WW.Configuration</PackageId>
    <Authors>ZB.MOM.WW</Authors>
    <Description>Startup configuration-validation toolkit for the ZB.MOM.WW SCADA family: a failure-accumulating IValidateOptions base, reusable rule primitives (port, host:port, required, positive-duration, one-of, min-count), a bind+validate+ValidateOnStart DI helper, and a pre-host ConfigPreflight aggregator for raw IConfiguration. Extracts the validation plumbing the apps share; domain rules stay per-project.</Description>
    <PackageTags>configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww</PackageTags>
    <PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</PackageProjectUrl>
    <RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</RepositoryUrl>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Options" />
    <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
  </ItemGroup>
</Project>

Step 4: tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="coverlet.collector" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <PackageReference Include="xunit" />
    <PackageReference Include="xunit.runner.visualstudio" />
    <PackageReference Include="Microsoft.Extensions.Hosting" />
    <PackageReference Include="Microsoft.Extensions.Configuration" />
  </ItemGroup>
  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\ZB.MOM.WW.Configuration\ZB.MOM.WW.Configuration.csproj" />
  </ItemGroup>
</Project>

Step 5: ZB.MOM.WW.Configuration.slnx

<Solution>
  <Folder Name="/src/">
    <Project Path="src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj" />
  </Folder>
  <Folder Name="/tests/">
    <Project Path="tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj" />
  </Folder>
</Solution>

Step 6: verify restore/build (NO git init — the library is tracked inside the outer repo)

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration
dotnet build ZB.MOM.WW.Configuration.slnx

Expected: build succeeds (0 source files yet → empty assembly is fine).

Step 7: Commit (outer repo, branch feat/zb-mom-ww-configuration)

git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "chore: scaffold ZB.MOM.WW.Configuration solution"

Task 4: Checks (internal) + ValidationBuilder

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (Tasks 57 depend on it)

Files:

  • Create: src/ZB.MOM.WW.Configuration/Checks.cs
  • Create: src/ZB.MOM.WW.Configuration/ValidationBuilder.cs
  • Test: tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs

Step 1: Write the failing tests (ValidationBuilderTests.cs)

using ZB.MOM.WW.Configuration;

namespace ZB.MOM.WW.Configuration.Tests;

public sealed class ValidationBuilderTests
{
    [Theory]
    [InlineData(0, false)]
    [InlineData(1, true)]
    [InlineData(65535, true)]
    [InlineData(65536, false)]
    public void Port_validates_range(int port, bool valid)
    {
        var b = new ValidationBuilder();
        b.Port(port, "X:Port");
        Assert.Equal(valid, b.IsValid);
    }

    [Theory]
    [InlineData(null, false)]
    [InlineData("", false)]
    [InlineData("   ", false)]
    [InlineData("ok", true)]
    public void Required_rejects_null_empty_whitespace(string? value, bool valid)
    {
        var b = new ValidationBuilder();
        b.Required(value, "X:Name");
        Assert.Equal(valid, b.IsValid);
    }

    [Theory]
    [InlineData("host:5000", true)]
    [InlineData("host", false)]
    [InlineData("host:0", false)]
    [InlineData("host:notaport", false)]
    public void HostPort_validates_endpoint(string value, bool valid)
    {
        var b = new ValidationBuilder();
        b.HostPort(value, "X:Endpoint");
        Assert.Equal(valid, b.IsValid);
    }

    [Fact]
    public void PositiveTimeSpan_rejects_zero_and_negative()
    {
        var b = new ValidationBuilder();
        b.PositiveTimeSpan(TimeSpan.Zero, "X:T1").PositiveTimeSpan(TimeSpan.FromSeconds(-1), "X:T2");
        Assert.Equal(2, b.Failures.Count);
    }

    [Fact]
    public void OneOf_is_case_insensitive()
    {
        var b = new ValidationBuilder();
        b.OneOf("CENTRAL", new[] { "Central", "Site" }, "X:Role");
        Assert.True(b.IsValid);
    }

    [Fact]
    public void MinCount_requires_minimum()
    {
        var b = new ValidationBuilder();
        b.MinCount(new[] { "a" }, 2, "X:Seeds");
        Assert.False(b.IsValid);
    }

    [Fact]
    public void Accumulates_all_failures_and_RequireThat_Add_work()
    {
        var b = new ValidationBuilder();
        b.Required(null, "A").RequireThat(false, "B failed").Add("C failed");
        Assert.Equal(3, b.Failures.Count);
    }
}

Step 2: Run — expect FAIL (Checks/ValidationBuilder not defined)

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test

Step 3: Implement Checks.cs

namespace ZB.MOM.WW.Configuration;

/// <summary>
/// Internal rule primitives shared by <see cref="ValidationBuilder"/> (validates a bound options
/// object) and <see cref="ConfigPreflight"/> (validates raw <c>IConfiguration</c>). Each method
/// returns <c>null</c> when valid, or a formatted <c>"&lt;field&gt; &lt;reason&gt;"</c> message
/// otherwise. Centralizing them keeps wording identical across both front-ends.
/// </summary>
internal static class Checks
{
    internal static string? Required(string? value, string field) =>
        string.IsNullOrWhiteSpace(value) ? $"{field} is required" : null;

    internal static string? Port(int value, string field) =>
        value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null;

    internal static string? HostPort(string? value, string field)
    {
        if (string.IsNullOrWhiteSpace(value)) return $"{field} is required";
        var idx = value.LastIndexOf(':');
        if (idx <= 0 || idx == value.Length - 1
            || !int.TryParse(value[(idx + 1)..], out var port)
            || port is < 1 or > 65535)
            return $"{field} must be 'host:port' with port 1-65535 (was '{value}')";
        return null;
    }

    internal static string? PositiveTimeSpan(TimeSpan value, string field) =>
        value <= TimeSpan.Zero ? $"{field} must be a positive duration (was {value})" : null;

    internal static string? OneOf(string? value, IReadOnlyCollection<string> allowed, string field) =>
        value is not null && allowed.Contains(value, StringComparer.OrdinalIgnoreCase)
            ? null
            : $"{field} must be one of [{string.Join(", ", allowed)}] (was '{value ?? "null"}')";

    internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) =>
        value is null || value.Count < min
            ? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})"
            : null;
}

Step 4: Implement ValidationBuilder.cs

namespace ZB.MOM.WW.Configuration;

/// <summary>
/// Accumulates validation failures for an options object. Passed by
/// <see cref="OptionsValidatorBase{TOptions}"/> into your <c>Validate</c> override; each primitive
/// both checks a value and appends a consistently-formatted message on failure. Use
/// <see cref="RequireThat"/>/<see cref="Add"/> for custom or cross-field rules.
/// </summary>
public sealed class ValidationBuilder
{
    private readonly List<string> _failures = [];

    /// <summary>The accumulated failure messages (empty when validation passed).</summary>
    public IReadOnlyList<string> Failures => _failures;

    /// <summary>True when no failures have been accumulated.</summary>
    public bool IsValid => _failures.Count == 0;

    /// <summary>Records <paramref name="message"/> as a failure when <paramref name="ok"/> is false.</summary>
    public ValidationBuilder RequireThat(bool ok, string message)
    {
        if (!ok) _failures.Add(message);
        return this;
    }

    /// <summary>Unconditionally records <paramref name="message"/> as a failure.</summary>
    public ValidationBuilder Add(string message)
    {
        _failures.Add(message);
        return this;
    }

    /// <summary>Requires a non-null, non-whitespace string.</summary>
    public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field));

    /// <summary>Requires a TCP port in 1-65535.</summary>
    public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field));

    /// <summary>Requires a 'host:port' endpoint with a valid port.</summary>
    public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field));

    /// <summary>Requires a strictly positive duration.</summary>
    public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field));

    /// <summary>Requires the value to be one of <paramref name="allowed"/> (case-insensitive).</summary>
    public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field) => AddIf(Checks.OneOf(value, allowed, field));

    /// <summary>Requires a collection with at least <paramref name="min"/> items.</summary>
    public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) => AddIf(Checks.MinCount(value, min, field));

    private ValidationBuilder AddIf(string? message)
    {
        if (message is not null) _failures.Add(message);
        return this;
    }
}

Step 5: Run — expect PASS

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test

Step 6: Commit (nested repo)

git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: Checks primitives + ValidationBuilder"

Task 5: OptionsValidatorBase<TOptions>

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 6, Task 7

Files:

  • Create: src/ZB.MOM.WW.Configuration/OptionsValidatorBase.cs
  • Test: tests/ZB.MOM.WW.Configuration.Tests/OptionsValidatorBaseTests.cs

Step 1: Write the failing tests

using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;

namespace ZB.MOM.WW.Configuration.Tests;

public sealed class OptionsValidatorBaseTests
{
    private sealed class SampleOptions
    {
        public int Port { get; set; }
        public string? Name { get; set; }
    }

    private sealed class SampleValidator : OptionsValidatorBase<SampleOptions>
    {
        protected override void Validate(ValidationBuilder v, SampleOptions o)
        {
            v.Port(o.Port, "Sample:Port");
            v.Required(o.Name, "Sample:Name");
        }
    }

    [Fact]
    public void Success_when_clean()
    {
        var r = new SampleValidator().Validate(null, new SampleOptions { Port = 8080, Name = "ok" });
        Assert.True(r.Succeeded);
    }

    [Fact]
    public void Fails_and_reports_all_failures()
    {
        var r = new SampleValidator().Validate(null, new SampleOptions { Port = 0, Name = "" });
        Assert.True(r.Failed);
        Assert.Equal(2, r.Failures!.Count());
    }
}

Step 2: Run — expect FAIL

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter OptionsValidatorBaseTests

Step 3: Implement OptionsValidatorBase.cs

using Microsoft.Extensions.Options;

namespace ZB.MOM.WW.Configuration;

/// <summary>
/// Base class for <see cref="IValidateOptions{TOptions}"/> implementations that removes the
/// failure-accumulation plumbing. Override <see cref="Validate(ValidationBuilder, TOptions)"/> and
/// use the supplied <see cref="ValidationBuilder"/>; the base aggregates ALL failures and returns
/// <see cref="ValidateOptionsResult.Success"/> only when none were recorded.
/// </summary>
/// <typeparam name="TOptions">The options type being validated.</typeparam>
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
    where TOptions : class
{
    /// <inheritdoc />
    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);
        var builder = new ValidationBuilder();
        Validate(builder, options);
        return builder.IsValid
            ? ValidateOptionsResult.Success
            : ValidateOptionsResult.Fail(builder.Failures);
    }

    /// <summary>Records validation failures for <paramref name="options"/> on <paramref name="builder"/>.</summary>
    /// <param name="builder">The accumulator to record failures on.</param>
    /// <param name="options">The options instance to validate.</param>
    protected abstract void Validate(ValidationBuilder builder, TOptions options);
}

Step 4: Run — expect PASS, then Commit (outer repo)

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: OptionsValidatorBase<TOptions>"

Task 6: ServiceCollectionExtensions.AddValidatedOptions

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5, Task 7

Files:

  • Create: src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs
  • Test: tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs

Step 1: Write the failing tests (drives ValidateOnStart via a real host start)

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;

namespace ZB.MOM.WW.Configuration.Tests;

public sealed class AddValidatedOptionsTests
{
    private sealed class NodeOptions { public int Port { get; set; } public string? Name { get; set; } }

    private sealed class NodeValidator : OptionsValidatorBase<NodeOptions>
    {
        protected override void Validate(ValidationBuilder v, NodeOptions o)
        {
            v.Port(o.Port, "Node:Port");
            v.Required(o.Name, "Node:Name");
        }
    }

    private static IHost BuildHost(Dictionary<string, string?> config)
    {
        var builder = Host.CreateApplicationBuilder();
        builder.Configuration.AddInMemoryCollection(config);
        builder.Services.AddValidatedOptions<NodeOptions, NodeValidator>(builder.Configuration, "Node");
        return builder.Build();
    }

    [Fact]
    public async Task Bad_config_throws_at_startup()
    {
        using var host = BuildHost(new() { ["Node:Port"] = "0", ["Node:Name"] = "" });
        await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
    }

    [Fact]
    public async Task Good_config_starts_and_binds()
    {
        using var host = BuildHost(new() { ["Node:Port"] = "8080", ["Node:Name"] = "central" });
        await host.StartAsync();
        var opts = host.Services.GetRequiredService<IOptions<NodeOptions>>().Value;
        Assert.Equal(8080, opts.Port);
        Assert.Equal("central", opts.Name);
        await host.StopAsync();
    }
}

Step 2: Run — expect FAIL

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter AddValidatedOptionsTests

Step 3: Implement ServiceCollectionExtensions.cs

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace ZB.MOM.WW.Configuration;

/// <summary>DI extensions for binding-and-validating an options section in one call.</summary>
public static class ServiceCollectionExtensions
{
    /// <summary>
    /// Binds <typeparamref name="TOptions"/> to the configuration section at
    /// <paramref name="sectionPath"/>, registers <typeparamref name="TValidator"/> as its
    /// <see cref="IValidateOptions{TOptions}"/>, and enables <c>ValidateOnStart</c> so a bad
    /// configuration fails fast at host startup rather than on first use.
    /// </summary>
    /// <returns>The <see cref="OptionsBuilder{TOptions}"/> for further chaining.</returns>
    public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
        this IServiceCollection services, IConfiguration configuration, string sectionPath)
        where TOptions : class
        where TValidator : class, IValidateOptions<TOptions>
    {
        ArgumentNullException.ThrowIfNull(services);
        ArgumentNullException.ThrowIfNull(configuration);
        ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);

        services.AddSingleton<IValidateOptions<TOptions>, TValidator>();
        return services.AddOptions<TOptions>()
            .Bind(configuration.GetSection(sectionPath))
            .ValidateOnStart();
    }
}

Step 4: Run — expect PASS, then Commit (outer repo)

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: AddValidatedOptions bind+validate+ValidateOnStart"

Task 7: ConfigPreflight

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5, Task 6

Files:

  • Create: src/ZB.MOM.WW.Configuration/ConfigPreflight.cs
  • Test: tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs

Step 1: Write the failing tests

using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Configuration;

namespace ZB.MOM.WW.Configuration.Tests;

public sealed class ConfigPreflightTests
{
    private static IConfiguration Config(Dictionary<string, string?> values) =>
        new ConfigurationBuilder().AddInMemoryCollection(values).Build();

    [Fact]
    public void Aggregates_all_failures()
    {
        var cfg = Config(new() { ["Node:Role"] = "Bogus", ["Node:RemotingPort"] = "0" });
        var pf = ConfigPreflight.For(cfg)
            .Require("Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
            .RequirePort("Node:RemotingPort");
        Assert.False(pf.IsValid);
        Assert.Equal(2, pf.Failures.Count);
    }

    [Fact]
    public void When_runs_block_only_if_condition_true()
    {
        var cfg = Config(new() { ["Node:Role"] = "Site" });
        var pf = ConfigPreflight.For(cfg)
            .When(cfg["Node:Role"] == "Site",
                p => p.RequireValue("Node:SiteId"));
        Assert.False(pf.IsValid);              // SiteId missing
        Assert.Contains(pf.Failures, f => f.Contains("Node:SiteId"));
    }

    [Fact]
    public void ThrowIfInvalid_throws_aggregated_message()
    {
        var cfg = Config(new() { ["Node:Name"] = "" });
        var ex = Assert.Throws<InvalidOperationException>(() =>
            ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid());
        Assert.StartsWith("Configuration validation failed:", ex.Message);
        Assert.Contains("  - Node:Name", ex.Message);
    }

    [Fact]
    public void ThrowIfInvalid_is_noop_when_valid()
    {
        var cfg = Config(new() { ["Node:Name"] = "ok" });
        ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid();   // does not throw
    }
}

Step 2: Run — expect FAIL

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter ConfigPreflightTests

Step 3: Implement ConfigPreflight.cs

using Microsoft.Extensions.Configuration;

namespace ZB.MOM.WW.Configuration;

/// <summary>
/// Fluent aggregator for validating raw <see cref="IConfiguration"/> BEFORE the host/DI container
/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via
/// <see cref="ThrowIfInvalid"/>. For options that flow through DI, prefer
/// <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions, TValidator}"/>.
/// </summary>
public sealed class ConfigPreflight
{
    private readonly IConfiguration _configuration;
    private readonly List<string> _failures = [];

    private ConfigPreflight(IConfiguration configuration) => _configuration = configuration;

    /// <summary>Starts a preflight over <paramref name="configuration"/>.</summary>
    public static ConfigPreflight For(IConfiguration configuration)
    {
        ArgumentNullException.ThrowIfNull(configuration);
        return new ConfigPreflight(configuration);
    }

    /// <summary>The accumulated failure messages (empty when valid).</summary>
    public IReadOnlyList<string> Failures => _failures;

    /// <summary>True when no failures have been accumulated.</summary>
    public bool IsValid => _failures.Count == 0;

    /// <summary>Requires the value at <paramref name="key"/> to satisfy <paramref name="predicate"/>.</summary>
    public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason)
    {
        ArgumentNullException.ThrowIfNull(predicate);
        if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}");
        return this;
    }

    /// <summary>Requires a non-empty value at <paramref name="key"/>.</summary>
    public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key));

    /// <summary>Requires a valid integer TCP port (1-65535) at <paramref name="key"/>.</summary>
    public ConfigPreflight RequirePort(string key)
    {
        var raw = _configuration[key];
        if (!int.TryParse(raw, out var port))
        {
            _failures.Add($"{key} must be an integer port 1-65535 (was '{raw ?? "null"}')");
            return this;
        }
        return AddIf(Checks.Port(port, key));
    }

    /// <summary>Runs <paramref name="block"/> only when <paramref name="condition"/> holds (role-conditional rules).</summary>
    public ConfigPreflight When(bool condition, Action<ConfigPreflight> block)
    {
        ArgumentNullException.ThrowIfNull(block);
        if (condition) block(this);
        return this;
    }

    /// <summary>Throws <see cref="InvalidOperationException"/> listing all failures when invalid; otherwise returns.</summary>
    public void ThrowIfInvalid()
    {
        if (_failures.Count > 0)
            throw new InvalidOperationException(
                $"Configuration validation failed:\n{string.Join("\n", _failures.Select(e => $"  - {e}"))}");
    }

    private ConfigPreflight AddIf(string? message)
    {
        if (message is not null) _failures.Add(message);
        return this;
    }
}

Step 4: Run — expect PASS, then Commit (outer repo)

cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: ConfigPreflight raw-config aggregator"

Phase 2 — Package + register

Task 8: README + dotnet pack → verify single nupkg

Classification: small Estimated implement time: ~4 min Parallelizable with: none

Files:

  • Create: ZB.MOM.WW.Configuration/README.md
  • Create: ZB.MOM.WW.Configuration/CLAUDE.md (short — mirror Telemetry's: purpose, build/test, layout)

Steps:

  1. README.md — what the package is, the four types with a 6-line usage snippet each (OptionsValidatorBase subclass, AddValidatedOptions call, ConfigPreflight chain), build/test command (dotnet test), Version 0.1.0.
  2. CLAUDE.md — one-screen orientation mirroring ~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/CLAUDE.md.
  3. Run full test + pack and verify exactly one nupkg:
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration
dotnet test
dotnet pack -c Release -o artifacts
ls artifacts/*.nupkg     # expect: ZB.MOM.WW.Configuration.0.1.0.nupkg (exactly one)

Expected: all tests green; exactly one .nupkg at 0.1.0.

Step 4: Commit (nested repo)

git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "docs: README + CLAUDE.md; verify 0.1.0 pack"

Task 9: Register in indexes (components/README, root CLAUDE.md, upcoming.md)

Classification: small Estimated implement time: ~4 min Parallelizable with: none Depends on: Tasks 1, 2, 8

Files:

  • Modify: components/README.md (add registry row for Configuration)
  • Modify: CLAUDE.md (component-normalization table row + a normalization paragraph mirroring the others)
  • Modify: upcoming.md (tick the "Config + validation" Tier-2 item; add a "Suggested order" Done entry)

Steps:

  1. components/README.md — add a Configuration row to the component registry table (Status Draft, Applies to all three, Goal Shared ZB.MOM.WW.Configuration lib (1 package), Folder configuration/).
  2. CLAUDE.md — add a row to the Component-normalization table (Config | Built (lib 0.1.0) | …) and a short normalization paragraph like the Health/Telemetry ones (packages, tests count, dotnet pack → 1 nupkg @ 0.1.0, consumer matrix, "not yet adopted → GAPS").
  3. upcoming.md — strike/annotate the Tier-2 "Config validation conventions" line as Done with links to components/configuration/ and ../ZB.MOM.WW.Configuration/.

Acceptance: all three indexes reference the new component/library; links resolve.

Step 4: Commit (outer repo)

git -C ~/Desktop/scadaproj add components/README.md CLAUDE.md upcoming.md
git -C ~/Desktop/scadaproj commit -m "docs: register ZB.MOM.WW.Configuration in indexes"

Done criteria

  • dotnet test green in ZB.MOM.WW.Configuration/; dotnet pack → exactly one nupkg ZB.MOM.WW.Configuration.0.1.0.nupkg.
  • Four public types implemented with the error-handling contract above; ConfigPreflight message is byte-compatible with ScadaBridge's StartupValidator.
  • components/configuration/ fully populated (spec, shared-contract, 3× current-state, GAPS, README).
  • Indexes updated (components/README.md, root CLAUDE.md, upcoming.md).
  • No sister-app code modified — adoption deferred to components/configuration/GAPS.md.