Skip to main content

Expression Decision Tables SQL Server

1. What this document is about

This document explains Expression Decision Tables as a mehanism for modeling and executing policy-oriented business decisions where rules are expressed as bounded, evaluable expressions, stored as data and executed by the application at runtime.

It focuses on:

  • Externalizing volatile decision policies from application code, not replacing domain logic
  • Executing conditional decision logic via controlled expression evaluation rather than hard-coded branching
  • Using SQL Server as the system of record for rule definition, versioning, and auditability, while keeping execution and safety guarantees in the application layer
  • Delegating expression parsing, validation, and execution to .NET to preserve runtime control, determinism, and observability
  • This approach applies to systems where decision policies evolve faster than deployment cycles and where auditability, explainability, and controlled change are non-negotiable.

It does not apply to:

  • Core domain invariants or state transitions that require compile-time guarantees
  • Simple, static decision logic with low change frequency
  • Latency-critical inner loops where dynamic evaluation is unacceptable
  • Systems that cannot tolerate runtime rule misconfiguration without strong validation, testing and rollback mechanisms

2. Why this matters in real systems

Expression Decision Tables emerge when systems reach a point where hard-coded conditional logic becomes a liability.

Typical pressures include:

  • Business rules changing independently of application releases
  • Multiple dimensions of decision criteria interacting combinatorially
  • Regulatory requirements demanding tracebility and explanation
  • Multiple teams contributing rules without owning the full codebase

What tends to break when this is ignored:

  • Conditional logic sprawls across services and layers
  • Releases become blocked by "small rule changes"
  • Engineers become de facto rule managers
  • Audits require reverse-engineering production code

Why simpler approaches stop working:

  • if/else trees do not scale cognitively
  • Features flags are not expressive enough
  • Configuration-based switches collapse under combinatorial growth

Ai this point, decisions must become data.


3. Core concept (mental model)

An Expression Decision Table is best understood as a deterministic evaluation pipeline:

Input facts → rule selection → expression evaluation → outcome

Each row in the table represents a candidate decision, defined by:

  • A condition expression that determines applicability
  • One or more outcome expressions that produce results
  • Metadata that governs ordering, versioning and lifecycle

The system evaluates rules in a controlled sequence, selecting the first (or all) matching expressions and producing a result that is explainable and repeatable.

This is not a rules engine magic box.

It is structured indirection.


4. How it works (step-by-step)

Step 1 — Inputs are normalized

Incoming data is projected into a stable, known shape.

Why:

  • Expression must not depend on raw transport formats
  • Input shape stability is an invariant

Step 2 — Candidate rules are selected

Rules are filtered by context (domain, type, version, validity window).

Why:

  • Prevents accidental cross-domain rule leakage
  • Enables safe coexistence of versions

Step 3 — Conditions are evaluated

Each rule's condition expression is evaluated against the input.

Assumptions:

  • Expressions are side-effect free
  • Evaluation is deterministic

Step 4 — Rule ordering is enforced

Priority, salience or explicit ordering resolves conflicts.

Why:

  • Avoids ambiguous outcomes
  • Makes behavior predictable and auditable

Step 5 — Outcomes are produces

The selected rule's outcome expressions are evaluated.

Invariant:

  • Outcomes must be explainable as a function of inputs + rule definition

Step 6 — Decision is recorded

Inputs, rule ID and outputs are persisted (optional).

Why:

  • Auditability
  • Debugging
  • Regulatory compliance

5. Minimal concrete example

5.1 — SQL Server

5.1.1 — Ruleset lifecycle

CREATE SCHEMA decision AUTHORIZATION dbo;
GO

CREATE TABLE decision.RuleSet (
RuleSetId INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
Name NVARCHAR(120) NOT NULL,
Version INT NOT NULL,
Status VARCHAR(20) NOT NULL, -- Draft | Published | Deprecated
Description NVARCHAR(500) NULL,
CreatedAtUtc DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
CreatedBy NVARCHAR(120) NOT NULL,
PublishedAtUtc DATETIME2(3) NULL,
PublishedBy NVARCHAR(120) NULL,
CONSTRAINT UQ_RuleSet_Name_Version UNIQUE(Name, Version),
CONSTRAINT CK_RuleSet_Status CHECK (Status IN ('Draft','Published','Deprecated'))
);
GO

-- Fast pointer to current published version (per ruleset name)
CREATE TABLE decision.RuleSetPointer (
Name NVARCHAR(120) NOT NULL PRIMARY KEY,
PublishedVersion INT NOT NULL,
UpdatedAtUtc DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME()
);
GO

5.1.2 — Rules (expressions + JSON outcomes)

CREATE TABLE decision.Rule (
RuleId BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
RuleSetId INT NOT NULL,
Priority INT NOT NULL, -- lower executes first
StopOnMatch BIT NOT NULL DEFAULT 1, -- 1 = first-match; 0 = multi-hit
IsActive BIT NOT NULL DEFAULT 1,

ConditionExpr NVARCHAR(MAX) NOT NULL, -- must evaluate to boolean
OutcomeJson NVARCHAR(MAX) NOT NULL, -- effects + reasons (JSON)

ValidFromUtc DATETIME2(3) NULL,
ValidToUtc DATETIME2(3) NULL,

-- Hash (tamper detection + audit correlation)
ExprHash VARBINARY(32) NOT NULL,

CreatedAtUtc DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
CreatedBy NVARCHAR(120) NOT NULL,

CONSTRAINT FK_Rule_RuleSet FOREIGN KEY (RuleSetId) REFERENCES decision.RuleSet(RuleSetId),
CONSTRAINT CK_Rule_Validity CHECK (ValidToUtc IS NULL OR ValidFromUtc IS NULL OR ValidToUtc > ValidFromUtc)
);
GO

CREATE INDEX IX_Rule_Execution
ON decision.Rule (RuleSetId, IsActive, Priority, RuleId)
INCLUDE (StopOnMatch, ValidFromUtc, ValidToUtc);
GO

5.1.3 — Decision audit log

CREATE TABLE decision.DecisionLog (
DecisionId UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
RuleSetName NVARCHAR(120) NOT NULL,
RuleSetVersion INT NOT NULL,

RequestedAtUtc DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
DurationMs INT NOT NULL,

InputJson NVARCHAR(MAX) NOT NULL,
OutputJson NVARCHAR(MAX) NOT NULL,

AppliedRuleIdsJson NVARCHAR(MAX) NOT NULL, -- e.g. [1010,1020,1099]
TraceJson NVARCHAR(MAX) NULL, -- optional evaluation trace

-- useful correlation fields
CorrelationId NVARCHAR(100) NULL,
TenantId NVARCHAR(80) NULL
);
GO

5.1.4 — Test vector (publish-time gate)

CREATE TABLE decision.RuleSetTest (
TestId BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
RuleSetId INT NOT NULL,
Name NVARCHAR(200) NOT NULL,
InputJson NVARCHAR(MAX) NOT NULL,
ExpectedJson NVARCHAR(MAX) NOT NULL, -- expected outputs (subset allowed)
IsActive BIT NOT NULL DEFAULT 1,
CreatedAtUtc DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
CreatedBy NVARCHAR(120) NOT NULL,
CONSTRAINT FK_RuleSetTest_RuleSet FOREIGN KEY (RuleSetId) REFERENCES decision.RuleSet(RuleSetId)
);
GO

5.2 — SQL Server core stored procedures

5.2.1 — Create a new draft version

CREATE OR ALTER PROCEDURE decision.CreateDraftRuleSet
@Name NVARCHAR(120),
@Description NVARCHAR(500) = NULL,
@CreatedBy NVARCHAR(120),
@NewVersion INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;

DECLARE @NextVersion INT = ISNULL(
(SELECT MAX(Version) FROM decision.RuleSet WHERE Name = @Name), 0
) + 1;

INSERT INTO decision.RuleSet (Name, Version, Status, Description, CreatedBy)
VALUES (@Name, @NextVersion, 'Draft', @Description, @CreatedBy);

SET @NewVersion = @NextVersion;
END
GO

5.2.2 — Insert rule into a draft

We hash the expressions at insert-time (still validated by .NET).

CREATE OR ALTER PROCEDURE decision.AddRule
@RuleSetName NVARCHAR(120),
@RuleSetVersion INT,
@Priority INT,
@StopOnMatch BIT,
@ConditionExpr NVARCHAR(MAX),
@OutcomeJson NVARCHAR(MAX),
@CreatedBy NVARCHAR(120),
@ValidFromUtc DATETIME2(3) = NULL,
@ValidToUtc DATETIME2(3) = NULL
AS
BEGIN
SET NOCOUNT ON;

