Tutorial: Score orders for fraud review
Triage incoming orders in two stages. A collect decision table adds up risk signals into a score and lists the reasons. A second, first table turns that score into a decision: approve, manual review or block. Orders arrive as JSON, so they are evaluated as dictionary facts.
You will learn to:
- write
collectandfirstdecision tables - accumulate a score with
addToOutputand reasons withappendToOutput - evaluate JSON with no C# model at all
- run decisions in stages, because rules never read each other's outputs
Step 1: The orders
Five orders, as a payment service might send them (download orders.json):
[
{
"Id": "A-1001",
"Customer": { "AccountAgeDays": 820, "EmailVerified": true },
"Order": { "Total": 64.5, "GiftCards": 0, "ShipTo": "home" }
},
{
"Id": "A-1002",
"Customer": { "AccountAgeDays": 2, "EmailVerified": false },
"Order": { "Total": 1450.0, "GiftCards": 3, "ShipTo": "freight-forwarder" }
},
{
"Id": "A-1003",
"Customer": { "AccountAgeDays": 5, "EmailVerified": true },
"Order": { "Total": 220.0, "GiftCards": 1, "ShipTo": "home" }
},
{
"Id": "A-1004",
"Customer": { "AccountAgeDays": 400, "EmailVerified": false },
"Order": { "Total": 1999.99, "GiftCards": 0, "ShipTo": "po-box" }
},
{
"Id": "A-1005",
"Customer": { "AccountAgeDays": 31 },
"Order": { "Total": 89.0, "ShipTo": "home" }
}
]
A-1005 has no EmailVerified and no GiftCards. That's realistic, and it matters in step 2.
Step 2: The risk signals
Each row is one signal. Every row that matches adds its points and its reason, because the hit policy is collect (download):
{
"decisionTable": {
"id": "signal",
"description": "Each matching row adds to the risk score and says why.",
"hitPolicy": "collect",
"inputs": [
{ "field": "Customer.AccountAgeDays", "operator": "LessThan" },
{ "field": "Order.Total", "operator": "GreaterThan" },
{ "field": "Order.GiftCards", "operator": "GreaterThan" },
{ "field": "Customer.EmailVerified", "operator": "Equals" },
{ "field": "Order.ShipTo", "operator": "In" }
],
"outputs": [
{ "target": "Score", "type": "addToOutput" },
{ "target": "Signals", "type": "appendToOutput" }
],
"rows": [
{ "when": [7, null, null, null, null], "then": [30, "account under a week old"] },
{ "when": [null, 1000, null, null, null], "then": [25, "order over 1,000"] },
{ "when": [null, null, 0, null, null], "then": [20, "gift cards in the basket"] },
{ "when": [null, null, null, false, null], "then": [15, "email not verified"] },
{
"when": [ null, null, null, null, ["freight-forwarder", "po-box"] ],
"then": [20, "hard-to-trace delivery address"]
}
]
}
}
- Five input columns, one per signal. A row sets only its own column: every other cell is
null, a wildcard. - Two output columns:
Scoreadds (addToOutput),Signalscollects (appendToOutput). - A missing field is null, and a null field fails every comparison except the null-aware ones. So a missing
EmailVerifiedis not evidence that the email is unverified: A-1005 isn't penalised for data it didn't send. If a missing value should count, score it with anIsNullrule in a second document evaluated against the same order, and add the two scores.
Step 3: The decision bands
The first matching row wins, so the rows run from the most severe band down to a catch-all (download):
{
"decisionTable": {
"id": "band",
"description": "Turns a risk score into a decision. The first matching row wins.",
"hitPolicy": "first",
"inputs": [ { "field": "Score", "operator": "GreaterThanOrEqual" } ],
"outputs": [ { "target": "Decision" } ],
"rows": [
{ "when": [70], "then": ["block"] },
{ "when": [40], "then": ["manual review"] },
{ "when": [null], "then": ["approve"] }
]
}
}
Step 4: Score every order
using System.Text.Json;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
RuleWrightEngine engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.Build();
LoadedRuleSet signals = engine.LoadRuleSet(File.ReadAllText("fraud-signals.json"));
LoadedRuleSet bands = engine.LoadRuleSet(File.ReadAllText("review-bands.json"));
using System.Text.Json;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
using JsonDocument orders = JsonDocument.Parse(File.ReadAllText("orders.json"));
Console.WriteLine($"{"Order",-8}{"Score",6} {"Decision",-14}Signals");
foreach (JsonElement order in orders.RootElement.EnumerateArray())
{
// Stage 1: every signal that applies adds to the score (hit policy "collect").
Dictionary<string, object?> fact = SystemTextJsonFacts.ToDictionary(order);
RuleEvaluationResult scored = engine.Evaluate(signals, fact);
object score = scored.Outputs.TryGetValue("Score", out object? s) ? s! : 0L;
List<object?> reasons = scored.Outputs.TryGetValue("Signals", out object? r) ? (List<object?>)r! : new();
// Stage 2: rules never read each other's outputs, so the score becomes the next fact.
var scoreFact = new Dictionary<string, object?> { ["Score"] = score };
RuleEvaluationResult banded = engine.Evaluate(bands, scoreFact);
string decision = (string)banded.Outputs["Decision"]!;
string id = order.GetProperty("Id").GetString()!;
Console.WriteLine($"{id,-8}{score,6} {decision,-14}{(reasons.Count == 0 ? "-" : string.Join("; ", reasons))}");
decisions[id] = decision;
}
Output
Order Score Decision Signals
A-1001 0 approve -
A-1002 110 block account under a week old; order over 1,000; gift cards in the basket; email not verified; hard-to-trace delivery address
A-1003 50 manual review account under a week old; gift cards in the basket
A-1004 60 manual review order over 1,000; email not verified; hard-to-trace delivery address
A-1005 0 approve -
Why two stages
A rule reads the fact, never the outputs of other rules. Evaluation is a single pass with no chaining, which keeps every decision explainable from its input alone. When one decision depends on another, run them in sequence and pass the first result into the second as a new fact, as the loop does with Score. Keeping the bands in their own document also means the risk team can move a threshold without touching the signals.
Step 5: Explain a decision
A decision table becomes ordinary rules when it loads, one per row, named <table id>-<row>. So a trace tells you exactly which rows matched, in the words of their conditions:
using System.Text.Json;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
LoadedRuleSet signals = engine.LoadRuleSet(File.ReadAllText("fraud-signals.json"));
// The table became five ordinary rules. The trace shows each row's condition as a rule.
var order = new Dictionary<string, object?>
{
["Customer"] = new Dictionary<string, object?> { ["AccountAgeDays"] = 3L, ["EmailVerified"] = true },
["Order"] = new Dictionary<string, object?> { ["Total"] = 80m, ["GiftCards"] = 2L, ["ShipTo"] = "home" },
};
RuleEvaluationResult result = engine.Evaluate(signals, order, new EvaluationOptions { EnableTrace = true });
foreach (RuleTrace row in result.Trace!.Rules)
{
Console.WriteLine($"{row.RuleId,-9} {(row.Fired ? "hit " : "miss")} {row.Condition!.Description}");
}
Output
signal-0 hit Customer.AccountAgeDays LessThan 7
signal-1 miss Order.Total GreaterThan 1000
signal-2 hit Order.GiftCards GreaterThan 0
signal-3 miss Customer.EmailVerified Equals false
signal-4 miss Order.ShipTo In ["freight-forwarder", "po-box"]
Store those row ids with the decision, and a reviewer can see why an order was held months later, even after the table has changed.
Going further
- Weight by amount. A
thencell can be an expression, such as{ "op": "divide", "operands": [ { "field": "Order.Total" }, 100 ] }points per hundred. See Decision tables. - Signals over lines. A table column compares one field. For "any line is a gift card over 500", write an ordinary rule with an
Anyquantifier andaddToOutput. A document is either a table or a rule set, so put such rules in a second document, evaluate both against the order, and add the scores. See Collections. - Test the thresholds. Test your rule documents shows table-driven tests that catch an accidental change to a boundary.