Table of Contents

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 decimal and long. 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 type may name a registered action.
  • Discoverable: engine.RegisteredActions lists them — see Discover the vocabulary.

Next

Build rules in C#, where the same action is just a RuleAction with your type name.