Skip to content
Talk to our solutions team

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.

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.

ApproachWhen to useCost
Mutually exclusive when over the input factsBranches decided by the input, not by earlier workNone — no chaining at all
Index assignment on a map factA later rule genuinely depends on an earlier decisionCaller must supply the map fact
Changed("out") in the writing ruleYou are already reading out and cannot restructureInvalidates every cached read of out

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.

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.

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.

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.

MechanismEffect
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 ruleNormal exit
Cycle cap — fixed at 5000Hard error, no result at all
Context cancellationHard 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.

WantUseNotes
A resultout.Set(key, value)Stamped with the firing rule name in the change history
A concatenated resultout.Append(key, str)Separator defaults to ", "; see the pitfall table
A decision traceaudit.Log(ruleID, msg) / audit.LogWithData(ruleID, msg, m)The engine also logs executed for every firing
A validation verdictout.Set a status keySee 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.

SymptomCauseFix
Rules fire in a different order on different runsEqual salience; the winner comes from map iteration orderGive every rule a distinct salience
A later rule never fires; a fallback overwrites the real answerout.* reads in when are memoised and never see a later writeChain through a map fact, or call Changed("out") in the writing rule
A value read twice in one rule returns the stale first valueIdentical call expressions are memoised within a ruleRead once at the end, through a different expression (in.GetMemAsMap())
in.GetStr / GetInt / GetFloat / GetBool / GetMap return zero values for facts that existThe adapter hands back a reflection wrapper, so every type assertion failsRead 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 memoryTransform 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 nothingUnknown or malformed transform name resolves to nothing — no error, no logCheck the name against the registry: trim, upper, lower, normalize_spaces, trim_currency, ExtractRegex(...)
strings.FindRegex returns the whole match, not the captureFindRegex returns match 0Use strings.ExtractRegex or strings.FindRegexGroups, or the ExtractRegex("pattern","default") transform
A regex helper returns empty or false for a pattern you believe matchesMalformed patterns degrade instead of erroring: match → false, find → "", groups → nothingTest the pattern before shipping
out.Append throws away the accumulated valueThe key held a non-string, so the existing value is replaced instead of appendedKeep append targets string-valued from the first write
A fact named out, in, findings or audit disappearsThe collectors are bound last and overwrite caller facts silentlyRename the input field
Values staged in a named memory slot never appearin.GetMemAsMap() snapshots the default slot only; nothing in the result reads other slotsCopy the slot out explicitly, or stage in the default slot
Change history under-reportsout.All() and out.GetMap() hand back live references; mutating them bypasses historyWrite through out.Set
A rule file is ignoredDirectory loading matches .grl only, with no warning for skipped filesRename the file
The findings list is always emptyNothing a rule can call adds a findingMirror the verdict into out
findings.Passed() is true despite warningsPassed() fails only on ERROR and CRITICALTreat WARNING explicitly if it should gate
7 / 2 is 3.5Integer division yields a floatRound explicitly

Everything the parser rejects reports the same message and nothing else:

/srv/rules/b.grl: got 3 error(s) in grl the script
ConstructWhy the load failsWrite instead
findings.Add(Finding{...})Struct literals do not exist in the grammaraudit.Log(ruleID, msg) plus an out.Set status key
if/else in a then blockNo conditional statement existsTwo rules with opposite conditions at different salience
Two conditions on separate lines in whenwhen takes exactly one expressionJoin with && / ||
A then statement without its trailing semicolonEvery statement ends in ;, including the lastAdd the ;
Two rules in the set sharing a nameRule names are unique per rule set, not per filePrefix names by file or phase

These abort the execution and return no result at all — no partial output, no findings, no audit. Their exact text is catalogued in Errors.

SymptomCauseFix
the GruleEngine successfully selected rule candidate ... after 5000 cyclesA rule re-qualifies every cycleRetract("SelfName") in every then
non existent key <fact>Any rule referenced an unsupplied fact — including a rule irrelevant to this requestGuard with in.Has(...) &&
this node identified as "X" is not referencing to an objectDot 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 mapTwo-level indexing into decoded JSON — the intermediate is interface-typedFlatten 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 ValueCompared 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 typeA caller fact named map, strings, time, math, num, array or util shadows the helper namespaceRename 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 float64An 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 intThe helper takes a plain Go int, which a rule literal can never beThat helper is unreachable from a rule; use one with int64 or float64 parameters
reflect: Call using interface {} as type stringAn any-typed value passed to a typed parameterPass the fact by name, or in.MemGetStr / in.MemGetRows
... returns multiple values, multiple value returns are not supportedCalled 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 mapA local variable holding a map or sliceLocals hold scalars only; index the call directly (strings.Split(s, ",")[0]) or stage in in.MemSet
this code part should not be reachedIndex 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 unqualifiedCheck Functions; rules are not checked until they run
ruleset <name> not foundThe set was loaded under a version other than the engine defaultLoad 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

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.