Files
scadaproj/docs/plans/2026-06-01-deploy-zb-configuration.md
T
Joseph Doherty c3ab37523a 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.
2026-06-01 23:18:02 -04:00

567 lines
27 KiB
Markdown

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