# 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.md` — **Scope** 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 — `StartupValidator` → `ConfigPreflight`).

**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)
```xml
<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`**
```xml
<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`**
```xml
<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`**
```xml
<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`**
```xml
<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)
```bash
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`)
```bash
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 5–7 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`)
```csharp
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)
```bash
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
```

**Step 3: Implement `Checks.cs`**
```csharp
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`**
```csharp
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**
```bash
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
```

**Step 6: Commit** (nested repo)
```bash
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**
```csharp
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**
```bash
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter OptionsValidatorBaseTests
```

**Step 3: Implement `OptionsValidatorBase.cs`**
```csharp
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)
```bash
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)
```csharp
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**
```bash
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter AddValidatedOptionsTests
```

**Step 3: Implement `ServiceCollectionExtensions.cs`**
```csharp
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)
```bash
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**
```csharp
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**
```bash
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter ConfigPreflightTests
```

**Step 3: Implement `ConfigPreflight.cs`**
```csharp
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)
```bash
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:
```bash
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)
```bash
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)
```bash
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`.
