Motiv is a .NET library for building composable, explainable boolean logic — so you never lose the why behind a true or false.
The boolean type has a problem: once evaluated, you lose all context about why the value is true or false.
This is known as the boolean blindness problem:
// Traditional approach - life before Motiv
if (user.Age >= 18 &&
user.HasValidId &&
(user.Country == "US" || user.HasInternationalPermit) &&
!user.IsRestricted)
{
// Access granted
}
else
{
// Access denied — but which condition failed?
}Motiv addresses this by preserving the structure of boolean expressions, so you can recover the underlying causes when you need them:
// With Motiv
var canAccess = Spec
.From((User user) =>
user.Age >= 18 &
user.HasValidId &
(user.Country == "US" | user.HasInternationalPermit) &
!user.IsRestricted)
.Create("can access");
var result = canAccess.Evaluate(user);
result.Satisfied; // false
result.Assertions; // ["user.Age < 18", "user.HasValidId == false"]Motiv overloads &, |, ^, and ! so the same operators compose propositions and their results.
The short-circuiting && / || are reserved for evaluated results — use .AndAlso() / .OrElse() on propositions.
Notice too that each failing clause is rendered in its own terms — user.Age < 18 for the comparison,
user.HasValidId == false for the boolean — and passing clauses are dropped from the result.
Transform boolean expressions into explanatory logic using the Spec.From() method:
var isEligible = Spec
.From((Customer c) => c.CreditScore > 600 & c.Income > 100000)
.Create("eligible for loan");
var result = isEligible.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["c.CreditScore > 600", "c.Income > 100000"]This takes a lambda expression tree (Expression<Func<T, bool>>) and transforms it into a hierarchy of propositions that mirror the expression's logic.
For full control, compose propositions manually — no expression trees:
var hasGoodCredit = Spec
.Build((Customer c) => c.CreditScore > 600)
.Create("good credit");
var hasIncome = Spec
.Build((Customer c) => c.Income > 100000)
.Create("sufficient income");
// create a new proposition
var isEligible = hasGoodCredit.And(hasIncome);
// alternatively, use operator syntax
// var isEligible = hasGoodCredit & hasIncome;
var result = isEligible.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["good credit == true", "sufficient income == true"]
// a bare name gets a == true / == false suffix to show the outcomeAdd readable explanations to your logic:
var hasGoodCredit = Spec
.Build((Customer c) => c.CreditScore > 600)
.WhenTrue("has good credit score")
.WhenFalse("credit score too low")
.Create();
var result = hasGoodCredit.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["has good credit score"]Supplying an explicit name via Create("name") instead of parameterless Create() changes the semantics: the name plus
a == true/== false suffix becomes the assertion, and the custom strings become metadata, available via Values:
var hasGoodCredit = Spec
.Build((Customer c) => c.CreditScore > 600)
.WhenTrue("has good credit score")
.WhenFalse("credit score too low")
.Create("good credit");
var result = hasGoodCredit.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["good credit == true"]
result.Values; // ["has good credit score"]Propositions built from Spec.From() retain a recoverable expression tree,
so they compose into a single predicate that a query provider can translate directly:
var isAdult = Spec.From((Customer c) => c.Age >= 18).Create("is adult");
var isActive = Spec.From((Customer c) => c.IsActive).Create("is active");
var eligible = isAdult & isActive;
// Translate to SQL via any IQueryable provider (e.g. EF Core)
var customers = dbContext.Customers.Where(eligible);
// Or take the raw expression anywhere expressions are accepted
Expression<Func<Customer, bool>> predicate = eligible.ToExpression();Compose rules that touch databases, APIs, or feature flags — with the same explainable results and true short-circuiting of asynchronous work:
var isAdult = Spec
.Build((User u) => u.Age >= 18)
.Create("is adult");
var hasCredit = Spec
.BuildAsync(async (User u, CancellationToken ct) =>
await creditApi.CheckAsync(u.Id, ct))
.WhenTrue("has credit")
.WhenFalse("no credit")
.Create();
var canBuy = isAdult.AndAlso(hasCredit); // credit API never called for minors
var result = await canBuy.EvaluateAsync(user, cancellationToken);
result.Satisfied; // false
result.Assertions; // ["is adult == false"]Async and sync propositions compose freely (sync operands are lifted
automatically), and independent async operands can opt into concurrent
evaluation with AndConcurrently/OrConcurrently/XOrConcurrently.
Attach logging, metrics, or other side-effects without altering a proposition's behavior:
var observed = isEligible
.TapWhenTrue((customer, result) =>
logger.LogInformation("Approved: {Id}", customer.Id))
.TapWhenFalse((customer, result) =>
logger.LogWarning("Denied: {Reason}", result.Reason));
// Use exactly like the original — result, assertions, reason are all unchanged
var result = observed.Evaluate(customer);Every top-level evaluation reports through OpenTelemetry — a span plus counter/histogram metrics — with no Motiv configuration required. Nothing is emitted until your application subscribes:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(MotivTelemetry.SourceName))
.WithMetrics(metrics => metrics.AddMeter(MotivTelemetry.MeterName));The rules stack reports on itself on a second source and meter (MotivRulesTelemetry.SourceName/MeterName):
bind failures, publish conflicts, store latency, replica lag, decision-queue depth and break-glass, plus a span
carrying which rule ran at which version. Stating a decision-log capture posture also sets the PII posture for
traces, so it is stated once.
Make assertions about collections of items (also known as higher-order logic):
var allNegative = Spec
.Build((int n) => n < 0)
.AsAllSatisfied()
.WhenTrue("all numbers are negative")
.WhenFalseYield(eval => eval.FalseModels.Select(n => $"{n} is not negative"))
.Create();
var result = allNegative.Evaluate([-1, 2, 3]);
result.Satisfied; // false
result.Assertions; // ["2 is not negative", "3 is not negative"]Hot-swap a running application's rules without redeploying. Declare a rule as a sealed class — the type is its identity — with a compiled spec (or a JSON rule document) as its default implementation:
public sealed class CanCheckoutRule() : Rule<Customer, string>(
"can-checkout", CanCheckoutSpec, "Gate for the checkout flow");Wire the rule up and inject the concrete type wherever the decision is made:
builder.Services.AddMotivRules(registry, options)
.AddRule<CanCheckoutRule>();
app.MapMotivRules("/api/rules");
app.MapPost("/api/checkout", (CanCheckoutRule canCheckout, Customer customer) =>
Results.Json(canCheckout.Evaluate(customer).Assertions));PUT /api/rules/rules/can-checkout replaces the implementation live — every
evaluation reads an immutable snapshot, writes are protected by optimistic
concurrency (409 on a stale baseVersion), and DELETE reverts to the
default. Available via the Motiv.Serialization and
Motiv.Serialization.AspNetCore packages.
Propositions are the building blocks rules are made of. Register them in C#, or author them at runtime and persist them server-side — either way a rule document references them by name:
builder.Services.AddMotivRules(registry, options)
// JsonFilePropositionStore is the sample host's own IPropositionStore, not a library type
.AddPropositions(new JsonFilePropositionStore("propositions.json"))
.AddRule<CanCheckoutRule>();Names are namespaced with dots, an authored document may override a compiled spec
(and DELETE reverts to it), and editing a proposition rebinds every rule and
proposition that references it — transactionally, so an edit that would break a
dependent is refused whole. Authored propositions are composition only: they
combine specs that already exist, because new primitive facts still come from C#.
Available via the Motiv.Serialization and Motiv.Serialization.AspNetCore
packages.
Register a store so a published rule survives a restart instead of reverting to its compiled default:
builder.Services.AddMotivRules(registry, options)
.AddRuleStore(new JsonFileRuleStore("rules.json"))
.AddRule<CanCheckoutRule>();Every publish appends an immutable, provenance-carrying row to an append-only
version log — who published, when, and why — rather than overwriting the last
one, so history is auditable and a rollback (RestoreAsync) appends a fresh
copy of an old version instead of rewriting it. A stored document that no
longer binds after a redeploy is quarantined — the rule keeps running its
compiled default rather than failing to evaluate — and, by default, stops
startup so nobody boots quietly into unapproved behaviour. Available via the
Motiv.Serialization and Motiv.Serialization.AspNetCore packages.
Motiv.Serialization.EntityFrameworkCore backs IRuleStore and
IPropositionStore with a real database instead of a JSON file — SQLite,
PostgreSQL or SQL Server:
builder.Services.AddMotivEntityFrameworkStore(options =>
options.UseSqlite("Data Source=motiv-store.db"));
builder.Services.AddMotivRules(registry, options)
.AddPropositions(provider => new EfPropositionStore(
provider.GetRequiredService<IDbContextFactory<MotivStoreDbContext>>()))
.AddRuleStore(provider => new EfRuleStore(
provider.GetRequiredService<IDbContextFactory<MotivStoreDbContext>>()))
.AddRule<CanCheckoutRule>();The (Name, Version) primary key is enforced by the database, so two replicas
racing a publish really do produce one 200 and one 409 rather than both
reading a stale file. Development calls EnsureCreatedAsync(); production
derives MotivStoreDbContext, registers it with
AddMotivEntityFrameworkStore<AppStoreDbContext>(...) and owns its migrations,
the same split Microsoft.AspNetCore.Identity.EntityFrameworkCore draws. A StoreImport
helper carries history in, once, from a pre-existing JsonFileRuleStore /
JsonFilePropositionStore pair. Available via the
Motiv.Serialization.EntityFrameworkCore package.
A durable store survives a restart, but a running replica never rereads it on
its own — two replicas can otherwise diverge for as long as they're both up.
AddRefresh() polls a cheap generation and rebuilds this replica whenever
another one has published:
builder.Services.AddMotivRules(registry, options)
.AddRuleStore(new JsonFileRuleStore("rules.json"))
.AddRule<CanCheckoutRule>()
.AddRefresh(); // opt-in — a single-replica host doesn't need itEach rebuild is a whole-world swap, not an in-place patch, and aborts rather
than silently regressing a live rule to its compiled default if a stored
document would no longer bind — the replica keeps serving what it has and
reports Degraded via the motiv-refresh health check until it's repaired.
MapMotivRules pins one world per request automatically, so a handler
evaluating several rules can't straddle a concurrent refresh, and every
response carries a Motiv-Generation header so a client can tell it was
routed to a replica serving an older world. Available via the
Motiv.Serialization and Motiv.Serialization.AspNetCore packages.
Motiv builds a full explanation on every evaluation and then discards it. Mark
a rule audited in its document and every evaluation is recorded instead — so
you can answer why this customer was declined, on the 3rd, at 14:07:
builder.Services.AddMotivRules(registry, options)
.AddDecisionLog(new InMemoryDecisionSink(), log =>
// No default: a rule marked audited over a model type with no posture
// registered here will not bind. ReferenceOnly keeps a key and nothing
// else, so erasure and audit can coexist.
log.Capture.ReferenceOnly<Customer>(customer => customer.CustomerId))
.AddRule<CanCheckoutRule>();The flag lives on the document, so it's versioned, toggling it is a governed
change, and a rule on a compiled default can't claim to be audited — it has
nowhere to put the flag. Each DecisionRecord pins behaviour with three
anchors (the rule's version, the build, and the versions of every authored
proposition it resolved through), carries the full justification, and keeps
only what your chosen capture posture allows of the model. Records leave the
evaluation path through a bounded queue drained into an IDecisionSink — your
seam for a durable table, a SIEM, or an outbox — and a full queue fails the
decision by default, because an audited decision that wasn't logged didn't
happen.
For production, SqlDecisionSink appends to a database of its own — separate
from the authoring store, over SQLite, PostgreSQL or SQL Server, with no
provider dependency of its own:
builder.Services.AddSingleton(_ => new SqlDecisionSink(
() => new SqliteConnection(decisionsConnectionString),
new SqlDecisionSinkOptions
{
Dialect = DecisionSqlDialect.Sqlite,
// Required. Version history is kept forever; an audited rule on a hot
// path is millions of rows, so there is no "keep everything" here.
Retention = TimeSpan.FromDays(90)
}));It refuses to be constructed without a retention window and purges past it on a
loop it starts itself — a purge you can forget to register is an unbounded
table. Available via the Motiv.Serialization,
Motiv.Serialization.AspNetCore and Motiv.Serialization.Sql packages.
Live rules are secure by default: MapMotivRules() requires authentication on
the whole endpoint group, and opening it up is an explicit opt-out
(AllowAnonymous()), never a silent default. Layer AddGovernance() on top for
a maker-checker gate that a publish must satisfy:
builder.Services.AddMotivRules(registry, options)
.AddGovernance() // permissive until a gate document is installed
.AddRule<CanCheckoutRule>();
app.MapMotivRules("/api/rules");
// or: app.MapMotivRules("/api/rules", o => o.AllowAnonymous());// PUT /api/rules/gate — publish requires an approval, and never from the author
{
"document": {
"rule": { "and": [
{ "spec": "change.approver-count-at-least", "args": { "n": 1 } },
{ "not": { "spec": "change.author-is-approver" } }
]}
}
}An unapproved publish refuses with the same explainability the library exists to provide:
// 403 from POST /api/rules/change-requests/{id}/publish
{
"reason": "change has fewer than 1 approvals",
"assertions": ["change has fewer than 1 approvals"],
"justification": "AND\n change has fewer than 1 approvals"
}The gate's default is permissive and namespace grants (IGrantSource) are
opt-in, so enabling either changes no response until it is configured.
Available via the Motiv.Serialization and Motiv.Serialization.AspNetCore
packages.
Evaluation is stack-safe, synchronously and asynchronously. Evaluate,
Matches, EvaluateAsync and MatchesAsync fold a composition of And, Or,
XOr, AndAlso, OrElse and Not onto the heap, so a chain of a hundred
thousand propositions evaluates on a 1 MB request thread where it once aborted
the process with an uncatchable StackOverflowException — at 12,787 operands
synchronously, and at just 634 asynchronously:
var combined = specs.Aggregate((left, right) => left.And(right)); // 100,000 of them
var result = combined.Evaluate(model); // returnsWhat bounds a composition now is cost, not stack. MotivLimits.MaxEvaluationSize
is the engine's backstop, counted in nodes and applying to Evaluate and
Matches alike, so a composition one accepts is never one the other refuses:
MotivLimits.MaxEvaluationSize = 50_000; // process-wide; set it once at startupRule documents are refused earlier and more helpfully, at the edge, by
RuleSerializerOptions' MaxCompositionDepth, MaxNodeCount and
MaxDocumentDepth. Available via the Motiv and Motiv.Serialization
packages.
Rules are authored in a UI, and the stack ships two cores so that UI can be
written in either runtime: @motiv-rules/core in TypeScript, and
Motiv.Serialization in C#. Both speak the same JSON rule document, so the
question is answered per runtime rather than per framework:
| Runtime | Tier | What you take |
|---|---|---|
| React | Supported | @motiv-rules/core + @motiv-rules/react |
| Vue, Svelte, vanilla | Enabled, not supported | @motiv-rules/core + ~180 lines of your own bindings |
| .NET, including Blazor | Enabled | Motiv.Serialization — no JavaScript package at all |
| Web components | Declined | — |
// A Blazor WebAssembly component, using Motiv.Serialization alone
var registry = new SpecRegistry()
.Register("customer.is-active", Spec.Build((Customer c) => c.IsActive).Create("is active"));
const string json = """{ "rule": { "spec": "customer.is-active" } }""";
var serializer = new RuleSerializer(registry);
var errors = serializer.Validate<Customer>(json); // $.rule… paths
var rule = serializer.Deserialize<Customer>(json); // a live propositionThe "enabled" tier is a claim about the artefact, so it is enforced like one:
@motiv-rules/core declares no dependencies, imports nothing outside itself,
compiles without the DOM, and is driven from plain Node in a tree where react
does not resolve — on every CI run. What that tier costs is enforced the same
way: a worked Vue adapter in ui/examples/vue-adapter offers the React surface
symbol for symbol, is tested on every CI run, and is what the price published for
a second runtime is measured from. The .NET row is worked too:
src/examples/Motiv.RuleAuthoring.Blazor is a standalone Blazor WebAssembly app
that authors, validates, binds and evaluates a rule document entirely in the
browser over Motiv.Serialization, with no JavaScript rules package present.
Neither npm package has been published yet;
the release train that will publish them, and the gate that checks the packed
tarball is installable, are in place. See
Runtimes and Support Tiers for
what each tier costs, where the .NET authoring surface ends, and how a release
is cut.
Install the Motiv NuGet package:
dotnet add package Motivor via the NuGet Package Manager:
Install-Package Motiv- Zero additional dependencies on .NET 8+
- The legacy
netstandard2.0target pulls inSystem.Diagnostics.DiagnosticSourcefor telemetry
- The legacy
- Metadata is evaluated lazily
- Compatible with both .NET and .NET Framework
- Zero-allocation fast paths for boolean-only evaluation
- Stack-safe evaluation and result traversal at any composition depth
- MIT licensed