VerdictRules 0.3.2

dotnet add package VerdictRules --version 0.3.2
                    
NuGet\Install-Package VerdictRules -Version 0.3.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="VerdictRules" Version="0.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="VerdictRules" Version="0.3.2" />
                    
Directory.Packages.props
<PackageReference Include="VerdictRules" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add VerdictRules --version 0.3.2
                    
#r "nuget: VerdictRules, 0.3.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package VerdictRules@0.3.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=VerdictRules&version=0.3.2
                    
Install as a Cake Addin
#tool nuget:?package=VerdictRules&version=0.3.2
                    
Install as a Cake Tool

Verdict — C# SDK

The C# implementation of Verdict — a small, zero-dependency, async-native rule-evaluation engine.

Install

dotnet add package VerdictRules
using VerdictRules;

A first rule

using VerdictRules;

static FunctionRule AtLeast(string name, string field, double floor) =>
    new(name, (ctx, cancellationToken) =>
    {
        var value = Convert.ToDouble(ctx[field]);
        return Task.FromResult(
            new RuleResult(name, value >= floor, $"{value} vs {floor}"));
    });

var eligible = new AndRule("eligible", new IRule[]
{
    AtLeast("age_ok", "age", 18),
    AtLeast("score_ok", "score", 60),
});

var engine = new RulesEngine(new IRule[] { eligible });
var verdict = await engine.RunNamedAsync("eligible", new Dictionary<string, object?>
{
    ["age"] = 21,
    ["score"] = 55,
});

Console.WriteLine(verdict.Passed); // False
Console.WriteLine(verdict.Detail); // 'score_ok' failed: 55 vs 60

Shape-based rules, within what C# allows

C# does have structural typing — for delegates. Any method or lambda matching the predicate signature is a rule via FunctionRule, with nothing declared and no type to name. A method group works directly:

static Task<RuleResult> HasQuorum(
  IReadOnlyDictionary<string, object?> ctx,
  CancellationToken cancellationToken = default)
    => Task.FromResult(new RuleResult("quorum", ctx.Count >= 3));

var rule = new FunctionRule("quorum", HasQuorum);

FunctionRule's predicate parameter is RulePredicate, a named delegate for Func<IReadOnlyDictionary<string, object?>, CancellationToken, Task<RuleResult>> — spelled out once so a field, a stored variable, or a helper that wraps a predicate never has to repeat that signature. A lambda or method group converts to it exactly as shown above, as long as it carries both parameters (the trailing CancellationToken needs to be there even though the delegate gives it a default — matching arity is required for the conversion itself, unlike calling an existing delegate value, which can omit a trailing optional parameter as normal). The one case worth knowing: an already-Func<...>-typed value does not implicitly convert to RulePredicate even with an identical signature, because C# delegate types are nominal once a value has one — wrap it explicitly (new RulePredicate(existing)) if that case comes up.

What C# lacks is structural typing for a multi-member interface. An object that happens to carry Name, Group and EvaluateAsync is not thereby an IRule — a rule shape owning its own name and group must declare : IRule explicitly.

That's why FunctionRule carries more weight in this SDK: it is the escape hatch back to shape-based rules, and most rules should use it rather than declaring a type.

Debugging

RuleResult and RunResult carry [DebuggerDisplay], and RunResult a debugger type proxy that expands straight to the per-rule results. A failing composite's Data is a nested list of sub-results, and stepping through one is the normal way anyone diagnoses it — so the shape is legible in a watch window without expanding every level by hand.

SourceLink is enabled and symbols ship as a .snupkg, so stepping into the package lands on real source rather than a decompiler.

Absence returns null, not a thrown error

RunNamedAsync/RunGroupAsync throw KeyNotFoundException on an unknown name or group. When absence is expected, TryRunNamedAsync/TryRunGroupAsync return null instead:

var result = await engine.TryRunGroupAsync("beta_checks", ctx);
var allowed = result?.Passed ?? true;   // absent means "no constraint here"

null means absent, never failed — a rule that exists and fails still returns a RuleResult with Passed false. These are the primitives; the throwing forms are assertions on top of them.

The fallback only applies to absence. A group that exists always reports its real verdict, so ?? true does not mean "sometimes true" — a failing group is still a failure whatever default you choose. If you test code using this, the case worth covering is a present, failing group rather than the absent one everybody thinks of first.

What it guarantees

  • Sequential evaluation, never concurrent. Composites use a plain foreach with await, never Task.WhenAll. Short-circuiting only means something if later work never starts — and because the returned boolean is identical either way, getting this wrong is silent.
  • Vacuous truth has a polarity. AndRule([]) passes, OrRule([]) fails. Deliberately asymmetric.
  • Emptiness is not absence. An empty composite folds to its identity; an unknown rule name or group throws KeyNotFoundException. A group exists only because some rule declared it, so a lookup matching nothing can only be a mistake — and a misspelled group silently approving is the worst failure an eligibility check can have. Use RuleNames / GroupNames to check membership, or TryRunNamedAsync / TryRunGroupAsync where your own domain has an answer for absence — both return null instead of throwing.
  • RuleResult.Data is opaque — only what actually ran, never padded, never flattened.
  • Zero runtime dependencies.

Where to go next

Doc For
docs/quickstart.md The quickstart — core concepts and a full worked example
docs/architecture/ Why it's shaped this way, in depth — type structure, the execution model
docs/extending/ Building on top of it from your own code, with no changes here
docs/maintenance/ Changing this package itself
docs/testing/ How the test suite is organized, and what a change needs to prove
docs/samples/ Worked examples — dynamic discounts, fee waivers, tier promotions, moderation routing, data-driven rule sets
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.1

    • No dependencies.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.2 26 9/18/2026
0.3.1 40 9/18/2026
0.3.0 48 9/18/2026
0.0.1 44 9/17/2026