DECLARE @RuleSetId INT;

SELECT @RuleSetId = RuleSetId
FROM decision.RuleSet
WHERE Name = @RuleSetName AND Version = @RuleSetVersion AND Status = 'Draft';

IF @RuleSetId IS NULL
THROW 50010, 'Draft ruleset not found.', 1;

DECLARE @ExprHash VARBINARY(32) =
HASHBYTES('SHA2_256', CONCAT(@ConditionExpr, CHAR(10), @OutcomeJson));

INSERT INTO decision.Rule (RuleSetId, Priority, StopOnMatch, ConditionExpr, OutcomeJson, ExprHash, CreatedBy, ValidFromUtc, ValidToUtc)
VALUES (@RuleSetId, @Priority, @StopOnMatch, @ConditionExpr, @OutcomeJson, @ExprHash, @CreatedBy, @ValidFromUtc, @ValidToUtc);
END
GO

5.2.3 — Get published rules (deterministic selection)

CREATE OR ALTER PROCEDURE decision.GetPublishedRules
@RuleSetName NVARCHAR(120)
AS
BEGIN
SET NOCOUNT ON;

DECLARE @Version INT =
(SELECT PublishedVersion FROM decision.RuleSetPointer WHERE Name = @RuleSetName);

IF @Version IS NULL
THROW 50001, 'No published ruleset version found.', 1;

DECLARE @RuleSetId INT =
(SELECT RuleSetId FROM decision.RuleSet WHERE Name = @RuleSetName AND Version = @Version AND Status = 'Published');

IF @RuleSetId IS NULL
THROW 50002, 'Published ruleset not found or pointer inconsistent.', 1;

SELECT
r.RuleId,
rs.Name AS RuleSetName,
rs.Version AS RuleSetVersion,
r.Priority,
r.StopOnMatch,
r.ConditionExpr,
r.OutcomeJson,
r.ExprHash
FROM decision.Rule r
JOIN decision.RuleSet rs ON rs.RuleSetId = r.RuleSetId
WHERE r.RuleSetId = @RuleSetId
AND r.IsActive = 1
AND (r.ValidFromUtc IS NULL OR r.ValidFromUtc <= SYSUTCDATETIME())
AND (r.ValidToUtc IS NULL OR r.ValidToUtc > SYSUTCDATETIME())
ORDER BY r.Priority ASC, r.RuleId ASC;
END
GO

5.2.4 — Publish a draft version (atomic pointer update)

Important: this procedure assumes .NET already validated + tested. SQL only enforces lifecycle.

CREATE OR ALTER PROCEDURE decision.PublishRuleSet
@RuleSetName NVARCHAR(120),
@RuleSetVersion INT,
@PublishedBy NVARCHAR(120)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;

BEGIN TRAN;

-- Lock the draft row
UPDATE decision.RuleSet
SET Status = Status
WHERE Name = @RuleSetName AND Version = @RuleSetVersion AND Status = 'Draft';

IF @@ROWCOUNT = 0
BEGIN
ROLLBACK;
THROW 50020, 'Draft ruleset not found or not in Draft status.', 1;
END

-- Mark as Published
UPDATE decision.RuleSet
SET Status = 'Published',
PublishedAtUtc = SYSUTCDATETIME(),
PublishedBy = @PublishedBy
WHERE Name = @RuleSetName AND Version = @RuleSetVersion;

-- Update pointer (upsert)
MERGE decision.RuleSetPointer AS t
USING (SELECT @RuleSetName AS Name, @RuleSetVersion AS PublishedVersion) AS s
ON (t.Name = s.Name)
WHEN MATCHED THEN
UPDATE SET PublishedVersion = s.PublishedVersion, UpdatedAtUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (Name, PublishedVersion) VALUES (s.Name, s.PublishedVersion);

COMMIT;
END
GO

5.2.5 — Rollback

CREATE OR ALTER PROCEDURE decision.RollbackRuleSetPointer
@RuleSetName NVARCHAR(120),
@ToVersion INT
AS
BEGIN
SET NOCOUNT ON;

IF NOT EXISTS (
SELECT 1 FROM decision.RuleSet
WHERE Name = @RuleSetName AND Version = @ToVersion AND Status = 'Published'
)
THROW 50030, 'Target version is not published.', 1;

UPDATE decision.RuleSetPointer
SET PublishedVersion = @ToVersion,
UpdatedAtUtc = SYSUTCDATETIME()
WHERE Name = @RuleSetName;

IF @@ROWCOUNT = 0
INSERT INTO decision.RuleSetPointer (Name, PublishedVersion) VALUES (@RuleSetName, @ToVersion);
END
GO

5.3 — Expression language (bounded DSL contract)

This needs to be explicit and enforced.

5.3.1 — Supported constructs

  • Identifiers: customer.type, customer.kycLevel, risk.score, order.amount, partner.tir, order.channel
  • Boolean operators: &&, ||, !
  • Comparasion: ==, !=, >, >=, <, <=
  • Literals: string 'VIP', numbers 812, booleans true/false, null null
  • Functions (example set):
    • colesce(a, b)
    • max(a, b)
    • in(x, a, b, c) (optional)

5.3.2 — Forbidden constructs

  • Method calls outside allowlist
  • Reflection/dynamic invocation
  • Object creation
  • Any IO/side effects

5.4 — .NET complete runtime

5.4.1 — Contracts

public sealed record DecisionRequest(
Guid DecisionId,
string RuleSetName,
IReadOnlyDictionary<string, object?> Facts,
string? CorrelationId = null,
string? TenantId = null);

public sealed record DecisionResult(
Guid DecisionId,
string RuleSetName,
int RuleSetVersion,
IReadOnlyDictionary<string, object?> Outputs,
IReadOnlyList<string> Reasons,
IReadOnlyList<long> AppliedRuleIds,
int DurationMs,
string? TraceJson);

5.4.2 — Rule DTO from SQL

public sealed record RuleRow(
long RuleId,
string RuleSetName,
int RuleSetVersion,
int Priority,
bool StopOnMatch,
string ConditionExpr,
string OutcomeJson,
byte[] ExprHash);

5.4.3 — Repository (Dapper)

using System.Data;
using Dapper;

public interface IRuleRepository
{
Task<IReadOnlyList<RuleRow>> GetPublishedRulesAsync(string ruleSetName, CancellationToken ct);
}

public sealed class SqlRuleRepository : IRuleRepository
{
private readonly Func<IDbConnection> _connectionFactory;

public SqlRuleRepository(Func<IDbConnection> connectionFactory)
=> _connectionFactory = connectionFactory;

public async Task<IReadOnlyList<RuleRow>> GetPublishedRulesAsync(string ruleSetName, CancellationToken ct)
{
using var con = _connectionFactory();
var rows = await con.QueryAsync<RuleRow>(
new CommandDefinition(
"decision.GetPublishedRules",
new { RuleSetName = ruleSetName },
commandType: CommandType.StoredProcedure,
cancellationToken: ct));

return rows.AsList();
}
}

5.4.4 — Cache key (versioned)

We cache complited delegats by (RuleSetName, RuleSetVersion). The version comes from the rule rows.

public readonly record struct RuleSetCacheKey(string Name, int Version);

5.4.5 — Compiled rule (cached unit)

public sealed record CompiledRule(
long RuleId,
bool StopOnMatch,
Func<EvalContext, bool> Condition,
Action<EvalContext> ApplyOutcome,
byte[] ExprHash);

5.4.6 — Evaluation context + trace

using System.Text.Json;

public sealed class EvalContext
{
public IReadOnlyDictionary<string, object?> Facts { get; }
public IDictionary<string, object?> Outputs { get; } = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
public List<string> Reasons { get; } = new();
public List<object> Trace { get; } = new();

public EvalContext(IReadOnlyDictionary<string, object?> facts)
=> Facts = facts;

public object? GetValue(string name)
{
// allow reading outputs as variables as well (for stacking rules)
if (Outputs.TryGetValue(name, out var o)) return o;
if (Facts.TryGetValue(name, out var f)) return f;
return null;
}

public void SetOutput(string key, object? value) => Outputs[key] = value;
}

5.5 — Safe expression evalution

5.5.1 — Allowlist registry

