Configuration is now adopted across all three sister apps (local branches), so flip the status lines in CLAUDE.md, components/configuration/GAPS.md, and the lib README/CLAUDE.md from 'not adopted' to adopted (also corrects 27->42 tests). Adds the brainstorm design doc + bite-sized implementation plan (+tasks.json) under docs/plans/ that drove the adoption.
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 - The shared library (paper API):
shared-contract/ZB.MOM.WW.Configuration.md - Divergences + adoption backlog:
GAPS.md - Current state, per project:
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/OpcUaare bound with bare.Bind()and trusted; a bad value fails opaquely on first use. (ItsDraftValidatoris runtime config-content validation, a different concern, out of scope.) - MxAccessGateway has one large
GatewayOptionsValidator(~360 LOC, nine sub-validators) with a privateList<string>accumulator andAddIfBlank/AddIfNotPositive/AddIfInvalidPathhelpers, wired through a bespokeAddGatewayConfigurationextension. - ScadaBridge is the heaviest: four per-module
*OptionsValidator(Cluster / Security / HealthMonitoring / AuditLog), each open-coding the same accumulation, plus a raw-config pre-AkkaStartupValidator.
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 for the
code-verified detail and its adoption plan.
Normalized vs. left per-project
Normalized (the shared target):
OptionsValidatorBase<TOptions>— abstractIValidateOptions<TOptions>; overrideprotected void Validate(ValidationBuilder, TOptions); the base aggregates all failures and returnsSuccessonly when clean.ValidationBuilderrule primitives —Required,Port,HostPort,PositiveTimeSpan,OneOf,MinCount, plusRequireThat/Addfor custom and cross-field rules; consistent"<field> <reason>"wording via the internalChecksseam.AddValidatedOptions<TOptions, TValidator>(IConfiguration, sectionPath)— bind + register validator +ValidateOnStartin one DI call; returnsOptionsBuilder<TOptions>.ConfigPreflight— fluent pre-host raw-IConfigurationaggregator (For/Require/RequireValue/RequirePort/When/ThrowIfInvalid); generalizesStartupValidator, with a byte-compatible thrown message.- The error-handling contract: accumulate ALL failures; two surfacing paths
(
OptionsValidationExceptionat host start viaValidateOnStart, vsConfigPreflight.ThrowIfInvalid()'sInvalidOperationException);"<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.exepaths, split-brain strategy, Akka heartbeat/threshold ordering, audit retention bounds, gRPC-port-vs-remoting-port topology, etc. - OtOpcUa's draft/generation-content validation (DB-side
sp_ValidateDraft; the C#DraftValidator/DraftSnapshotis dormant, nosrc/caller) — 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). 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/ (.NET 10; single
package; 27 tests; dotnet pack → 1 nupkg @ 0.1.0). Build/test/pack from ZB.MOM.WW.Configuration/:
dotnet test ZB.MOM.WW.Configuration.slnx
dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts