Source files
  • Rules/RulesEngine.cs & RulesEngine.ExpressionEvaluation.cs
  • Rules/RuleParserFactory.cs, RuleCatalog.cs, RuleRegistry.cs, Tokenizer.cs
  • Rules/BuiltinParsers/*.cs
  • Rules/BuiltinRules/**/*.cs

Overview

The Rules Engine lets you attach named business-logic units to any data pipeline without coupling rule code to controllers or service classes. Every rule implements one interface, returns a typed output bag, and is auto-discovered from assemblies — no manual wiring required.

Folder map

Rules/
  RulesEngine.cs                   core engine (register, solve, audit)
  RulesEngine.ExpressionEvaluation.cs   shunting-yard + RPN evaluator
  RuleParserFactory.cs             pluggable parser registry
  RuleCatalog.cs                   lifecycle-managed rule-structure store
  RuleRegistry.cs                  assembly-scanning auto-wiring
  RulesParser.cs                   default tokenizer-based parser
  Tokenizer.cs                     stateless lexer
  InMemoryRuleStateStore.cs        ConcurrentDictionary state for stateful rules
  BuiltinParsers/
    CsvColumnParser.cs             col[N] / row.col[N] references
    FormulaParser.cs               Excel-style SUM(A,B), A1 cell refs
    SqlWhereParser.cs              field = 'v', IN (...), BETWEEN, IS NULL
    JsonRuleTreeParser.cs          n8n-style JSON node tree
    NfelParser.cs                  Natural Formula Expression Language
  BuiltinRules/
    Compute/       arithmetic, IfElse, MapValueTable
    Date/          DateDiff, AddDatePart, business hours
    DQ/            IsNullOrEmpty, IsInRange, MatchesRegex, IsUnique...
    Enrich/        AddMetadataFields, LookupEnrich, MergeFields
    Flow/          CircuitBreaker, RetryWithBackoff, DeadLetterQueue
    Notify/        EmitAuditTrail, LogMessage, SendWebhook
    Numeric/       Clamp, RoundNumeric, GetEntityAggregate
    Query/         RunScalarQuery, RunParameterizedLookup, SetFromQuery
    Record/        SetFieldValue, CopyField, IncrementField, ConditionalSetField
    Route/         RouteOnCondition, SplitIntoBuckets, ThrottleRule
    Schema/        EnforceNotNull, ValidateSchemaFields
    Security/      hashing, masking, tokenisation
    Transform/     string cleaning and reshaping
    Validate/      email, phone, URL, credit-card, SSN

Common parameter conventions

KeyMeaning
ValuePrimary input scalar (string, number, date).
RecordIDictionary<string,object> or "field=val;field2=val2" string. Record-group rules return an updated copy in outputs["Record"].
IDMEEditorRequired by data-access rules.
DataSourceNameRegistered connection name.
FiltersSemicolon-separated "field:op:value" triples.

Quick-Start — Three lines to run a rule

// 1. Build registry once at startup
var registry = new RuleRegistry(editor);
registry.Discover();   // scans loaded assemblies automatically

// 2. Solve a named built-in rule
var (outputs, result) = registry.Engine.SolveRule(
    "DQ.IsNullOrEmpty",
    new Dictionary<string, object> { ["Value"] = "alice@example.com" });

bool isEmpty = (bool)result;   // false

Subscribing to audit events

registry.Engine.RuleEvaluated += (_, e) =>
    Console.WriteLine($"{e.RuleKey} | ok={e.Success} | {e.Elapsed.TotalMilliseconds:F1} ms");

RuleEngine API

public partial class RuleEngine : IRuleEngine
{
    public RuleEngine(IRuleParser parser);

    // Fires after every SolveRule call (success or fail)
    public event EventHandler<RuleAuditEventArgs> RuleEvaluated;

    // Registration
    public void              RegisterRule(IRule rule);       // throws on duplicate key
    public bool              UnregisterRule(string ruleKey);
    public bool              HasRule(string ruleKey);
    public IEnumerable<string> GetRegisteredKeys();

    // Execution
    public (Dictionary<string, object> outputs, object result)
        SolveRule(string ruleKey, Dictionary<string, object> parameters);
    public (Dictionary<string, object> outputs, object result)
        SolveRule(string ruleKey, Dictionary<string, object> parameters, RuleExecutionPolicy policy);

    // Direct expression evaluation
    public object EvaluateExpression(IList<Token> tokens, Dictionary<string, object> parameters);
    public object EvaluateExpression(IList<Token> tokens, Dictionary<string, object> parameters, RuleExecutionPolicy policy);
}

RuleAuditEventArgs

PropertyTypeDescription
RuleKeystringKey of the evaluated rule.
Successbooltrue if evaluation completed without exception.
ElapsedTimeSpanWall-clock time (Stopwatch).
ResultobjectPrimary result value (null on failure).
DiagnosticsList<ParseDiagnostic>Parse or validation messages.

RuleExecutionPolicy

var policy = new RuleExecutionPolicy
{
    MaxDepth                 = 10,      // max recursive rule-reference depth
    AllowDeprecatedExecution = false,   // reject Deprecated rules
    MinimumLifecycleState    = RuleLifecycleState.Approved,
    AllowedTokenTypes        = null     // null = allow all types
};
var (_, result) = engine.SolveRule("MyRule", parameters, policy);

Exceptions

ExceptionDiagnosticCodeCause
RuleEvaluationExceptionMaxDepthExceededRecursive depth > policy.MaxDepth
RuleEvaluationExceptionCircularRuleReferenceCycle in @RuleReference chain
RuleEvaluationExceptionRuleNotFoundKey not registered
RuleEvaluationExceptionLifecycleStateViolationRule state blocked by policy
RuleEvaluationExceptionDivisionByZeroDivide/modulo by zero
RuleParseException—Parser returned errors during lazy parse
RuleCatalogExceptionDuplicateRuleRegistrationRegisterRule called with existing key

Expression Evaluation

Rule expressions use a shunting-yard → RPN pipeline: infix token stream → Reverse Polish Notation → stack-based evaluator with automatic type coercion.

Operator precedence (high → low)

PrecedenceOperatorsAssociativity
5NOTright
4* / %left
3+ -left
2= != < > <= >=left
1ANDleft
0ORleft

Token types and syntax

Token typeSyntaxExample
NumericLiteralDigits, optional .42, 3.14
StringLiteral"…" or '…', \" escape"hello"
BooleanLiteraltrue / false (case-insensitive)true
NullLiteralnullnull
EntityFieldPrefixed with ::price
RuleReferencePrefixed with @@DQ.IsNullOrEmpty
IdentifierBare alphanumeric nameqty
StringLiteral (hash)Prefixed with ##active

Type coercion rules

  • Any arithmetic/comparison with null propagates null, except =/!= which compare nullity.
  • + with a string operand performs string concatenation.
  • Comparison operators attempt numeric comparison first; fall back to StringComparer.OrdinalIgnoreCase.
  • AND/OR coerce both sides to bool via Convert.ToBoolean.

Rule references (recursive)

engine.RegisterRule(new SimpleRule("DQ.Base", ":score > 0"));
engine.RegisterRule(new SimpleRule("DQ.Full", "@DQ.Base AND :score < 100"));

var (_, ok) = engine.SolveRule("DQ.Full",
    new Dictionary<string, object> { [":score"] = 55.0 });  // true
Circular references are detected at runtime and throw CircularRuleReference. Default max depth is 10.

Tokenizer

Tokenizer is a stateless, deterministic lexer — never throws for malformed input, always returns structured diagnostics.

var result = new Tokenizer(":price * 1.1 > 50").Tokenize();
// result.Tokens     → [ EntityField(":price"), Multiply, NumericLiteral("1.1"), GreaterThan, NumericLiteral("50") ]
// result.Diagnostics → []

ParseDiagnostic fields

