docs(plans): design ZB.MOM.WW.Configuration shared startup-options-validation library

Approved brainstorming design for the Config + validation normalization pass
(Tier-2 candidate in upcoming.md). Scope: startup options validation only,
single package ZB.MOM.WW.Configuration, Approach A (lightweight base + rule
primitives + DI/startup helpers). Full pass = components/configuration/ docs +
built library.
This commit is contained in:
Joseph Doherty
2026-06-01 09:10:35 -04:00
parent a09cc02d46
commit 18e4b70572
@@ -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<T>` + `IValidateOptions<T>` + `ValidateOnStart()` — and each hand-rolls a
`List<string>` 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<TOptions>` (abstract `IValidateOptions<TOptions>`)
Removes the `List<string>``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<ClusterOptions>
{
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<TOptions, TValidator>(config, sectionPath)`
One call = bind section + register the validator + `ValidateOnStart()`. Returns the
`OptionsBuilder<TOptions>` so callers can chain `.PostConfigure(...)`. Replaces each module's
bespoke `AddXxx()` wiring.
```csharp
services.AddValidatedOptions<ClusterOptions, ClusterOptionsValidator>(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:** `"<Section:Key> <reason>"`, one per failure.
## 6. Testing
xUnit, matching the sibling repos; target ~4060 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`** (~4060 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.