verdict_rules 0.0.3
verdict_rules: ^0.0.3 copied to clipboard
A small, zero-dependency, async-native rule-evaluation engine. Compose conditions into one explainable pass/fail verdict, with real short-circuiting as a guarantee.
Verdict — Dart #
The Dart implementation of Verdict — a small, zero-dependency, async-native rule-evaluation engine.
Install #
dependencies:
verdict_rules: ^0.0.2
import 'package:verdict_rules/verdict_rules.dart';
A first rule #
import 'package:verdict_rules/verdict_rules.dart';
FunctionRule atLeast(String name, String field, num floor) => FunctionRule(
name,
(ctx) async {
final value = ctx[field]! as num;
return RuleResult(
ruleName: name,
passed: value >= floor,
detail: '$value vs $floor',
);
},
);
Future<void> main() async {
final eligible = AndRule('eligible', [
atLeast('age_ok', 'age', 18),
atLeast('score_ok', 'score', 60),
]);
final engine = RulesEngine([eligible]);
final verdict = await engine.runNamed('eligible', {'age': 21, 'score': 55});
print(verdict.passed); // false
print(verdict.detail); // 'score_ok' failed: 55 vs 60
}
Shape-based rules, within what Dart allows #
Dart does have structural typing — for function types. Any function matching
the predicate signature is a rule through FunctionRule, with nothing declared
and no type to name. A tear-off works directly:
Future<RuleResult> isBusinessHours(Map<String, Object?> ctx) async =>
RuleResult(
ruleName: 'is_business_hours',
passed: (ctx['hour']! as int) >= 9 && (ctx['hour']! as int) < 17,
);
final rule = FunctionRule('is_business_hours', isBusinessHours);
What Dart lacks is structural typing for a multi-member interface. An object
carrying name, group and evaluate is not thereby a Rule — a rule shape
owning its own name and group must say implements Rule 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.
When you do declare one, note that Rule is an abstract interface class:
consumers implement it, never extend it. Forbidding extension means an
instance method calling another method on this always reaches a known
implementation, rather than landing in a consumer's override.
Absence returns null, not a thrown error #
runNamed/runGroup throw on an unknown name or group. When absence is
expected, tryRunNamed/tryRunGroup return null instead:
final result = await engine.tryRunGroup('beta_checks', ctx);
final 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 loop with
await, neverFuture.wait. 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. 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/groupNamesto check membership, ortryRunNamed/tryRunGroupwhere your own domain has an answer for absence — both return null instead of throwing. RuleResult.datais opaque — only what actually ran, never padded, never flattened.- Zero runtime dependencies.
Where to go next #
| Doc | For |
|---|---|
doc/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 |