Open Source · MIT · Zero Dependencies · Forever

Every rule gets a fair hearing.

You've written the if/elif ladder. You've pulled the thresholds into a config table. You've reached for a real rules engine — and inherited its class hierarchy, its registry, its own ladder hiding one level down. Then a rule needed to explain itself, and none of them could.

The familiar story
Exhibit A

No. 1 — PY · Ct. of Rule Evaluation

ver·dict

/ vər-dikt / noun

A decision reached by weighing the facts before it — what a jury renders after testimony. Here: what a Rule renders after weighing a context. Pass or fail, with its reasoning attached.

"The verdict wasn't guilty or innocent — it was 'passed, because three of four conditions held, and here's which one didn't.'"

The familiar story

Act I

if/el·if

/ if-el-if / n., ladder

It ships fast. A total check, a membership check, a promo-code fallback — three conditions, one function, done. Then a fourth condition arrives that depends on the outcome of the first two. Evaluation order becomes load-bearing, and nobody wrote that down.

async def ships_free(order): if order["total"] >= 100: return True elif order["is_premium"]: return True else: return await check_promo(order) # the promo check runs last because # someone happened to write it last — # not because it's the expensive one.

You survive it. You add a comment. You swear the next one will be a real design.

Act II

con·fig ta·ble

/ kon-fig tay-bel / n.

You pull the thresholds into a database row — no more redeploying for a marketing change. Genuinely better. But nothing says whether the rows combine with all-must-pass or any-one-will-do, and an empty table's meaning ("nothing configured") is a coin flip nobody decided on purpose.

# rule_rows table { "category": "Manager", "group": "HQ" } { "category": "Analyst", "group": "Support" } # AND across rows, or OR? Depends who # you ask. An empty table: passes or # fails? Also depends who you ask.

It's data now. The ambiguity just moved with it.

Act III

the frame·work

/ dhe fraym-werk / n.

You reach for a proper rules engine — a real one, with a base class to extend, a registry to populate, a DSL to learn. Then you need one rule that only applies to a single screen, and the abstraction gets in the way of the one thing it was supposed to make easy: adding a rule.

# somewhere in the framework's docs class MyRule(BaseRule): def __init__(self): super().__init__(registry=True) # register(), validate(), compile() ... # six methods to override for one check

One requirement. A framework's worth of ceremony. Permanent overhead.

The same story. Every codebase, every domain. Enter Verdict

Enter Verdict.

One protocol. Every rule.

A Rule is anything with a name, an optional group, and an async evaluate(context) method — a structural Protocol, not a base class. No registration, no inheritance, no subclassing to satisfy. If it walks like a rule, it is one.

AndRule/OrRule short-circuit exactly the way and/or already do in your head — and it's a real contract, not an optimization: the rule after the deciding one genuinely never runs. Every verdict carries its own reasoning in RuleResult.data, opaque to Verdict itself, fully readable by you.

Ships today

Python JS / TS Dart C#

Same Rule/Engine/Result design, every language it ever ships for.

Structural typing Real short-circuiting Vacuous-truth safety Zero dependencies Oracle/differential testing AI-agent skill included
Before — hand-rolled ladder Silent drift
# a 4th condition means editing this
async def can_proceed(ctx):
    if ctx["requests"] >= ctx["limit"]:
        return False
    if ctx["status"] != "active":
        return False
    return True
# which check failed? no idea — just False.
After — verdict Self-explaining
# a 4th condition is a 4th FunctionRule
can_proceed = AndRule("can_proceed", [
    FunctionRule("under_limit", under_limit),
    FunctionRule("in_good_standing", in_good_standing),
])
result = await engine.run_named("can_proceed", ctx)
# result.data → which sub-rule passed,
# which didn't. Every time. For free.
On the record 6 findings

01 / structure

Structural, not inherited

Rule is a Protocol. Any object with the right shape qualifies — no base class, no registry, no import cycle back into this package.

02 / execution

Short-circuiting is a promise

Evaluation is always sequential, never concurrent — the rule after a deciding failure or pass genuinely never runs. Proven by a call-counter test, not assumed.

03 / correctness

Vacuous truth, decided on purpose