FieldDescription
CodeDiagnosticCode enum value.
SeverityError or Warning.
Start / LengthByte offset + span within the input string.
MessageHuman-readable description.
SuggestionActionable fix hint.

Parsers

RuleParserFactory is a case-insensitive registry of IRuleParser implementations. The default parser handles the standard Beep expression language; five additional built-in parsers cover common domain syntaxes.

public sealed class RuleParserFactory : IRuleParserFactory
{
    void                   RegisterParser(string ruleType, IRuleParser parser);  // throws on duplicate
    IRuleParser            GetParser(string ruleType);     // throws if not found
    bool                   HasParser(string ruleType);
    IEnumerable<string>   GetRegisteredTypes();
}

Built-in parsers

Parser keyClassSyntax handled
RulesParserRuleParserStandard Beep expression language — tokenizer-driven, full operator set.
CsvColumnParserCsvColumnParsercol[N] or row.col[N] — 0-based column reference for CSV rules.
FormulaParserFormulaParserExcel/spreadsheet: SUM(A,B), cell refs A1, $B$2, Sheet1!C3.
SqlWhereParserSqlWhereParserSQL WHERE subset: comparisons, IN, BETWEEN, IS NULL, AND/OR.
JsonRuleTreeParserJsonRuleTreeParserJSON tree with rule, params, children keys. DFS traversal → flat token stream.
NfelParserNfelParserNatural Formula Expression Language — &&/||, ternary ? :, field paths, !flag.

Parser examples

// SQL WHERE
var sqlResult = new SqlWhereParser().ParseRule("Status IN ('Active','Pending') AND Score >= 50");

// NFEL
var nfelResult = new NfelParser().ParseRule("order.total > 100 && !isCancelled");

// CSV Column
var csvResult = new CsvColumnParser().ParseRule("row.Amount[3]");
// Token[0]: Identifier "Amount", Token[1]: NumericLiteral "3"

// JSON tree
string json = @"{""rule"":""DQ.IsInRange"",""params"":{""Min"":""0"",""Max"":""100""},""children"":[]}";
var jsonResult = new JsonRuleTreeParser().ParseRule(json);

Registering a custom parser

[RuleParser(parserKey: "MyDslParser")]
public sealed class MyDslParser : IRuleParser
{
    List<IRuleStructure> IRuleParser.RuleStructures { get; } = new();
    public ParseResult ParseRule(string expression) { /* tokenise, return ParseResult */ }
    public ParseResult ParseRule(IRule rule) => ParseRule(rule.RuleText);
    public void Clear() { }
}

// Manual registration or auto-discovered via RuleRegistry.Discover():
factory.RegisterParser("MyDslParser", new MyDslParser());

RuleCatalog

In-memory store for IRuleStructure instances with lifecycle-state management. Use it to enumerate, filter, and promote rules independently of the execution engine.

public sealed class RuleCatalog : IRuleCatalog
{
    void                           Register(IRuleStructure structure);   // throws on duplicate GuidID or name
    void                           Unregister(string guidId);
    IRuleStructure                 GetByGuid(string guidId);
    IRuleStructure                 GetByName(string ruleName);
    IEnumerable<IRuleStructure>   GetAll();
    IEnumerable<IRuleStructure>   GetByModule(string module);
    IEnumerable<IRuleStructure>   GetByTag(string tag);           // comma-separated Tags field
    IEnumerable<IRuleStructure>   GetByLifecycleState(RuleLifecycleState state);
    void                           Promote(string guidId, RuleLifecycleState newState);  // forward-only
}

RuleLifecycleState

ValueMeaning
DraftWork-in-progress, not yet validated.
ReviewUnder peer review.
ApprovedValidated, production-ready.
DeprecatedExecutable if policy allows, scheduled for removal.
RetiredNo longer executable.
catalog.Promote(guid, RuleLifecycleState.Approved);  // forward only — downgrade throws

RuleRegistry — Auto-wiring

