Skip to content

Latest commit

 

History

932 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Motiv logo

Motiv

Build Status NuGet codecov

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.

Core Features

Automatic Propositions

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.

Manual Composition

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 outcome

Custom Assertions

Add 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"]

Query Provider Integration

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();

Asynchronous Propositions

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.

Side-Effect Observers

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);

Observability

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.

Collection Logic

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"]

Live Rules

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.

Runtime Propositions

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>();
// POST /api/rules/propositions
{
  "name": "customer.eligibility.is-eligible",
  "modelType": "customer",
  "document": {
    "rule": { "andAlso": [{ "spec": "customer.is-active" }, { "spec": "customer.is-adult" }] }
  }
}

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.

Rule Durability

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.

Entity Framework Core Store

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.

Multi-Instance Refresh

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 it

Each 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.

The Decision Log

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.

Governance and Access Control

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.

Structural Limits

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);                            // returns

What 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 startup

Rule documents are refused earlier and more helpfully, at the edge, by RuleSerializerOptions' MaxCompositionDepth, MaxNodeCount and MaxDocumentDepth. Available via the Motiv and Motiv.Serialization packages.

Authoring UIs and Runtimes

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 proposition

The "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.

Quick Start

Install the Motiv NuGet package:

dotnet add package Motiv

or via the NuGet Package Manager:

Install-Package Motiv

Technical Notes

  • Zero additional dependencies on .NET 8+
    • The legacy netstandard2.0 target pulls in System.Diagnostics.DiagnosticSource for telemetry
  • 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

Learn More

Used by

Contributors

Languages