Evaluation
Diagnostics
Distinguish normal business-rule failures from evaluation errors and produce complete, structured explanations without leaking candidate data.
There are two deliberate evaluation modes. Matches answers a fast Boolean
question. Check produces an explanation. Neither converts an exception into a
normal failed rule.
Fast Boolean evaluation
Use Matches when the caller needs only yes or no. Generated domain properties
use the same path. And and Or short-circuit from left to right.
If a predicate throws, Matches raises
SpecificationEvaluationException. The exception retains the rule ID, node
path, and original exception without serializing the candidate into its
message:
[Fact]
public void Matches_wraps_a_leaf_exception_with_rule_identity_and_node_path()
{
var failure = new InvalidOperationException("boom");
var broken = Rule("broken", "Broken", _ => ExceptionProbe.Throw(failure));
var exception = Assert.Throws<SpecificationEvaluationException>(() =>
broken.Matches(new Order()));
Assert.Equal("order.broken", exception.RuleId);
Assert.Equal("$", exception.NodePath);
Assert.Same(failure, exception.InnerException);
}
Structured explanations
Use Check when an application must explain why a rule did not pass:
public static CheckResult ExplainWhyShippingIsBlocked(Order order) =>
CanShip.Check(order);
A failure can carry a stable ID, safe message, machine code, domain path, node path, and explicitly supplied context:
[Fact]
public void Check_returns_a_structured_business_failure()
{
var paid = Spec.Define<Order>(
id: "order.paid",
name: "Paid",
predicate: order => order.Paid,
failure: "Payment has not been received.",
code: "payment-required",
path: "PaymentStatus");
var result = paid.Check(new Order());
Assert.Equal(CheckOutcome.Failed, result.Outcome);
Assert.False(result.Passed);
Assert.True(result.IsComplete);
Assert.Empty(result.Errors);
var ruleFailure = Assert.Single(result.Failures);
Assert.Equal(RuleFailureKind.Rule, ruleFailure.Kind);
Assert.Equal("order.paid", ruleFailure.RuleId);
Assert.Equal("Paid", ruleFailure.Name);
Assert.Equal("Payment has not been received.", ruleFailure.Message);
Assert.Equal("payment-required", ruleFailure.Code);
Assert.Equal("PaymentStatus", ruleFailure.Path);
Assert.Equal("$", ruleFailure.NodePath);
Assert.Empty(ruleFailure.Causes);
}
CheckResult.Outcome is Passed, Failed, or Error. A returned false is a
business failure; a thrown predicate is an evaluation error. Callers can handle
those outcomes differently without inspecting exception strings.
Complete is the default
Complete diagnostics evaluate every leaf from left to right, even after the
Boolean outcome is known. That permits a failed And to report all failed
requirements and permits a passing Or to retain an error found in another
alternative.
CheckOptions.ShortCircuit is available for expensive trees. The result then
sets IsComplete to false, making the tradeoff visible rather than implying
that an abbreviated explanation is exhaustive.
Alternatives remain alternatives
When an Or fails, its branches are grouped rather than flattened into a list
that would incorrectly imply every alternative was mandatory:
[Fact]
public void Failed_or_keeps_its_alternatives_grouped()
{
var paid = Rule("paid", "Paid", _ => false, "Payment is required.");
var manual = Rule("manual", "Manual override", _ => false, "Approval is required.");
var result = paid.Or(manual).Check(new Order());
Assert.Equal(CheckOutcome.Failed, result.Outcome);
var alternatives = Assert.Single(result.Failures);
Assert.Equal(RuleFailureKind.Alternatives, alternatives.Kind);
Assert.Equal(2, alternatives.Causes.Count);
Assert.Equal("order.paid", alternatives.Causes[0].RuleId);
Assert.Equal("order.manual", alternatives.Causes[1].RuleId);
}
Named compositions wrap their child failures in the domain message while retaining the underlying causes. This allows a UI to start with “The order is not ready to ship” and reveal the specific payment or address reasons when needed.
Safe by default
Candidate values, captured arguments, expression bodies, and exception details are omitted from default rendering. Add diagnostic context explicitly, and only when it is safe to retain and display. The library snapshots that context so a later mutation cannot rewrite a recorded explanation.