public sealed class ExpressionPolicy
{
public HashSet<string> AllowedIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, Delegate> AllowedFunctions { get; } = new(StringComparer.OrdinalIgnoreCase);

public ExpressionPolicy AllowIdentifier(params string[] names)
{
foreach (var n in names) AllowedIdentifiers.Add(n);
return this;
}

public ExpressionPolicy AllowFunction(string name, Delegate fn)
{
AllowedFunctions[name] = fn;
return this;
}

public static ExpressionPolicy Default()
=> new ExpressionPolicy()
.AllowIdentifier(
"customer.type",
"customer.kycLevel",
"customer.hasPriorDefault",
"customer.country",
"risk.score",
"order.amount",
"order.channel",
"partner.tier",
// outputs can be read too:
"discountRate",
"riskTier",
"eligible",
"denyReason",
"pricingStrategy")
.AllowFunction("coalesce", (Func<object?, object?, object?>)((a, b) => a ?? b))
.AllowFunction("max", (Func<decimal, decimal, decimal>)((a, b) => Math.Max(a, b)));
}

5.5.2 — Expression compiler interface

public interface IExpressionCompiler
{
Func<EvalContext, bool> CompileCondition(string expr);
Action<EvalContext> CompileOutcome(string outcomeJson);
void ValidateCondition(string expr);
void ValidateOutcomeJson(string outcomeJson);
}

5.5.3 — Compiler implementation (validation + compilation + sandbox)

using System.Text.Json;
using DynamicExpresso;

public sealed class SafeExpressionCompiler : IExpressionCompiler
{
private readonly ExpressionPolicy _policy;

public SafeExpressionCompiler(ExpressionPolicy policy)
=> _policy = policy;

public void ValidateCondition(string expr)
{
// parse without executing, validate tokens/identifiers
_ = BuildInterpreterForValidation();
ValidateIdentifiers(expr);
// optionally: parse expression to ensure syntactic correctness
var interpreter = BuildInterpreterForValidation();
interpreter.Parse(expr, typeof(bool));
}

public void ValidateOutcomeJson(string outcomeJson)
{
using var doc = JsonDocument.Parse(outcomeJson);

if (!doc.RootElement.TryGetProperty("outputs", out var outputs) || outputs.ValueKind != JsonValueKind.Object)
throw new InvalidOperationException("OutcomeJson must contain 'outputs' object.");

if (doc.RootElement.TryGetProperty("reasons", out var reasons) &&
reasons.ValueKind != JsonValueKind.Array)
throw new InvalidOperationException("'reasons' must be an array when present.");

// Validate every expression string inside outputs/reasons
foreach (var p in outputs.EnumerateObject())
{
if (p.Value.ValueKind != JsonValueKind.String)
throw new InvalidOperationException($"Output '{p.Name}' must be a string expression.");

ValidateIdentifiers(p.Value.GetString()!);
BuildInterpreterForValidation().Parse(p.Value.GetString()!, typeof(object));
}

if (doc.RootElement.TryGetProperty("reasons", out var rr) && rr.ValueKind == JsonValueKind.Array)
{
foreach (var r in rr.EnumerateArray())
{
if (r.ValueKind != JsonValueKind.String)
throw new InvalidOperationException("Each reason must be a string expression.");

ValidateIdentifiers(r.GetString()!);
BuildInterpreterForValidation().Parse(r.GetString()!, typeof(object));
}
}
}

public Func<EvalContext, bool> CompileCondition(string expr)
{
ValidateCondition(expr);

return ctx =>
{
var interpreter = BuildInterpreter(ctx);

var lambda = interpreter.Parse(expr, typeof(bool));
var value = lambda.Invoke();

return value is bool b && b;
};
}

public Action<EvalContext> CompileOutcome(string outcomeJson)
{
ValidateOutcomeJson(outcomeJson);

using var doc = JsonDocument.Parse(outcomeJson);

var outputs = doc.RootElement.GetProperty("outputs");
var outputAssignments = outputs.EnumerateObject()
.Select(p => (Key: p.Name, Expr: p.Value.GetString()!))
.ToList();

var reasonsExpr = new List<string>();
if (doc.RootElement.TryGetProperty("reasons", out var rr) && rr.ValueKind == JsonValueKind.Array)
reasonsExpr = rr.EnumerateArray().Select(x => x.GetString()!).ToList()!;

return ctx =>
{
var interpreter = BuildInterpreter(ctx);

foreach (var (key, expr) in outputAssignments)
{
var lambda = interpreter.Parse(expr, typeof(object));
var v = lambda.Invoke();
ctx.SetOutput(key, NormalizeJsonScalar(v));
}

foreach (var rexpr in reasonsExpr)
{
var lambda = interpreter.Parse(rexpr, typeof(object));
var v = lambda.Invoke();
if (v != null) ctx.Reasons.Add(v.ToString()!);
}
};
}

private Interpreter BuildInterpreter(EvalContext ctx)
{
var interpreter = new Interpreter(InterpreterOptions.Default);

// Expose a single "get" function to access facts/outputs by name
interpreter.SetFunction("get", (Func<string, object?>)(name => ctx.GetValue(name)));

// Bind allowed identifiers as variables resolved via get(name)
foreach (var id in _policy.AllowedIdentifiers)
{
// Variables become calls to get("id")
// We'll inject them as parameters by setting variables dynamically:
interpreter.SetVariable(id, ctx.GetValue(id));
}

// Bind allowed functions
foreach (var (name, fn) in _policy.AllowedFunctions)
interpreter.SetFunction(name, fn);

return interpreter;
}

private Interpreter BuildInterpreterForValidation()
{
var interpreter = new Interpreter(InterpreterOptions.Default);

// allow parsing of identifiers and functions only
foreach (var id in _policy.AllowedIdentifiers)
interpreter.SetVariable(id, null);

foreach (var (name, fn) in _policy.AllowedFunctions)
interpreter.SetFunction(name, fn);

interpreter.SetFunction("get", (Func<string, object?>)(_ => null));

return interpreter;
}

private void ValidateIdentifiers(string expr)
{
// This is intentionally conservative.
// Strategy: scan for tokens that look like identifiers with dots: foo.bar
// Then enforce allowlist.
// In a stricter system, you'd parse AST and walk identifier nodes.
var candidates = ExtractIdentifierCandidates(expr);

foreach (var c in candidates)
{
// Functions like max(...) are handled separately; ignore if function name.
if (_policy.AllowedFunctions.ContainsKey(c)) continue;

if (!_policy.AllowedIdentifiers.Contains(c) &&
!IsLiteralOrKeyword(c))
throw new InvalidOperationException($"Identifier '{c}' is not allowed by policy.");
}
}

private static IReadOnlyList<string> ExtractIdentifierCandidates(string expr)
{
// minimal tokenizer: extract sequences of [a-zA-Z0-9_.]
// and keep those containing at least one dot (customer.type)
var list = new List<string>();
var token = new System.Text.StringBuilder();

void flush()
{
if (token.Length == 0) return;
var t = token.ToString();
token.Clear();

if (t.Contains('.') && t.Any(char.IsLetter))
list.Add(t);
}

foreach (var ch in expr)
{
if (char.IsLetterOrDigit(ch) || ch == '_' || ch == '.')
token.Append(ch);
else
flush();
}
flush();

return list;
}

private static bool IsLiteralOrKeyword(string t)
=> t.Equals("true", StringComparison.OrdinalIgnoreCase)
|| t.Equals("false", StringComparison.OrdinalIgnoreCase)
|| t.Equals("null", StringComparison.OrdinalIgnoreCase);

private static object? NormalizeJsonScalar(object? v)
{
// Normalize common scalar types to predictable JSON-friendly types
return v switch
{
null => null,
int or long or short or byte => Convert.ToInt64(v),
float or double or decimal => Convert.ToDecimal(v),
bool => v,
string => v,
_ => v.ToString()
};
}
}

5.6 — Compilation + caching (compiled delegates per published version)

using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;

public sealed class CompiledRuleSet
{
public string RuleSetName { get; init; } = default!;
public int RuleSetVersion { get; init; }
public IReadOnlyList<CompiledRule> Rules { get; init; } = Array.Empty<CompiledRule>();
}

public interface IRuleSetCompiler
{
CompiledRuleSet Compile(IReadOnlyList<RuleRow> rows);
}

public sealed class RuleSetCompiler : IRuleSetCompiler
{
private readonly IExpressionCompiler _compiler;

public RuleSetCompiler(IExpressionCompiler compiler) => _compiler = compiler;

public CompiledRuleSet Compile(IReadOnlyList<RuleRow> rows)
{
if (rows.Count == 0)
throw new InvalidOperationException("No rules returned for published ruleset.");

var name = rows[0].RuleSetName;
var version = rows[0].RuleSetVersion;

var compiled = rows
.OrderBy(r => r.Priority).ThenBy(r => r.RuleId)
.Select(r => new CompiledRule(
r.RuleId,
r.StopOnMatch,
_compiler.CompileCondition(r.ConditionExpr),
_compiler.CompileOutcome(r.OutcomeJson),
r.ExprHash))
.ToList();

return new CompiledRuleSet
{
RuleSetName = name,
RuleSetVersion = version,
Rules = compiled
};
}
}

