docs(config): components/configuration normalization (spec, shared-contract, current-state x3, GAPS, README)

This commit is contained in:
Joseph Doherty
2026-06-01 09:48:49 -04:00
parent b754873a44
commit 46c4bfae31
7 changed files with 1033 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# Configuration validation — gaps & adoption backlog
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to adopt
the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (27 tests) at
[`../../ZB.MOM.WW.Configuration/`](../../ZB.MOM.WW.Configuration/) but **NOT YET ADOPTED** by any
app — so every item below is an *adoption* item, not a library-build item. This mirrors the Auth /
UI-Theme / Health pattern: the shared library is built first; adoption is opt-in and tracked here,
not forced. (Unlike the observability pass, there is **no in-pass sister-repo adoption** in this
release.)
Status legend: ⛔ gap · 🟡 partial · ✅ matches.
## Divergence vs spec
### §1 `IValidateOptions` base — failure accumulation (everyone hand-rolls it)
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Uses `OptionsValidatorBase` | ⛔ n/a (no validators) | ⛔ hand-rolled | ⛔ hand-rolled ×4 |
| Private `List<string>` accumulation | n/a | 🟡 `GatewayOptionsValidator` | 🟡 four validators |
| Aggregates ALL failures | n/a | ✅ (yes, manually) | ✅ (yes, manually) |
MxGateway and ScadaBridge already accumulate all failures correctly — they just open-code the
plumbing (the `List<string>`, the `Count == 0 ? Success : Fail` tail, and in MxGateway the
`AddIfBlank`/`AddIfNotPositive` helpers). OtOpcUa has **no validators at all**.
**Gap B1:** MxGateway: `GatewayOptionsValidator``OptionsValidatorBase<GatewayOptions>`.
**Gap B2:** ScadaBridge: four `*OptionsValidator``OptionsValidatorBase<T>`.
**Gap B3:** OtOpcUa: *optionally* add `OptionsValidatorBase` subclasses for `Ldap`/`OpcUa` (no
existing validators to migrate — additive only).
### §2 Rule primitives (re-implemented as private helpers)
| Primitive | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| required-string | ⛔ none | 🟡 `AddIfBlank` | 🟡 inline `IsNullOrWhiteSpace` |
| port range | ⛔ none | 🟡 inline `Port` check | 🟡 inline (in `StartupValidator`) |
| positive `TimeSpan` | ⛔ none | n/a | 🟡 inline `<= TimeSpan.Zero` ×7 |
| one-of-set | ⛔ none | 🟡 inline enum/string checks | 🟡 inline `HashSet.Contains` |
| min-count | ⛔ none | n/a | 🟡 inline `Count < n` |
The same five rules recur as private helpers / inline checks across both heavy consumers. The
shared `ValidationBuilder` primitives (`Required`, `Port`, `HostPort`, `PositiveTimeSpan`, `OneOf`,
`MinCount`) plus `RequireThat`/`Add` replace them with identical wording (the internal `Checks`
seam).
**Gap P1:** MxGateway/ScadaBridge: re-express inline checks/helpers as `ValidationBuilder`
primitives; keep app-specific rules (`.exe` path, heartbeat ordering, seed-node topology) as
`RequireThat`/`Add`.
### §3 DI wiring — `AddValidatedOptions` (everyone open-codes the triple)
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| `bind + register-validator + ValidateOnStart` in one call | ⛔ bare `.Bind()`, no validate | ⛔ `AddGatewayConfiguration` open-codes it | ⛔ per-module `AddXxx` open-codes it ×4 |
MxGateway's `AddGatewayConfiguration` (`AddOptions().BindConfiguration().ValidateOnStart()` +
`AddSingleton<IValidateOptions...>`) and ScadaBridge's four module extensions all spell out exactly
what `AddValidatedOptions<TOptions, TValidator>(config, sectionPath)` collapses into one line.
OtOpcUa binds with bare `.Bind()` and never validates.
**Gap W1:** MxGateway: `AddGatewayConfiguration``AddValidatedOptions`.
**Gap W2:** ScadaBridge: four module `AddXxx``AddValidatedOptions`.
**Gap W3:** OtOpcUa: replace bare `.Bind()` (`Program.cs:99`, `OtOpcUaServerHostedService.cs:63`)
with `AddValidatedOptions` if validators are added (B3).
### §4 Pre-host preflight — `ConfigPreflight` (only ScadaBridge has the concern)
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Pre-host raw-config validation | ⛔ none | ⛔ none (single host, no pre-Akka stage) | ✅ `StartupValidator` (open-coded) |
| Message byte-compatible with `ConfigPreflight` | n/a | n/a | ✅ **confirmed** |
ScadaBridge's `StartupValidator` is the *reason* `ConfigPreflight` exists. Its thrown
`InvalidOperationException` message is **byte-identical** to `ConfigPreflight.ThrowIfInvalid()`
(`"Configuration validation failed:\n - <field> <reason>"`, verified against
`ConfigPreflight.cs:6368` and `StartupValidator.cs:8183`), so the swap is behaviour-preserving.
**Gap F1:** ScadaBridge: `StartupValidator``ConfigPreflight` (gated on the byte-compatibility
test). OtOpcUa/MxGateway have no pre-host stage — not applicable.
### §5 OtOpcUa has no startup options validation at all (surprise)
OtOpcUa has **zero** `IValidateOptions` / `ValidateOnStart` usages in `src/`. Its `Ldap` and
`OpcUa` sections are bound and trusted; a bad value fails opaquely on first use (the exact failure
mode ScadaBridge's `SecurityOptionsValidator` was written to prevent). This is an absence, not a
drift — adoption here is *additive*, optional, and the lowest-stakes of the three.
**Gap A1:** OtOpcUa: add fail-fast validation for `Ldap` (required server/search-base) and any
`OpcUa` invariants via `OptionsValidatorBase` + `AddValidatedOptions`. Optional; low priority.
### §6 Draft validation is out of scope (no gap)
OtOpcUa's `DraftValidator` / `DraftSnapshot` (runtime draft/snapshot validation of operator config
*content*) is **not** options/config validation and is explicitly out of the shared library's scope
(SPEC §0). It is **not a gap** and requires **no change** on adoption — listed here only to record
that it was considered and deliberately excluded.
## Adoption backlog (ordered)
| # | Item | Projects | Priority | Effort | Risk | Notes |
|---|---|---|---|---|---|---|
| 1 | MxGateway: `GatewayOptionsValidator``OptionsValidatorBase`; helpers → primitives (Gaps B1, P1) | MxGateway | P2 | M | Low | One validator (~360 LOC); messages preserved verbatim |
| 2 | MxGateway: `AddGatewayConfiguration``AddValidatedOptions` (Gap W1) | MxGateway | P2 | S | Low | Bundles with #1; pass `GatewayOptions.SectionName` as `sectionPath` |
| 3 | ScadaBridge: four `*OptionsValidator``OptionsValidatorBase`; inline checks → primitives (Gaps B2, P1) | ScadaBridge | P2 | M | Low | Cluster/Security/HealthMonitoring/AuditLog; messages preserved |
| 4 | ScadaBridge: four module `AddXxx``AddValidatedOptions` (Gap W2) | ScadaBridge | P2 | S | Low | Bundles with #3; keep HealthMonitoring idempotency guard |
| 5 | ScadaBridge: `StartupValidator``ConfigPreflight` (Gap F1) | ScadaBridge | P2 | S | Low | Gated on byte-compatibility test; `Program.cs:39` call site unchanged |
| 6 | OtOpcUa: add `Ldap`/`OpcUa` validators via `OptionsValidatorBase` + `AddValidatedOptions` (Gaps A1, B3, W3) | OtOpcUa | P3 | S | Low | Additive (no validators today); lowest stakes |
**Sequencing:** items #1#2 (MxGateway) and #3#5 (ScadaBridge) are independent and can land in
either order; each pair lands in its own sister repo once the `0.1.0` nupkg is referenced. Item #6
(OtOpcUa) is optional new work, deferrable indefinitely. No item is a breaking change — every
migration is a behaviour-preserving plumbing swap (the `ConfigPreflight` swap is the only one that
changes a thrown-message *implementation*, and it is byte-compatible by construction). There is no
ops-coordination risk (unlike the observability `ms``s` / Meter-rename items) because no
externally-observed contract (metric label, dashboard, wire format) changes.
## Decisions settled (no longer open)
- **Draft validation excluded (SETTLED):** OtOpcUa's `DraftValidator`/`DraftSnapshot` is runtime
config-content validation, not startup options validation, and stays per-project. See SPEC §0 and
[`current-state/otopcua/CURRENT-STATE.md`](current-state/otopcua/CURRENT-STATE.md).
- **`ConfigPreflight` message envelope pinned (SETTLED):** the library reproduces ScadaBridge's
`StartupValidator` envelope byte-for-byte (`InvalidOperationException`,
`"Configuration validation failed:\n - <field> <reason>"`), so the migration is
behaviour-preserving. Verified in `ConfigPreflightTests`.
- **Single package, no ASP.NET Core dependency (SETTLED):** the library closes over only
`Microsoft.Extensions.*` abstractions — validators run in plain DI, no framework reference. See
[`shared-contract/ZB.MOM.WW.Configuration.md`](shared-contract/ZB.MOM.WW.Configuration.md).
## Decisions still open
- **Filesystem-path validity primitive:** MxGateway's `AddIfInvalidPath` (valid-path + `.exe`
extension) is currently mapped to a custom `RequireThat`/`Add` rule. If a second app grows the
same need, consider promoting a `Path`/`FilePath` primitive to `ValidationBuilder` — for now it
stays app-specific.
- **No-validator ScadaBridge modules:** `Communication`, `DataConnectionLayer`, `Transport`,
`Notification*`, etc. bind options without validation today. Whether to add validators (and thus
`AddValidatedOptions`) for them is a per-module call, out of scope for the initial adoption.
+99
View File
@@ -0,0 +1,99 @@
# Configuration validation (config binding + startup validation)
Normalized component for **startup configuration validation** across the three sister projects.
**Goal: path to shared code** — converge the apps onto one `IValidateOptions` failure-accumulation
base, a shared set of rule primitives, a single bind+validate+`ValidateOnStart` DI helper, and a
pre-host raw-config aggregator, extracted as the `ZB.MOM.WW.Configuration` library (single package),
while each app keeps its own options classes and domain rules.
- The one target: [`spec/SPEC.md`](spec/SPEC.md)
- The shared library (paper API): [`shared-contract/ZB.MOM.WW.Configuration.md`](shared-contract/ZB.MOM.WW.Configuration.md)
- Divergences + adoption backlog: [`GAPS.md`](GAPS.md)
- Current state, per project: [`current-state/`](current-state/)
## Why config validation is a normalization candidate
All three apps fail-fast on bad configuration at startup — and all three hand-roll the same
plumbing to do it:
- **OtOpcUa** has **no startup options validation at all**`Ldap`/`OpcUa` are bound with bare
`.Bind()` and trusted; a bad value fails opaquely on first use. (Its `DraftValidator` is runtime
*config-content* validation, a different concern, out of scope.)
- **MxAccessGateway** has one large `GatewayOptionsValidator` (~360 LOC, nine sub-validators) with a
private `List<string>` accumulator and `AddIfBlank`/`AddIfNotPositive`/`AddIfInvalidPath` helpers,
wired through a bespoke `AddGatewayConfiguration` extension.
- **ScadaBridge** is the heaviest: **four** per-module `*OptionsValidator` (Cluster / Security /
HealthMonitoring / AuditLog), each open-coding the same accumulation, **plus** a raw-config
pre-Akka `StartupValidator`.
The common core — accumulate-all-failures `IValidateOptions`, reusable rule primitives,
`AddValidatedOptions`, and a `ConfigPreflight` that generalizes `StartupValidator` — is genuinely
shareable; the **options classes and domain rules stay per-project**. The unifying detail:
`ConfigPreflight.ThrowIfInvalid()` reproduces ScadaBridge's `StartupValidator` thrown message
**byte-for-byte**, so the heaviest migration is behaviour-preserving.
## Status by project
| Project | Options validators today | Pre-host preflight | Failure accumulation | Adoption status |
|---|---|---|---|---|
| **OtOpcUa** | ⛔ **none** (bare `.Bind()`; `DraftValidator` is out-of-scope runtime validation) | ⛔ none | n/a | Not started (additive, optional) |
| **MxAccessGateway** | 🟡 `GatewayOptionsValidator` (hand-rolled `IValidateOptions`) | ⛔ none | 🟡 manual `List<string>` | Not started (follow-on) |
| **ScadaBridge** | 🟡 four `*OptionsValidator` (hand-rolled) | ✅ `StartupValidator` (raw config, pre-Akka) | 🟡 manual `List<string>` ×4 | Not started (follow-on; heaviest) |
See each project's [`current-state/<project>/CURRENT-STATE.md`](current-state/) for the
code-verified detail and its adoption plan.
## Normalized vs. left per-project
**Normalized (the shared target):**
- `OptionsValidatorBase<TOptions>` — abstract `IValidateOptions<TOptions>`; override
`protected void Validate(ValidationBuilder, TOptions)`; the base aggregates **all** failures and
returns `Success` only when clean.
- `ValidationBuilder` rule primitives — `Required`, `Port`, `HostPort`, `PositiveTimeSpan`,
`OneOf`, `MinCount`, plus `RequireThat`/`Add` for custom and cross-field rules; consistent
`"<field> <reason>"` wording via the internal `Checks` seam.
- `AddValidatedOptions<TOptions, TValidator>(IConfiguration, sectionPath)` — bind + register
validator + `ValidateOnStart` in one DI call; returns `OptionsBuilder<TOptions>`.
- `ConfigPreflight` — fluent pre-host raw-`IConfiguration` aggregator (`For`/`Require`/`RequireValue`/
`RequirePort`/`When`/`ThrowIfInvalid`); generalizes `StartupValidator`, with a byte-compatible
thrown message.
- The error-handling contract: accumulate ALL failures; two surfacing paths
(`OptionsValidationException` at host start via `ValidateOnStart`, vs
`ConfigPreflight.ThrowIfInvalid()`'s `InvalidOperationException`); `"<field> <reason>"` messages.
**Left per-project (not forced together):**
- Each app's options classes (`GatewayOptions`, `ClusterOptions`, `SecurityOptions`,
`HealthMonitoringOptions`, `AuditLogOptions`, `NodeOptions`, …) and all of their domain rules —
worker `.exe` paths, split-brain strategy, Akka heartbeat/threshold ordering, audit retention
bounds, gRPC-port-vs-remoting-port topology, etc.
- OtOpcUa's runtime draft/snapshot validation (`DraftValidator` + `DraftSnapshot`) — config-content
validation, **out of scope** entirely.
## Package structure
`ZB.MOM.WW.Configuration` ships as a **single package, one DLL** — no third-party packages, no
ASP.NET Core framework reference:
| Package | Contents | Consumers |
|---|---|---|
| `ZB.MOM.WW.Configuration` | `OptionsValidatorBase<TOptions>`, `ValidationBuilder`, `ServiceCollectionExtensions.AddValidatedOptions`, `ConfigPreflight`, internal `Checks` | All three (ScadaBridge heaviest) |
Dependency closure: `Microsoft.Extensions.{Options, Options.ConfigurationExtensions,
Configuration.Abstractions, DependencyInjection.Abstractions}`.
## Component status
**Status: Draft. Library BUILT @ 0.1.0; NOT YET ADOPTED by the three apps. Adoption is the
backlog** (tracked in [`GAPS.md`](GAPS.md)). Unlike the observability pass, this release carries
**no in-pass sister-repo adoption** — it is library-only.
The shared library lives at
[`~/Desktop/scadaproj/ZB.MOM.WW.Configuration/`](../../ZB.MOM.WW.Configuration/) (.NET 10; single
package; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0). Build/test/pack from `ZB.MOM.WW.Configuration/`:
```bash
dotnet test ZB.MOM.WW.Configuration.slnx
dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts
```
@@ -0,0 +1,122 @@
# Configuration validation — current state: MxAccessGateway
Repo: `~/Desktop/MxAccessGateway` (`mxaccessgw`). Stack: .NET 10 gateway (x64) + .NET 4.8 worker
(x86), gRPC; solution `src/MxGateway.sln`. All paths relative to repo root. Verified 2026-06-01.
MxGateway has **one large, well-structured options validator** for a single composite
`GatewayOptions`, wired through a bespoke DI extension. It is the textbook hand-rolled version of
exactly what the shared library normalizes: a private `List<string>` accumulator, a stack of
`AddIfXxx` helper methods, and an `AddOptions().BindConfiguration().ValidateOnStart()` registration
— all of which collapse onto `OptionsValidatorBase` + `AddValidatedOptions` with the domain rules
left untouched.
## 1. `GatewayOptionsValidator` — hand-rolled `IValidateOptions<GatewayOptions>`
`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs`:
- `:6``public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>`
implements the interface directly (no shared base).
- `:1734``Validate(string? name, GatewayOptions options)`: creates `List<string> failures`
(`:19`), dispatches to **nine** sub-validators (`:2129`), and returns the
`failures.Count == 0 ? Success : Fail(failures)` tail (`:3133`). This is precisely the
accumulate-all-then-decide convention the base owns.
- Sub-validators (each takes `(section options, List<string> failures)` and `failures.Add(...)`s):
- `:36` `ValidateAuthentication``Enum.IsDefined` on `Mode`; conditional required
`SqlitePath` / `PepperSecretName` when `Mode == ApiKey`.
- `:61` `ValidateLdap` — short-circuits when `!Enabled` (`:63`); seven required-string checks
(`:6889`), `Port` positivity (`:90`), and a cross-field `UseTls`/`AllowInsecureLdap` rule (`:92`).
- `:98` `ValidateWorker` — required `ExecutablePath` (`:100`), valid-path + `.exe`-extension
checks (`:101110`), `Enum.IsDefined` on architecture (`:120`), eight positive-int checks
(`:125152`), and a cross-field `HeartbeatGraceSeconds >= HeartbeatIntervalSeconds` rule
(`:154`), plus a `MaxMessageBytes` range (`:160`).
- `:167` `ValidateSessions` — five positive-int checks (`:169185`) + an "unsupported feature"
guard (`:187`).
- `:194` `ValidateEvents``QueueCapacity` positivity (`:196`) + `Enum.IsDefined` on policy (`:198`).
- `:204` `ValidateDashboard``GroupToRole` map shape (`:211224`) + interval/limit bounds (`:226237`).
- `:240` `ValidateAlarms` — short-circuits when `!Enabled` (`:242`); a "need expression or area"
rule (`:251`) + a canonical-prefix rule (`:258`).
- `:269` `ValidateTls``ValidityYears` range (`:271`), required non-blank cert path + valid path
(`:278285`), non-blank DNS-name entries (`:287`).
- `:296` `ValidateProtocol` — exact `WorkerProtocolVersion` match (`:298`) + `MaxGrpcMessageBytes`
range (`:304`).
- **Private helpers that duplicate the shared primitives** (`:311358`):
- `:311` `AddIfBlank` → maps to `ValidationBuilder.Required`.
- `:319` `AddIfNotPositive` → maps to `RequireThat(value > 0, ...)`.
- `:327` `AddIfNegative` → maps to `RequireThat(value >= 0, ...)`.
- `:335` `AddIfInvalidPath` → app-specific; stays as a `RequireThat`/`Add` custom rule
(filesystem-path validity is not a shared primitive).
Every failure message is the gateway's own (`"MxGateway:<Section>:<Field> ..."`) — these are
**domain rules and stay per-project**; only the accumulation plumbing and the trivial helpers move
to the base.
## 2. DI wiring — `AddGatewayConfiguration`
`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs`:
- `:1021``AddGatewayConfiguration(this IServiceCollection services)`:
- `:1215``services.AddOptions<GatewayOptions>().BindConfiguration(GatewayOptions.SectionName).ValidateOnStart();`
- `:17``services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();`
- `:18` — also registers `IGatewayConfigurationProvider` (a separate concern; stays).
Lines `:1217` are exactly the `bind + register-validator + ValidateOnStart` triple that
`AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>` collapses into one call. (One nuance:
the gateway uses `BindConfiguration(SectionName)` — which reads the section path off the type/const
— whereas `AddValidatedOptions` takes an explicit `sectionPath` string; the adoption passes
`GatewayOptions.SectionName` as that argument.)
A bad `MxGateway` section surfaces as **`OptionsValidationException`** at host start, via
`ValidateOnStart()` — the same path `AddValidatedOptions` produces.
## 3. Summary
| Surface | What exists | Shared-lib mapping |
|---|---|---|
| Options validator | `GatewayOptionsValidator : IValidateOptions<GatewayOptions>` (~360 LOC, 9 sub-validators) | → `OptionsValidatorBase<GatewayOptions>` |
| Failure accumulation | private `List<string> failures` + `Count == 0 ? Success : Fail` tail | → owned by base + `ValidationBuilder` |
| Rule helpers | `AddIfBlank` / `AddIfNotPositive` / `AddIfNegative` | → `Required` / `RequireThat` primitives |
| App-specific helper | `AddIfInvalidPath` | → stays as a custom `RequireThat`/`Add` rule |
| DI wiring | `AddGatewayConfiguration` (`AddOptions().BindConfiguration().ValidateOnStart()` + `AddSingleton<IValidateOptions...>`) | → `AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>` |
| Pre-host preflight | none (single host, no pre-Akka stage) | n/a — `ConfigPreflight` not needed |
---
## Adoption plan → `ZB.MOM.WW.Configuration`
**Migrate the validator to the shared base:**
- Change `GatewayOptionsValidator : IValidateOptions<GatewayOptions>`
`GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>`
(`GatewayOptionsValidator.cs:6`).
- Replace the public `Validate(string? name, GatewayOptions options)` (`:17`) with the
`protected override void Validate(ValidationBuilder v, GatewayOptions options)`. Delete the
`List<string> failures` and the `Count == 0 ? Success : Fail` tail (`:19`, `:3133`) — the base
supplies both.
- Keep the nine sub-validators but re-thread them to take the `ValidationBuilder` instead of
`List<string>`. Map the helpers: `AddIfBlank``v.Required(...)`, `AddIfNotPositive(x, msg)`
`v.RequireThat(x > 0, msg)`, `AddIfNegative(x, msg)``v.RequireThat(x >= 0, msg)`. Keep
`AddIfInvalidPath` as a private helper that records via `v.Add(...)` (filesystem-path validity is
app-specific; not a shared primitive). **All gateway message strings are preserved verbatim**
domain rules do not change.
**Migrate the DI wiring:**
- In `AddGatewayConfiguration` (`GatewayConfigurationServiceCollectionExtensions.cs:1217`),
replace the `AddOptions().BindConfiguration().ValidateOnStart()` + `AddSingleton<IValidateOptions...>`
pair with:
```csharp
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
configuration, GatewayOptions.SectionName);
```
(The extension gains an `IConfiguration` parameter, or resolves it from the builder, since
`AddValidatedOptions` binds from an explicit `IConfiguration` rather than the ambient
`BindConfiguration`.) The `IGatewayConfigurationProvider` registration (`:18`) is unrelated and
stays.
**Keep bespoke (unchanged):**
- Every `"MxGateway:<Section>:<Field>"` message and every domain rule (worker `.exe` extension,
heartbeat grace ≥ interval, protocol-version exact match, `\\`-prefixed alarm expression, etc.).
- `GatewayOptions` and its section types — these are MxGateway's options classes; not shared.
- The net48 x86 worker — does no `IConfiguration` validation; excluded entirely.
**Status:** follow-on (tracked in [`../GAPS.md`](../GAPS.md)). Medium-weight, low-risk —
behaviour-preserving plumbing swap; one validator, one DI extension.
@@ -0,0 +1,98 @@
# Configuration validation — current state: OtOpcUa
Repo: `~/Desktop/OtOpcUa`. Stack: .NET 10, OPC UA, gRPC; solution `ZB.MOM.WW.OtOpcUa.slnx`.
All paths relative to repo root. Verified 2026-06-01.
**Headline:** OtOpcUa has **no startup options validation at all**. A repo-wide search for
`IValidateOptions` and `ValidateOnStart` returns **zero** hits in `src/`. Options are bound with
bare `.Bind(...)` and never validated. The only "validation" in the configuration namespace is
`DraftValidator` — but that is **runtime draft/snapshot validation of operator config drafts**,
not `IConfiguration`/options validation, and it is **out of scope** for the shared library.
This makes OtOpcUa the **lightest** consumer: there is nothing to *replace*, only an optional
opportunity to *add* the missing startup validation using the shared base.
## 1. Options binding — no validation
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`:
- `:99``builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));`
Bound, **not** validated — no `ValidateOnStart()`, no registered `IValidateOptions<LdapOptions>`.
A blank `Ldap:Server` / `Ldap:SearchBase` would surface only later, as a low-level LDAP error on
the first login (the exact failure mode ScadaBridge's `SecurityOptionsValidator` exists to
prevent).
`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs`:
- `:63``_configuration.GetSection("OpcUa").Bind(options);` — the `OpcUa` section is bound
imperatively inside the hosted service, again with no validation pass.
There is no `*OptionsValidator` type and no `AddValidatedOptions`-style helper anywhere in `src/`.
The repo simply trusts its config sections.
## 2. `DraftValidator` / `DraftSnapshot` — runtime draft validation (OUT OF SCOPE)
`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs`:
- `:14``public static class DraftValidator` — a **managed pre-publish validator** (its own
doc-comment, `:713`, frames it as the managed-code complement to the T-SQL `sp_ValidateDraft`).
- `:24``public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)` — runs seven
rule groups (`:2834`): UNS segment regex (`:42`), path length ≤ 200 (`:64`), EquipmentUuid
immutability (`:89`), same-cluster namespace binding (`:104`), reservation pre-flight (`:125`),
EquipmentId derivation (`:153`), driver/namespace compatibility (`:165`).
- `:206``public static IReadOnlyList<ValidationError> ValidateClusterTopology(...)` — a second
managed guard for cluster topology vs `RedundancyMode`.
- It returns **every** failing rule in one pass — same "surface all errors" philosophy this
component normalizes — but over **database draft rows** (`DraftSnapshot`), not `IConfiguration`.
`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs`:
- `:9``public sealed class DraftSnapshot` — the input bag: namespaces, driver instances,
equipment, UNS areas/lines, tags, poll groups, plus prior-generation rows for cross-generation
invariants. These are domain entities (`ZB.MOM.WW.OtOpcUa.Configuration.Entities`), not options.
`DraftValidator` is referenced only by its tests
(`tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs`) and the publish
pipeline — never from any DI / options registration. It produces `ValidationError`
(`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/ValidationError.cs`), a domain record, not
`ValidateOptionsResult`.
**Why it stays per-project:** it validates an operator's configuration *content* (the equipment
hierarchy they are about to publish), with rules that are entirely OtOpcUa domain knowledge (UNS
regex, EquipmentId derivation, Galaxy driver/namespace rules). It is not the cross-cutting
"validate the host's config section at startup" concern the shared library normalizes. Nothing
about it changes on adoption.
## 3. Summary
| Surface | What exists | Shared-lib relevance |
|---|---|---|
| Startup options validation | **None**`LdapOptions`/`OpcUa` bound with bare `.Bind()` | **Gap** — could adopt `OptionsValidatorBase` + `AddValidatedOptions` |
| `IValidateOptions` / `ValidateOnStart` | **Zero usages in `src/`** | nothing to migrate |
| Pre-host raw-config preflight | **None** | could adopt `ConfigPreflight` if pre-host keys emerge |
| Runtime draft validation | `DraftValidator` + `DraftSnapshot` (one-pass, all errors) | **out of scope** — stays per-project |
---
## Adoption plan → `ZB.MOM.WW.Configuration`
OtOpcUa is the lightest consumer — adoption is **additive**, not a replacement, and is entirely
optional (no existing validation is wrong, there just isn't any).
**Add startup validation for the bound sections (optional, recommended):**
- For `Ldap`: add an `LdapStartupOptionsValidator : OptionsValidatorBase<LdapOptions>` that calls
`v.Required(o.Server, "Ldap:Server")` and `v.Required(o.SearchBase, "Ldap:SearchBase")`
(mirroring ScadaBridge's `SecurityOptionsValidator` intent), then replace
`Program.cs:99`'s `AddOptions<LdapOptions>().Bind(...)` with
`AddValidatedOptions<LdapOptions, LdapStartupOptionsValidator>(builder.Configuration, "Ldap")`.
- For `OpcUa`: if any field has a fail-fast invariant (e.g. a required endpoint or a port), add an
`OptionsValidatorBase<OpcUaOptions>` and move the `:63` imperative `.Bind` into
`AddValidatedOptions` at composition time. Skip if the section has no hard invariants.
**Keep bespoke (unchanged):**
- `DraftValidator` and `DraftSnapshot`**out of scope**. Runtime draft/snapshot validation,
domain rules, `ValidationError` output, publish-pipeline call site — all stay exactly as they
are. Do **not** fold them into `OptionsValidatorBase`; they are not options validation.
**Status:** OtOpcUa has no validator to migrate today, so its adoption is purely the *new*
guarding work above. It is a **follow-on** (tracked in [`../GAPS.md`](../GAPS.md)), low priority —
the lowest-stakes of the three because there is no drift to correct, only an absence to optionally
fill once the package is referenced.
@@ -0,0 +1,191 @@
# Configuration validation — current state: ScadaBridge
Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET, Docker; solution
`ZB.MOM.WW.ScadaBridge.slnx`. All paths relative to repo root. Verified 2026-06-01.
ScadaBridge is the **heaviest** consumer — it has the most validation surface and the only
pre-host preflight in the family:
1. **Four per-module `*OptionsValidator : IValidateOptions<T>`** (Cluster, Security,
HealthMonitoring, AuditLog), each open-coding the same `List<string>` accumulation, each wired
through its module's bespoke `AddXxx` DI extension.
2. **One raw-config, pre-Akka `StartupValidator`** that validates critical node/cluster keys
*before* the actor system is built — the canonical motivation for `ConfigPreflight`. Its thrown
message is **byte-compatible** with `ConfigPreflight.ThrowIfInvalid()`.
## 1. Per-module options validators
All four follow the same shape: `List<string> failures`, a run of `if (...) failures.Add(...)`,
and `failures.Count > 0 ? Fail(failures) : Success` (order varies). They are registered via
`TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<T>, ...>())` so a misconfigured
section throws `OptionsValidationException` (with `ValidateOnStart`) or on first `IOptions<T>` resolve.
### `ClusterOptionsValidator`
`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs`:
- `:13``public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>`.
- `:28``var failures = new List<string>();`.
- `:30` `SeedNodes` ≥ 2 (→ `MinCount`); `:44` `SplitBrainResolverStrategy` ∈ {`keep-oldest`}
(→ `OneOf`, with the allowed set at `:1619`); `:52` `MinNrOfMembers == 1` (→ `RequireThat`);
`:59`/`:64`/`:69` three positive-`TimeSpan` checks (→ `PositiveTimeSpan`); `:74` cross-field
`HeartbeatInterval < FailureDetectionThreshold` (→ `RequireThat`); `:82` `DownIfAlone` must be
true (→ `RequireThat`).
- `:9193` — the `failures.Count > 0 ? Fail : Success` tail.
- Wired: `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:2829`
`TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<ClusterOptions>, ClusterOptionsValidator>())`.
### `SecurityOptionsValidator`
`src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs`:
- `:32``public sealed class SecurityOptionsValidator : IValidateOptions<SecurityOptions>`.
- `:48``var failures = new List<string>();`; `:50` required `LdapServer`, `:58` required
`LdapSearchBase` (both → `Required`). `JwtSigningKey` is intentionally **not** validated here
(`:2430` — it fails fast in `JwtTokenService`'s constructor instead).
- `:6668``failures.Count == 0 ? Success : Fail(failures)` tail.
- Wired: `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs:2830`
`AddOptions<SecurityOptions>().ValidateOnStart()` + `TryAddEnumerable(...Singleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>())`.
### `HealthMonitoringOptionsValidator`
`src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs`:
- `:17``public sealed class HealthMonitoringOptionsValidator : IValidateOptions<HealthMonitoringOptions>`.
- `:26``var failures = new List<string>();`; `:28`/`:35`/`:42` three positive-`TimeSpan` checks
(→ `PositiveTimeSpan`); `:49` cross-field `CentralOfflineTimeout >= OfflineTimeout` (→ `RequireThat`).
- `:6062` — the `Count > 0 ? Fail : Success` tail.
- Wired: `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs:6064` — a private
idempotent `AddOptionsValidation` does
`TryAddEnumerable(...Singleton<IValidateOptions<HealthMonitoringOptions>, HealthMonitoringOptionsValidator>())`,
called from all three `Add*HealthMonitoring`/`AddCentralHealthAggregation` entry points (`:16`, `:29`, `:42`).
### `AuditLogOptionsValidator`
`src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs`:
- `:16``public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>`.
- `:35``var failures = new List<string>();`; `:37` `DefaultCapBytes > 0` (→ `RequireThat`);
`:44` cross-field `ErrorCapBytes >= DefaultCapBytes` (→ `RequireThat`); `:52` `RetentionDays`
[30, 3650] (→ `RequireThat`, bounds at `:1922`); `:59` `InboundMaxBytes` ∈ [8 KiB, 16 MiB]
(→ `RequireThat`, bounds at `:2528`).
- `:6668` — the `Count == 0 ? Success : Fail` tail.
- Wired: `src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs:6568`
`AddOptions<AuditLogOptions>().Bind(config.GetSection(ConfigSectionName)).ValidateOnStart()` +
`AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>()`. This is the exact
`AddValidatedOptions` triple, spelled out.
> Other modules bind options with no validator (`Communication`, `DataConnectionLayer`,
> `Transport`, `Notification*`, `ExternalSystemGateway`, `ManagementService`, `SiteCallAudit`,
> `DeploymentManager` — all `AddOptions().BindConfiguration(...)` without `ValidateOnStart` or a
> validator). They are candidates for `AddValidatedOptions` only if/when they grow validators;
> not part of this pass's adoption.
## 2. `StartupValidator` — raw-config, pre-Akka preflight
`src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs`:
- `:7``public static class StartupValidator`; `:11`
`public static void Validate(IConfiguration configuration)`.
- `:13``var errors = new List<string>();`. Reads raw keys off `configuration` (no binding):
- `:16` `ScadaBridge:Node:Role` ∈ {`Central`, `Site`} (→ `Require(key, predicate, reason)`);
- `:20` `Node:NodeHostname` required (→ `RequireValue`);
- `:23` `Node:RemotingPort` parseable port 165535 (→ `RequirePort`);
- `:27` `Node:SiteId` required **when** role == Site (→ `When(role == "Site", ...)`);
- `:3041` `Database:ConfigurationDb` / `Security:LdapServer` / `Security:JwtSigningKey`
required **when** role == Central (→ `When(role == "Central", ...)`);
- `:43` `Cluster:SeedNodes` ≥ 2 entries (→ a `Require`/custom rule over the bound list);
- `:4779` Site-only rules: `GrpcPort` range (`:49`), `GrpcPort != RemotingPort` (`:58`),
`Database:SiteDbPath` required (`:61`), and seed-node-must-not-target-gRPC-port (`:6978`) —
all under a `When(role == "Site", ...)` block, with `SeedNodePort` (`:90`) as a domain helper
that stays per-project.
- `:8183`**the throw:**
```csharp
throw new InvalidOperationException(
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}");
```
- Called once, before the actor system is built:
`src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:39` — `StartupValidator.Validate(configuration);`.
### Message byte-compatibility with `ConfigPreflight` ✅
`StartupValidator`'s throw (`:8183`) and `ConfigPreflight.ThrowIfInvalid()`
(`ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs:6368`) build the **same
string**:
- both prefix `"Configuration validation failed:\n"`;
- both join the failures with `"\n"`;
- both format each failure as `" - " + message`.
The library deliberately copied this envelope so the migration is a **behaviour-preserving swap**:
same exception type (`InvalidOperationException`), same message bytes. The individual failure
messages are `"<field> <reason>"` (`StartupValidator` open-codes them; `ConfigPreflight` produces
them via the shared `Checks` primitives for the standardized rules — `RequireValue` → `"<key> is
required"`, `RequirePort` → `"<key> must be between 1 and 65535 (was '<raw>')"`). Rules that have
no shared primitive (role-set membership, gRPC-port-vs-remoting, seed-node-vs-gRPC-port) keep their
exact wording via `Require(key, predicate, reason)` and `When(...)`.
## 3. Summary
| Surface | What exists | Shared-lib mapping |
|---|---|---|
| Cluster validator | `ClusterOptionsValidator : IValidateOptions<ClusterOptions>` | → `OptionsValidatorBase<ClusterOptions>` |
| Security validator | `SecurityOptionsValidator : IValidateOptions<SecurityOptions>` | → `OptionsValidatorBase<SecurityOptions>` |
| Health validator | `HealthMonitoringOptionsValidator : IValidateOptions<HealthMonitoringOptions>` | → `OptionsValidatorBase<HealthMonitoringOptions>` |
| Audit validator | `AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>` | → `OptionsValidatorBase<AuditLogOptions>` |
| Failure accumulation (×4) | private `List<string>` + `Count`-based tail in each | → owned by base + `ValidationBuilder` |
| DI wiring (×4) | per-module `TryAddEnumerable`/`AddSingleton` + `AddOptions().Bind().ValidateOnStart()` | → `AddValidatedOptions<T, TValidator>` |
| Pre-host preflight | `StartupValidator` (raw config, pre-Akka, `Program.cs:39`) | → `ConfigPreflight` (**byte-compatible** message) |
---
## Adoption plan → `ZB.MOM.WW.Configuration`
ScadaBridge is the heaviest adoption — five validation surfaces — but every change is
behaviour-preserving.
**Migrate the four module validators to the base:**
- For each of `ClusterOptionsValidator`, `SecurityOptionsValidator`,
`HealthMonitoringOptionsValidator`, `AuditLogOptionsValidator`: change the declaration from
`: IValidateOptions<T>` to `: OptionsValidatorBase<T>`, replace
`Validate(string?, T)` with `protected override void Validate(ValidationBuilder v, T o)`, delete
the `List<string> failures` and the `Count`-based tail, and re-express each rule on `v`:
- `SeedNodes` ≥ 2 → `v.MinCount(o.SeedNodes, 2, "ClusterOptions.SeedNodes")`;
- strategy set → `v.OneOf(o.SplitBrainResolverStrategy, ["keep-oldest"], "...")`;
- positive durations → `v.PositiveTimeSpan(...)`;
- required strings → `v.Required(...)`;
- cross-field / bounds rules (`MinNrOfMembers == 1`, heartbeat < threshold, `ErrorCapBytes >=
DefaultCapBytes`, retention bounds, etc.) → `v.RequireThat(condition, message)` with the
**existing message strings preserved verbatim**.
- Update each module's `ServiceCollectionExtensions` to register via
`AddValidatedOptions<T, TValidator>(configuration, "<section>")` instead of the
`AddOptions().Bind/BindConfiguration(...).ValidateOnStart()` + `AddSingleton`/`TryAddEnumerable`
pair (`ClusterInfrastructure/ServiceCollectionExtensions.cs:2829`;
`Security/ServiceCollectionExtensions.cs:2830`;
`HealthMonitoring/ServiceCollectionExtensions.cs:6064`;
`AuditLog/ServiceCollectionExtensions.cs:6568`). Where a module uses `TryAddEnumerable` for
idempotency across multiple entry points (HealthMonitoring), keep an idempotency guard around the
single `AddValidatedOptions` call.
**Migrate `StartupValidator` → `ConfigPreflight`:**
- Replace the body of `StartupValidator.Validate(IConfiguration)` with a `ConfigPreflight.For(configuration)`
chain: `.Require("ScadaBridge:Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")`,
`.RequireValue("ScadaBridge:Node:NodeHostname")`, `.RequirePort("ScadaBridge:Node:RemotingPort")`,
`.When(role == "Site", p => p.RequireValue("ScadaBridge:Node:SiteId"))`,
`.When(role == "Central", p => p.RequireValue("ScadaBridge:Database:ConfigurationDb")...)`, the
Site-only block (`GrpcPort` range, `GrpcPort != RemotingPort`, `SiteDbPath`, seed-vs-gRPC-port),
then `.ThrowIfInvalid()`. Keep the `SeedNodePort` helper and the seed-node/gRPC-port custom rules
as `Require(...)` predicates — they have no shared primitive.
- **Verify the byte-compatibility** (covered by the library's `ConfigPreflightTests`): the swap
preserves the exact `"Configuration validation failed:\n - ..."` message and the
`InvalidOperationException` type. The call site (`Program.cs:39`) is unchanged.
**Keep bespoke (unchanged):**
- All options classes (`ClusterOptions`, `SecurityOptions`, `HealthMonitoringOptions`,
`AuditLogOptions`, `NodeOptions`) and every domain message/rule — split-brain strategy, Akka
heartbeat/threshold relationship, audit retention bounds, gRPC-port-vs-remoting-port, seed-node
topology. The library carries plumbing, not policy.
- The no-validator modules (`Communication`, `DataConnectionLayer`, `Transport`, etc.) — they have
no validation to migrate; leave them until they grow validators.
**Status:** follow-on (tracked in [`../GAPS.md`](../GAPS.md)). Heaviest of the three (five surfaces),
but every item is a behaviour-preserving swap — low risk, the preflight swap gated on the
byte-compatibility test.
@@ -0,0 +1,183 @@
# Shared library: `ZB.MOM.WW.Configuration`
The public surface that extracts the startup configuration-validation plumbing the three
projects share. Realizes [`../spec/SPEC.md`](../spec/SPEC.md). **BUILT @ `0.1.0`** — the
implementation lives at [`../../../ZB.MOM.WW.Configuration/`](../../../ZB.MOM.WW.Configuration/)
(.NET 10; single package; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0). **Not yet adopted** by the
three apps — adoption is the follow-on tracked in [`../GAPS.md`](../GAPS.md).
This doc is the contract; the source is authoritative. Signatures below match the built source
(`src/ZB.MOM.WW.Configuration/*.cs`) verified at `0.1.0`.
## Package (.NET 10)
```
ZB.MOM.WW.Configuration # OptionsValidatorBase, ValidationBuilder, AddValidatedOptions, ConfigPreflight
```
A **single package, one DLL**. Minimal dependency closure — only `Microsoft.Extensions.*`
abstractions, **no** third-party packages and **no** ASP.NET Core framework reference:
| Dependency | Why |
|---|---|
| `Microsoft.Extensions.Options` | `IValidateOptions<T>`, `ValidateOptionsResult`, `OptionsBuilder<T>` |
| `Microsoft.Extensions.Options.ConfigurationExtensions` | `.Bind(IConfigurationSection)` on the options builder |
| `Microsoft.Extensions.Configuration.Abstractions` | `IConfiguration` / `GetSection` for `AddValidatedOptions` + `ConfigPreflight` |
| `Microsoft.Extensions.DependencyInjection.Abstractions` | `IServiceCollection`, `AddOptions`, `AddSingleton` |
Library, not a service — linked into each app at build time; all validation runs in-process at
startup. Published to the Gitea NuGet feed; SemVer.
---
## `OptionsValidatorBase<TOptions>`
```csharp
namespace ZB.MOM.WW.Configuration;
/// Base class for IValidateOptions<TOptions> that removes the failure-accumulation plumbing.
/// Override Validate(builder, options); the base aggregates ALL failures and returns
/// ValidateOptionsResult.Success only when none were recorded.
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
// Guards null, runs the override against a fresh ValidationBuilder, and returns
// Success when builder.IsValid else Fail(builder.Failures).
public ValidateOptionsResult Validate(string? name, TOptions options);
// Record failures for `options` on `builder`. Never return early — record everything.
protected abstract void Validate(ValidationBuilder builder, TOptions options);
}
```
The override is the only thing a consumer writes. Accumulation, the `Success`/`Fail` decision,
and null-guarding are owned by the base.
---
## `ValidationBuilder`
```csharp
namespace ZB.MOM.WW.Configuration;
/// Accumulates validation failures for a bound options object. Passed into the Validate override;
/// each primitive checks a value and appends a "<field> <reason>" message on failure.
public sealed class ValidationBuilder
{
public IReadOnlyList<string> Failures { get; } // accumulated messages (empty when valid)
public bool IsValid { get; } // true when no failures recorded
// Escape hatches (custom + cross-field rules):
public ValidationBuilder RequireThat(bool ok, string message); // records message when !ok
public ValidationBuilder Add(string message); // unconditional failure
// Rule primitives (each delegates wording to internal Checks):
public ValidationBuilder Required(string? value, string field);
public ValidationBuilder Port(int value, string field);
public ValidationBuilder HostPort(string? value, string field);
public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field);
public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field);
public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field);
}
```
All methods are chainable (return `this`). `OneOf` treats a `null` value as a failure — call
`Required` first if you want a "required" message instead of a "must be one of" message.
---
## `ServiceCollectionExtensions.AddValidatedOptions`
```csharp
namespace ZB.MOM.WW.Configuration;
public static class ServiceCollectionExtensions
{
/// Binds TOptions to the section at sectionPath, registers TValidator as its
/// IValidateOptions<TOptions> (singleton), and enables ValidateOnStart so a bad
/// configuration fails fast at host start. Returns the OptionsBuilder for chaining.
public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
this IServiceCollection services, IConfiguration configuration, string sectionPath)
where TOptions : class
where TValidator : class, IValidateOptions<TOptions>;
}
```
Guards null `services`/`configuration` and whitespace `sectionPath`. The validator is registered
as a **singleton** (it backs the singleton options factory) — it must be singleton-safe (no
scoped dependencies). Bad sections surface as **`OptionsValidationException`** at host start.
---
## `ConfigPreflight`
```csharp
namespace ZB.MOM.WW.Configuration;
/// Fluent aggregator for validating raw IConfiguration BEFORE the host/DI container exists
/// (pre-Akka startup). Collects all failures and surfaces them together via ThrowIfInvalid.
public sealed class ConfigPreflight
{
public static ConfigPreflight For(IConfiguration configuration); // start a preflight
public IReadOnlyList<string> Failures { get; } // accumulated (empty when valid)
public bool IsValid { get; }
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason); // "<key> <reason>" on fail
public ConfigPreflight RequireValue(string key); // non-empty value at key
public ConfigPreflight RequirePort(string key); // integer TCP port 1-65535 at key
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block); // role-conditional rules
/// Throws InvalidOperationException listing all failures when invalid; otherwise returns.
/// Message envelope (byte-compatible with ScadaBridge StartupValidator):
/// "Configuration validation failed:\n - <field> <reason>\n - <field> <reason>"
public void ThrowIfInvalid();
}
```
`Require`/`RequireValue`/`RequirePort` guard a whitespace `key`. `ThrowIfInvalid()` is the only
surfacing path — call it last. The message envelope is pinned to match ScadaBridge's
`StartupValidator` (see [`../current-state/scadabridge/CURRENT-STATE.md`](../current-state/scadabridge/CURRENT-STATE.md)
and SPEC §4); the swap is behaviour-preserving.
---
## Internal `Checks` seam
```csharp
namespace ZB.MOM.WW.Configuration;
// internal — shared by ValidationBuilder (bound options) and ConfigPreflight (raw config).
// Each method returns null when valid, else a "<field> <reason>" message. Centralizing the
// wording keeps a given rule identical across both front-ends.
internal static class Checks
{
internal static string? Required(string? value, string field);
internal static string? Port(int value, string field);
internal static string? PortValue(string? raw, string field); // parse + range, for raw-config callers
internal static string? HostPort(string? value, string field); // non-bracketed host:port; rejects [::1]:port
internal static string? PositiveTimeSpan(TimeSpan value, string field);
internal static string? OneOf(string? value, IReadOnlyCollection<string> allowed, string field);
internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field);
}
```
`Checks` is the **single source of failure wording**. `ValidationBuilder.Port` uses `Checks.Port`
(typed `int`); `ConfigPreflight.RequirePort` uses `Checks.PortValue` (raw string → parse → range),
so a port failure reads the same whether it came from a bound options object or a raw config key.
Not public — consumers get the wording through the primitives, not the seam.
---
## Consumer matrix
| Consumer | Package | What it adopts | Weight |
|---|---|---|---|
| **ScadaBridge** | `ZB.MOM.WW.Configuration` | Four `*OptionsValidator``OptionsValidatorBase`; four module `AddXxx``AddValidatedOptions`; `StartupValidator``ConfigPreflight` (byte-compatible). | **Heaviest** — the most validators + the preflight. |
| **MxGateway** | `ZB.MOM.WW.Configuration` | `GatewayOptionsValidator``OptionsValidatorBase` (drop the `List<string>` + `AddIfBlank`/`AddIfNotPositive`/`AddIfInvalidPath` helpers); `AddGatewayConfiguration``AddValidatedOptions`. | Medium — one large validator. |
| **OtOpcUa** | `ZB.MOM.WW.Configuration` | *Optional* — add `OptionsValidatorBase` subclasses + `AddValidatedOptions` for `Ldap` / `OpcUa` sections (currently unvalidated). `DraftValidator`/`DraftSnapshot` stay per-project (out of scope). | **Lightest** — no validators today. |
All three consume the same single package; none needs ASP.NET Core. The net48 x86 mxaccessgw
worker does no `IConfiguration` validation and is excluded.
See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
+201
View File
@@ -0,0 +1,201 @@
# Configuration validation — normalized target spec
Status: **Draft**. The single design the sister projects converge on for **startup
configuration validation**. Derived from the three code-verified current-state docs
(`../current-state/`). Goal is *path to shared code*
(`../shared-contract/ZB.MOM.WW.Configuration.md`), so each normalized section maps to a shared
library seam. The library is **already built** at
[`../../../ZB.MOM.WW.Configuration/`](../../../ZB.MOM.WW.Configuration/) (`0.1.0`, 27 tests).
## 0. Scope
The common concern is **fail-fast validation of configuration at process startup**: bind an
`appsettings.json` / environment section to a typed options object (or read raw keys before the
host exists), check every field, and refuse to start when anything is wrong — surfacing **all**
problems at once so an operator fixes them in one edit rather than one boot-loop per typo. All
three apps already do this; they do it with three private copies of the same plumbing.
**Normalized here** (goes in the shared `ZB.MOM.WW.Configuration` library):
- **The `IValidateOptions<T>` failure-accumulation convention.** Every app hand-rolls a
`List<string> failures`, a pile of `if (...) failures.Add(...)`, and the
`failures.Count == 0 ? Success : Fail(failures)` tail. That plumbing becomes
`OptionsValidatorBase<TOptions>`: override `protected void Validate(ValidationBuilder, TOptions)`,
record failures on the builder, and the base aggregates them and returns a single
`ValidateOptionsResult` (Success only when the builder is clean).
- **Reusable rule primitives.** The same checks recur across apps — required-string, TCP port
range, `host:port` endpoint, positive `TimeSpan`, one-of-a-set, minimum collection count. They
become `ValidationBuilder` primitives (`Required`, `Port`, `HostPort`, `PositiveTimeSpan`,
`OneOf`, `MinCount`) plus `RequireThat(bool, message)` / `Add(message)` escape hatches for
custom and cross-field rules. Wording is centralized in an internal `Checks` seam so a
given rule reads identically everywhere.
- **`AddValidatedOptions<TOptions, TValidator>(IConfiguration, sectionPath)`** — one DI call that
binds the section, registers the validator as the options' `IValidateOptions<TOptions>`, and
enables `ValidateOnStart()`. Replaces the per-module `AddOptions().Bind(...).ValidateOnStart()`
+ `AddSingleton<IValidateOptions<...>, ...>()` pair that each app open-codes.
- **The pre-host `ConfigPreflight` aggregator** — a fluent checker over raw `IConfiguration` for
the keys that must be valid *before* the host / DI container / actor system is built (node
role, remoting port, site id). Generalizes ScadaBridge's `StartupValidator`. Fluent surface:
`For(config)`, `.Require(key, predicate, reason)`, `.RequireValue(key)`, `.RequirePort(key)`,
`.When(condition, block)` (role-conditional rules), `.ThrowIfInvalid()`.
**The error-handling contract** (shared across both front-ends):
- **Accumulate ALL failures.** Never short-circuit on the first failure — collect every problem
and surface them together. (`OptionsValidatorBase` and `ConfigPreflight` both do this; it is
the behaviour every app already wanted.)
- **Two surfacing paths**, by where validation runs:
1. **Options bound through DI**`ValidateOnStart()` raises an
**`OptionsValidationException`** at host start (the .NET options pipeline aggregates the
failures). This is the `AddValidatedOptions` path.
2. **Raw config, pre-host**`ConfigPreflight.ThrowIfInvalid()` throws an
**`InvalidOperationException`** listing all failures.
- **Message format `"<field> <reason>"`** for each individual failure, produced by the shared
`Checks` primitives (e.g. `"ScadaBridge:Node:RemotingPort must be between 1 and 65535 (was '0')"`).
`ConfigPreflight.ThrowIfInvalid()` wraps the accumulated lines in the exact envelope
ScadaBridge's `StartupValidator` uses today (§4) so the migration is byte-compatible.
**Explicitly NOT normalized** (domain-specific — stays per project):
- **Each app's options classes and their domain rules.** `GatewayOptions` (worker exe path,
heartbeat grace ≥ interval, TLS validity years), `ClusterOptions` (split-brain strategy,
`MinNrOfMembers == 1`, heartbeat ≪ failure-detection), `SecurityOptions` (LDAP server /
search base), `HealthMonitoringOptions` (positive `PeriodicTimer` intervals),
`AuditLogOptions` (payload caps, retention bounds), and ScadaBridge's `Node` topology rules
(gRPC port ≠ remoting port, seed nodes must not target the gRPC port) all stay where they
live. Only the *plumbing they sit on* is shared; the *rules* are theirs.
- **OtOpcUa's runtime draft/snapshot validation** (`DraftValidator` + `DraftSnapshot`). This is
**not** options/config validation at all — it is managed pre-publish validation of an operator's
*configuration draft* (UNS segment regex, EquipmentId derivation, cross-cluster namespace
binding, reservation pre-flight), run in the publish pipeline against database rows, not against
`IConfiguration`. It shares only a *philosophy* (return every failure in one pass) with this
component and is **out of scope** for the shared library. It stays entirely in OtOpcUa.
## 1. `IValidateOptions` base — `OptionsValidatorBase<TOptions>`
The headline plumbing fix. Today each validator re-implements: the `Validate(string?, TOptions)`
signature, a local `List<string>`, the `failures.Count == 0 ? Success : Fail(failures)` tail,
and (in several) private `AddIfBlank` / `AddIfNotPositive` helpers. The base owns all of that:
```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, ["keep-oldest"], "ClusterOptions.SplitBrainResolverStrategy");
v.PositiveTimeSpan(o.StableAfter, "ClusterOptions.StableAfter");
v.RequireThat(o.MinNrOfMembers == 1,
$"ClusterOptions.MinNrOfMembers must be 1 (was {o.MinNrOfMembers})");
// cross-field rule:
v.RequireThat(o.HeartbeatInterval < o.FailureDetectionThreshold,
"ClusterOptions.HeartbeatInterval must be below FailureDetectionThreshold");
}
}
```
`OptionsValidatorBase<TOptions>.Validate(string?, TOptions)` guards null, creates a
`ValidationBuilder`, calls the override, and returns `Success` only when `builder.IsValid`.
**Accumulation is automatic** — the override never returns early; it records everything.
## 2. Rule primitives — `ValidationBuilder`
`ValidationBuilder` is the accumulator passed into the override. Primitives both check a value
and append a consistently-worded `"<field> <reason>"` message on failure; escape hatches cover
the rest:
| Primitive | Checks | Failure wording (from `Checks`) |
|---|---|---|
| `Required(value, field)` | non-null, non-whitespace string | `"<field> is required"` |
| `Port(value, field)` | int in 165535 | `"<field> must be between 1 and 65535 (was <value>)"` |
| `HostPort(value, field)` | `host:port` with port 165535 | `"<field> must be 'host:port' with port 1-65535 (was '<value>')"` |
| `PositiveTimeSpan(value, field)` | `> TimeSpan.Zero` | `"<field> must be a positive duration (was <value>)"` |
| `OneOf(value, allowed, field)` | case-insensitive membership | `"<field> must be one of [<allowed>] (was '<value>')"` |
| `MinCount(value, min, field)` | collection ≥ `min` items | `"<field> must contain at least <min> item(s) (had <n>)"` |
| `RequireThat(ok, message)` | arbitrary boolean (cross-field, custom) | caller-supplied |
| `Add(message)` | unconditional failure | caller-supplied |
Properties: `Failures` (read-only accumulated list) and `IsValid`. Every method returns the
builder for chaining. `Add`/`RequireThat` carry the rules that are genuinely app-specific (e.g.
MxGateway's "ExecutablePath must point to a .exe", ScadaBridge's heartbeat-vs-threshold
ordering) without forcing them into a primitive.
## 3. DI wiring — `AddValidatedOptions`
```csharp
builder.Services.AddValidatedOptions<ClusterOptions, ClusterOptionsValidator>(
builder.Configuration, "ScadaBridge:Cluster");
```
Binds `ScadaBridge:Cluster``ClusterOptions`, registers `ClusterOptionsValidator` as a
singleton `IValidateOptions<ClusterOptions>`, and calls `ValidateOnStart()`. Returns the
`OptionsBuilder<TOptions>` for further chaining (e.g. `.PostConfigure(...)`). This collapses the
three-line idiom every module repeats (`AddOptions().Bind(...).ValidateOnStart()` +
`AddSingleton<IValidateOptions<...>, ...>()`) into one call.
> The validator is registered as a **singleton** (it backs the singleton options factory). It
> must be singleton-safe — no scoped dependencies. All current validators are stateless, so this
> holds.
When a section bound this way fails, the .NET options pipeline raises **`OptionsValidationException`**
at host start (because of `ValidateOnStart()`), with all accumulated messages.
## 4. Pre-host preflight — `ConfigPreflight`
For keys that must be valid **before** the host / DI / actor system exists, `ConfigPreflight`
reads raw `IConfiguration` and accumulates failures the same way:
```csharp
ConfigPreflight.For(configuration)
.Require("ScadaBridge:Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
.RequireValue("ScadaBridge:Node:NodeHostname")
.RequirePort("ScadaBridge:Node:RemotingPort")
.When(role == "Site", p => p.RequireValue("ScadaBridge:Node:SiteId"))
.ThrowIfInvalid();
```
`.ThrowIfInvalid()` throws **`InvalidOperationException`** when any failure was recorded, with
this exact envelope:
```
Configuration validation failed:
- <field> <reason>
- <field> <reason>
```
> **Byte-compatibility with ScadaBridge's `StartupValidator`.** ScadaBridge's
> `StartupValidator.Validate` throws
> `$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}"`.
> `ConfigPreflight.ThrowIfInvalid()` produces the **identical** string
> (`"Configuration validation failed:\n" + the same `" - <field> <reason>"` lines, `\n`-joined`).
> The migration is a behaviour-preserving swap: same exception type
> (`InvalidOperationException`), same message bytes. This is verified in the library's
> `ConfigPreflightTests` and is the reason the message format is pinned in §0.
`.When(condition, block)` carries role-conditional rules (ScadaBridge only validates database /
security / gRPC-port keys when the node is `Central` or `Site` respectively) without an `if` ladder.
## 5. Per-project migration
| Project | Current state | Primary gaps | What normalizes |
|---|---|---|---|
| **OtOpcUa** | **No options validation at all** — options bound with bare `.Bind()` (`LdapOptions`, `OpcUa`); zero `IValidateOptions` / `ValidateOnStart` in the repo. Only validator is `DraftValidator` (runtime draft/snapshot, **out of scope**). | No startup validation of `Ldap` / `OpcUa` sections — a bad value fails opaquely on first use. | *Optional* adoption: add `OptionsValidatorBase` subclasses + `AddValidatedOptions` for the sections worth guarding. `DraftValidator`/`DraftSnapshot` stay per-project untouched. Lightest consumer. |
| **MxGateway** | One large `GatewayOptionsValidator : IValidateOptions<GatewayOptions>` (~360 LOC, 9 sub-validators, private `AddIfBlank`/`AddIfNotPositive`/`AddIfInvalidPath` helpers); wired via `AddGatewayConfiguration` (`AddOptions().BindConfiguration().ValidateOnStart()`). | Hand-rolled accumulation + helpers duplicate the base; bespoke DI wiring duplicates `AddValidatedOptions`. | `GatewayOptionsValidator``OptionsValidatorBase<GatewayOptions>` (delete the `List<string>`/tail/helpers; keep the domain rules); `AddGatewayConfiguration``AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>`. Domain rules unchanged. |
| **ScadaBridge** | **Heaviest.** Four per-module `*OptionsValidator : IValidateOptions<T>` (Cluster / Security / HealthMonitoring / AuditLog) each with their own `List<string>` accumulation, wired through bespoke `AddXxx` extensions; **plus** a raw-config pre-Akka `StartupValidator`. | Four copies of the accumulation plumbing + bespoke DI wiring; `StartupValidator` open-codes the preflight envelope. | Each `*OptionsValidator``OptionsValidatorBase<T>`; each module's `AddXxx``AddValidatedOptions`; `StartupValidator``ConfigPreflight` (byte-compatible message, §4). Domain rules unchanged. |
> No sister-repo adoption is in scope for this release — the library is built; adoption is the
> follow-on tracked in [`../GAPS.md`](../GAPS.md). (Unlike the observability pass, which carried
> one in-pass MxGateway adoption, this pass is library-only.)
## 6. Acceptance (what "converged" means)
A project is converged when: (a) every options validator it owns derives from
`OptionsValidatorBase<TOptions>` and records failures on the supplied `ValidationBuilder` (no
private `List<string>` plumbing, no early return); (b) every bind-and-validate registration goes
through `AddValidatedOptions<TOptions, TValidator>(config, sectionPath)`; (c) any pre-host raw-config
checks go through `ConfigPreflight` and surface via `ThrowIfInvalid()`; (d) all validation
**accumulates every failure** and surfaces them together (`OptionsValidationException` at host
start, or `InvalidOperationException` from `ConfigPreflight`); and (e) failure wording for the
shared primitives comes from the library's `Checks` seam, identical across the fleet. Each app's
**options classes and domain rules stay its own**; only the plumbing is shared. OtOpcUa's
`DraftValidator` is explicitly exempt — it is not part of the converged surface.