docs: record ZB.MOM.WW.Configuration fleet-wide adoption + add design/plan

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.
This commit is contained in:
Joseph Doherty
2026-06-01 23:18:02 -04:00
parent 2f124fa02c
commit c3ab37523a
7 changed files with 804 additions and 11 deletions
+7 -2
View File
@@ -124,7 +124,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
| Config + validation (options / startup validation) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| Audit (event model + writer seam) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
@@ -208,7 +208,12 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Configurat
(.NET 10; single package `ZB.MOM.WW.Configuration`; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0).
The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/configuration/GAPS.md`](components/configuration/GAPS.md).
**Adopted across all three apps on 2026-06-01** (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's
local default branch (`main`/`master`) — merged, **not yet pushed** to remotes; the package was first
published to the Gitea feed. Behaviour-preserving onto `OptionsValidatorBase`/`AddValidatedOptions`
for MxGateway + ScadaBridge (validator messages byte-identical), `StartupValidator``ConfigPreflight`
for ScadaBridge, and net-new `Ldap`/`OpcUa` validators for OtOpcUa. Per-app result tracked in
[`components/configuration/GAPS.md`](components/configuration/GAPS.md).
Build/test from `ZB.MOM.WW.Configuration/`: `dotnet test`. Consumer matrix: all three apps consume the
single package; ScadaBridge is the heaviest adopter (per-module validators + `StartupValidator`
`ConfigPreflight`); OtOpcUa adoption is additive (it has no `IValidateOptions` usage today).
+2 -2
View File
@@ -4,7 +4,7 @@ Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtO
The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project.
**Built at 0.1.0. Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge.** Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
---
@@ -66,7 +66,7 @@ ZB.MOM.WW.Configuration/
## Status
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge** — follow-on adoption is tracked in:
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes) — per-app result is tracked in:
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
+1 -1
View File
@@ -101,7 +101,7 @@ No third-party packages; no ASP.NET Core framework reference.
## Status
**Built at 0.1.0. Not yet adopted by the three apps.** Adoption is tracked in the component backlog:
**Built at 0.1.0. Adopted across all three apps on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption is tracked in the component backlog:
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
+8 -6
View File
@@ -1,12 +1,14 @@
# 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.)
the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (42 tests) at
[`../../ZB.MOM.WW.Configuration/`](../../ZB.MOM.WW.Configuration/) and was **ADOPTED across all three
apps on 2026-06-01** — published to the Gitea feed, then consumed on each repo's local default branch
(merged, **not yet pushed** to remotes). The adoption items below are now largely closed: MxGateway +
ScadaBridge migrated to `OptionsValidatorBase`/`AddValidatedOptions` behaviour-preservingly (validator
messages byte-identical), ScadaBridge's `StartupValidator``ConfigPreflight`, and OtOpcUa gained
net-new `Ldap`/`OpcUa` validators (plus a follow-on pass: real `Security:Ldap` binding, `ValidateOnStart`
wired for ScadaBridge Cluster/HealthMonitoring, and assorted hardening).
Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -0,0 +1,202 @@
# Design: Deploy `ZB.MOM.WW.Configuration` fleet-wide
**Date:** 2026-06-01
**Status:** Approved — ready for implementation planning (writing-plans).
**Scope:** Adopt the shared `ZB.MOM.WW.Configuration` library into all three sister apps
(OtOpcUa, MxAccessGateway, ScadaBridge).
> Every state claim below was **code-verified on 2026-06-01**, not taken from the
> `components/*/GAPS.md` prose — those docs proved unreliable in both directions (they
> claimed Health was un-adopted when it is fully adopted, and claimed Telemetry was
> adopted before it was). See memory `component-status-claims-are-optimistic`.
---
## 0. Why this module
Verified fleet-wide adoption state (real `PackageReference` + usage scan of the three
sister-app `src/` trees, plus Gitea-feed `curl`):
| Module | OtOpcUa | MxAccessGateway | ScadaBridge | Status |
|---|---|---|---|---|
| Health | ✅ | ✅ | ✅ | already deployed fleet-wide |
| Telemetry (observability) | ✅ | ✅ | ✅ | already deployed fleet-wide |
| **Configuration** | — | — | — | **chosen: not adopted anywhere** |
| Auth | — | — | — | not adopted |
| UI Theme | — | — | — | not adopted |
| Audit | — | — | — | not adopted |
Configuration was chosen as the next fleet-wide adoption because it is the same
cross-cutting-infra flavour as the already-done Health + Telemetry, it is the
lowest-risk (behaviour-preserving for the two heavy consumers), and it still delivers
real new value (OtOpcUa gains fail-fast startup validation it lacks entirely today).
### Decisions locked during brainstorming
- **Module:** Configuration.
- **OtOpcUa depth:** add **real** validators (net-new `Ldap`/`OpcUa` startup validation),
not just a package reference.
- **Rollout:** per-repo **sequential**, increasing risk order: Foundation → MxGateway →
OtOpcUa → ScadaBridge; each repo on its own branch, verified green before the next.
- **ScadaBridge `StartupValidator``ConfigPreflight`:** included in this pass.
---
## 1. Goal & scope
Move the config-validation **plumbing** (failure accumulation, the bind+validate+
`ValidateOnStart` triple, the pre-host raw-config aggregator) into the shared library so
it is written once; leave every **domain rule and failure message** per-project.
**Out of scope:**
- OtOpcUa's `DraftValidator` / `sp_ValidateDraft` — domain *content* validation over
database draft rows, dormant in `src/`, not the host-config concern this library owns.
- Any change to rule wording or validation semantics (behaviour-preserving except the
*additive* OtOpcUa validators).
---
## 2. The contract being adopted (verified public API)
From `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/`:
- **`OptionsValidatorBase<TOptions>`** — abstract `IValidateOptions<TOptions>`. Override
`protected abstract void Validate(ValidationBuilder, TOptions)`; the base creates the
builder, runs the override, and returns `Success` only when no failures were recorded
(else `Fail(builder.Failures)`).
- **`ValidationBuilder`** — rule primitives `Required`, `Port`, `HostPort`,
`PositiveTimeSpan`, `OneOf`, `MinCount`, plus `RequireThat(bool, message)` and
`Add(message)` for custom / cross-field rules. `Failures` / `IsValid` expose state.
- **`ServiceCollectionExtensions.AddValidatedOptions<TOptions, TValidator>(config, sectionPath)`**
`TryAddEnumerable` the validator (singleton) + `AddOptions().Bind(section).ValidateOnStart()`
in one call; returns the `OptionsBuilder` for chaining.
- **`ConfigPreflight.For(IConfiguration)`** — fluent pre-host checker for raw config
before the DI container exists: `RequireValue(key)`, `RequirePort(key)`,
`Require(key, predicate, reason)`, `When(condition, block)`, terminating in
`ThrowIfInvalid()` (throws `InvalidOperationException` listing all failures).
Library health: `dotnet test`**42 passed, 0 failed** (the `CLAUDE.md` "27 tests" line
is stale-low; the suite passes regardless).
---
## 3. Foundation phase (must land before any repo adopts)
This is the part the status docs hide. Verified 2026-06-01:
1. **Pack + push the package.** `ZB.MOM.WW.Configuration` is **404 on the Gitea feed**
(`registration/zb.mom.ww.configuration/index.json`), while the known-adopted Health
package returns 200. `dotnet pack -c Release` then push the `.nupkg` to
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget`.
2. **Per-app feed wiring** (all three `nuget.config` files): the `dohertj2-gitea`
`packageSourceMapping` currently routes only `ZB.MOM.WW.MxGateway.*`,
`ZB.MOM.WW.Health*`, `ZB.MOM.WW.Telemetry*`. Add
`<package pattern="ZB.MOM.WW.Configuration" />`. Without this, restore fails even with
the package on the feed.
3. **Central version pin** in each app's `Directory.Packages.props`:
`<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />`.
4. **Verify gate:** `curl` the registration index → **200** before any repo work begins.
---
## 4. Per-repo adoption (sequential)
Each repo: branch `feat/adopt-zb-configuration`, `PackageReference` (no version — central
package management), migrate, `dotnet build` + `dotnet test` green, then move on.
### Repo 1 — MxAccessGateway (medium; pure refactor)
- `PackageReference Include="ZB.MOM.WW.Configuration"` in
`src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj`.
- `GatewayOptionsValidator : IValidateOptions<GatewayOptions>`
`: OptionsValidatorBase<GatewayOptions>`. Drop the private `List<string>` and the
`Count == 0 ? Success : Fail` tail (now the base's job). Map private helpers:
`AddIfBlank``Required`; `AddIfNotPositive` / `AddIfNegative``RequireThat(... , msg)`.
Keep `AddIfInvalidPath`, the `.exe`-extension rule, the cross-field
`HeartbeatGraceSeconds >= HeartbeatIntervalSeconds`, range checks, and all nine
sub-validators as `RequireThat`/`Add` custom rules. **Every message string unchanged.**
- `AddGatewayConfiguration`'s `AddOptions().BindConfiguration(SectionName).ValidateOnStart()`
+ `AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>()`
`services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(config, GatewayOptions.SectionName)`.
Keep the separate `IGatewayConfigurationProvider` registration.
### Repo 2 — OtOpcUa (lightest base, but net-new validation added)
- `PackageReference` in
`src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`.
- New `LdapOptionsValidator : OptionsValidatorBase<LdapOptions>`
(`LdapOptions` lives in `ZB.MOM.WW.OtOpcUa.Security/Ldap/`): `Required` on Server /
SearchBase (and other not-optional fields). `Program.cs:99`
`AddOptions<LdapOptions>().Bind(GetSection("Ldap"))`
`AddValidatedOptions<LdapOptions, LdapOptionsValidator>(config, "Ldap")`.
- New validator for the `OpcUa` section; replace the imperative
`GetSection("OpcUa").Bind(options)` at `OtOpcUaServerHostedService.cs:63` with validated
options resolved from DI. Exact rule list finalized in the implementation plan from the
real `OpcUaOptions` fields (ports → `Port`, endpoints → `HostPort`, required strings →
`Required`, durations → `PositiveTimeSpan`).
- New unit tests for both validators (valid config passes; each missing/invalid field
produces its message).
### Repo 3 — ScadaBridge (heaviest; refactor + preflight)
- `PackageReference` in `src/ZB.MOM.WW.ScadaBridge.Host/...csproj` and the module projects
that own validators (ClusterInfrastructure, Security, HealthMonitoring, AuditLog).
- Four `*OptionsValidator``OptionsValidatorBase<T>`:
- `ClusterOptionsValidator`: `SeedNodes` ≥ 2 → `MinCount`; strategy ∈ set → `OneOf`;
three positive `TimeSpan``PositiveTimeSpan`; cross-field heartbeat/threshold and
`DownIfAlone`/`MinNrOfMembers``RequireThat`.
- `SecurityOptionsValidator`: `Required` LdapServer / LdapSearchBase (JwtSigningKey stays
validated in `JwtTokenService` ctor — unchanged).
- `HealthMonitoringOptionsValidator`: three `PositiveTimeSpan` + cross-field
`CentralOfflineTimeout >= OfflineTimeout``RequireThat`. Preserve the idempotent
registration called from all three `Add*HealthMonitoring` entry points.
- `AuditLogOptionsValidator`: positive/`>=`/range checks → `RequireThat`.
- Each module `AddXxx``AddValidatedOptions<T, TValidator>` where the section binding
shape allows (preserve `ValidateOnStart` + `TryAddEnumerable` semantics).
- `StartupValidator.Validate(configuration)` at `Program.cs:41` → `ConfigPreflight.For(
configuration).RequireValue(...)/RequirePort(...)/When(...).ThrowIfInvalid()`. **Must
keep `StartupValidatorTests` green** — the thrown message is byte-compatible with
`ConfigPreflight.ThrowIfInvalid()`.
---
## 5. Error handling / behaviour preservation
- Failure surface is unchanged everywhere: `OptionsValidationException` thrown at host
start via `ValidateOnStart`; `ConfigPreflight.ThrowIfInvalid()` throws the same
`InvalidOperationException` text ScadaBridge's `StartupValidator` throws today.
- MxGateway + ScadaBridge: **zero message changes** — the existing validator tests and
`StartupValidatorTests` are the regression guard.
- OtOpcUa: **additive** — a config that was silently accepted (then failed late as an LDAP
error on first login, or an OPC UA bind error) now fails fast at startup. That is the
intended improvement, called out so it is not mistaken for a regression.
---
## 6. Testing & verification (gate per repo, before moving on)
- Library: re-run `dotnet test` (already 42 green).
- Each repo on its branch: `dotnet build` + `dotnet test` green.
- MxGateway: `src/MxGateway.Tests` (fake worker — no MXAccess needed).
- OtOpcUa: full solution test + the new validator unit tests.
- ScadaBridge: four validator tests + `StartupValidatorTests` still green.
- **Restore proof** per repo: a clean restore pulls `ZB.MOM.WW.Configuration 0.1.0` from
Gitea — confirms both the push and the source-mapping edit.
---
## 7. Risks & mitigations
| Risk | Mitigation |
|---|---|
| Package 404 / source-mapping omission breaks restore | Foundation phase + per-repo restore proof gate. |
| A "trivial" message tweak during refactor changes behaviour | Behaviour-preserving rule; existing tests fail loudly if a message drifts. |
| ScadaBridge preflight message drift | `StartupValidatorTests` must pass unchanged. |
| OtOpcUa `OpcUa`/`Ldap` rule set guesses wrong fields | Plan finalizes rules from the actual options classes; additive-only. |
| `AddValidatedOptions` singleton constraint (no scoped deps in validators) | All four ScadaBridge + the gateway validators are already stateless singletons. |
---
## 8. Deliverable & next step
This design doc, then a step-by-step implementation plan produced via the **writing-plans**
skill. No source changes in any repo until the plan is approved and execution begins.
> Note: `~/Desktop/scadaproj` is **not** a git repository, so this design is not committed
> here; it is saved under `docs/plans/`. (Per memory, do not `git init` it without asking.)
@@ -0,0 +1,566 @@
# Deploy `ZB.MOM.WW.Configuration` Fleet-Wide — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Adopt the shared `ZB.MOM.WW.Configuration` library into all three sister apps (MxAccessGateway, OtOpcUa, ScadaBridge) so the config-validation *plumbing* is owned by the library while *domain rules and messages* stay per-project.
**Architecture:** Foundation first (publish the package to the Gitea feed + wire each app's NuGet source-mapping/version pin), then per-repo sequential adoption in increasing-risk order: MxGateway → OtOpcUa → ScadaBridge. Each repo on its own `feat/adopt-zb-configuration` branch, built + tested green before the next.
**Tech Stack:** .NET 10, `Microsoft.Extensions.Options` (`IValidateOptions`, `ValidateOnStart`), xUnit, central package management, Gitea NuGet feed.
**Design doc:** [`2026-06-01-deploy-zb-configuration-design.md`](2026-06-01-deploy-zb-configuration-design.md)
---
## ⚠️ Decisions & corrections baked into this plan (read first)
1. **Behaviour-preserving = use `RequireThat`, NOT the wording-imposing primitives.**
`ValidationBuilder.Required/Port/PositiveTimeSpan/...` emit **standardized** messages
(`"{field} is required"`, `"{field} must be between 1 and 65535 (was …)"`, `"{field} must be a
positive duration (was …)"`). MxGateway and ScadaBridge use **bespoke** messages (often with
trailing rationale, e.g. `"…; it is used directly as a PeriodicTimer period."`). Mapping their
checks onto the primitives would **silently change the messages and break the existing validator
tests.** Therefore, for MxGateway + ScadaBridge migrations: keep every check as
`builder.RequireThat(<condition>, "<exact existing message>")` (or `builder.Add("<message>")` for
unconditional adds). The `components/configuration/GAPS.md` "→ Required / → PositiveTimeSpan"
mappings are **wrong for byte-compatibility** — do not follow them. The wording-imposing
primitives are used **only in OtOpcUa**, where the validators are net-new and we author the
wording fresh.
2. **OtOpcUa gets real, net-new validators** (Ldap + OpcUa) — approved scope. This adds fail-fast
startup validation OtOpcUa lacks today; a previously silently-accepted bad config now throws at
host start. That is the intended improvement, not a regression.
3. **Flagged discrepancy (do not silently "fix"):** `OtOpcUa Program.cs:99` binds
`GetSection("Ldap")` but `LdapOptions.SectionName = "Authentication:Ldap"`. This plan
**preserves** the current `"Ldap"` section path and surfaces the mismatch to the user in Task 3.
Do not switch to the constant without an explicit decision.
4. **Out of scope:** OtOpcUa's `DraftValidator` / `sp_ValidateDraft` (dormant domain-content
validation), and any rule-wording change to existing validators.
---
## Task 1: Foundation — publish package + wire all three consumers
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (everything else depends on this)
**Files:**
- Pack source: `~/Desktop/scadaproj/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx`
- Modify: `~/Desktop/MxAccessGateway/nuget.config`
- Modify: `~/Desktop/OtOpcUa/NuGet.config`
- Modify: `~/Desktop/OtOpcUa/Directory.Packages.props`
- Modify: `~/Desktop/ScadaBridge/nuget.config`
- Modify: `~/Desktop/ScadaBridge/Directory.Packages.props`
> Context: verified 2026-06-01 — `ZB.MOM.WW.Configuration` is **404** on the Gitea feed (Health is
> 200), and **no** app's `packageSourceMapping` routes it to Gitea. Both must be fixed before any
> repo can restore it. The lib builds clean: `dotnet test` = **42 passed**.
**Step 1: Verify the lib is green**
Run: `cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test ZB.MOM.WW.Configuration.slnx`
Expected: `Passed! - Failed: 0, Passed: 42`.
**Step 2: Pack**
Run: `cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts`
Expected: `ZB.MOM.WW.Configuration.0.1.0.nupkg` in `./artifacts`.
**Step 3: Push to Gitea** (use the same credentials/source already used for Health/Telemetry)
Run: `dotnet nuget push ./artifacts/ZB.MOM.WW.Configuration.0.1.0.nupkg --source dohertj2-gitea` (or the full feed URL `https://gitea.dohertylan.com/api/packages/dohertj2/nuget` with API key).
**Step 4: Verify it's live**
Run: `curl -s -o /dev/null -w "%{http_code}\n" https://gitea.dohertylan.com/api/packages/dohertj2/nuget/registration/zb.mom.ww.configuration/index.json`
Expected: `200`.
**Step 5: Add source-mapping in each `nuget.config`**
In all three (`MxAccessGateway/nuget.config`, `OtOpcUa/NuGet.config`, `ScadaBridge/nuget.config`),
inside the `dohertj2-gitea` `<packageSource>` block, add alongside the existing Health/Telemetry
patterns:
```xml
<package pattern="ZB.MOM.WW.Configuration" />
```
**Step 6: Pin the version (central package management)**
In `OtOpcUa/Directory.Packages.props` and `ScadaBridge/Directory.Packages.props`, add to the
`<ItemGroup>` of `<PackageVersion>`s:
```xml
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
```
> Note: MxAccessGateway pins versions inline on the `PackageReference` (verified: its Health refs
> carry `Version="0.1.0"`), so its pin happens in Task 2 on the `PackageReference` itself. Confirm
> per repo whether `ManagePackageVersionsCentrally` is set and follow the repo's existing convention.
**Step 7: Restore proof**
Run (one app is enough): `cd ~/Desktop/ScadaBridge && dotnet restore` after Task 7 adds the
reference — OR a throwaway probe now: temporarily add the ref to a scratch project. Minimum gate:
Step 4 returns 200 and the mapping/pin edits are saved in all three repos.
**Step 8: Commit each touched repo** (these are separate git repos; `scadaproj` itself is NOT a git repo)
```bash
# in each of MxAccessGateway / OtOpcUa / ScadaBridge:
git checkout -b feat/adopt-zb-configuration
git add nuget.config NuGet.config Directory.Packages.props
git commit -m "build: add ZB.MOM.WW.Configuration feed mapping + version pin"
```
---
## Task 2: MxAccessGateway — migrate `GatewayOptionsValidator` to the shared base
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj`
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs`
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs`
- Test (regression guard, do not change): `~/Desktop/MxAccessGateway/src/MxGateway.Tests/**` (the existing `GatewayOptionsValidator` tests)
**Step 1: Add the package reference**
In `ZB.MOM.WW.MxGateway.Server.csproj`, beside the existing Health refs:
```xml
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
```
**Step 2: Re-base the validator (messages byte-identical)**
`GatewayOptionsValidator.cs` — change the class + entry point and retarget the sub-validators and
helpers from `List<string>` to `ValidationBuilder`. The nine `ValidateXxx` methods and the four
helpers stay; only their parameter type and the `.Add` target change.
```csharp
using ZB.MOM.WW.Configuration; // add
using ZB.MOM.WW.MxGateway.Contracts;
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions> // was : IValidateOptions<GatewayOptions>
{
private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
protected override void Validate(ValidationBuilder builder, GatewayOptions options) // was public ValidateOptionsResult Validate(string? name, GatewayOptions options)
{
ValidateAuthentication(options.Authentication, builder);
ValidateLdap(options.Ldap, builder);
ValidateWorker(options.Worker, builder);
ValidateSessions(options.Sessions, builder);
ValidateEvents(options.Events, builder);
ValidateDashboard(options.Dashboard, builder);
ValidateProtocol(options.Protocol, builder);
ValidateAlarms(options.Alarms, builder);
ValidateTls(options.Tls, builder);
// NOTE: no List<string> and no `return Count==0 ? Success : Fail` — the base does that.
}
// ... sub-validators unchanged except `List<string> failures` param → `ValidationBuilder builder`
// and every `failures.Add(msg)` → `builder.Add(msg)`.
```
Helper conversions (keep the four helpers; retarget to the builder — **messages unchanged**):
```csharp
private static void AddIfBlank(string? value, string message, ValidationBuilder builder) =>
builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
private static void AddIfNotPositive(int value, string message, ValidationBuilder builder) =>
builder.RequireThat(value > 0, message);
private static void AddIfNegative(int value, string message, ValidationBuilder builder) =>
builder.RequireThat(value >= 0, message);
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value)) return;
try { _ = Path.GetFullPath(value); }
catch (ArgumentException) { builder.Add(message); }
catch (NotSupportedException) { builder.Add(message); }
catch (PathTooLongException) { builder.Add(message); }
}
```
> DO NOT replace `AddIfBlank` with `builder.Required(...)` etc. — that changes the message text.
> Mechanical rule for the bodies: `failures.Add(x)` → `builder.Add(x)`; the early-`return` guards
> (e.g. `if (!options.Enabled) return;` in `ValidateLdap`/`ValidateAlarms`, and the
> `Enum.IsDefined` short-circuit `return` in `ValidateAuthentication`) stay exactly as written.
**Step 3: Collapse the DI triple → `AddValidatedOptions`**
`GatewayConfigurationServiceCollectionExtensions.cs` — replace the
`AddOptions().BindConfiguration(SectionName).ValidateOnStart()` + `AddSingleton<IValidateOptions…>`
trio with one call (keep the separate `IGatewayConfigurationProvider` registration):
```csharp
using ZB.MOM.WW.Configuration; // add
// was:
// services.AddOptions<GatewayOptions>().BindConfiguration(GatewayOptions.SectionName).ValidateOnStart();
// services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
configuration, GatewayOptions.SectionName);
```
> `AddValidatedOptions` takes an `IConfiguration`; if `AddGatewayConfiguration` doesn't already
> receive one, thread `builder.Configuration` (or `IConfiguration`) into it. The original used
> `BindConfiguration(SectionName)` (path read off the type); `AddValidatedOptions` takes the path as
> the `sectionPath` argument — pass `GatewayOptions.SectionName`. Net binding is identical.
**Step 4: Build + test (regression guard)**
Run: `cd ~/Desktop/MxAccessGateway && dotnet build src/MxGateway.sln && dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj`
Expected: build succeeds; **all existing `GatewayOptionsValidator` tests pass unchanged** (proves messages are byte-identical). No MXAccess needed (fake worker).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.MxGateway.Server
git commit -m "refactor: adopt ZB.MOM.WW.Configuration in MxGateway (behaviour-preserving)"
```
---
## Task 3: OtOpcUa — net-new `LdapOptionsValidator`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4 (different files — but keep on the same OtOpcUa branch)
**Files:**
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`
- Create: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:99`
- Create: `~/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/LdapOptionsValidatorTests.cs` (match the repo's actual Host test project path — verify before writing)
**Step 1: Package reference**
In `ZB.MOM.WW.OtOpcUa.Host.csproj` (no `Version` — central management, pinned in Task 1):
```xml
<PackageReference Include="ZB.MOM.WW.Configuration" />
```
**Step 2: Write the failing test** (`LdapOptionsValidatorTests.cs`)
```csharp
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using Xunit;
public class LdapOptionsValidatorTests
{
private static ValidateOptionsResult Run(LdapOptions o) =>
new LdapOptionsValidator().Validate(null, o);
[Fact]
public void Valid_options_pass() =>
Assert.True(Run(new LdapOptions { Enabled = true, Server = "ldap", SearchBase = "dc=x", Port = 389 }).Succeeded);
[Fact]
public void Disabled_skips_all_checks() =>
Assert.True(Run(new LdapOptions { Enabled = false, Server = "", SearchBase = "", Port = 0 }).Succeeded);
[Fact]
public void Blank_server_fails_when_enabled() =>
Assert.Contains("Authentication:Ldap:Server is required when LDAP login is enabled.",
Run(new LdapOptions { Enabled = true, Server = "", SearchBase = "dc=x", Port = 389 }).Failures!);
}
```
**Step 3: Run it — expect FAIL** (`LdapOptionsValidator` not defined).
Run: `cd ~/Desktop/OtOpcUa && dotnet test --filter FullyQualifiedName~LdapOptionsValidatorTests`
**Step 4: Implement** (`LdapOptionsValidator.cs`) — gate on `Enabled` like MxGateway; author wording fresh
```csharp
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
{
protected override void Validate(ValidationBuilder builder, LdapOptions options)
{
if (!options.Enabled) return;
builder.RequireThat(!string.IsNullOrWhiteSpace(options.Server),
"Authentication:Ldap:Server is required when LDAP login is enabled.");
builder.RequireThat(!string.IsNullOrWhiteSpace(options.SearchBase),
"Authentication:Ldap:SearchBase is required when LDAP login is enabled.");
builder.Port(options.Port, "Authentication:Ldap:Port");
}
}
```
**Step 5: Wire the binding**`Program.cs:99`
```csharp
// was: builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, "Ldap");
```
> **FLAG to the user (do not auto-resolve):** the section path stays `"Ldap"` to preserve current
> behaviour, even though `LdapOptions.SectionName == "Authentication:Ldap"`. The message strings
> above intentionally say `Authentication:Ldap:` (matching the conceptual section name); if the user
> prefers the path to match the constant, change both the `sectionPath` and re-confirm config keys.
**Step 6: Run tests — expect PASS.** `dotnet test --filter FullyQualifiedName~LdapOptionsValidatorTests`
**Step 7: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Host tests
git commit -m "feat: add fail-fast LDAP options validation in OtOpcUa via ZB.MOM.WW.Configuration"
```
---
## Task 4: OtOpcUa — net-new `OpcUa` validator + route through DI
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3
**Files:**
- Create: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs`
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (register validated options)
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs:41-63` (inject `IOptions`, drop imperative bind)
- Create: `OpcUaApplicationHostOptionsValidatorTests.cs` (Host test project)
> Why high-risk: changes the hosted service constructor and makes a bad `OpcUa` section throw at host
> start (`ValidateOnStart`). Today `StartAsync` swallows SDK-start exceptions (`OtOpcUaServerHostedService.cs:75-82`);
> validation now fails fast *before* that path. This is the intended fail-fast improvement, but it is
> a behaviour change — keep it isolated and tested.
**Step 1: Write the failing test** — valid passes; bad port fails with fresh primitive wording
```csharp
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using Xunit;
public class OpcUaApplicationHostOptionsValidatorTests
{
private static ValidateOptionsResult Run(OpcUaApplicationHostOptions o) =>
new OpcUaApplicationHostOptionsValidator().Validate(null, o);
[Fact] public void Defaults_pass() => Assert.True(Run(new OpcUaApplicationHostOptions()).Succeeded);
[Fact] public void Bad_port_fails() =>
Assert.Contains("OpcUa:OpcUaPort must be between 1 and 65535 (was 0)",
Run(new OpcUaApplicationHostOptions { OpcUaPort = 0 }).Failures!);
}
```
**Step 2: Run — expect FAIL.**
**Step 3: Implement the validator** — net-new, so use the wording-imposing primitives freely
```csharp
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<OpcUaApplicationHostOptions>
{
protected override void Validate(ValidationBuilder builder, OpcUaApplicationHostOptions o)
{
builder.Required(o.ApplicationName, "OpcUa:ApplicationName");
builder.Required(o.ApplicationUri, "OpcUa:ApplicationUri");
builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
builder.MinCount(o.EnabledSecurityProfiles, 1, "OpcUa:EnabledSecurityProfiles");
}
}
```
**Step 4: Register validated options**`Program.cs` (near the other host registrations)
```csharp
builder.Services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
builder.Configuration, "OpcUa");
```
**Step 5: Consume via DI in the hosted service**`OtOpcUaServerHostedService.cs`
Add `IOptions<OpcUaApplicationHostOptions> options` to the constructor (store `_options`), then
replace lines 62-63:
```csharp
// was:
// var options = new OpcUaApplicationHostOptions();
// _configuration.GetSection("OpcUa").Bind(options);
var options = _options.Value;
```
(If `_configuration` becomes unused after this, leave it — other members may use it; verify before removing.)
**Step 6: Run tests + full build.**
Run: `cd ~/Desktop/OtOpcUa && dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test ZB.MOM.WW.OtOpcUa.slnx`
Expected: green, including the two new tests.
**Step 7: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Host tests
git commit -m "feat: validate OpcUa host options at startup (route through IOptions + ValidateOnStart)"
```
---
## Task 5: ScadaBridge — migrate the four `*OptionsValidator` to the shared base
**Classification:** standard
**Estimated implement time:** ~6 min (split per-validator if needed — they are independent files)
**Parallelizable with:** Task 6 (StartupValidator is a different file)
**Files:**
- Modify (add `PackageReference Include="ZB.MOM.WW.Configuration"` to each owning project):
- `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/…csproj`
- `src/ZB.MOM.WW.ScadaBridge.Security/…csproj`
- `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/…csproj`
- `src/ZB.MOM.WW.ScadaBridge.AuditLog/…csproj`
- Modify:
- `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs`
- `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs`
- `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs`
- `src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs`
- Test (regression guard, do not change): the existing four validator test classes.
**Transformation (identical shape for all four):**
1. `: IValidateOptions<T>``: OptionsValidatorBase<T>` (`using ZB.MOM.WW.Configuration;`).
2. `public ValidateOptionsResult Validate(string? name, T options)`
`protected override void Validate(ValidationBuilder builder, T options)`.
3. Delete `var failures = new List<string>();` and the
`return failures.Count … ? Fail(failures) : Success;` tail.
4. Each `if (<bad>) failures.Add("<msg>");``builder.RequireThat(!(<bad>), "<msg>");`
(i.e. invert the condition to the *valid* predicate), **message unchanged**.
Worked example — `HealthMonitoringOptionsValidator` (the others follow the same recipe):
```csharp
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase<HealthMonitoringOptions>
{
protected override void Validate(ValidationBuilder builder, HealthMonitoringOptions options)
{
builder.RequireThat(options.ReportInterval > TimeSpan.Zero,
$"ScadaBridge:HealthMonitoring:ReportInterval must be a positive duration " +
$"(was {options.ReportInterval}); it is used directly as a PeriodicTimer period.");
builder.RequireThat(options.OfflineTimeout > TimeSpan.Zero,
$"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " +
$"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence.");
builder.RequireThat(options.CentralOfflineTimeout > TimeSpan.Zero,
$"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " +
$"(was {options.CentralOfflineTimeout}).");
builder.RequireThat(
!(options.OfflineTimeout > TimeSpan.Zero
&& options.CentralOfflineTimeout > TimeSpan.Zero
&& options.CentralOfflineTimeout < options.OfflineTimeout),
$"ScadaBridge:HealthMonitoring:CentralOfflineTimeout ({options.CentralOfflineTimeout}) " +
$"must be >= OfflineTimeout ({options.OfflineTimeout}): the synthetic 'central' site has " +
"no heartbeat source and is fed only by the slower self-report loop, so it needs at " +
"least as much offline grace as a real site.");
}
}
```
> Reminder: do **not** swap to `builder.PositiveTimeSpan/MinCount/OneOf` — their wording differs
> from these bespoke messages and would break the existing tests. `ClusterOptionsValidator` has the
> most rules (SeedNodes≥2, strategy one-of, three positive-`TimeSpan`, cross-field heartbeat,
> `DownIfAlone`, `MinNrOfMembers`); apply the same invert-condition-keep-message recipe to each.
**Step — build + test (guard):**
Run: `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter FullyQualifiedName~OptionsValidator`
Expected: the four validators' existing tests pass unchanged.
**Step — commit:** `git commit -am "refactor: ScadaBridge validators onto OptionsValidatorBase (messages unchanged)"`
(Optional follow-on, separate task: collapse each module's `AddXxx` `Bind+ValidateOnStart+TryAddEnumerable`
into `AddValidatedOptions<T,TValidator>` where the binding shape matches — preserve HealthMonitoring's
idempotent registration called from three entry points. Verify each test still passes.)
---
## Task 6: ScadaBridge — `StartupValidator` → `ConfigPreflight`
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs` (re-implement body over `ConfigPreflight`) — or inline into `Program.cs:41` and delete the class.
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:41`
- Test (regression guard, MUST stay green unchanged): `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs`
> The final thrown message is **byte-identical** between `StartupValidator`
> (`"Configuration validation failed:\n - …"`) and `ConfigPreflight.ThrowIfInvalid()` — verified.
> The individual messages are bespoke and several are **cross-field** (GrpcPort≠RemotingPort,
> MetricsPort≠RemotingPort/GrpcPort, seed-node-port≠GrpcPort). `ConfigPreflight` has no
> `Add`/`RequireThat`; reproduce these via the `Require(key, predicate, reason)` escape hatch where
> the predicate **closes over** the other resolved values and ignores its passed argument, and
> `reason` is the exact tail so `$"{key} {reason}"` equals the original message.
**Recipe (preserve every message):**
- `RequireValue(key)` only where the original message is exactly `"{key} is required"`
(e.g. `ScadaBridge:Node:NodeHostname is required`).
- Everything else → `Require(key, pred, reason)`:
- `Require("ScadaBridge:Node:Role", raw => raw is "Central" or "Site", "must be 'Central' or 'Site'")`.
- `Require("ScadaBridge:Node:RemotingPort", raw => int.TryParse(raw, out var p) && p is >= 1 and <= 65535, "must be 1-65535")`**do not** use `RequirePort` (its wording differs).
- `Require("ScadaBridge:Cluster:SeedNodes", _ => (seedNodes?.Count ?? 0) >= 2, "must have at least 2 entries")` (read `seedNodes` once via `.Get<List<string>>()`).
- Role-conditional blocks → `.When(role == "Central", p => { … })` / `.When(role == "Site", p => { … })`.
- Cross-field, value-ignoring predicate example:
`p.Require("ScadaBridge:Node:GrpcPort", _ => port != grpcPort, "must differ from RemotingPort")`.
- Seed-node loop: `foreach (var seed in seedNodes ?? []) p.Require("ScadaBridge:Cluster:SeedNodes", _ => SeedNodePort(seed) != grpcPort, $"entry '{seed}' must not target the gRPC port ({grpcPort}); seed nodes must reference Akka remoting ports");` (keep the private `SeedNodePort` helper).
Resolve `role`, `port`, `grpcPort` (default 8083), `metricsPort` (default 8084) with the **exact**
parse-or-default logic from the current `StartupValidator` before building the preflight, then end
with `.ThrowIfInvalid()`.
**Step — run the guard test (unchanged):**
Run: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter FullyQualifiedName~StartupValidatorTests`
Expected: PASS with no test edits — this is the byte-compatibility proof.
**Step — full ScadaBridge build + test:**
Run: `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx`
Expected: all green (four validators + `StartupValidatorTests`).
**Step — commit:** `git commit -am "refactor: ScadaBridge StartupValidator → ConfigPreflight (byte-compatible)"`
---
## Final verification (all repos)
- `ZB.MOM.WW.Configuration` registration index → 200.
- Each repo: clean `dotnet restore` pulls `ZB.MOM.WW.Configuration 0.1.0` from Gitea.
- Each repo: `dotnet build` + `dotnet test` green on its `feat/adopt-zb-configuration` branch.
- No message-string drift anywhere except OtOpcUa's net-new validators.
- Open the three per-repo PRs (or finish per `superpowers-extended-cc:finishing-a-development-branch`).
- Update `components/configuration/GAPS.md` + the CLAUDE.md matrix to reflect actual adoption.
## Notes
- DRY/YAGNI/TDD honored: net-new OtOpcUa code is test-first; migrations rely on existing tests as the regression guard.
- `scadaproj` itself is NOT a git repo — do not `git init` it. Commits happen inside each sister repo.
- Skills: `@superpowers-extended-cc:executing-plans`, `@superpowers-extended-cc:test-driven-development`, `@superpowers-extended-cc:verification-before-completion`.
@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-06-01-deploy-zb-configuration.md",
"tasks": [
{"id": 11, "subject": "Task 1: Foundation — publish package + wire 3 consumers", "classification": "small", "status": "completed", "result": "Published ZB.MOM.WW.Configuration 0.1.0 to Gitea (was 404; now 200). nuget.config source-mapping + version pins on feat/adopt-zb-configuration in all 3 repos. Commits: MxGw 437ab65, OtOpcUa 0cbb82e, ScadaBridge 9bca6aa."},
{"id": 12, "subject": "Task 2: MxGateway — GatewayOptionsValidator → base", "classification": "standard", "status": "completed", "blockedBy": [11], "commit": "459a88b", "result": "Migrated to OptionsValidatorBase via RequireThat (messages byte-identical); AddGatewayConfiguration → AddValidatedOptions (+4 call sites). Tests 571/574 (3 pre-existing macOS failures). Spec ✅, code Approved-with-minors."},
{"id": 13, "subject": "Task 3: OtOpcUa — net-new LdapOptionsValidator", "classification": "standard", "status": "completed", "blockedBy": [12], "commit": "f35ebd7", "result": "New LdapOptionsValidator; Program.cs:99 → AddValidatedOptions(config,'Ldap') — behaviour-preserving per user decision A. FLAG: OtOpcUa LDAP binds nonexistent sections (real config = Security:Ldap); recorded as memory otopcua-ldap-config-section-mismatch. 4/4 new tests; build 0/0."},
{"id": 14, "subject": "Task 4: OtOpcUa — OpcUa validator + DI routing", "classification": "high-risk", "status": "completed", "blockedBy": [12], "commit": "88e773a", "result": "New OpcUaApplicationHostOptionsValidator; AddValidatedOptions(config,'OpcUa') in hasDriver block; hosted service now consumes IOptions (dead _configuration removed). 4/4 new tests; build 0/0. Spec ✅, code Approved-with-minors."},
{"id": 15, "subject": "Task 5: ScadaBridge — 4 validators → base", "classification": "standard", "status": "completed", "blockedBy": [13, 14], "commit": "aac59c9", "result": "Cluster/Security/HealthMonitoring/AuditLog → OptionsValidatorBase via RequireThat (no primitives; messages verbatim). DI untouched (AddValidatedOptions collapse deferred). 33/33 validator tests unchanged. Spec ✅, code Approved-with-minors (De Morgan readability nits)."},
{"id": 16, "subject": "Task 6: ScadaBridge — StartupValidator → ConfigPreflight", "classification": "high-risk", "status": "completed", "blockedBy": [13, 14], "commit": "6dbbc7a", "result": "StartupValidator body re-implemented over ConfigPreflight (Require escape-hatch for bespoke + cross-field rules; default int.TryParse + IsNullOrEmpty preserved). StartupValidatorTests 46/46 UNCHANGED (byte-compat proof). Spec ✅, code Approved-with-minors."}
],
"deferred": [
"ScadaBridge: collapse module AddXxx → AddValidatedOptions (DI simplification; preserve HealthMonitoring idempotent registration).",
"MxGateway pre-existing (not regressions): Ldap:Port allows >65535; AddIfInvalidPath doesn't catch IOException.",
"OtOpcUa pre-existing bug (flagged + memory): LdapOptions binds Security:Ldap nowhere; DevStubMode never applies — separate behaviour-changing fix.",
"Cosmetic: De Morgan predicate comments (ScadaBridge validators); vestigial `var options = _options` in OtOpcUaServerHostedService."
],
"lastUpdated": "2026-06-01"
}