5.7 — Decision Engine (complete orchestrator + trace + audit)

using System.Diagnostics;
using System.Text.Json;

public interface IDecisionLogger
{
Task LogAsync(DecisionRequest req, DecisionResult res, CancellationToken ct);
}

public sealed class DecisionEngine
{
private readonly IRuleRepository _repo;
private readonly IRuleSetCompiler _compiler;
private readonly IMemoryCache _cache;
private readonly IDecisionLogger _logger;

public DecisionEngine(IRuleRepository repo, IRuleSetCompiler compiler, IMemoryCache cache, IDecisionLogger logger)
{
_repo = repo;
_compiler = compiler;
_cache = cache;
_logger = logger;
}

public async Task<DecisionResult> EvaluateAsync(DecisionRequest request, CancellationToken ct)
{
var sw = Stopwatch.StartNew();

var rows = await _repo.GetPublishedRulesAsync(request.RuleSetName, ct);
var ruleSetName = rows[0].RuleSetName;
var version = rows[0].RuleSetVersion;

var cacheKey = new RuleSetCacheKey(ruleSetName, version);

// Cache compiled delegates per published version
var compiled = _cache.GetOrCreate(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SlidingExpiration = TimeSpan.FromMinutes(2);
return _compiler.Compile(rows);
})!;

var ctx = new EvalContext(request.Facts);
var applied = new List<long>();

foreach (var rule in compiled.Rules)
{
bool matched;
try
{
matched = rule.Condition(ctx);
}
catch (Exception ex)
{
// hard fail (policy decision): invalid runtime evaluation is an incident
ctx.Trace.Add(new { ruleId = rule.RuleId, matched = (bool?)null, error = ex.GetType().Name });
throw;
}

ctx.Trace.Add(new { ruleId = rule.RuleId, matched });

if (!matched) continue;

rule.ApplyOutcome(ctx);
applied.Add(rule.RuleId);

if (rule.StopOnMatch) break;
}

sw.Stop();

var output = new Dictionary<string, object?>(ctx.Outputs, StringComparer.OrdinalIgnoreCase);
var reasons = ctx.Reasons.AsReadOnly();

var result = new DecisionResult(
request.DecisionId,
compiled.RuleSetName,
compiled.RuleSetVersion,
output,
reasons,
applied.AsReadOnly(),
(int)sw.ElapsedMilliseconds,
TraceJson: JsonSerializer.Serialize(ctx.Trace));

await _logger.LogAsync(request, result, ct);
return result;
}
}

5.8 — Decision Logger (persist audit log)

using System.Data;
using Dapper;
using System.Text.Json;

public sealed class SqlDecisionLogger : IDecisionLogger
{
private readonly Func<IDbConnection> _connectionFactory;

public SqlDecisionLogger(Func<IDbConnection> connectionFactory)
=> _connectionFactory = connectionFactory;

public async Task LogAsync(DecisionRequest req, DecisionResult res, CancellationToken ct)
{
using var con = _connectionFactory();

var inputJson = JsonSerializer.Serialize(req.Facts);
var outputJson = JsonSerializer.Serialize(new
{
outputs = res.Outputs,
reasons = res.Reasons
});

var appliedJson = JsonSerializer.Serialize(res.AppliedRuleIds);

const string sql = @"
INSERT INTO decision.DecisionLog
(DecisionId, RuleSetName, RuleSetVersion, RequestedAtUtc, DurationMs, InputJson, OutputJson, AppliedRuleIdsJson, TraceJson, CorrelationId, TenantId)
VALUES
(@DecisionId, @RuleSetName, @RuleSetVersion, SYSUTCDATETIME(), @DurationMs, @InputJson, @OutputJson, @AppliedRuleIdsJson, @TraceJson, @CorrelationId, @TenantId);";

await con.ExecuteAsync(new CommandDefinition(sql, new
{
DecisionId = res.DecisionId,
RuleSetName = res.RuleSetName,
RuleSetVersion = res.RuleSetVersion,
DurationMs = res.DurationMs,
InputJson = inputJson,
OutputJson = outputJson,
AppliedRuleIdsJson = appliedJson,
TraceJson = res.TraceJson,
CorrelationId = req.CorrelationId,
TenantId = req.TenantId
}, cancellationToken: ct));
}
}

5.9 — Publish-time gate (validation + test vectors)

A publish pipeline must reject broken rules.

5.9.1 — Publish workflow

  1. Load draft ruleset rules
  2. Validate every ConditionExpr
  3. Validate every OutcomeJson
  4. Execute all active tests (RuleSetTest)
  5. If all pass:
    • call decision.PublishRuleSet

5.9.2 — .NET publish service

using System.Text.Json;
using Dapper;
using System.Data;

