Rule patterns and pitfalls
A rule set is not a script. Every cycle evaluates the when of every rule still on the agenda,
fires exactly one rule, the highest salience among the matches, and starts over. A rule that fires
leaves the agenda for the rest of the execution, so each rule fires at most once per run unless it
asks to repeat. Those two facts drive every pattern on this page: how a later rule sees an earlier
write, how you order work, how you loop and stop, and how one broken rule affects the others.
For the grammar and the operators see Rule language; for the callable surface see Functions.
Chaining rules
Section titled “Chaining rules”A rule’s when that reads out sees what an earlier rule wrote. Write the decision with out.Set
in one rule and read it in the next:
rule Classify salience 20 { when in.Str("header", "").Contains("Bank Statement") then out.Set("type", "statement");}
rule Extract salience 10 { when out.Str("type", "") == "statement" then out.Set("balance", in.Double("closing_balance", 0.0));}{ "header": "Bank Statement, March 2026", "closing_balance": 7325.0 }{ "type": "statement", "balance": 7325 }Classify fires first because its salience is higher. Its write reaches Extract’s when on the
next cycle, and Extract fires. Nothing else is needed: no signal to the engine, no state fact.
Read out in a when through a typed reader with a default, out.Str("type", ""), rather than
out.Get("type") == "statement": comparing what out.Get returns for a key nobody wrote aborts the
execution (see the run-time pitfalls).
Two ways to branch, in order of preference:
| Approach | When to use |
|---|---|
Mutually exclusive when conditions over the input facts | The branch is decided by the input, not by earlier work. No chaining at all |
A later rule reading out | A later rule genuinely depends on an earlier rule’s decision |
A fallback at lower salience
Section titled “A fallback at lower salience”A default that applies only when no earlier rule decided is a !out.Has(key) rule at the lowest
salience:
rule ClassifyInvoice salience 20 { when in.Str("header", "").Contains("INVOICE") then out.Set("document_type", "invoice");}
rule ClassifyUnknown salience 1 { when !out.Has("document_type") then out.Set("document_type", "unknown"); out.Set("requires_review", true);}{ "header": "INVOICE 2026-114" }{ "document_type": "invoice" }ClassifyInvoice writes document_type first, so when the engine evaluates ClassifyUnknown
again its !out.Has("document_type") is false and it does not fire. The fallback depends on its
salience being lower than every rule that can set the key. Give it the lowest salience in the set.
Memoisation inside one then
Section titled “Memoisation inside one then”Within one then, a statement repeated verbatim, with no other write between the two, does not
run the second time: the engine reuses the first evaluation.
rule Count salience 10 { when true then out.Set("count", out.Int("count", 0) + 1); out.Set("count", out.Int("count", 0) + 1);}{}{ "count": 1 }Any other write between the two lets the second one run:
rule Count salience 10 { when true then out.Set("n", out.Int("n", 0) + 1); out.Set("x", 1); out.Set("n", out.Int("n", 0) + 1);}{}{ "n": 2, "x": 1 }Do not repeat a statement to apply it twice. Write the result once,
out.Set("count", out.Int("count", 0) + 2).
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 in.Double("order.Total", 0.0) > 10000.0 then out.Set("route", "manager_approval");}
rule NormalValue salience 10 { when in.Double("order.Total", 0.0) <= 10000.0 then out.Set("route", "auto");}{ "order": { "Total": 12500.0 } }{ "route": "manager_approval" }Both conditions read the same fact, so neither depends on the other having run. 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.
Repeating a rule
Section titled “Repeating a rule”A rule that must fire more than once calls agenda.Repeat() in its then. That keeps it on the
agenda, so its when is evaluated again next cycle and it fires again while the when holds. Bound
the loop in the when:
rule NextPage salience 10 { when out.Int("page", 0) < in.Int("pages", 0) then out.Set("page", out.Int("page", 0) + 1); agenda.Repeat();}{ "pages": 3 }{ "page": 3 }The rule fires three times and stops when its when turns false. A repeating rule whose when
never turns false runs until the cycle cap and fails the execution:
the GruleEngine successfully selected rule candidate for execution after 5000 cycles, this could possibly caused by rule entry(s) that keep added into execution pool but when executed it does not change any data in context. Please evaluate your rule entries "When" and "Then" scope. You can adjust the maximum cycle using GruleEngine.MaxCycle variableTerminating cleanly
Section titled “Terminating cleanly”| Mechanism | Effect |
|---|---|
| A rule fires | It leaves the agenda for the rest of this execution. Each rule fires at most once per run |
agenda.Repeat() in a then | Keeps that rule on the agenda for the next cycle |
Retract("OtherRule") | Takes another rule off the agenda for the rest of this execution |
Complete() | Ends the whole execution at once. No further rule fires, however high its salience |
| No rule matches | Normal exit |
| The cycle cap | The execution fails. kis rules stops at 5000 cycles; rules.svc at rules.exec.maxcycles (5000 by default) or a static scope’s limits.maxcycles |
| The time bound | The execution fails. rules.svc bounds it with rules.exec.timeout (10 seconds by default) or a static scope’s limits.timeout; kis rules abandons a run after five minutes |
Retract("SelfName") at the end of a then does nothing a rule needs: the rule already leaves the
agenda when it fires. Existing rules that still call it run unchanged.
Complete() is a hard stop for the execution, not for the current cycle. Use it for terminal
decisions, a document rejected outright, where no later rule should run:
rule Reject salience 100 { when in.Eq("status", "void") then out.Set("decision", "rejected"); Complete();}
rule Route salience 10 { when true then out.Set("route", "review");}{ "status": "void" }{ "decision": "rejected" }The cycle cap counts rule firings; it is not a timeout. A rule blocked inside a slow call is bounded by the time bound, not by the cap. Both are set on the service, see Configuration and Scopes.
How one failure affects the run
Section titled “How one failure affects the run”Every rule still on the agenda is evaluated every cycle, and a failed evaluation aborts the
execution. A rule that names a fact the caller did not send raises non existent key <fact> and
aborts, even if that rule was irrelevant to this request and other rules had already written
correct output. What was written before the failure is not lost on the service: rules.svc answers
422 rules-execution-failed with it under the error’s meta.partial. kis rules prints the error
for that input and no result.
Four consequences to design around.
Read optional facts through in. The in readers take a default and cannot abort, so a rule
that reads a fact the caller may leave out never takes the run down with it:
rule Rush salience 20 { when in.Str("priority", "") == "rush" then out.Set("queue", "fast");}
rule Standard salience 10 { when !out.Has("queue") then out.Set("queue", "standard");}{ "claim_id": "CLM-1041" }{ "queue": "standard" }priority is absent, in.Str returns its default, and the run carries on. Written as the bare name,
priority == "rush", the same rule aborts the execution. See
Reading facts.
Keep unrelated concerns in separate scopes. Everything in one scope shares one failure domain
and one flat rule-name namespace. rules.svc runs one scope per call, a top folder of the product’s
rules/ or a static scope of rules/rules.yaml, so a rule failing in one scope’s execution cannot
affect another’s.
The corollary is that rules in different scopes cannot see or chain with each other. See
Scopes.
Make rule names unique inside a set. Every .grl file loaded together lands in the same set. A
repeated name fails the load of the file that reintroduces it, reported as
got 1 error(s) in grl the script, with no mention of the name. Prefix names by file or by phase.
Put anything a caller branches on in out. out is the result. kis rules prints out, and
the history and audit trail only when asked (--history, --audit). rules.svc returns out,
findings and actions on every execute, and the audit trail and history when the request asks
for them. The trail is the reasoning behind a decision, not the decision.
Recording decisions and diagnostics
Section titled “Recording decisions and diagnostics”| Want | Use | Notes |
|---|---|---|
| A result | out.Set(key, value) | Stamped with the firing rule’s name in the change history |
| A decision trace | audit.Log(ruleID, msg) / audit.LogWithData(ruleID, msg, m) | The engine also logs executed for every firing |
| A validation verdict | findings.Error/Warn/Info/Critical(ruleID, category, message) | rules.svc returns findings with every execute; kis rules does not print them, so also out.Set a status key when the CLI run must show it |
| A proposed write to a record | act.* | See act |
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 statement written twice in a row ran once | A statement repeated verbatim in one then, with no write between, reuses its first evaluation | Write the result once |
strings.FindRegex returns the whole match, not the capture | FindRegex returns match 0 | Use strings.ExtractRegex(pattern, s, sep) or strings.FindRegexGroups |
| A regex helper returns empty or false for a pattern you believe matches | A malformed pattern degrades instead of erroring: MatchRegex gives false, FindRegex gives "", FindRegexGroups gives nothing | Test the pattern before shipping |
An in reader returns 0 or "" instead of its default | The default applies only when nothing is at the path. A value that is there but does not convert, in.Int("status", 7) on "submitted", reads as the zero value | Check the value’s type with in.Value(path), or compare with in.Eq |
A key in the output holds "[a b]" or "map[k:v]" | A list of plain values (strings.Split) or a map of strings (strings.FindRegexGroups) written to out is stored as one string | Write a list of records, or build an object with map.Of, or join the values with strings.Join |
out.Append turns a number into text | out.Append reads the existing value as a string and joins with ", ": 5 then "x" gives "5, x" | out.Append is deprecated; build the string and write it with out.Set |
A fact named out, in, findings or audit disappears under kis rules | The collector is bound in its place and the caller’s value is lost | Rename the input field. rules.svc refuses such a fact with 400 rules-facts-invalid |
| Change history under-reports | out.All() hands back the live result map; writing through it bypasses history | Write through out.Set |
| A rule file is ignored | Directory loading reads .grl and vocabulary files only, with no warning for anything skipped | Rename the file |
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 | Division always yields a float | Round explicitly, math.Round(x, 0) |
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 | findings.Add(ruleID, severity, category, message, fields). The string form |
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 |
Calls are checked when the rule set loads, too. A method that does not exist on a binding, or a call with the wrong number of arguments, is an error naming the rule and line, with the nearest name when one is close:
StampClaim:3: error: strings has no method ToUper (did you mean ToUpper?)rules.svc, kis rules check and kis rules --scope refuse to load a set with such an error;
kis rules --validate lists them. A plain kis rules -r run does not stop for them: it runs the set
and fails at the first call that cannot resolve. See
Validating a rule set.
Pitfalls that fail at run time
Section titled “Pitfalls that fail at run time”These abort the execution. rules.svc answers 422 rules-execution-failed, naming the rule, with what
was written before the failure under meta.partial; kis rules prints the error and no result. The
load-time check does not see them. Their exact text is catalogued in
Errors.
| Symptom | Cause | Fix |
|---|---|---|
the GruleEngine successfully selected rule candidate ... after 5000 cycles | A rule calls agenda.Repeat() and its when never turns false | Bound the loop in the when |
non existent key <fact> | A rule named a fact the caller did not send, in any rule’s when, including a rule irrelevant to this request | Read it through in with a default: in.Str("optional_field", "") |
this node identified as "X" is not referencing to an object | Dot access on a map fact (order.Total) | in.Double("order.Total", 0.0), or order["Total"] |
<fact>[string-><key>] is not an array nor map | Two-level bracket indexing into a fact (doc["header"]["total"]) | A dotted path: in.Double("doc.header.total", 0.0) |
reflect: call of reflect.Value.Type on zero Value | Compared a value that is not there: out.Get("k") == "v" on an unset key, or anything compared with nil | out.Str("k", "") == "v". IsNil() is not a substitute: it panics on strings |
A helper call aborts: this node identified as "map" call function Of is not supported for map, or another dispatch error naming the fact’s type | A caller fact named like a helper (map, strings, time, math, num, array, …) replaces that helper under kis rules | Rename the input field. rules.svc refuses such a fact with 400 rules-facts-invalid |
reflect: Call using int64 as type float64 | An integer literal passed to a float parameter: math.Min(10, 20), in.Double("x", 0) | Write 10.0, 0.0. The reverse also fails: an int64 parameter rejects a float literal |
reflect: Call using int64 as type int | The helper takes a narrower integer than a rule literal can express (array.Slice) | 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: strings.ToUpper(out.Get("k")) | Use a typed reader: strings.ToUpper(out.Str("k", "")) |
... returns multiple values, multiple value returns are not supported | Called a method returning a value and an error: out.JSON(), audit.JSON() | Not callable from a rule; 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 a list | Locals hold scalars only. Chain the call directly (strings.Split(s, ",")[0]), or stage it with out.Set and read it back (out.Map(k), out.Value(k).Rows()) |
this code part should not be reached | Index assignment into a local variable (m = map.New(); m["k"] = 1;) | Build the map in place: map.Of("k", 1) passed straight to the call |
The load-time check flags the last two at load when the variable is assigned from a binding call
(rows = strings.Split(…)): strings.Split returns []string, which a rule variable cannot hold.
Before you deploy
Section titled “Before you deploy”Check the set first. kis rules check loads a product’s rules/ folder the way rules.svc does and
reports every call that cannot resolve and every wrong argument count, by rule and line, with no
input and nothing run; kis rules -r ./rules --validate --strict does the same for a folder of
rules. Neither catches argument types, a fact the caller does not send, or dot access on a map fact:
those still fail only when the rule runs.
Then run the set through the CLI over representative inputs, including ones missing optional
fields, and check --history to confirm each value was written by the rule you expect.
A rule set that queries a document (bbox.*) runs through kis rules only: rules.svc serves no
documents today, and a scope whose rules name bbox does not load there.
See Command line for the run and check commands, and Troubleshooting for diagnosing a failing execution.