Custom actions
The four built-in action types replace, add, append and remove. When a fired rule should combine its value into the outputs some other way — keep the highest, cap at a limit, write two keys at once — register an action type. The document names it in type; the behaviour is your handler.
A keep-the-highest action
This document runs two bids; whichever is higher should win, whatever order the rules fire in:
{
"name": "Best bid",
"rules": [
{
"id": "vip-bid",
"priority": 2,
"condition": { "field": "Customer.IsVip", "operator": "Equals", "value": true },
"actions": [
{
"type": "setIfHigher",
"target": "Bid",
"value": {
"op": "multiply",
"operands": [ { "field": "Order.Total" }, 0.1 ]
}
}
]
},
{
"id": "floor-bid",
"priority": 1,
"condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
"actions": [
{ "type": "setIfHigher", "target": "Bid", "value": 5 }
]
}
]
}
using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;
RuleWrightEngine engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterAction("setIfHigher", context =>
{
// JSON integers arrive as long, computed arithmetic as decimal.
decimal? bid = context.Value switch { decimal d => d, long l => l, _ => null };
context.TryGetOutput(context.Target, out object? current);
decimal? held = current switch { decimal d => d, long l => l, _ => null };
if (bid is decimal b && (held is not decimal h || b > h))
{
context.SetOutput(context.Target, b);
}
})
.Build();
LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("custom-action.json"));
foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
RuleEvaluationResult result = engine.Evaluate(rules, checkout);
string wrote = string.Join(" ", result.FiredRules.Select(f =>
$"{f.RuleId}: {(f.Outputs.Count == 0 ? "(kept the higher bid)" : $"Bid={f.Outputs["Bid"]}")}"));
Console.WriteLine($"{checkout.Customer.Name,-6} Bid = {result.Outputs["Bid"],-4} {wrote}");
}
Output
Aroha Bid = 24.0 vip-bid: Bid=24.0 floor-bid: (kept the higher bid)
Ben Bid = 5 floor-bid: Bid=5
For Aroha the computed 10% (24.0) fired first and the floor of 5 lost; her floor-bid fired but wrote nothing, and its own FiredRule.Outputs snapshot is honestly empty. For Ben only the floor fired.
What a handler sees
The handler receives a RuleActionContext per firing:
| Member | Meaning |
|---|---|
Value |
The action's value, already evaluated: a constant, field read, computed expression or call result. null when the action declared no value — for a registered type, value is optional. |
Target |
The action's target key. |
RuleId, Branch |
Which rule fired, and whether its actions or else branch ran. |
Fact |
The fact under evaluation. Treat it as read-only. |
TryGetOutput(key, out value) |
Read what earlier-fired rules have written. |
SetOutput(key, value), RemoveOutput(key) |
Write. Both land in the merged result.Outputs and the firing rule's own snapshot, exactly as the built-in types record theirs. |
Handlers run identically on the compiled and interpreted paths, in priority order with every other action.
Misspellings still fail
The action vocabulary stays closed: engine.Validate and LoadRuleSet fold the engine's registered names into it, so an unknown type is still an error with a pointer — and the message lists the registered custom types beside the built-ins. An engine without the registration rejects the document too, which is what you want: a document can't smuggle in behaviour its engine doesn't have.
For standalone validation without an engine, RuleSetValidator.Validate(document, new RuleDocumentOptions(names)) takes the same vocabulary, and RuleSetParser.Parse has a matching overload.
Rules for custom actions
- Stay inside the context. Evaluation is a pure fact-in, result-out computation; a handler that launches side effects gives up the replayability that makes rules testable. Write outputs; act on them after evaluation returns.
- Expect
decimalandlong. As everywhere: rule arithmetic is decimal, whole JSON numbers arrive as long. - Be thread-safe. One handler instance serves every concurrent evaluation.
- Built-in names are reserved. Registering
setOutput(or any name twice) throws. - Decision tables can use them. An output column's
typemay name a registered action. - Discoverable:
engine.RegisteredActionslists them — see Discover the vocabulary.
Next
Build rules in C#, where the same action is just a RuleAction with your type name.