Rule patterns and pitfalls
A rule set is not a script. Every cycle re-evaluates the when of every rule that has not been
retracted, fires exactly one rule — the highest salience among the matches — and starts over. That
one fact drives every pattern on this page: how a later rule sees an earlier write, how you order
work, how you stop, and how you keep one broken rule from discarding everything the others produced.
For the grammar and the operators see Rule language; for the callable surface see Functions.
Chaining rules
Section titled “Chaining rules”Sub-expression results are memoised in a working memory that is only invalidated when the engine
observes a change to the data context. Index assignment into a map fact is observed. Assignment to a
top-level scalar name is not, and neither is any method call on the collectors — out.Set,
in.MemSet, audit.Log. So the obvious chaining idiom does not work:
rule Classify salience 20 { when header.Contains("Bank Statement") then out.Set("type", "statement"); Retract("Classify");}
// NEVER FIRES — out.Has("type") was evaluated in cycle 1, before Classify ran,// and the cached false is never recomputed.rule Extract salience 10 { when out.Has("type") && out.GetStr("type") == "statement" then out.Set("balance", 0.0); Retract("Extract");}Three ways to build a chain, in order of preference.
| Approach | When to use | Cost |
|---|---|---|
Mutually exclusive when over the input facts | Branches decided by the input, not by earlier work | None — no chaining at all |
| Index assignment on a map fact | A later rule genuinely depends on an earlier decision | Caller must supply the map fact |
Changed("out") in the writing rule | You are already reading out and cannot restructure | Invalidates every cached read of out |
Chain through a map fact
Section titled “Chain through a map fact”Index assignment on a map fact is a tracked data-context change, so it re-triggers evaluation with no explicit signal:
// `state` is a caller-supplied map fact, e.g. {"stage": "init"}rule Classify salience 20 { when header.Contains("Bank Statement") then state["stage"] = "classified"; out.Set("type", "statement"); Retract("Classify");}
rule Extract salience 10 { when state["stage"] == "classified" then out.Set("balance", content.Len()); Retract("Extract");}The map has to come from the caller — -f state=state.json on the CLI, a nested object on the
request body over HTTP. Read it with bracket access on both surfaces; state.stage aborts the
execution
(the rule language). A rule
cannot stand one up itself: there is no map literal in the grammar, and a local variable cannot hold
a map — m = map.New(); m["k"] = 1; aborts the execution with
this code part should not be reached.
Signal the change explicitly
Section titled “Signal the change explicitly”If you keep the state in out, the writing rule must invalidate the cache:
rule First salience 10 { when !out.Has("tier") then out.Set("tier", "gold"); Changed("out"); Retract("First");}
rule Second salience 5 { when out.Has("tier") && out.GetStr("tier") == "gold" then out.Set("bonus", 10); Retract("Second");}Forget(snippet) is the same operation under another name, matched by substring against the cached
expression text rather than by variable: Forget("out.") behaves like Changed("out"), and
Forget("out.GetStr(\"tier\")") clears only that one expression.
Memoisation also applies inside one rule
Section titled “Memoisation also applies inside one rule”Repeating an identical call expression within a single then block returns the first result, not
the current value:
rule Normalise salience 10 { when in.Has("amount") then in.MemSet("amount", amount); // " $1,250.00 " in.Transform("amount", "trim"); in.Transform("amount", "trim_currency"); // in.MemGetStr("amount") here still returns the ORIGINAL string. // Read the slot once, through a different expression: out.Set("fields", in.GetMemAsMap()); // {"amount": "1250.00"} Retract("Normalise");}in.GetMemAsMap() returns an independent deep copy, so a later MemSet cannot retroactively change
a value you have already emitted.
Ordering with salience
Section titled “Ordering with salience”Salience is the only ordering control. It is an optional integer literal on the rule header
(salience -10 is legal), defaulting to 0.
There is no if/else in the language. Branching is two rules with opposite conditions at
different salience:
rule HighValue salience 20 { when order["Total"] > 10000.0 then out.Set("route", "manager_approval"); Retract("HighValue");}
rule NormalValue salience 10 { when order["Total"] <= 10000.0 then out.Set("route", "auto"); Retract("NormalValue");}Both conditions read the same fact, so neither depends on the other having run — no Changed and
no state fact needed. Give each rule its own salience value; banding by phase (guards highest,
then extraction, then derivation, then defaults) keeps them distinct as the set grows.
Terminating cleanly
Section titled “Terminating cleanly”| Mechanism | Effect |
|---|---|
Retract("SelfName") | Skips that rule for the rest of this execution; reset on the next execution |
Complete() | Ends the whole execution immediately — no further rule fires, however high its salience |
| No runnable rule | Normal exit |
| Cycle cap — fixed at 5000 | Hard error, no result at all |
| Context cancellation | Hard error, no result at all |
End every then with Retract naming its own rule. It is not optional hygiene:
Complete() is a hard stop for the execution, not for the current cycle. A rule that calls it does
not also need Retract. Use it for terminal decisions — a document rejected outright — where no
later rule should run.
The cycle cap is 5000 and it is fixed — no CLI flag and no configuration key changes it. It is also
not a timeout: the counter only advances when a rule actually fires, so a rule blocked inside a slow
call runs indefinitely regardless of the limit. The only wall-clock bound is the caller’s context,
which over HTTP is the X-Api-Timeout header on the POST /rule request.
Structuring a set so one failure does not destroy the run
Section titled “Structuring a set so one failure does not destroy the run”Abort-on-failed-evaluation is on unconditionally and cannot be turned off, and every non-retracted
rule is evaluated every cycle. A rule that references a fact the caller did not supply raises
non existent key <fact> and aborts the execution — even if that rule was irrelevant to this
request and a dozen other rules had already produced correct output. There is no per-rule isolation.
Four consequences to design around.
Guard every optional fact. in.Has(...) is the one input accessor that works. Put it first in
the when and rely on short-circuiting:
rule OnlyIfPresent salience 10 { when in.Has("optional_field") && optional_field.Len() > 0 then out.Set("seen", optional_field); Retract("OnlyIfPresent");}Read the value through its bare fact name, not through in.GetStr — see the pitfall table.
Keep unrelated concerns in separate rule sets. Everything loaded under one rule-set name shares one failure domain and one flat rule-name namespace. The deployed service already enforces this: each rule definition becomes its own rule set, selected by name on the request, so one definition failing cannot affect another. The corollary is that rules in different definitions cannot see or chain with each other. See Running the service.
Make rule names globally unique inside a set. Directory loading pools every .grl file — and
with recursion, every subdirectory — into the same set. A repeated name fails the load of the file
that reintroduces it, and a name repeated across two files can leave the rule set unexecutable
afterwards. Prefix names by file or by phase.
Mirror anything that matters into out. out is the only channel that leaves the engine
unasked-for: the CLI prints out unless you also request --history or --audit, and the service
returns out unless the body sets "audit": true. Findings leave on neither surface. Anything a
caller branches on belongs in out; the trail is the reasoning behind it, not the result.
Recording decisions and diagnostics
Section titled “Recording decisions and diagnostics”| Want | Use | Notes |
|---|---|---|
| A result | out.Set(key, value) | Stamped with the firing rule name in the change history |
| A concatenated result | out.Append(key, str) | Separator defaults to ", "; see the pitfall table |
| A decision trace | audit.Log(ruleID, msg) / audit.LogWithData(ruleID, msg, m) | The engine also logs executed for every firing |
| A validation verdict | out.Set a status key | See below |
Pitfalls that silently produce wrong results
Section titled “Pitfalls that silently produce wrong results”These exit successfully. Nothing in the output says anything went wrong.
| Symptom | Cause | Fix |
|---|---|---|
| Rules fire in a different order on different runs | Equal salience; the winner comes from map iteration order | Give every rule a distinct salience |
| A later rule never fires; a fallback overwrites the real answer | out.* reads in when are memoised and never see a later write | Chain through a map fact, or call Changed("out") in the writing rule |
| A value read twice in one rule returns the stale first value | Identical call expressions are memoised within a rule | Read once at the end, through a different expression (in.GetMemAsMap()) |
in.GetStr / GetInt / GetFloat / GetBool / GetMap return zero values for facts that exist | The adapter hands back a reflection wrapper, so every type assertion fails | Read the fact by its bare name; use in.Has to guard and in.MemGetStr / in.MemGetRows for scratch memory |
in.Transform puts an empty string in memory | Transform falls back to the broken context reader when the key is not already in memory, so it transforms "" | Seed the slot first with in.MemSet("k", k) — the bare fact name, never in.GetStr |
| A transform silently does nothing | Unknown or malformed transform name resolves to nothing — no error, no log | Check the name against the registry: trim, upper, lower, normalize_spaces, trim_currency, ExtractRegex(...) |
strings.FindRegex returns the whole match, not the capture | FindRegex returns match 0 | Use strings.ExtractRegex or strings.FindRegexGroups, or the ExtractRegex("pattern","default") transform |
| A regex helper returns empty or false for a pattern you believe matches | Malformed patterns degrade instead of erroring: match → false, find → "", groups → nothing | Test the pattern before shipping |
out.Append throws away the accumulated value | The key held a non-string, so the existing value is replaced instead of appended | Keep append targets string-valued from the first write |
A fact named out, in, findings or audit disappears | The collectors are bound last and overwrite caller facts silently | Rename the input field |
| Values staged in a named memory slot never appear | in.GetMemAsMap() snapshots the default slot only; nothing in the result reads other slots | Copy the slot out explicitly, or stage in the default slot |
| Change history under-reports | out.All() and out.GetMap() hand back live references; mutating them bypasses history | Write through out.Set |
| A rule file is ignored | Directory loading matches .grl only, with no warning for skipped files | Rename the file |
| The findings list is always empty | Nothing a rule can call adds a finding | Mirror the verdict into out |
findings.Passed() is true despite warnings | Passed() fails only on ERROR and CRITICAL | Treat WARNING explicitly if it should gate |
7 / 2 is 3.5 | Integer division yields a float | Round explicitly |
Pitfalls that fail the load
Section titled “Pitfalls that fail the load”Everything the parser rejects reports the same message and nothing else:
/srv/rules/b.grl: got 3 error(s) in grl the script| Construct | Why the load fails | Write instead |
|---|---|---|
findings.Add(Finding{...}) | Struct literals do not exist in the grammar | audit.Log(ruleID, msg) plus an out.Set status key |
if/else in a then block | No conditional statement exists | Two rules with opposite conditions at different salience |
Two conditions on separate lines in when | when takes exactly one expression | Join with && / || |
A then statement without its trailing semicolon | Every statement ends in ;, including the last | Add the ; |
| Two rules in the set sharing a name | Rule names are unique per rule set, not per file | Prefix names by file or phase |
Pitfalls that fail loudly at run time
Section titled “Pitfalls that fail loudly at run time”These abort the execution and return no result at all — no partial output, no findings, no audit. Their exact text is catalogued in Errors.
| Symptom | Cause | Fix |
|---|---|---|
the GruleEngine successfully selected rule candidate ... after 5000 cycles | A rule re-qualifies every cycle | Retract("SelfName") in every then |
non existent key <fact> | Any rule referenced an unsupplied fact — including a rule irrelevant to this request | Guard with in.Has(...) && |
this node identified as "X" is not referencing to an object | Dot access on a map fact (order.Total) | Use order["Total"] or map.GetFloat(order, "Total", 0.0) |
<fact>[string-><key>] is not an array nor map | Two-level indexing into decoded JSON — the intermediate is interface-typed | Flatten the fact into top-level keys, or send the inner object as a fact of its own |
reflect: call of reflect.Value.Type on zero Value | Compared a missing key (out.Get, in.Get) | Guard with Has(...) &&; IsNil() is not a substitute — it panics on strings |
A helper call aborts — function <Fn> requires no argument, or another dispatch error naming the fact’s type | A caller fact named map, strings, time, math, num, array or util shadows the helper namespace | Rename the input field. On the service every top-level body key except the reserved bbox and audit becomes a fact |
reflect: Call using int64 as type float64 | An integer literal passed to a float parameter (math.Min(10, 20)) | Write 10.0. The reverse also fails — an int64 parameter rejects a float literal |
reflect: Call using int64 as type int | The helper takes a plain Go int, which a rule literal can never be | That helper is unreachable from a rule; use one with int64 or float64 parameters |
reflect: Call using interface {} as type string | An any-typed value passed to a typed parameter | Pass the fact by name, or in.MemGetStr / in.MemGetRows |
... returns multiple values, multiple value returns are not supported | Called a method returning (value, error) — out.JSON(), audit.JSON() | Not callable from a rule at all; write what you need to out instead |
reflect: Call using zero Value argument / <var> is not an array nor map | A local variable holding a map or slice | Locals hold scalars only; index the call directly (strings.Split(s, ",")[0]) or stage in in.MemSet |
this code part should not be reached | Index assignment into a local variable (m = map.New(); m["k"] = 1;) | Map facts come from the caller; build values with in.NewMapWithValues(...) |
have no function named <Fn> | Misspelled method, or a helper used unqualified | Check Functions; rules are not checked until they run |
ruleset <name> not found | The set was loaded under a version other than the engine default | Load with an empty version. The version argument is stored but execution always looks up the default, so any other value makes the set permanently unexecutable |
Before you deploy
Section titled “Before you deploy”Run the set through the CLI over representative inputs — including the ones missing optional fields
— and check --history to confirm each value was written by the rule you expect. Rules are compiled
at load time but method names, argument types and fact names are only checked when a rule actually
runs, so a rule that never matched in testing can still abort production traffic on its first match.
A rule set that queries a document behaves the same on both surfaces, so the CLI run is a valid
rehearsal for the deployed one. What -b page=invoice.bbox loads from disk travels inline as the
reserved bbox object on the POST /rule body, alongside the facts:
{ "name": "invoice-checks", "currency": "EUR", "bbox": {"page": {"pages": []}}}Send document input on every call to a rule set that names bbox. A request without it aborts on
non existent key bbox and discards everything the rules that already fired had written — the same
failure mode as any other unsupplied fact.
See Command line for the run and inspect commands, and Troubleshooting for diagnosing a failing execution.