public sealed class RuleRegistry
{
    public IRuleEngine        Engine        { get; }   // populated by Discover()
    public IRuleParserFactory ParserFactory { get; }

    public RuleRegistry(IDMEEditor editor);
    public RuleRegistry(IDMEEditor editor, IRuleEngine engine, IRuleParserFactory parserFactory);

    // Scans ConfigEditor.RuleParserClasses + ConfigEditor.Rules
    // Safe to call multiple times — already-registered keys are skipped
    public void Discover();
}
var registry = new RuleRegistry(editor);
registry.Discover();

var engine  = registry.Engine;
var factory = registry.ParserFactory;

Rule State Store

Stateful rules (CircuitBreaker, RetryWithBackoff, DeadLetterQueue, ThrottleRule) use IRuleStateStore to persist counters between calls. Pass one via the "IRuleStateStore" parameter key.

public interface IRuleStateStore
{
    object?  Get(string stateKey);
    void     Set(string stateKey, object value);
    int      Increment(string stateKey, int delta = 1);
    void     Remove(string stateKey);
    bool     Contains(string stateKey);
    void     Clear();
}
Distributed systems: Replace InMemoryRuleStateStore with a Redis- or database-backed implementation to share state across process instances.

Built-in Rules Reference

Group: DQ — Data Quality

Rule keyParametersReturns
DQ.IsNullOrEmptyValue (object)bool — true when null, DBNull, empty, or whitespace.
DQ.IsInRangeValue, Min, Max (numeric); optional Inclusive (default "true")bool
DQ.IsInListValue; List (comma-string or IEnumerable)bool
DQ.MatchesRegexValue (string); Patternbool
DQ.MatchesExpectedTypeValue; ExpectedType ("int", "double", "DateTime", "bool")bool
DQ.IsUniqueValue; IDMEEditor; DataSourceName; EntityName; FieldNamebool — queries data source; outputs Count.
DQ.ReferentialIntegrityValue; IDMEEditor; DataSourceName; RefEntityName; RefFieldNamebool
var (_, inRange) = engine.SolveRule("DQ.IsInRange",
    new Dictionary<string, object> { ["Value"] = 75.0, ["Min"] = 0.0, ["Max"] = 100.0 });
// inRange → true

Group: Validate

Rule keyParametersReturns
Validate.ValidEmailValuebool (RFC 5321/5322 via MailAddress)
Validate.ValidPhoneValue; optional Format ("E164")bool
Validate.ValidUrlValue; optional RequireHttpsbool
Validate.ValidCreditCardValue — Luhn checkbool, outputs CardType
Validate.ValidSSNValue — US SSN formatbool

Group: Record

Rule keyParametersOutputs
Record.SetFieldValueFieldName, Value (supports {Token} interpolation), optional RecordUpdated Record
Record.CopyFieldSourceField, TargetField, RecordUpdated Record
Record.IncrementFieldFieldName, optional By (delta, default 1), RecordUpdated Record, NewValue
Record.ConditionalSetFieldFieldName, Value, Condition, RecordUpdated Record only if condition true
// {Token} interpolation in SetFieldValue
var (outputs, _) = engine.SolveRule("Record.SetFieldValue",
    new Dictionary<string, object>
    {
        ["FieldName"] = "FullName",
        ["Value"]     = "{FirstName} {LastName}",
        ["Record"]    = new Dictionary<string, object> { ["FirstName"] = "Alice", ["LastName"] = "Smith" }
    });
// outputs["Record"]["FullName"] → "Alice Smith"

Group: Compute

Rule keyParametersReturns
Compute.EvalExpressionExpression (Beep expr string) + field paramsEvaluated result.
Compute.IfElseValueCondition (bool), TrueValue, FalseValueSelected value.
Compute.AgeFromDateBirthDate; optional ReferenceDateint full years.
Compute.MapValueTableValue; MapTable (JSON or k=v;…); optional DefaultMapped output value.

Group: Date