public sealed class RuleSetPublisher
{
private readonly Func<IDbConnection> _conFactory;
private readonly SafeExpressionCompiler _compiler;
private readonly DecisionEngine _engine;

public RuleSetPublisher(Func<IDbConnection> conFactory, SafeExpressionCompiler compiler, DecisionEngine engine)
{
_conFactory = conFactory;
_compiler = compiler;
_engine = engine;
}

public async Task PublishAsync(string ruleSetName, int version, string publishedBy, CancellationToken ct)
{
// 1) load draft rules
var draftRules = await LoadDraftRulesAsync(ruleSetName, version, ct);

// 2) validate expressions
foreach (var r in draftRules)
{
_compiler.ValidateCondition(r.ConditionExpr);
_compiler.ValidateOutcomeJson(r.OutcomeJson);
}

// 3) run test vectors
var tests = await LoadTestsAsync(ruleSetName, version, ct);
foreach (var t in tests)
{
var req = new DecisionRequest(
DecisionId: Guid.NewGuid(),
RuleSetName: ruleSetName,
Facts: t.Facts,
CorrelationId: $"publish-test:{t.TestId}");

var result = await _engine.EvaluateAsync(req, ct);

if (!MatchesExpectedSubset(result.Outputs, result.Reasons, t.Expected))
throw new InvalidOperationException($"Publish blocked: test '{t.Name}' failed (TestId={t.TestId}).");
}

// 4) publish in SQL (atomic pointer update)
using var con = _conFactory();
await con.ExecuteAsync(new CommandDefinition(
"decision.PublishRuleSet",
new { RuleSetName = ruleSetName, RuleSetVersion = version, PublishedBy = publishedBy },
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
}

private static bool MatchesExpectedSubset(
IReadOnlyDictionary<string, object?> outputs,
IReadOnlyList<string> reasons,
ExpectedDecision expected)
{
// ExpectedJson is treated as subset:
// - expected.outputs must match (if present)
// - expected.reasons must be contained (if present)
foreach (var (k, v) in expected.ExpectedOutputs)
{
if (!outputs.TryGetValue(k, out var actual)) return false;
if (!Equals(Normalize(actual), Normalize(v))) return false;
}

foreach (var r in expected.ExpectedReasons)
if (!reasons.Contains(r)) return false;

return true;
}

private static object? Normalize(object? x)
=> x switch
{
JsonElement je => je.ToString(),
_ => x
};

private async Task<IReadOnlyList<RuleRow>> LoadDraftRulesAsync(string name, int version, CancellationToken ct)
{
using var con = _conFactory();
const string sql = @"
SELECT
r.RuleId,
rs.Name AS RuleSetName,
rs.Version AS RuleSetVersion,
r.Priority,
r.StopOnMatch,
r.ConditionExpr,
r.OutcomeJson,
r.ExprHash
FROM decision.Rule r
JOIN decision.RuleSet rs ON rs.RuleSetId = r.RuleSetId
WHERE rs.Name = @Name AND rs.Version = @Version AND rs.Status = 'Draft'
ORDER BY r.Priority ASC, r.RuleId ASC;";

var rows = await con.QueryAsync<RuleRow>(new CommandDefinition(sql, new { Name = name, Version = version }, cancellationToken: ct));
return rows.AsList();
}

private async Task<IReadOnlyList<TestVector>> LoadTestsAsync(string name, int version, CancellationToken ct)
{
using var con = _conFactory();
const string sql = @"
SELECT t.TestId, t.Name, t.InputJson, t.ExpectedJson
FROM decision.RuleSetTest t
JOIN decision.RuleSet rs ON rs.RuleSetId = t.RuleSetId
WHERE rs.Name = @Name AND rs.Version = @Version AND rs.Status = 'Draft'
AND t.IsActive = 1;";

var raw = await con.QueryAsync(new CommandDefinition(sql, new { Name = name, Version = version }, cancellationToken: ct));

var list = new List<TestVector>();
foreach (var r in raw)
{
var facts = JsonSerializer.Deserialize<Dictionary<string, object?>>( (string)r.InputJson )!;
var expected = JsonSerializer.Deserialize<ExpectedDecision>( (string)r.ExpectedJson )!;
list.Add(new TestVector((long)r.TestId, (string)r.Name, facts, expected));
}
return list;
}
}

public sealed record ExpectedDecision(
Dictionary<string, object?> ExpectedOutputs,
List<string> ExpectedReasons);

public sealed record TestVector(long TestId, string Name, Dictionary<string, object?> Facts, ExpectedDecision Expected);

5.10 — Example (Eligibility + Pricing)

5.10.1 — Facts (normalized)

{
"customer.type": "VIP",
"customer.kycLevel": "FULL",
"customer.hasPriorDefault": false,
"risk.score": 812,
"order.amount": 15500.00,
"order.channel": "WEB",
"partner.tier": "GOLD"
}

5.10.2 — RuleSet name

  • RuleSetName = 'EligibilityAndPricing'
  • published pointer points to e.g. Version = 42

5.10.3 — Rule inserted

Rule 1001 — deny is KYC not FULL (hard stop)

Condition:

customer.kycLevel != 'FULL'

Outcome:

{
"outputs": {
"eligible": "false",
"denyReason": "'KYC_INSUFFICIENT'",
"riskTier": "null",
"discountRate": "0.0",
"pricingStrategy": "'DENY'"
},
"reasons": [
"'ELIGIBILITY_DENIED'",
"'KYC_REQUIRED'"
]
}

Rule 1010 — VIP high-score base pricing (multi-hit)

Condition:

customer.type == 'VIP' && risk.score >= 800 && order.amount >= 10000

Outcome:

{
"outputs": {
"eligible": "true",
"riskTier": "'A'",
"discountRate": "0.10",
"pricingStrategy": "'VIP_HIGH_SCORE'"
},
"reasons": [
"'VIP_CUSTOMER'",
"'HIGH_RISK_SCORE'",
"'HIGH_AMOUNT'"
]
}

**Rule 1020 — GOLD partner web override (multi-hit, stacks)

Condition:

partner.tier == 'GOLD' && order.channel == 'WEB'

Outcome:

{
"outputs": {
"discountRate": "max((decimal)get('discountRate'), 0.12)",
"partnerBenefit": "'GOLD_WEB_BONUS'"
},
"reasons": [
"'PARTNER_TIER_GOLD'",
"'WEB_CHANNEL_BONUS'"
]
}

Rule 1099 — default fallback finalize (stop)

Condition:

true

Outcome:

{
"outputs": {
"eligible": "coalesce(get('eligible'), true)",
"riskTier": "coalesce(get('riskTier'), 'B')",
"discountRate": "coalesce((decimal)get('discountRate'), 0.00)",
"pricingStrategy": "coalesce(get('pricingStrategy'), 'DEFAULT')"
},
"reasons": [
"'DEFAULT_FALLBACK'"
]
}

5.10.4 — Expected runtime output

Given the facts above, the expected output is:

{
"eligible": true,
"riskTier": "A",
"discountRate": 0.12,
"pricingStrategy": "VIP_HIGH_SCORE",
"partnerBenefit": "GOLD_WEB_BONUS",
"reasons": [
"VIP_CUSTOMER",
"HIGH_RISK_SCORE",
"HIGH_AMOUNT",
"PARTNER_TIER_GOLD",
"WEB_CHANNEL_BONUS",
"DEFAULT_FALLBACK"
],
"appliedRuleIds": [1010, 1020, 1099]
}

And DecisionLog.TraceJson will resemble:

[
{ "ruleId": 1001, "matched": false },
{ "ruleId": 1010, "matched": true },
{ "ruleId": 1020, "matched": true },
{ "ruleId": 1099, "matched": true }
]

6. Design trade-offs

AspectExpression Decision TablesHardcoded Logic
FlexibilityHighLow
Runtime SafetyMedium (must be enforced)High
Change VelocityHighLow
Debug ComplexityHigherLower
AuditabilityFirst-classEmergent

What you gain:

  • Decoupled decision evolution
  • Centralized rule visibility
  • Faster business response

What you give up:

  • Compile-time guarantees
  • Simplicity of static code paths

What you implicitly accept:

  • Runtime validation responsibility
  • Operational discipline requirements

7. Common mistakes and misconceptions

“Expressions are just config”

Why it happens:

  • Underestimation of execution power

Problem:

  • Unvalidated expressions become production code

Avoidance:

  • Treat expressions as code: validate, test, version

“SQL should evaluate everything”

Why it happens:

  • Desire to keep logic close to data

Problem:

  • Dynamic SQL evaluation becomes unsafe and opaque

Avoidance:

  • Use SQL for selection, not arbitrary execution

“Anyone can edit rules”

Why it happens:

  • Business enablement pressure

Problem:

  • Production outages caused by invalid logic

Avoidance:

  • Gate rule changes through validation pipelines

8. Operational and production considerations

Once deployed:

  • Monitor
    • Rule evaluation failures
    • Evaluation latency
    • Rule hit distribution
  • Degrades first
    • CPU due to expression compilation
    • Cache churn when rules change frequently
  • Becomes expensive
    • Overly granular rules
    • Excessive expression complexity

Operational risks:

  • Expression injection
  • Version drift
  • Silent rule shadowing

Observability signals:

  • RuleId in logs
  • Input snapshot hashes
  • Outcome traces

9. When NOT to use this

Do not use Expression Decision Tables when:

  • Decision logic is stable and rarely changes
  • Latency budgets are sub-millisecond
  • Failures must be caught at compile time
  • The team lacks operational maturity

10. Key takeaways

  • Treat decisions as data only when change velocity justifies it
  • Expression power must be matched with strict validation
  • SQL is a registry and selector, not an execution engine
  • Ordering and versioning are non-negotiable
  • Auditability should be designed, not retrofitted
  • Dynamic rules shift risk from build-time to runtime
  • Operational discipline determines success more than syntax

11. Extra

"Do Expression Decision Tables replace business rules?"

No. Expression Decision Tables do not replace domain business rules. They relocate volatile decision policies out of code and define how those policies are governed.

They eliminate:

  • Scattered if/else for volatile rules
  • Redeployments for policy changes
  • Hidden decision without audit or explanation

They do not replace domain invariants, state transitions or core processes.


The correct mental model

Domain code protects invariants and processes.

Decision Tables govern policies and decisions.

Expression Decision Tables are a policy mechanism, not domain model.


A simple decision heuristic

Ask three questions:

  1. If this rule changes tomorrow, would I accept a redeploy?
    • If no, it's a candidate.
  2. If this rule is misconfigured, can the system become inconsistent?
    • If yes, keep in the domain or behind strong guardrails.
  3. Is this a pure decision (facts → outputs)?
    • If yes, it's a good fit.

Decision Framework: Should this be an Expression Decision Table?

Table-fit score (0–10)

Add the points:

Volatility & governance

  • Changes without redeploys (+2)
  • Needs fast rollback (+1)
  • Requires explainability (+2)

Logic shape

  • Pure decision (+2)
  • High combinatorial complexity (+1)
  • Expressible via bounded DSL (+1)

Operational risk

  • Error tolerable with fallback (+1)
  • Error breaks invariants (−3)

Interpretation

  • ≥7 → strong candidate
  • 4–6 → hybrid
  • ≤3 → domain code

12. High-Level Overview

Visual representation of Expression Decision Tables, emphasizing bounded evaluation, versioned policies, deterministic execution, and auditability.

Scroll to zoom • Drag to pan
Expression Decision Tables — Unified Enterprise Blueprint (SQL Server + .NET Runtime)Expression Decision Tables — Unified Enterprise Blueprint (SQL Server + .NET Runtime)Business API / Domain Service(owns invariants + workflows)Decisioning PlatformAdmin & GovernanceRuntime Engine (.NET)SQL Server (System of Record)ObservabilityDomain code keeps:- invariants- state machine- transactional guaranteesDecision Tables handle:- volatile policies (facts→outputs)- auditability & versioningDecision Admin API(.NET)Publish Orchestrator(.NET Worker/Job)Rollout Controls(Shadow / Canary / LKG)Decision Engine FacadeRule Repository(Dapper/ADO.NET)Compiled RuleSet Cache(IMemoryCache/Redis)Safe Expression Compiler(DSL + Allowlist)Evaluation Pipeline(stop-on-match / multi-hit)Trace Builder(sampled)Decision Audit LoggerTelemetry Adapter(OTel)Decisioning DBTables- decision.RuleSet- decision.RuleSetPointer- decision.Rule- decision.RuleSetTest- decision.DecisionLogStored Procedures- decision.GetPublishedRules- decision.AddRule- decision.PublishRuleSet- decision.RollbackRuleSetPointerCore data model:- Ruleset versioning + status- Pointer: O(1) published version lookup- Rule rows: conditionExpr + outcomeJson + priority + stopOnMatch- Test vectors: publish-time gate- DecisionLog: audit + traceabilityLogs/Metrics/Traces(OpenTelemetry)Dashboards & AlertsPolicy Owner(Analyst)SRE / OperationsEngineers(Domain Teams)1) API normalizes facts (no DB lookups during evaluation)2) Engine loads published ruleset (pointer -> version)3) Cache compiled delegates by (ruleset,version)4) Evaluate:- For each rule by priority:- conditionExpr => bool- if true: apply outcomeJson (outputs + reasons)- stopOnMatch or multi-hit stacking5) Persist DecisionLog:- inputs, outputs, appliedRuleIds, ruleset version, trace6) Emit telemetry:- duration, cache hit/miss, errors, rule hit distributionFlow 1: Runtime Evaluation (facts → outputs)Concrete Applicability Example (Eligibility + Pricing)Facts:- customer.type = VIP- customer.kycLevel = FULL- risk.score = 812- order.amount = 15500- partner.tier = GOLD- order.channel = WEBExpected:- eligible = true- riskTier = 'A'- discountRate = 0.12- reasons include VIP + partner bonusGate 1) Static validation- allowlist identifiers/functions- syntax correctness- complexity budgetsGate 2) Test vectors- Given facts → expected outputs/reasons- publish fails if any test failsPublish Tx (SQL)- status=Published- update pointer atomicallyPost-publish- cache warm for hot rulesets- optional shadow evaluation & canary rolloutFlow 2: Publish Pipeline (Draft → Publish)If runtime errors spike or invalid behavior detected:- Ops switches RuleSetPointer to previous published version- Next request uses prior version immediately- Cache key changes → compiled delegates rebuilt for old versionNo redeploy required.Flow 3: Last-Known-Good (LKG) RollbackSafety contract (bounded DSL)- allowlist identifiers + functions- no reflection/dynamic invocation- no IO, no side effects- publish-time validation preferredRuntime eval failures are incidents.SQL responsibility- system of record- deterministic rule selection- audit persistenceSQL is NOT an expression execution engine.Domain boundaries- invariants stay in code- state transitions stay in code- tables decide policies only (facts→outputs)author draftsrules + testsimplement workflows+ invariantsEvaluate(ruleset, facts)(HTTP/gRPC/in-proc)rollback / rollout ops(pointer switch)trigger publish jobapply rollout policy(shadow/canary)emit signalsderive alertsmonitor incidents(error spikes, latency)SELECT published rules(via SP)INSERT DecisionLogmanage drafts/tests(metadata)validate/test/publish(pointer update)warm cache(optional)load published rules(name -> version)getOrCreate(key=(name,version))compile delegates(on miss)run evaluation loopappend per-rule eventspersist decision auditemit decision telemetryReading Guide1) Runtime Evaluation (facts → outputs)2) Publishing Pipeline (draft → validate → test → publish)3) Rollback / LKG (pointer switch)Non-negotiable invariants- SQL selects + stores + audits- .NET validates + evaluates (bounded DSL)- No dynamic SQL evaluation of expressions- Domain invariants remain in code (not tables)plantuml-src bLZTSkks4RxdKs3EHIBDiOxY_7Gwdd5cP9bsb2DBgYJdD9Fa0Y8X6JK9C0Hf7xMJcLxr0JfzmZv9lrs052sVnevlB0Ax2-pkjt-eT9Kf4NyiIkMSjaPSgaJpZxNSPCg9__xpF-BMwArMgOXDfSgYr4w9YwnM-6KgqLlzPIfMgdnGfNWhtitYjLZMfjAvwaUHkzUcaANCXRkJgNtKPYUsCdEgirFQsgI9BGq4d7xTsHYLfNrSt-datk1ouhIpTQcsiiwgAskgcSoLcD3DEliBcTpBdLfLUsXNggIIPfTr3qriNbYZJ7LC4dqk1h_tX_rfV1tFBiLGl8z_V94yKpjbKb7gtLqL2N5-BfPAfcI7wrgd2WjOFUqtDXFnWyngMP47UbkPLF30l_ujR5qLTUNweFsoBnRr9jFkZeGiTA4oRPJefQNSLamC2JgLbUAFIZc_MdWc4l4LZhDPje6HeCNq_RNe5LQJ2OLxr5N2HFwoCsi6HkrifGaEGfi7MMffS2s--42GzvtA5DtqhN2L1P3mGzQfhXnJC2IQEz6UyYhYTsz3lWUoBbVJFXFFh4ZtHkOwOSdgO0sx5Qg5gPTyQNEfJVTIfGehS32CspEs4XK3kH_13TuPh_7nQBoUBrUmJlpNTRoSZG3n7rVh-6Rr8YzyPalnPc4pdUp5_16m_jdqHaPcUrVrtmZfHC5x3ULg6UESUQ5ALjGnYIrSin-R7HoiIWSnGUErahdpifJPHL4BN_7cedQQmsMqc41iOA3ur8c21FjeNDTYRyMZBU-tcNqC0cMXnJzWNfXEYOsjAflZIpIIsAxtIXNkZ9S77M5XmLK4lbmcGAWAQrKfZOD2K51cObSZ284tvLcESywT95SryXzi1iD0DpQR9jXmO03KdR0WIWQTtEXCLtlncK168ga84-nJ499AqM-lSVzbF9wi9lFPP7Oj5jFH-cg-l14NyzlPvMZvSimNFlk8DurY7AdGOclBt2CWVC3Ik9kN9qQNDvCPV7Oz_u4WDnl7l0w2Lk0epU63pyIrXN96caHvdz4orjXnGlpK6jJJ0mhmFCLX_vUMV5HeJnooYPYNoHsI1L1eoq0kFW0RgXn-RpUUCoGHv5fcfIm27uWnyd1fCqBeYlCyj1XB8ykzpo-UkVJKOFsje_NoThQUt3nNjKs837_Xhy_QbdvdSRzxfcyWlfA9JDKl8JuQ5Fwqh86YfIgiqz1nJ_4a2yJTS7GvFrYd186d7wD807Yf8CQLWfx0D0NHv4Rb4C2VmwLAjVEi2IrutfNSFgchGHPP5iaEOJU2CHvXp2gmXdtFtQa2JOO78x9hCK12pYLoD2oRemJgmPqE8YXPUlPrAHFKw5fdAP_eP5v03qzMqUOnJYXonDJkTghq4A45_nr48iVdge97HwaiAXOxnsAGsUmUEPTxWtW9F3_pRwTjwAsG6bLEANwfq0cati_kr-pWOnGFkP7kAAWkBvWotKHCjAKqySPdaP_DGAI1z5rmuAVM5hxoFTlw5EqQyV5alRaBBCKNyRMcUvaLLSHKB4gRgBGkZ-vrZVD3IALqnjFTKPe-kqaW_wH2eQO_LSoRiWYNugpEpGXPErnNyDyO5sKpYnmvFZiBomD6lmFw3_cKAxsiQjVIXCFEnBo7PgP94sd38p9hx-lYYKZaWqTt1k0RG0qq52mGZ0o1M53VEt2z5UWnRKdfd7kBOcvk2Fkj7788pg122L7Xs07dZHsAK4lNyTEPnpO4SY24Si6KROru2kBv1K5uT36PJjO_FiFnVEE0uY36r-wDw-Gc7Ec6DuWDdRWX1oQbIPHxquQKZo5xUheFOIdTtSRACdL8zICaXShveEIVVBjNLR7LUdaxNjykqRsCvxDPF5wZfBtSmVZs14JVjfL2aDMgExGZt6P2Wv90GKQ41nnwgvsdPr9DsIP7StteBNusRxkD0j4HlS_EO0a9J_LA3xOJmJM-3rDzjrulXhlbOZpKPb0WZleHkgFZwvM7XZRK6UgXm7xSsROy9A2jPv003r6wQzR4t-mcEkmJTICOnYYARDz-UuDHdkNYDqouzFMZDYioD_aNh2e7-9pUeGzo4Q-n9knQR2DXwmVLkBfLjDdE4N_K5MgJO7emv8cUmfm4_Gfzhzo9G6UXJ8BJ8ofap9rkmBcAfm109pHBdszw3rgArGA6EQJy0zTa1kolk_7pr81Cb4DVX8A7nf0H0RdGN58wwHzPaLcQUO2fXu6WuwsweDrZrXOkZnAzKnAgiIryMmcL8PleGbuBNvpyNewMoInq-ar7yc6oHF_t8cUu50E4B7w6P2VJJnZQqAGAgf0J-rrErWMiErNDot6fe3muxjN-dDbE6fv-v2cP9xGCugpv2VHWnA7HYinYHAwTUt8CjGWKBBNfZbP8paN4MsmyRXuOuvGGqHyDI0Cm4Dwwqhg8EID2XtQchKT-kqj_Y8SG1YtfeNzuXQEkflCFulIizLFymsXwEwBS9dh8S-Dbl8xxByhXIfYfBHV2GqiPOUYzGiRY0rurazCaZ9f7yvP1ImzW_nsPqTDX04MF4geV-fIwfEfvC7q_mZWVcbP2ZJk63Sfk6nDTw624v-wpyNnwSBtOx4MRHbk6h_jjZkNAFX1NI1eAClWWOcfgRQZyWwFIVFujna6RDPiQxKDPgoONTijsBvW6vG58nagOq7WsR8kumFbj1qlzH79FezetVR48IEfMxIZCcYJtfB7z2H-kVUyqIV5TFkrLJaAt-oTeJu1hSHRamI7iXvCGWBZ9a8BcH71I3G9fMQ39HuNOr4GUjPs2Xn6K8XIXgaKOXP9IOMr4DqkQQJJ-o2suwCMPtkcm-1Pj8IPQi-j7LmINimWshs6YN9NlgdsXnBduORBehjxlaoa2CSFErUrqYgrIk_jtBg7cxLpy-VHBBA631gdCBV2BnTDllldY2omNigmCITO0qpd6pEbbIvnWvZOizaDy4KLmE9egbTAD5Dyu8_7ay73asWlvVFGv5c2RX0vRqk1_BhvunzS8lgTQbTMf8bMee_Et09PCxJg6V6NSVtac5hSNqydgEvxD9ujuEfd5hulsjkzjvkKss4bgAFk75xNBzaKjx76eNvEEYFSL6c2TD8MCG350cGupdU3YJA-cfHjkQyCF7muKRcyg-H6HLTAR3JtKa7SjOUCZGM9JfoW1g5Ty3993jyC5wJMQ0oCEIKW5HpKHCGn69tS7XRPIPux2LPgzVmxabIXgD5v_v4VSVZG8NVrvEvDWoLTYqIGX2Hm2rbc6LBsmhXg4OqWD3Y4kpLl4zfsjcd2awpHLEhm4T-lFPy9tJEqBmHz6nLTd_8ovd4-d5wFn-zV1mJzk4HxyfDcYWQH9gFRUs4SpkBOs5JrwnJYycW87axPgX5pXEp31pHcZGcpKdNpGr3GZ8JJXD4STyBqeKlNJKGrv14bOWQLspsSeyCxKHzXK_LgJ5si70PopsobBvxbADTm5xmn2WK0F8IYwTyeZvXC5erIRMcSLUyrcXmDd5bkfAZAxvrCrljxz8UUCbvFrP4olfwEh6CFJRBwELt_KE-tJ2Bsf84OIUcr211mz7_-Vy4FAwGQW8Pssz5wDXM7prWoFsQI9Of1CvYVqpz4BlTfkwNNx455-lZm4Flbi2mmWN9gEWPif2ZEQ-8KirQ5XVtTSF0wAffk8NaN0Kk2oeKfmqcYUHqf-7g6C2qILI69G5jVd8kLVuKCYuX4tT6GO1nGBreuSW3pGUKd7JvLmKGlTzU_SZ-fRU0PcosEQKsINpdCo4iY-UNwFcgTWVlpbHk8PWU_KkH5CEo-yrg2bUFxCszpiVm00?>Expression Decision Tables — Unified Enterprise Blueprint (SQL Server + .NET Runtime)Expression Decision Tables — Unified Enterprise Blueprint (SQL Server + .NET Runtime)Business API / Domain Service(owns invariants + workflows)Decisioning PlatformAdmin & GovernanceRuntime Engine (.NET)SQL Server (System of Record)ObservabilityDomain code keeps:- invariants- state machine- transactional guaranteesDecision Tables handle:- volatile policies (facts→outputs)- auditability & versioningDecision Admin API(.NET)Publish Orchestrator(.NET Worker/Job)Rollout Controls(Shadow / Canary / LKG)Decision Engine FacadeRule Repository(Dapper/ADO.NET)Compiled RuleSet Cache(IMemoryCache/Redis)Safe Expression Compiler(DSL + Allowlist)Evaluation Pipeline(stop-on-match / multi-hit)Trace Builder(sampled)Decision Audit LoggerTelemetry Adapter(OTel)Decisioning DBTables- decision.RuleSet- decision.RuleSetPointer- decision.Rule- decision.RuleSetTest- decision.DecisionLogStored Procedures- decision.GetPublishedRules- decision.AddRule- decision.PublishRuleSet- decision.RollbackRuleSetPointerCore data model:- Ruleset versioning + status- Pointer: O(1) published version lookup- Rule rows: conditionExpr + outcomeJson + priority + stopOnMatch- Test vectors: publish-time gate- DecisionLog: audit + traceabilityLogs/Metrics/Traces(OpenTelemetry)Dashboards & AlertsPolicy Owner(Analyst)SRE / OperationsEngineers(Domain Teams)1) API normalizes facts (no DB lookups during evaluation)2) Engine loads published ruleset (pointer -> version)3) Cache compiled delegates by (ruleset,version)4) Evaluate:- For each rule by priority:- conditionExpr => bool- if true: apply outcomeJson (outputs + reasons)- stopOnMatch or multi-hit stacking5) Persist DecisionLog:- inputs, outputs, appliedRuleIds, ruleset version, trace6) Emit telemetry:- duration, cache hit/miss, errors, rule hit distributionFlow 1: Runtime Evaluation (facts → outputs)Concrete Applicability Example (Eligibility + Pricing)Facts:- customer.type = VIP- customer.kycLevel = FULL- risk.score = 812- order.amount = 15500- partner.tier = GOLD- order.channel = WEBExpected:- eligible = true- riskTier = 'A'- discountRate = 0.12- reasons include VIP + partner bonusGate 1) Static validation- allowlist identifiers/functions- syntax correctness- complexity budgetsGate 2) Test vectors- Given facts → expected outputs/reasons- publish fails if any test failsPublish Tx (SQL)- status=Published- update pointer atomicallyPost-publish- cache warm for hot rulesets- optional shadow evaluation & canary rolloutFlow 2: Publish Pipeline (Draft → Publish)If runtime errors spike or invalid behavior detected:- Ops switches RuleSetPointer to previous published version- Next request uses prior version immediately- Cache key changes → compiled delegates rebuilt for old versionNo redeploy required.Flow 3: Last-Known-Good (LKG) RollbackSafety contract (bounded DSL)- allowlist identifiers + functions- no reflection/dynamic invocation- no IO, no side effects- publish-time validation preferredRuntime eval failures are incidents.SQL responsibility- system of record- deterministic rule selection- audit persistenceSQL is NOT an expression execution engine.Domain boundaries- invariants stay in code- state transitions stay in code- tables decide policies only (facts→outputs)author draftsrules + testsimplement workflows+ invariantsEvaluate(ruleset, facts)(HTTP/gRPC/in-proc)rollback / rollout ops(pointer switch)trigger publish jobapply rollout policy(shadow/canary)emit signalsderive alertsmonitor incidents(error spikes, latency)SELECT published rules(via SP)INSERT DecisionLogmanage drafts/tests(metadata)validate/test/publish(pointer update)warm cache(optional)load published rules(name -> version)getOrCreate(key=(name,version))compile delegates(on miss)run evaluation loopappend per-rule eventspersist decision auditemit decision telemetryReading Guide1) Runtime Evaluation (facts → outputs)2) Publishing Pipeline (draft → validate → test → publish)3) Rollback / LKG (pointer switch)Non-negotiable invariants- SQL selects + stores + audits- .NET validates + evaluates (bounded DSL)- No dynamic SQL evaluation of expressions- Domain invariants remain in code (not tables)plantuml-src bLZTSkks4RxdKs3EHIBDiOxY_7Gwdd5cP9bsb2DBgYJdD9Fa0Y8X6JK9C0Hf7xMJcLxr0JfzmZv9lrs052sVnevlB0Ax2-pkjt-eT9Kf4NyiIkMSjaPSgaJpZxNSPCg9__xpF-BMwArMgOXDfSgYr4w9YwnM-6KgqLlzPIfMgdnGfNWhtitYjLZMfjAvwaUHkzUcaANCXRkJgNtKPYUsCdEgirFQsgI9BGq4d7xTsHYLfNrSt-datk1ouhIpTQcsiiwgAskgcSoLcD3DEliBcTpBdLfLUsXNggIIPfTr3qriNbYZJ7LC4dqk1h_tX_rfV1tFBiLGl8z_V94yKpjbKb7gtLqL2N5-BfPAfcI7wrgd2WjOFUqtDXFnWyngMP47UbkPLF30l_ujR5qLTUNweFsoBnRr9jFkZeGiTA4oRPJefQNSLamC2JgLbUAFIZc_MdWc4l4LZhDPje6HeCNq_RNe5LQJ2OLxr5N2HFwoCsi6HkrifGaEGfi7MMffS2s--42GzvtA5DtqhN2L1P3mGzQfhXnJC2IQEz6UyYhYTsz3lWUoBbVJFXFFh4ZtHkOwOSdgO0sx5Qg5gPTyQNEfJVTIfGehS32CspEs4XK3kH_13TuPh_7nQBoUBrUmJlpNTRoSZG3n7rVh-6Rr8YzyPalnPc4pdUp5_16m_jdqHaPcUrVrtmZfHC5x3ULg6UESUQ5ALjGnYIrSin-R7HoiIWSnGUErahdpifJPHL4BN_7cedQQmsMqc41iOA3ur8c21FjeNDTYRyMZBU-tcNqC0cMXnJzWNfXEYOsjAflZIpIIsAxtIXNkZ9S77M5XmLK4lbmcGAWAQrKfZOD2K51cObSZ284tvLcESywT95SryXzi1iD0DpQR9jXmO03KdR0WIWQTtEXCLtlncK168ga84-nJ499AqM-lSVzbF9wi9lFPP7Oj5jFH-cg-l14NyzlPvMZvSimNFlk8DurY7AdGOclBt2CWVC3Ik9kN9qQNDvCPV7Oz_u4WDnl7l0w2Lk0epU63pyIrXN96caHvdz4orjXnGlpK6jJJ0mhmFCLX_vUMV5HeJnooYPYNoHsI1L1eoq0kFW0RgXn-RpUUCoGHv5fcfIm27uWnyd1fCqBeYlCyj1XB8ykzpo-UkVJKOFsje_NoThQUt3nNjKs837_Xhy_QbdvdSRzxfcyWlfA9JDKl8JuQ5Fwqh86YfIgiqz1nJ_4a2yJTS7GvFrYd186d7wD807Yf8CQLWfx0D0NHv4Rb4C2VmwLAjVEi2IrutfNSFgchGHPP5iaEOJU2CHvXp2gmXdtFtQa2JOO78x9hCK12pYLoD2oRemJgmPqE8YXPUlPrAHFKw5fdAP_eP5v03qzMqUOnJYXonDJkTghq4A45_nr48iVdge97HwaiAXOxnsAGsUmUEPTxWtW9F3_pRwTjwAsG6bLEANwfq0cati_kr-pWOnGFkP7kAAWkBvWotKHCjAKqySPdaP_DGAI1z5rmuAVM5hxoFTlw5EqQyV5alRaBBCKNyRMcUvaLLSHKB4gRgBGkZ-vrZVD3IALqnjFTKPe-kqaW_wH2eQO_LSoRiWYNugpEpGXPErnNyDyO5sKpYnmvFZiBomD6lmFw3_cKAxsiQjVIXCFEnBo7PgP94sd38p9hx-lYYKZaWqTt1k0RG0qq52mGZ0o1M53VEt2z5UWnRKdfd7kBOcvk2Fkj7788pg122L7Xs07dZHsAK4lNyTEPnpO4SY24Si6KROru2kBv1K5uT36PJjO_FiFnVEE0uY36r-wDw-Gc7Ec6DuWDdRWX1oQbIPHxquQKZo5xUheFOIdTtSRACdL8zICaXShveEIVVBjNLR7LUdaxNjykqRsCvxDPF5wZfBtSmVZs14JVjfL2aDMgExGZt6P2Wv90GKQ41nnwgvsdPr9DsIP7StteBNusRxkD0j4HlS_EO0a9J_LA3xOJmJM-3rDzjrulXhlbOZpKPb0WZleHkgFZwvM7XZRK6UgXm7xSsROy9A2jPv003r6wQzR4t-mcEkmJTICOnYYARDz-UuDHdkNYDqouzFMZDYioD_aNh2e7-9pUeGzo4Q-n9knQR2DXwmVLkBfLjDdE4N_K5MgJO7emv8cUmfm4_Gfzhzo9G6UXJ8BJ8ofap9rkmBcAfm109pHBdszw3rgArGA6EQJy0zTa1kolk_7pr81Cb4DVX8A7nf0H0RdGN58wwHzPaLcQUO2fXu6WuwsweDrZrXOkZnAzKnAgiIryMmcL8PleGbuBNvpyNewMoInq-ar7yc6oHF_t8cUu50E4B7w6P2VJJnZQqAGAgf0J-rrErWMiErNDot6fe3muxjN-dDbE6fv-v2cP9xGCugpv2VHWnA7HYinYHAwTUt8CjGWKBBNfZbP8paN4MsmyRXuOuvGGqHyDI0Cm4Dwwqhg8EID2XtQchKT-kqj_Y8SG1YtfeNzuXQEkflCFulIizLFymsXwEwBS9dh8S-Dbl8xxByhXIfYfBHV2GqiPOUYzGiRY0rurazCaZ9f7yvP1ImzW_nsPqTDX04MF4geV-fIwfEfvC7q_mZWVcbP2ZJk63Sfk6nDTw624v-wpyNnwSBtOx4MRHbk6h_jjZkNAFX1NI1eAClWWOcfgRQZyWwFIVFujna6RDPiQxKDPgoONTijsBvW6vG58nagOq7WsR8kumFbj1qlzH79FezetVR48IEfMxIZCcYJtfB7z2H-kVUyqIV5TFkrLJaAt-oTeJu1hSHRamI7iXvCGWBZ9a8BcH71I3G9fMQ39HuNOr4GUjPs2Xn6K8XIXgaKOXP9IOMr4DqkQQJJ-o2suwCMPtkcm-1Pj8IPQi-j7LmINimWshs6YN9NlgdsXnBduORBehjxlaoa2CSFErUrqYgrIk_jtBg7cxLpy-VHBBA631gdCBV2BnTDllldY2omNigmCITO0qpd6pEbbIvnWvZOizaDy4KLmE9egbTAD5Dyu8_7ay73asWlvVFGv5c2RX0vRqk1_BhvunzS8lgTQbTMf8bMee_Et09PCxJg6V6NSVtac5hSNqydgEvxD9ujuEfd5hulsjkzjvkKss4bgAFk75xNBzaKjx76eNvEEYFSL6c2TD8MCG350cGupdU3YJA-cfHjkQyCF7muKRcyg-H6HLTAR3JtKa7SjOUCZGM9JfoW1g5Ty3993jyC5wJMQ0oCEIKW5HpKHCGn69tS7XRPIPux2LPgzVmxabIXgD5v_v4VSVZG8NVrvEvDWoLTYqIGX2Hm2rbc6LBsmhXg4OqWD3Y4kpLl4zfsjcd2awpHLEhm4T-lFPy9tJEqBmHz6nLTd_8ovd4-d5wFn-zV1mJzk4HxyfDcYWQH9gFRUs4SpkBOs5JrwnJYycW87axPgX5pXEp31pHcZGcpKdNpGr3GZ8JJXD4STyBqeKlNJKGrv14bOWQLspsSeyCxKHzXK_LgJ5si70PopsobBvxbADTm5xmn2WK0F8IYwTyeZvXC5erIRMcSLUyrcXmDd5bkfAZAxvrCrljxz8UUCbvFrP4olfwEh6CFJRBwELt_KE-tJ2Bsf84OIUcr211mz7_-Vy4FAwGQW8Pssz5wDXM7prWoFsQI9Of1CvYVqpz4BlTfkwNNx455-lZm4Flbi2mmWN9gEWPif2ZEQ-8KirQ5XVtTSF0wAffk8NaN0Kk2oeKfmqcYUHqf-7g6C2qILI69G5jVd8kLVuKCYuX4tT6GO1nGBreuSW3pGUKd7JvLmKGlTzU_SZ-fRU0PcosEQKsINpdCo4iY-UNwFcgTWVlpbHk8PWU_KkH5CEo-yrg2bUFxCszpiVm00?>