# ZB.MOM.WW.Configuration — shared startup-options-validation library (design) **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. ## 1. Problem & intent 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`. 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."* **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."* ### Decisions locked during 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 | | 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) - **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). ## 2. Approaches considered - **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. ## 3. Architecture & repository layout 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 ``` **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. ## 4. Public API surface Four public types in namespace `ZB.MOM.WW.Configuration`; everything else internal. ### 4.1 `OptionsValidatorBase` (abstract `IValidateOptions`) 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. ```csharp public sealed class ClusterOptionsValidator : OptionsValidatorBase { protected override void Validate(ValidationBuilder v, ClusterOptions o) { 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"); } } ``` ### 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"); ``` ### 4.4 `ConfigPreflight` (fluent aggregator over raw `IConfiguration`) For checks that must run *before* the host/DI exists (generalizes ScadaBridge's pre-Akka `StartupValidator`). ```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 ``` **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. ## 5. Error-handling contract - **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. ## 6. Testing xUnit, matching the sibling repos; target ~40–60 tests (cf. Health 58 / Telemetry 19). - 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. ## 7. Versioning, packaging & adoption - **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. ### Current-state anchors (to verify against live code during the pass) - **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`. ## 8. Index/registry housekeeping (part of this pass) - 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). ## 9. Implementation task outline Tracked in the session task list; dependency order: 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). A detailed, bite-sized implementation plan follows via the writing-plans skill.