Rule keyParametersReturns
Date.DateDiffStartDate, EndDate; Unit ("Days", "Hours", "Minutes", "Months", "Years")double
Date.AddDatePartDate, Amount, PartDateTime
Date.IsWithinBusinessHoursoptional StartHour (9), EndHour (17), TimeZonebool

Group: Numeric

Rule keyParametersReturns
Numeric.ClampValue, Min, MaxClamped double.
Numeric.RoundNumericValue; optional Decimals (default 2)Rounded double.
Numeric.GetEntityAggregateIDMEEditor, DataSourceName, EntityName, FieldName, Aggregation ("Sum", "Avg", "Min", "Max", "Count")double

Group: Enrich

Rule keyParametersOutputs
Enrich.AddMetadataFieldsRecord; optional Fields (default: CreatedAt, UpdatedAt, RecordId)Updated Record.
Enrich.LookupEnrichRecord; IDMEEditor; DataSourceName; LookupEntity; MatchField; MatchValue; OutputFieldsRecord with looked-up fields.
Enrich.MergeFieldsRecord; Overrides (string or IDictionary)Merged Record.

Group: Flow — Resilience

Rule keyKey parametersReturns
Flow.CircuitBreakerStateKey, FailureThreshold (5), ResetSeconds (30), CallSucceeded, IRuleStateStorebool — true=Closed/HalfOpen (allow); false=Open (reject). Outputs CircuitState.
Flow.RetryWithBackoffStateKey, MaxRetries (3), InitialDelayMs (500), BackoffMultiplier (2.0), CallSucceededbool — true=retry allowed. Outputs RetryCount, DelayMs.
Flow.DeadLetterQueueStateKey, Record, optional Reasonint — DLQ size after enqueue.
var store = new InMemoryRuleStateStore();
var (outputs, allowed) = engine.SolveRule("Flow.CircuitBreaker",
    new Dictionary<string, object>
    {
        ["StateKey"]         = "PaymentGateway",
        ["FailureThreshold"] = "3",
        ["ResetSeconds"]     = "60",
        ["CallSucceeded"]    = lastCallOk.ToString(),
        ["IRuleStateStore"]  = store
    });
// (bool)allowed  → true  = Closed/HalfOpen (proceed)
// outputs["CircuitState"] → "Closed" | "Open" | "HalfOpen"

Group: Route

Rule keyParametersReturns
Route.RouteOnConditionCondition, TrueRoute, FalseRoutestring — chosen route label.
Route.SplitIntoBucketsValue; Buckets (JSON array of {Label, Max})string — matching bucket label.
Route.ThrottleRuleStateKey, MaxCalls, WindowSeconds, IRuleStateStorebool — true=allowed; outputs CurrentCount.

Group: Notify

Rule keyParametersReturns
Notify.LogMessageMessage; optional Level ("Info", "Warn", "Error"); IDMEEditorbool always true.
Notify.EmitAuditTrailRecord; EventName; optional IDMEEditor, DataSourceNamebool
Notify.SendWebhookUrl; Payload; optional Method, Headersbool — HTTP 2xx = true; outputs StatusCode.

Group: Query

Rule keyParametersReturns
Query.RunScalarQueryIDMEEditor, DataSourceName, EntityName, FieldName, optional FiltersFirst matching field value.
Query.RunParameterizedLookupIDMEEditor, DataSourceName, Query, ParametersFirst row as IDictionary<string,object>.
Query.SetFromQueryRecord, IDMEEditor, DataSourceName, EntityName, Filters, OutputFieldsRecord enriched from query row.

Group: Schema

Rule keyParametersReturns
Schema.EnforceNotNullRecord; Fields (comma-separated)bool; outputs ViolatingFields.
Schema.ValidateSchemaFieldsRecord; IDMEEditor; DataSourceName; EntityNamebool; outputs Violations.

Creating a Custom Rule

Implement IRule, annotate with [Rule], deploy the assembly — RuleRegistry.Discover() picks it up.

