# Code Review — Configuration

| Field | Value |
|-------|-------|
| Library | `ZB.MOM.WW.Configuration/` |
| Packages | `ZB.MOM.WW.Configuration` |
| Component spec | `components/configuration/spec/SPEC.md` |
| Shared contract | `components/configuration/shared-contract/ZB.MOM.WW.Configuration.md` |
| Status | Reviewed |
| Last reviewed | 2026-06-01 |
| Reviewer | Claude (automated baseline) |
| Commit reviewed | `5f75cd4` |
| Open findings | 0 |

## Summary

The library is small (five source files, ~230 LOC) and in good health. Its core promise —
**accumulate every failure, never short-circuit** — holds across all three entry points
(`ValidationBuilder`, `OptionsValidatorBase`, `ConfigPreflight`): the override never returns
early, each primitive appends to a `List<string>` and returns `this`, and the `Success`/`Fail`
decision is taken only after every rule has run. The boundary cases that matter are correct:
`Port` rejects 0 and 65536 and accepts 1/65535; `HostPort` correctly rejects bare hosts, empty
ports, out-of-range ports, and (deliberately) unbracketed IPv6; `Required` treats null / empty /
whitespace alike; `OneOf` is case-insensitive and treats null as a failure (documented);
`MinCount` treats null as zero. Null-guarding is consistent (`ArgumentNullException`/
`ArgumentException.ThrowIfNullOrWhiteSpace` on every public entry point). The
`ConfigPreflight.ThrowIfInvalid()` envelope is **byte-compatible** with the ScadaBridge
`StartupValidator` format the SPEC pins (`"Configuration validation failed:\n  - <field> <reason>"`,
`\n`-joined), and the validator created by `OptionsValidatorBase` is stateless (fresh
`ValidationBuilder` per call), so the singleton-registration requirement in SPEC §3 is honoured.
Dependency footprint is minimal and exactly as documented (four `Microsoft.Extensions.*`
abstractions, no ASP.NET Core). The four findings are all **Low**: a non-idempotent DI
registration (`AddSingleton` vs `TryAddEnumerable`), a small wording inconsistency in the raw-port
check, over-permissive integer port parsing, and the XML-doc-not-shipped packaging gap (which is
fleet-wide, not specific to this library). Test coverage (27 tests across the four public surfaces)
is solid for the happy and primary-failure paths; a few edge cases noted below are untested.

## Checklist coverage

| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ☑ | Accumulation never short-circuits; port/host:port/duration/min-count boundaries correct. Port parsing over-permissive (003). |
| 2 | Public API surface & compatibility | ☑ | Surface matches the shared contract exactly; nullability annotations correct; `sealed`/`abstract` deliberate; internal `Checks` does not leak. No issues. |
| 3 | Concurrency & thread safety | ☑ | Validator is stateless (fresh builder per `Validate`); `ValidationBuilder`/`ConfigPreflight` are per-call locals. Safe as a singleton. No issues. |
| 4 | Error handling & resilience | ☑ | Guard clauses on every public entry; correct exception types (`OptionsValidationException` via `ValidateOnStart`, `InvalidOperationException` from `ConfigPreflight`); no swallowed exceptions. No issues. |
| 5 | Security & secret handling | ☑ | No secrets handled; failure messages echo config *values* (port/host/role), which are non-sensitive by design. No issues. |
| 6 | Performance & resource management | ☑ | Startup-only, no hot path, nothing disposable, no async. No issues. |
| 7 | Spec & shared-contract adherence | ☑ | `ConfigPreflight` envelope byte-compatible with `StartupValidator`; primitives match §2 table. Minor wording inconsistency in `PortValue` (002). |
| 8 | Packaging, dependencies & project layout | ☑ | Single package, minimal closure, central versions, `.gitignore` present, no tracked build output. XML docs / README not packaged (004). |
| 9 | Testing coverage | ☑ | 27 tests cover the four public surfaces + accumulation + byte-format. Untested: `AddValidatedOptions` null guards, double-registration, exact wording strings. |
| 10 | Documentation & XML docs | ☑ | Public XML docs present and accurate on every type/member. Not emitted into the nupkg (004). |

## Findings

### Configuration-001 — `AddValidatedOptions` uses `AddSingleton`, so a double call registers (and runs) the validator twice

