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

915 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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`)
```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`.