Skip to content
Talk to our solutions team

Writing rules

A rule file is plain text holding one or more rule declarations written in GRL, the language the Rules engine parses. Files are named .grl. Many files load into one rule set, and a rule set is what you execute — never a single rule.

rule <Name> ["<description>"] [salience <N>] {
when
<one boolean expression>
then
<statement>;
<statement>;
}
PartRequiredNotes
ruleyesKeyword. Case-insensitive, like every other keyword.
<Name>yesLetter first, then letters, digits or _. Unique across the whole rule set.
"<description>"noSingle or double quotes. Must come before salience.
salience <N>noInteger literal, negative allowed. Default 0. Higher fires first.
whenyesExactly one boolean expression.
thenyesOne or more statements, each terminated by ;. Cannot be empty.

There are no other clauses. The header is the whole grammar of a rule — no groups, no inheritance, no effective dates.

salience takes an integer only — salience 1.5 fails the load.

rule, when, then, salience, true, false and nil are matched case-insensitively, so RULE R { WHEN true THEN out.Set("a", 1); } parses. Everything else is matched exactly: rule names, fact names and method names are case-sensitive, and the string you pass to Retract must match the rule’s declared name character for character.

Two conditions on consecutive lines is not an implicit AND — it is a syntax error. Join conditions with && and ||:

when
order["total"] > 1000 && order["status"] == "open"

The semicolon is mandatory on every statement including the last one. A statement is either an assignment or an expression, which in practice means a method call such as out.Set(...), audit.Log(...) or Retract(...).

There is no if, no else, no loop and no return inside then. Branching is expressed as a second rule with the opposite condition at a lower salience — see Rule patterns.

// line and /* block */ are both skipped by the parser and are valid anywhere, including inside when and then.

classify.grl
rule ClassifyInvoice "Invoice header present" salience 100 {
when
doc["header"] == "INVOICE"
then
out.Set("document_type", "invoice");
Changed("out");
Retract("ClassifyInvoice");
}
rule ClassifyReceipt "Receipt header present" salience 90 {
when
doc["header"] == "RECEIPT"
then
out.Set("document_type", "receipt");
Changed("out");
Retract("ClassifyReceipt");
}
rule ClassifyUnknown "Fallback when nothing matched" salience 1 {
when
!out.Has("document_type")
then
out.Set("document_type", "unknown");
out.Set("requires_review", true);
Retract("ClassifyUnknown");
}

Three things every rule in that file does: it declares a salience distinct from every other rule, it writes its result with out.Set, and it ends with Retract on its own name. The Changed("out") calls are what let the fallback rule see the earlier write — see the memoised-collector trap.

doc here is a fact supplied by the caller. Facts decoded from JSON are maps and must be read with bracket access; dot access on a map fact aborts the execution. The full accessor and operator vocabulary is in The rule language, and the collectors and helper namespaces (in, out, audit, strings, num, …) are in Built-in functions.

A .grl file contains rule declarations and comments, and nothing else. There is no import, no header, no shared preamble and no per-file namespace: a file is just a bag of rules that gets poured into a rule set.

A rule set is a named collection of compiled rules. It is the unit you load into and the unit you name at execution time. Loading is additive — every file you point the loader at lands in the same rule set.

You loadWhat is read
A single file pathThat file, whatever its extension
A directoryOnly the *.grl entries directly inside it
A directory, recursivelyEvery *.grl file in the tree

Because the rule set is flat, rule names must be unique across every file loaded into it, including across subdirectories on a recursive load. A repeat fails the load, reported as the same got N error(s) in grl the script count as a syntax error — the duplicated name is not in the message.

How the set gets its name depends on how you run rules:

SurfaceRule-set name
kis rulesAlways rules; every --rules path loads into that one set, and --ruleset is not read
kis ocr page / kis ocr docThe value of --ruleset
rules.svcThe name of each delivered definition entry; that name is what a caller sends as name on the execute request

See CLI and Running the service.

A document rule set runs the same on either surface. kis ocr page reads the OCR output sitting next to your facts file; rules.svc takes the document on the execute request, alongside the facts, keyed by the same names a rule passes to bbox.Get:

{
"name": "invoice-checks",
"bbox": {
"page": {"pages": [{"number": 1, "width": 612, "height": 792, "words": []}]}
},
"total": 1200
}

The document bindings resolve identically under both — see The rules bridge.

Rules are not a script executed top to bottom. The engine forward-chains:

cycle:
1. Evaluate the `when` of every rule that has not been retracted.
2. If no rule qualifies -> stop, execution is complete.
3. Fire the `then` of the single qualifying rule with the highest salience.
4. If that rule called Complete() -> stop.
5. Go to 1.

Exactly one rule fires per cycle. Every other rule’s condition is then re-evaluated before the next one fires, which is why a rule that has already done its work must take itself out of the running.

Two consequences worth holding on to:

  • A rule’s when is tested again after every other rule fires, so ordering is expressed with salience, not with position in the file.
  • Every rule is evaluated every cycle, including rules that have nothing to do with this request. An error raised by any one of them aborts the entire execution and discards all output — see Troubleshooting.
Ends whenOutcome
No rule qualifiesNormal completion; the result is returned
A rule calls Complete()Immediate stop — no further rule fires, even a still-qualifying one; the result is returned
The cycle count exceeds 5000Execution fails with an error and returns nothing at all
The caller’s context is cancelled or times outExecution fails

Complete() is a hard stop for the whole execution, not just the current cycle.

Retract("RuleName") marks that rule as skipped for the rest of the current execution. Retraction lives on a per-execution copy of the rule set, so it never leaks into the next execution. Almost every then block ends with it.

Rules of equal salience fire in an order the engine does not define. Each cycle it collects the qualifying rules by iterating a map, then keeps the first one unless a later one has a strictly higher salience — so which of several equal-salience rules wins changes from run to run. Repeated runs of the same three rules, all at the default salience of 0, produce several different firing orders. If the correctness of your set depends on one rule running before another — and a fallback rule always does — assign distinct salience values.

Sub-expressions evaluated in a when are cached. Assignments to facts invalidate that cache, but method calls on the collectors do not — so out.Set(...) in one rule is invisible to another rule whose when reads out.Has(...) or out.GetStr(...). The reading rule keeps the answer it computed in the first cycle.

The writing rule must signal the change:

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");
}

The out.Has("tier") && guard in Second is not needed for safety: out.GetStr returns "" for a key that was never set, so the comparison is simply false. Guards matter on facts instead — referencing a fact the caller did not supply, or indexing a key the fact does not have, aborts the whole execution. && short-circuits, so a guard placed first keeps the unsafe side from being evaluated. Rule patterns covers the chaining idioms in full.