[Rule(ruleKey: "Pricing.ApplyDiscount",
      ParserKey = "RulesParser",
      RuleName  = "ApplyDiscount",
      RuleType  = "Transform")]
public sealed class ApplyDiscount : IRule
{
    public string         RuleText  { get; set; } = "Pricing.ApplyDiscount";
    public IRuleStructure Structure { get; set; } = new RuleStructure();

    public (Dictionary<string, object> outputs, object result)
        SolveRule(Dictionary<string, object> parameters = null)
    {
        var output = new Dictionary<string, object>();
        if (!parameters.TryGetValue("Price",    out var priceRaw) ||
            !parameters.TryGetValue("Discount", out var discRaw))
        { output["Error"] = "Missing parameters"; return (output, null); }

        double price    = Convert.ToDouble(priceRaw);
        double discount = Convert.ToDouble(discRaw);       // 0–100 %
        double final    = price * (1 - discount / 100.0);

        output["FinalPrice"]  = final;
        output["SavedAmount"] = price - final;
        return (output, final);
    }
}

// Usage:
var (outputs, finalPrice) = engine.SolveRule("Pricing.ApplyDiscount",
    new Dictionary<string, object> { ["Price"] = 120.00, ["Discount"] = 15.0 });
// finalPrice → 102.0 | outputs["SavedAmount"] → 18.0

Rule attribute fields

PropertyRequiredDescription
ruleKeyYesUnique lookup key. Convention: Group.Name.
ParserKeyYesWhich parser to use.
RuleNameRecommendedShort display name for RuleCatalog.
RuleTypeOptionalCategory tag.
RuleAuthorOptionalAuthor identifier.

End-to-End Examples

1. Validate and enrich an order record

var registry = new RuleRegistry(editor);
registry.Discover();
var engine = registry.Engine;

var record = new Dictionary<string, object>
    { ["CustomerEmail"] = "bob@example.com", ["OrderTotal"] = 250.0, ["Status"] = "New" };

// Validate email
var (_, emailOk) = engine.SolveRule("Validate.ValidEmail",
    new Dictionary<string, object> { ["Value"] = record["CustomerEmail"] });
if (!(bool)emailOk) throw new Exception("Invalid email");

// Check total in range
var (_, rangeOk) = engine.SolveRule("DQ.IsInRange",
    new Dictionary<string, object> { ["Value"] = record["OrderTotal"], ["Min"] = 1.0, ["Max"] = 9999.0 });
if (!(bool)rangeOk) throw new Exception("Total out of range");

// Add metadata timestamps
var (enrichOut, _) = engine.SolveRule("Enrich.AddMetadataFields",
    new Dictionary<string, object> { ["Record"] = record });
var enriched = (IDictionary<string, object>)enrichOut["Record"];
// enriched now has CreatedAt, UpdatedAt, RecordId

2. Expression evaluation without a stored rule

var parseResult = new RuleParser().ParseRule(":discount > 10 AND :total >= 200");
if (parseResult.Success)
{
    object ok = engine.EvaluateExpression(
        parseResult.Structure.Tokens,
        new Dictionary<string, object> { [":discount"] = 15.0, [":total"] = 350.0 });
    // ok → true
}

3. DQ pipeline with audit log

engine.RuleEvaluated += (_, e) =>
    logger.LogInformation("[{Key}] ok={Ok} {Ms:F1}ms", e.RuleKey, e.Success, e.Elapsed.TotalMilliseconds);

string[] dqKeys = { "DQ.IsNullOrEmpty", "DQ.MatchesRegex", "Schema.EnforceNotNull" };
var bag = new Dictionary<string, object>
{
    ["Value"]   = record["CustomerEmail"],
    ["Pattern"] = @"^[\w.+-]+@[\w-]+\.[a-z]{2,}$",
    ["Record"]  = record,
    ["Fields"]  = "CustomerEmail,OrderTotal,Status"
};
bool allPassed = dqKeys.All(key => engine.SolveRule(key, bag).result is bool b && b);