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

27 KiB

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


⚠️ 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:

<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:

<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)

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

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

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):

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):

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

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):

<PackageReference Include="ZB.MOM.WW.Configuration" />

Step 2: Write the failing test (LdapOptionsValidatorTests.cs)

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

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 bindingProgram.cs:99

// 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

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

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

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 optionsProgram.cs (near the other host registrations)

builder.Services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
    builder.Configuration, "OpcUa");

Step 5: Consume via DI in the hosted serviceOtOpcUaServerHostedService.cs

Add IOptions<OpcUaApplicationHostOptions> options to the constructor (store _options), then replace lines 62-63:

// 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

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):

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 — StartupValidatorConfigPreflight

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.