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.
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)
-
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 asbuilder.RequireThat(<condition>, "<exact existing message>")(orbuilder.Add("<message>")for unconditional adds). Thecomponents/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. -
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.
-
Flagged discrepancy (do not silently "fix"):
OtOpcUa Program.cs:99bindsGetSection("Ldap")butLdapOptions.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. -
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.Configurationis 404 on the Gitea feed (Health is 200), and no app'spackageSourceMappingroutes 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 carryVersion="0.1.0"), so its pin happens in Task 2 on thePackageReferenceitself. Confirm per repo whetherManagePackageVersionsCentrallyis 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 existingGatewayOptionsValidatortests)
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
AddIfBlankwithbuilder.Required(...)etc. — that changes the message text. Mechanical rule for the bodies:failures.Add(x)→builder.Add(x); the early-returnguards (e.g.if (!options.Enabled) return;inValidateLdap/ValidateAlarms, and theEnum.IsDefinedshort-circuitreturninValidateAuthentication) 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);
AddValidatedOptionstakes anIConfiguration; ifAddGatewayConfigurationdoesn't already receive one, threadbuilder.Configuration(orIConfiguration) into it. The original usedBindConfiguration(SectionName)(path read off the type);AddValidatedOptionstakes the path as thesectionPathargument — passGatewayOptions.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 binding — Program.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 thoughLdapOptions.SectionName == "Authentication:Ldap". The message strings above intentionally sayAuthentication:Ldap:(matching the conceptual section name); if the user prefers the path to match the constant, change both thesectionPathand 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(injectIOptions, drop imperative bind) - Create:
OpcUaApplicationHostOptionsValidatorTests.cs(Host test project)
Why high-risk: changes the hosted service constructor and makes a bad
OpcUasection throw at host start (ValidateOnStart). TodayStartAsyncswallows 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 options — Program.cs (near the other host registrations)
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:
// 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/…csprojsrc/ZB.MOM.WW.ScadaBridge.Security/…csprojsrc/ZB.MOM.WW.ScadaBridge.HealthMonitoring/…csprojsrc/ZB.MOM.WW.ScadaBridge.AuditLog/…csproj
- Modify:
src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cssrc/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cssrc/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cssrc/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):
: IValidateOptions<T>→: OptionsValidatorBase<T>(using ZB.MOM.WW.Configuration;).public ValidateOptionsResult Validate(string? name, T options)→protected override void Validate(ValidationBuilder builder, T options).- Delete
var failures = new List<string>();and thereturn failures.Count … ? Fail(failures) : Success;tail. - 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.ClusterOptionsValidatorhas 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 overConfigPreflight) — or inline intoProgram.cs:41and 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 - …") andConfigPreflight.ThrowIfInvalid()— verified. The individual messages are bespoke and several are cross-field (GrpcPort≠RemotingPort, MetricsPort≠RemotingPort/GrpcPort, seed-node-port≠GrpcPort).ConfigPreflighthas noAdd/RequireThat; reproduce these via theRequire(key, predicate, reason)escape hatch where the predicate closes over the other resolved values and ignores its passed argument, andreasonis 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 useRequirePort(its wording differs).Require("ScadaBridge:Cluster:SeedNodes", _ => (seedNodes?.Count ?? 0) >= 2, "must have at least 2 entries")(readseedNodesonce 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 privateSeedNodePorthelper).
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.Configurationregistration index → 200.- Each repo: clean
dotnet restorepullsZB.MOM.WW.Configuration 0.1.0from Gitea. - Each repo:
dotnet build+dotnet testgreen on itsfeat/adopt-zb-configurationbranch. - 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.
scadaprojitself is NOT a git repo — do notgit initit. Commits happen inside each sister repo.- Skills:
@superpowers-extended-cc:executing-plans,@superpowers-extended-cc:test-driven-development,@superpowers-extended-cc:verification-before-completion.