diff --git a/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md index b50fd52..7dabdc4 100644 --- a/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md +++ b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md @@ -1,211 +1,910 @@ -# ZB.MOM.WW.Configuration — shared startup-options-validation library (design) +# ZB.MOM.WW.Configuration Shared Library Implementation Plan -**Date:** 2026-06-01 -**Status:** Design (approved) — implementation plan to follow via writing-plans -**Component:** Config + validation (Tier-2 normalization candidate, `upcoming.md`) -**Deliverable:** Full normalization pass — `components/configuration/` docs **and** a built -`ZB.MOM.WW.Configuration` library + this design doc. +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. -## 1. Problem & intent +**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. -The three sister apps (OtOpcUa, MxAccessGateway, ScadaBridge) each re-implement the same -configuration-validation plumbing and drift apart. All three use the same .NET primitives — -`IOptions` + `IValidateOptions` + `ValidateOnStart()` — and each hand-rolls a -`List` failure accumulator that returns `ValidateOptionsResult`. ScadaBridge also has a -pre-host `StartupValidator` that validates raw `IConfiguration` *before* the Akka actor system -exists (so it cannot use `IOptions`), aggregates all errors, and throws one -`InvalidOperationException`. +**Architecture:** A new standalone nested repo (`~/Desktop/scadaproj/ZB.MOM.WW.Configuration`), +.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. -This was flagged as a Tier-2 normalization candidate in -[`upcoming.md`](../../upcoming.md): *"all three use IOptions + `IValidateOptions` + -`ValidateOnStart`; a shared validation base + startup-validation helper is reusable."* +**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). -**Goal:** extract the genuinely-common plumbing into one shared library, removing the -duplicated boilerplate while leaving every app's domain-specific rules per-project — per the -`components/README.md` rule: *"extract only what is genuinely common; leave domain-specific -logic in the projects."* +**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. -### Decisions locked during brainstorming +--- + +## Design summary (approved via brainstorming) | Decision | Choice | Rationale | |---|---|---| -| Deliverable | **Full pass** (normalization docs + built library) | Match Auth/Theme/Health/Telemetry exactly | -| Scope | **Startup options validation only** | Genuinely common across all 3; honors YAGNI | +| 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 | -### Explicitly out of scope (YAGNI) +**Out of scope (YAGNI):** runtime draft/snapshot validation (stays in OtOpcUa's `DraftValidator`); +any FluentValidation/DataAnnotations dependency; editing the three apps' code. -- **Runtime draft/snapshot validation** — OtOpcUa's `DraftValidator`/`DraftSnapshot` - (validating admin-UI-submitted config drafts before commit) stays per-project; only ~1 of 3 - apps uses it. -- **A FluentValidation or DataAnnotations dependency** — the apps hand-roll `IValidateOptions` - *because* their rules are conditional/cross-field, which DataAnnotations can't express and - FluentValidation only solves by adding a new fleet-wide dependency. -- **Editing the three apps' code** — adoption is a tracked follow-on in `GAPS.md`, not part of - this pass (same stance as Auth/Health/Theme). +**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`). -## 2. Approaches considered +**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 `" "`. -- **A — Lightweight base + rule primitives + DI/startup helpers (chosen).** Extract exactly - the boilerplate the apps repeat; keep domain rules per-project. Minimal surface, idiomatic, - stays inside .NET's native options pipeline, no new external dependency → single package. -- **B — Fluent rule-builder DSL** (`RuleFor(o => o.Port).InRange(...)`). Nicer ergonomics but a - much larger surface to build/test/maintain, and property-chains express the apps' real - role-conditional/cross-field rules awkwardly. Rejected: over-built. -- **C — Wrap DataAnnotations or FluentValidation.** Less code to own, but DataAnnotations is too - weak for the apps' conditional rules (the reason they hand-roll today) and FluentValidation is - a new fleet-wide dependency for modest gain. Rejected. +**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). +Library work is committed inside the nested repo `~/Desktop/scadaproj/ZB.MOM.WW.Configuration`; +docs/registry work is committed in the outer `scadaproj` repo. The `Files:` block IS the +`files_to_edit` contract. -## 3. Architecture & repository layout +**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) -One new .NET 10 package in its own nested git repo at the workspace root, plus the -normalization paper trail and this design doc: +--- -``` -scadaproj/ - components/configuration/ - README.md - spec/SPEC.md # the one normalized target - shared-contract/ZB.MOM.WW.Configuration.md # proposed public API - current-state/{otopcua,mxaccessgw,scadabridge}/CURRENT-STATE.md - GAPS.md # divergence + adoption backlog - ZB.MOM.WW.Configuration/ # the built library (nested git repo) - src/ZB.MOM.WW.Configuration/ - tests/ZB.MOM.WW.Configuration.Tests/ - ZB.MOM.WW.Configuration.slnx - docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md # this file +## 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 + + ``` -**Dependencies (single package — no heavy dep to isolate):** -`Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions`, -`Microsoft.Extensions.Configuration.Abstractions`, -`Microsoft.Extensions.DependencyInjection.Abstractions` — all already referenced by all three -apps. This is why a single package is the right shape, unlike the multi-package splits Auth -(LDAP/crypto/ASP.NET) and Health (Akka/EF) needed. +**Step 2: `Directory.Packages.props`** +```xml + + + true + + + + + + + + + + + + + + + + +``` -## 4. Public API surface +**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 + + + + + + + + +``` -Four public types in namespace `ZB.MOM.WW.Configuration`; everything else internal. +**Step 4: `tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj`** +```xml + + + false + + + + + + + + + + + + + + + + +``` -### 4.1 `OptionsValidatorBase` (abstract `IValidateOptions`) +**Step 5: `ZB.MOM.WW.Configuration.slnx`** +```xml + + + + + + + + +``` -Removes the `List` → `ValidateOptionsResult` plumbing every validator repeats. The base -implements `Validate(name, options)`, hands a fresh `ValidationBuilder` to one overridden method, -and returns `Success` only when no failures were accumulated — otherwise `Fail` with **all** -messages. +**Step 6: init repo + verify restore/build** +```bash +cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && git init -q +dotnet build ZB.MOM.WW.Configuration.slnx +``` +Expected: build succeeds (0 source files yet → empty assembly is fine). +**Step 7: Commit** (nested repo) +```bash +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration 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 -public sealed class ClusterOptionsValidator : OptionsValidatorBase +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.Configuration.Tests; + +public sealed class ValidationBuilderTests { - protected override void Validate(ValidationBuilder v, ClusterOptions o) + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(65535, true)] + [InlineData(65536, false)] + public void Port_validates_range(int port, bool valid) { - v.MinCount(o.SeedNodes, 2, "ClusterOptions.SeedNodes"); - v.OneOf(o.SplitBrainResolverStrategy, AllowedStrategies, "ClusterOptions.SplitBrainResolverStrategy"); - v.RequireThat(o.MinNrOfMembers == 1, "ClusterOptions.MinNrOfMembers must be 1"); - v.PositiveTimeSpan(o.StableAfter, "ClusterOptions.StableAfter"); + 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); } } ``` -### 4.2 `ValidationBuilder` (failure accumulator) - -Carries the standard rule primitives (each *checks and* appends a consistently-formatted -message) plus a low-level escape hatch for custom/cross-field rules. - -- **Primitives:** `Required(string?, field)`, `Port(int, field)`, `HostPort(string?, field)`, - `PositiveTimeSpan(TimeSpan, field)`, `OneOf(value, allowed, field)`, - `MinCount(collection, n, field)`. -- **Escape hatch:** `RequireThat(bool ok, string message)` and `Add(string message)` — for the - role-conditional / cross-field rules the apps genuinely need (e.g. "SiteId required only when - Role=Site"). - -### 4.3 `ServiceCollectionExtensions.AddValidatedOptions(config, sectionPath)` - -One call = bind section + register the validator + `ValidateOnStart()`. Returns the -`OptionsBuilder` so callers can chain `.PostConfigure(...)`. Replaces each module's -bespoke `AddXxx()` wiring. - -```csharp -services.AddValidatedOptions(config, "ScadaBridge:Cluster"); +**Step 2: Run — expect FAIL** (`Checks`/`ValidationBuilder` not defined) +```bash +cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test ``` -### 4.4 `ConfigPreflight` (fluent aggregator over raw `IConfiguration`) - -For checks that must run *before* the host/DI exists (generalizes ScadaBridge's pre-Akka -`StartupValidator`). - +**Step 3: Implement `Checks.cs`** ```csharp -ConfigPreflight.For(configuration) - .Require("ScadaBridge:Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'") - .RequirePort("ScadaBridge:Node:RemotingPort") - .When(role == "Site", p => p.Require("ScadaBridge:Node:SiteId", NotEmpty, "required for Site nodes")) - .ThrowIfInvalid(); // throws one InvalidOperationException listing ALL errors +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; +} ``` -**DRY:** `ValidationBuilder` and `ConfigPreflight` share one internal `Checks` helper so rule -logic and message wording are identical whether validating a bound options object or raw config -keys. +**Step 4: Implement `ValidationBuilder.cs`** +```csharp +namespace ZB.MOM.WW.Configuration; -## 5. Error-handling contract +/// +/// 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 = []; -- **Accumulate, never fail-fast-on-first.** Both front-ends collect *all* failures and surface - them together — one boot shows every config problem, not just the first. -- **Two surfacing paths:** options validators flow through `ValidateOnStart()` → host throws - `OptionsValidationException` at startup; `ConfigPreflight.ThrowIfInvalid()` throws a single - `InvalidOperationException` with a newline-bulleted list — **byte-compatible with - ScadaBridge's current `StartupValidator` message shape**, so its swap is a drop-in. -- **Consistent message format:** `" "`, one per failure. + /// The accumulated failure messages (empty when validation passed). + public IReadOnlyList Failures => _failures; -## 6. Testing + /// True when no failures have been accumulated. + public bool IsValid => _failures.Count == 0; -xUnit, matching the sibling repos; target ~40–60 tests (cf. Health 58 / Telemetry 19). + /// Records as a failure when is false. + public ValidationBuilder RequireThat(bool ok, string message) + { + if (!ok) _failures.Add(message); + return this; + } -- Each primitive at its boundaries — `Port` at 0/1/65535/65536, `Required` null/empty/whitespace, - `HostPort` parse success/failure, `PositiveTimeSpan` zero/negative, `OneOf` case-insensitivity, - `MinCount`. -- Base aggregation: multiple failures all reported; clean options → `Success`. -- `AddValidatedOptions`: bad config makes the host throw at start; good config resolves; - `ValidateOnStart` actually fires. -- `ConfigPreflight`: aggregation, `When(...)` conditional branches, `ThrowIfInvalid` message - format. + /// Unconditionally records as a failure. + public ValidationBuilder Add(string message) + { + _failures.Add(message); + return this; + } -## 7. Versioning, packaging & adoption + /// Requires a non-null, non-whitespace string. + public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field)); -- **Version `0.1.0`**, `dotnet pack` → **1 nupkg**. Build/test from `ZB.MOM.WW.Configuration/` - via `dotnet test` — identical conventions to the other four libraries. -- **Not adopted in-pass** (same stance as Auth/Health/Theme). The `current-state/` docs map each - app's validators with `file:line` refs, and `GAPS.md` becomes the per-project - delete/replace backlog. -- **Consumer matrix:** all three apps consume the single package. ScadaBridge is the heaviest - adopter (most `*OptionsValidator` + the `StartupValidator` → `ConfigPreflight` swap); OtOpcUa - and MxGateway adopt the base + `AddValidatedOptions` for their options validators. + /// Requires a TCP port in 1-65535. + public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field)); -### Current-state anchors (to verify against live code during the pass) + /// Requires a 'host:port' endpoint with a valid port. + public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field)); -- **OtOpcUa:** `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/{DraftValidator,DraftSnapshot}.cs` - (draft validation stays per-project); options validators elsewhere in `…Configuration`. -- **MxAccessGateway:** `src/ZB.MOM.WW.MxGateway.Server/Configuration/{GatewayOptionsValidator, - GatewayConfigurationServiceCollectionExtensions}.cs`. -- **ScadaBridge:** per-module `*OptionsValidator.cs` (`ClusterInfrastructure`, `Security`, - `HealthMonitoring`, `AuditLog`) + `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs` + - each module's `ServiceCollectionExtensions.cs`. + /// Requires a strictly positive duration. + public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field)); -## 8. Index/registry housekeeping (part of this pass) + /// Requires the value to be one of (case-insensitive). + public ValidationBuilder OneOf(string? value, IReadOnlyCollection allowed, string field) => AddIf(Checks.OneOf(value, allowed, field)); -- New row in [`components/README.md`](../../components/README.md) registry. -- Component-normalization table + a normalization paragraph in the root - [`CLAUDE.md`](../../CLAUDE.md). -- Tick the "Config + validation" Tier-2 item in [`upcoming.md`](../../upcoming.md). + /// Requires a collection with at least items. + public ValidationBuilder MinCount(IReadOnlyCollection? value, int min, string field) => AddIf(Checks.MinCount(value, min, field)); -## 9. Implementation task outline + private ValidationBuilder AddIf(string? message) + { + if (message is not null) _failures.Add(message); + return this; + } +} +``` -Tracked in the session task list; dependency order: +**Step 5: Run — expect PASS** +```bash +cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test +``` -1. **Write `components/configuration/` normalization docs** (spec, shared-contract, 3× - current-state with `file:line`, GAPS, README). -2. **Implement `ZB.MOM.WW.Configuration`** (the four public types + internal `Checks`). -3. **Add `ZB.MOM.WW.Configuration.Tests`** (~40–60 xUnit tests). -4. **Update registry/index + package** (`dotnet pack` → 1 nupkg @ 0.1.0; init nested git repo). +**Step 6: Commit** (nested repo) +```bash +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration commit -m "feat: Checks primitives + ValidationBuilder" +``` -A detailed, bite-sized implementation plan follows via the writing-plans skill. +--- + +### 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** (nested repo) +```bash +cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration 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** (nested repo) +```bash +cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration 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** (nested repo) +```bash +cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration 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/ZB.MOM.WW.Configuration add -A +git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration 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`. diff --git a/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.tasks.json b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.tasks.json new file mode 100644 index 0000000..2a1fad6 --- /dev/null +++ b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.tasks.json @@ -0,0 +1,15 @@ +{ + "planPath": "docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md", + "tasks": [ + {"id": 1, "subject": "Task 1: components/configuration spec + shared-contract", "status": "pending"}, + {"id": 2, "subject": "Task 2: components/configuration current-state x3 + GAPS + README", "status": "pending"}, + {"id": 3, "subject": "Task 3: scaffold nested repo, solution, library + test projects", "status": "pending"}, + {"id": 4, "subject": "Task 4: Checks (internal) + ValidationBuilder", "status": "pending", "blockedBy": [3]}, + {"id": 5, "subject": "Task 5: OptionsValidatorBase", "status": "pending", "blockedBy": [4]}, + {"id": 6, "subject": "Task 6: ServiceCollectionExtensions.AddValidatedOptions", "status": "pending", "blockedBy": [4]}, + {"id": 7, "subject": "Task 7: ConfigPreflight", "status": "pending", "blockedBy": [4]}, + {"id": 8, "subject": "Task 8: README + dotnet pack -> verify single nupkg", "status": "pending", "blockedBy": [5, 6, 7]}, + {"id": 9, "subject": "Task 9: register in indexes (components/README, root CLAUDE.md, upcoming.md)", "status": "pending", "blockedBy": [1, 2, 8]} + ], + "lastUpdated": "2026-06-01" +}