| | |
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs:36` |

**Description**

`AddValidatedOptions` registers the validator with
`services.AddSingleton<IValidateOptions<TOptions>, TValidator>()`. `IValidateOptions<TOptions>`
is a multi-registration (enumerable) service — the options pipeline resolves *all* registered
`IValidateOptions<TOptions>` and runs each. Because `AddSingleton` appends unconditionally, calling
`AddValidatedOptions<TOptions, TValidator>` twice for the same `TOptions`/`TValidator` pair (e.g.
two modules both guarding the same section, or an accidental duplicate during refactoring)
registers the validator twice, so it executes twice and every accumulated failure is reported
twice in the resulting `OptionsValidationException`. The framework-idiomatic registration for
`IValidateOptions<T>` is `TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>,
TValidator>())`, which is idempotent. Impact is limited (consumers normally call once per section,
and duplicate messages are cosmetic, not a correctness break of the accumulate-all contract), hence
Low — but the deviation from the .NET idiom is real and easy to fix. The SPEC §3 wording ("registers
the validator as a singleton") is satisfied either way.

**Recommendation**

Replace `services.AddSingleton<IValidateOptions<TOptions>, TValidator>()` with
`services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>, TValidator>())`
(via `Microsoft.Extensions.DependencyInjection.Extensions`). Add a test asserting that calling
`AddValidatedOptions` twice yields a single set of failure messages.

**Resolution**

Resolved in `544a6dd`, 2026-06-01 — `AddValidatedOptions` now registers the validator via `TryAddEnumerable(ServiceDescriptor.Singleton<...>())`, so a double call is idempotent (test: `AddValidatedOptionsTests.Calling_twice_registers_validator_once`).

### Configuration-002 — `Checks.PortValue` quotes the raw value on a parse failure but not on a range failure

| | |
|--|--|
| Severity | Low |
| Category | Spec & shared-contract adherence |
| Status | Resolved |
| Location | `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs:21` |

**Description**

`PortValue` produces two different message shapes for the same field depending on *why* the port
is invalid. When the raw string parses but is out of range (e.g. `"0"` or `"70000"`), it delegates
to `Checks.Port(int, field)`, which renders `"<field> must be between 1 and 65535 (was 0)"` —
**unquoted**. When the raw string does not parse (e.g. `"notaport"`, `null`), it renders
`"<field> must be between 1 and 65535 (was 'notaport')"` — **quoted**. So two failures of the same
rule, from the same raw-config caller, read with inconsistent quoting. The shared contract
(`shared-contract/ZB.MOM.WW.Configuration.md`, "Internal `Checks` seam") states `Checks` is "the
single source of failure wording" so "a port failure reads the same whether it came from a bound
options object or a raw config key" — this inconsistency is in mild tension with that claim. It is
cosmetic (no consumer parses the text) and the overlapping integer case *does* match the bound-options
wording, so it is Low.

**Recommendation**

Make the two branches use a consistent quoting convention for the offending value — e.g. have the
range-failure branch also quote (`(was '0')`), or have the parse-failure branch route through a
shared formatter. Lock the exact strings down with a wording assertion test.

**Resolution**

Resolved in `544a6dd`, 2026-06-01 — `Checks.PortValue` now quotes the offending raw value on both the parse-failure and range-failure branches (`(was '0')`), wording pinned by test (test: `ChecksWordingTests.PortValue_range_failure_quotes_the_value`).

### Configuration-003 — Port parsing accepts leading sign and surrounding whitespace and is culture-sensitive

| | |
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs:22`, `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs:36` |

**Description**

Both `PortValue` (`int.TryParse(raw, out var port)`) and `HostPort`
(`int.TryParse(value[(idx + 1)..], out var port)`) use the default `int.TryParse` overload, which
applies `NumberStyles.Integer` (`AllowLeadingWhite | AllowTrailingWhite | AllowLeadingSign`) and the
current culture. As a result strings the documentation describes as "integer TCP port" are accepted
more loosely than intended: `"+5000"`, `" 5000 "`, and `"host: 5000"` (space after the colon) all
parse and pass, and parsing is locale-dependent. A leading `-` parses to a negative number that the
subsequent range check rejects (so `"-1"` is correctly rejected), but the whitespace/leading-`+`
cases silently pass. This is a robustness nuance rather than a security or correctness break — a port
that survives the loose parse is still in `1..65535` — so it is Low.

**Recommendation**

Parse with an explicit, culture-invariant, strict style, e.g.
`int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var port)` (rejects sign and
whitespace), in both `PortValue` and `HostPort`. Add `Theory` cases for `"+5000"`, `" 5000 "`, and a
space-after-colon endpoint to pin the behaviour.

**Resolution**

Resolved in `544a6dd`, 2026-06-01 — both `PortValue` and `HostPort` now parse with `int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, ...)`, rejecting leading sign/whitespace and culture-dependent formats (test: `ChecksWordingTests.PortValue_rejects_loose_inputs`, `HostPort_rejects_loose_port_inputs`).

### Configuration-004 — XML documentation and README are not packaged into the nupkg

| | |
|--|--|
| Severity | Low |
| Category | Documentation & XML docs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj:1`, `ZB.MOM.WW.Configuration/Directory.Build.props:1` |

**Description**

Every public type and member carries accurate XML doc comments, but neither the project nor
`Directory.Build.props` sets `<GenerateDocumentationFile>true</GenerateDocumentationFile>`, so no
`.xml` doc file is produced or included in the `dotnet pack` output. Consumers of the package
therefore get **no IntelliSense documentation** for `OptionsValidatorBase`, `ValidationBuilder`,
`AddValidatedOptions`, or `ConfigPreflight`, despite the docs existing in source. The project also
does not set `PackageReadmeFile`, so the README is not embedded in the package for display on the
NuGet feed. Category 10 of the review process explicitly notes "these are libraries — public docs
matter," so the gap is worth recording. It is fleet-wide (the sibling `ZB.MOM.WW.Audit` library has
the same omission), not a Configuration-specific regression, hence Low.

**Recommendation**

Add `<GenerateDocumentationFile>true</GenerateDocumentationFile>` (ideally in
`Directory.Build.props` so the whole family inherits it) and consider `<PackageReadmeFile>README.md</PackageReadmeFile>`
plus packing the README. Treat as a shared-infra change across the six libraries rather than a
one-off.

**Resolution**

Resolved in `544a6dd`, 2026-06-01 — `GenerateDocumentationFile=true` added to `Directory.Build.props` (test project opts out) and `PackageReadmeFile`/README pack item added to the csproj, so `dotnet pack` now ships `ZB.MOM.WW.Configuration.xml` and `README.md` in the nupkg.
