Skip to content
Talk to our solutions team

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.

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));
}
facts
{ "header": "Bank Statement, March 2026", "closing_balance": 7325.0 }
out
{ "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:

ApproachWhen to use
Mutually exclusive when conditions over the input factsThe branch is decided by the input, not by earlier work. No chaining at all
A later rule reading outA later rule genuinely depends on an earlier rule’s decision

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);
}
facts
{ "header": "INVOICE 2026-114" }
out
{ "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.

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);
}
facts
{}
out
{ "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);
}
facts
{}
out
{ "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).

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");
}
facts
{ "order": { "Total": 12500.0 } }
out
{ "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.

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();
}
facts
{ "pages": 3 }
out
{ "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 variable
MechanismEffect
A rule firesIt leaves the agenda for the rest of this execution. Each rule fires at most once per run
agenda.Repeat() in a thenKeeps 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 matchesNormal exit
The cycle capThe 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 boundThe 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");
}
facts
{ "status": "void" }
out
{ "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.

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");
}
facts
{ "claim_id": "CLM-1041" }
out
{ "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.

WantUseNotes
A resultout.Set(key, value)Stamped with the firing rule’s name in the change history
A decision traceaudit.Log(ruleID, msg) / audit.LogWithData(ruleID, msg, m)The engine also logs executed for every firing
A validation verdictfindings.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 recordact.*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.

SymptomCauseFix
Rules fire in a different order on different runsEqual salience; the winner comes from map iteration orderGive every rule a distinct salience
A statement written twice in a row ran onceA statement repeated verbatim in one then, with no write between, reuses its first evaluationWrite the result once
strings.FindRegex returns the whole match, not the captureFindRegex returns match 0Use strings.ExtractRegex(pattern, s, sep) or strings.FindRegexGroups
A regex helper returns empty or false for a pattern you believe matchesA malformed pattern degrades instead of erroring: MatchRegex gives false, FindRegex gives "", FindRegexGroups gives nothingTest the pattern before shipping
An in reader returns 0 or "" instead of its defaultThe 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 valueCheck 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 stringWrite a list of records, or build an object with map.Of, or join the values with strings.Join
out.Append turns a number into textout.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 rulesThe collector is bound in its place and the caller’s value is lostRename the input field. rules.svc refuses such a fact with 400 rules-facts-invalid
Change history under-reportsout.All() hands back the live result map; writing through it bypasses historyWrite through out.Set
A rule file is ignoredDirectory loading reads .grl and vocabulary files only, with no warning for anything skippedRename the file
findings.Passed() is true despite warningsPassed() fails only on ERROR and CRITICALTreat WARNING explicitly if it should gate
7 / 2 is 3.5Division always yields a floatRound explicitly, math.Round(x, 0)

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 grammarfindings.Add(ruleID, severity, category, message, fields). The string form
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

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.

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.

SymptomCauseFix
the GruleEngine successfully selected rule candidate ... after 5000 cyclesA rule calls agenda.Repeat() and its when never turns falseBound 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 requestRead it through in with a default: in.Str("optional_field", "")
this node identified as "X" is not referencing to an objectDot access on a map fact (order.Total)in.Double("order.Total", 0.0), or order["Total"]
<fact>[string-><key>] is not an array nor mapTwo-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 ValueCompared a value that is not there: out.Get("k") == "v" on an unset key, or anything compared with nilout.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 typeA caller fact named like a helper (map, strings, time, math, num, array, …) replaces that helper under kis rulesRename the input field. rules.svc refuses such a fact with 400 rules-facts-invalid
reflect: Call using int64 as type float64An 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 intThe 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 stringAn 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 supportedCalled 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 mapA local variable holding a map or a listLocals 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 reachedIndex 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.

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.