diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs
new file mode 100644
index 0000000..1935f35
--- /dev/null
+++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs
@@ -0,0 +1,40 @@
+namespace ZB.MOM.WW.Configuration;
+
+///
+/// Internal rule primitives shared by (validates a bound options
+/// object) and (validates raw IConfiguration). Each method
+/// returns null when valid, or a formatted "<field> <reason>" message
+/// otherwise. Centralizing them keeps wording identical across both front-ends.
+///
+internal static class Checks
+{
+ internal static string? Required(string? value, string field) =>
+ string.IsNullOrWhiteSpace(value) ? $"{field} is required" : null;
+
+ internal static string? Port(int value, string field) =>
+ value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null;
+
+ internal static string? HostPort(string? value, string field)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return $"{field} is required";
+ var idx = value.LastIndexOf(':');
+ if (idx <= 0 || idx == value.Length - 1
+ || !int.TryParse(value[(idx + 1)..], out var port)
+ || port is < 1 or > 65535)
+ return $"{field} must be 'host:port' with port 1-65535 (was '{value}')";
+ return null;
+ }
+
+ internal static string? PositiveTimeSpan(TimeSpan value, string field) =>
+ value <= TimeSpan.Zero ? $"{field} must be a positive duration (was {value})" : null;
+
+ internal static string? OneOf(string? value, IReadOnlyCollection allowed, string field) =>
+ value is not null && allowed.Contains(value, StringComparer.OrdinalIgnoreCase)
+ ? null
+ : $"{field} must be one of [{string.Join(", ", allowed)}] (was '{value ?? "null"}')";
+
+ internal static string? MinCount(IReadOnlyCollection? value, int min, string field) =>
+ value is null || value.Count < min
+ ? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})"
+ : null;
+}
diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs
new file mode 100644
index 0000000..e221734
--- /dev/null
+++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs
@@ -0,0 +1,56 @@
+namespace ZB.MOM.WW.Configuration;
+
+///
+/// Accumulates validation failures for an options object. Passed by
+/// into your Validate override; each primitive
+/// both checks a value and appends a consistently-formatted message on failure. Use
+/// / for custom or cross-field rules.
+///
+public sealed class ValidationBuilder
+{
+ private readonly List _failures = [];
+
+ /// The accumulated failure messages (empty when validation passed).
+ public IReadOnlyList Failures => _failures;
+
+ /// True when no failures have been accumulated.
+ public bool IsValid => _failures.Count == 0;
+
+ /// Records as a failure when is false.
+ public ValidationBuilder RequireThat(bool ok, string message)
+ {
+ if (!ok) _failures.Add(message);
+ return this;
+ }
+
+ /// Unconditionally records as a failure.
+ public ValidationBuilder Add(string message)
+ {
+ _failures.Add(message);
+ return this;
+ }
+
+ /// Requires a non-null, non-whitespace string.
+ public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field));
+
+ /// Requires a TCP port in 1-65535.
+ public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field));
+
+ /// Requires a 'host:port' endpoint with a valid port.
+ public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field));
+
+ /// Requires a strictly positive duration.
+ public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field));
+
+ /// Requires the value to be one of (case-insensitive).
+ public ValidationBuilder OneOf(string? value, IReadOnlyCollection allowed, string field) => AddIf(Checks.OneOf(value, allowed, field));
+
+ /// Requires a collection with at least items.
+ public ValidationBuilder MinCount(IReadOnlyCollection? value, int min, string field) => AddIf(Checks.MinCount(value, min, field));
+
+ private ValidationBuilder AddIf(string? message)
+ {
+ if (message is not null) _failures.Add(message);
+ return this;
+ }
+}
diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs
new file mode 100644
index 0000000..e28a495
--- /dev/null
+++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs
@@ -0,0 +1,74 @@
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.Configuration.Tests;
+
+public sealed class ValidationBuilderTests
+{
+ [Theory]
+ [InlineData(0, false)]
+ [InlineData(1, true)]
+ [InlineData(65535, true)]
+ [InlineData(65536, false)]
+ public void Port_validates_range(int port, bool valid)
+ {
+ var b = new ValidationBuilder();
+ b.Port(port, "X:Port");
+ Assert.Equal(valid, b.IsValid);
+ }
+
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("", false)]
+ [InlineData(" ", false)]
+ [InlineData("ok", true)]
+ public void Required_rejects_null_empty_whitespace(string? value, bool valid)
+ {
+ var b = new ValidationBuilder();
+ b.Required(value, "X:Name");
+ Assert.Equal(valid, b.IsValid);
+ }
+
+ [Theory]
+ [InlineData("host:5000", true)]
+ [InlineData("host", false)]
+ [InlineData("host:0", false)]
+ [InlineData("host:notaport", false)]
+ public void HostPort_validates_endpoint(string value, bool valid)
+ {
+ var b = new ValidationBuilder();
+ b.HostPort(value, "X:Endpoint");
+ Assert.Equal(valid, b.IsValid);
+ }
+
+ [Fact]
+ public void PositiveTimeSpan_rejects_zero_and_negative()
+ {
+ var b = new ValidationBuilder();
+ b.PositiveTimeSpan(TimeSpan.Zero, "X:T1").PositiveTimeSpan(TimeSpan.FromSeconds(-1), "X:T2");
+ Assert.Equal(2, b.Failures.Count);
+ }
+
+ [Fact]
+ public void OneOf_is_case_insensitive()
+ {
+ var b = new ValidationBuilder();
+ b.OneOf("CENTRAL", new[] { "Central", "Site" }, "X:Role");
+ Assert.True(b.IsValid);
+ }
+
+ [Fact]
+ public void MinCount_requires_minimum()
+ {
+ var b = new ValidationBuilder();
+ b.MinCount(new[] { "a" }, 2, "X:Seeds");
+ Assert.False(b.IsValid);
+ }
+
+ [Fact]
+ public void Accumulates_all_failures_and_RequireThat_Add_work()
+ {
+ var b = new ValidationBuilder();
+ b.Required(null, "A").RequireThat(false, "B failed").Add("C failed");
+ Assert.Equal(3, b.Failures.Count);
+ }
+}