# 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` 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`, `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` — abstract `IValidateOptions`; 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(config, sectionPath)` — bind + register validator + `ValidateOnStart()`; returns `OptionsBuilder`. - `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 `" "`. **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` 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; `" "` 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 net10.0 enable enable latest 0.1.0 true ``` **Step 2: `Directory.Packages.props`** ```xml true ``` **Step 3: `src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj`** ```xml true ZB.MOM.WW.Configuration ZB.MOM.WW 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. configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration ``` **Step 4: `tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj`** ```xml false ``` **Step 5: `ZB.MOM.WW.Configuration.slnx`** ```xml ``` **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; /// /// Internal rule primitives shared by (validates a bound options /// object) and (validates raw IConfiguration). Each method /// returns null when valid, or a formatted "<field> <reason>" message /// otherwise. Centralizing them keeps wording identical across both front-ends. /// 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 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(IReadOnlyCollection? 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; /// /// Accumulates validation failures for an options object. Passed by /// into your Validate override; each primitive /// both checks a value and appends a consistently-formatted message on failure. Use /// / for custom or cross-field rules. /// public sealed class ValidationBuilder { private readonly List _failures = []; /// The accumulated failure messages (empty when validation passed). public IReadOnlyList Failures => _failures; /// True when no failures have been accumulated. public bool IsValid => _failures.Count == 0; /// Records as a failure when is false. public ValidationBuilder RequireThat(bool ok, string message) { if (!ok) _failures.Add(message); return this; } /// Unconditionally records as a failure. public ValidationBuilder Add(string message) { _failures.Add(message); return this; } /// Requires a non-null, non-whitespace string. public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field)); /// Requires a TCP port in 1-65535. public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field)); /// Requires a 'host:port' endpoint with a valid port. public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field)); /// Requires a strictly positive duration. public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field)); /// Requires the value to be one of (case-insensitive). public ValidationBuilder OneOf(string? value, IReadOnlyCollection allowed, string field) => AddIf(Checks.OneOf(value, allowed, field)); /// Requires a collection with at least items. public ValidationBuilder MinCount(IReadOnlyCollection? 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` **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 { 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; /// /// Base class for implementations that removes the /// failure-accumulation plumbing. Override and /// use the supplied ; the base aggregates ALL failures and returns /// only when none were recorded. /// /// The options type being validated. public abstract class OptionsValidatorBase : IValidateOptions where TOptions : class { /// 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); } /// Records validation failures for on . /// The accumulator to record failures on. /// The options instance to validate. 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" ``` --- ### 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 { 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 config) { var builder = Host.CreateApplicationBuilder(); builder.Configuration.AddInMemoryCollection(config); builder.Services.AddValidatedOptions(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(() => 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>().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; /// DI extensions for binding-and-validating an options section in one call. public static class ServiceCollectionExtensions { /// /// Binds to the configuration section at /// , registers as its /// , and enables ValidateOnStart so a bad /// configuration fails fast at host startup rather than on first use. /// /// The for further chaining. public static OptionsBuilder AddValidatedOptions( this IServiceCollection services, IConfiguration configuration, string sectionPath) where TOptions : class where TValidator : class, IValidateOptions { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); services.AddSingleton, TValidator>(); return services.AddOptions() .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 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(() => 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; /// /// Fluent aggregator for validating raw BEFORE the host/DI container /// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via /// . For options that flow through DI, prefer /// . /// public sealed class ConfigPreflight { private readonly IConfiguration _configuration; private readonly List _failures = []; private ConfigPreflight(IConfiguration configuration) => _configuration = configuration; /// Starts a preflight over . public static ConfigPreflight For(IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); return new ConfigPreflight(configuration); } /// The accumulated failure messages (empty when valid). public IReadOnlyList Failures => _failures; /// True when no failures have been accumulated. public bool IsValid => _failures.Count == 0; /// Requires the value at to satisfy . public ConfigPreflight Require(string key, Func predicate, string reason) { ArgumentNullException.ThrowIfNull(predicate); if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}"); return this; } /// Requires a non-empty value at . public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key)); /// Requires a valid integer TCP port (1-65535) at . 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)); } /// Runs only when holds (role-conditional rules). public ConfigPreflight When(bool condition, Action block) { ArgumentNullException.ThrowIfNull(block); if (condition) block(this); return this; } /// Throws listing all failures when invalid; otherwise returns. 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`.