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 new file mode 100644 index 0000000..b50fd52 --- /dev/null +++ b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md @@ -0,0 +1,211 @@ +# 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.