An empty AndRule passes; an empty OrRule fails — the same asymmetry all([])/any([]) already have in plain Python, made explicit per composite.

04 / footprint

Zero dependencies

Nothing to audit, nothing to pin, nothing that breaks because a transitive package changed underneath it. One small, standalone package.

05 / testing

Oracle/differential testing

The shipped example runs 500 deterministically-seeded random cases against an independent, deliberately-dumb oracle — a regression net wider than anyone would hand-curate.

06 / tooling

Ships with an AI-agent skill

A vendorable skill teaches Claude, Cursor, and six other harnesses when to reach for Verdict and how to extend it — scripts/install.sh wires it into any repo.

Start in one minute

Install Verdict.

Zero dependencies, in every language it ships for.

$ pip install verdict-rules Copy
$ uv add verdict-rules Copy
$ npm install verdict-rules Copy

Also available as script/cdnsee the README.

$ dotnet add package VerdictRules Copy
$ dart pub add verdict_rules Copy

Works unchanged in a Flutter project too — flutter pub add verdict_rules there.

first_rule.py
from verdict import FunctionRule, AndRule, RuleResult, RulesEngine async def under_limit(ctx): return RuleResult(rule_name="under_limit", passed=ctx["requests_this_minute"] < ctx["limit"]) async def in_good_standing(ctx): return RuleResult(rule_name="in_good_standing", passed=ctx["account_status"] == "active") can_proceed = AndRule("can_proceed", [ FunctionRule("under_limit", under_limit), FunctionRule("in_good_standing", in_good_standing), ]) engine = RulesEngine([can_proceed]) result = await engine.run_named("can_proceed", { "requests_this_minute": 3, "limit": 10, "account_status": "active", }) # result.passed True
import { FunctionRule, AndRule, RulesEngine } from "verdict-rules"; async function underLimit(ctx) { return { ruleName: "under_limit", passed: ctx.requestsThisMinute < ctx.limit }; } async function inGoodStanding(ctx) { return { ruleName: "in_good_standing", passed: ctx.accountStatus === "active" }; } const canProceed = new AndRule("can_proceed", [ new FunctionRule("under_limit", underLimit), new FunctionRule("in_good_standing", inGoodStanding), ]); const engine = new RulesEngine([canProceed]); const result = await engine.runNamed("can_proceed", { requestsThisMinute: 3, limit: 10, accountStatus: "active", }); // result.passed true
using VerdictRules; static Task<RuleResult> UnderLimit(IReadOnlyDictionary<string, object?> ctx, CancellationToken cancellationToken = default) => Task.FromResult(new RuleResult("under_limit", (double)ctx["requests_this_minute"]! < (double)ctx["limit"]!)); static Task<RuleResult> InGoodStanding(IReadOnlyDictionary<string, object?> ctx, CancellationToken cancellationToken = default) => Task.FromResult(new RuleResult("in_good_standing", (string)ctx["account_status"]! == "active")); var canProceed = new AndRule("can_proceed", new IRule[] { new FunctionRule("under_limit", UnderLimit), new FunctionRule("in_good_standing", InGoodStanding), }); var engine = new RulesEngine(new IRule[] { canProceed }); var result = await engine.RunNamedAsync("can_proceed", new Dictionary<string, object?> { ["requests_this_minute"] = 3.0, ["limit"] = 10.0, ["account_status"] = "active", }); // result.Passed True
import 'package:verdict_rules/verdict_rules.dart'; Future<RuleResult> underLimit(Map<String, Object?> ctx) async => RuleResult(ruleName: 'under_limit', passed: (ctx['requests_this_minute']! as num) < (ctx['limit']! as num)); Future<RuleResult> inGoodStanding(Map<String, Object?> ctx) async => RuleResult(ruleName: 'in_good_standing', passed: ctx['account_status'] == 'active'); final canProceed = AndRule('can_proceed', [ FunctionRule('under_limit', underLimit), FunctionRule('in_good_standing', inGoodStanding), ]); final engine = RulesEngine([canProceed]); final result = await engine.runNamed('can_proceed', { 'requests_this_minute': 3, 'limit': 10, 'account_status': 'active', }); // result.passed true

Having doubts?