# 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.
