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.
The shape of a rule
Section titled “The shape of a rule”rule <Name> ["<description>"] [salience <N>] { when <one boolean expression> then <statement>; <statement>;}| Part | Required | Notes |
|---|---|---|
rule | yes | Keyword. Case-insensitive, like every other keyword. |
<Name> | yes | Letter first, then letters, digits or _. Unique across the whole rule set. |
"<description>" | no | Single or double quotes. Must come before salience. |
salience <N> | no | Integer literal, negative allowed. Default 0. Higher fires first. |
when | yes | Exactly one boolean expression. |
then | yes | One 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.
Case sensitivity
Section titled “Case sensitivity”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.
when takes one expression
Section titled “when takes one expression”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"then statements end in a semicolon
Section titled “then statements end in a semicolon”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.
Comments
Section titled “Comments”// line and /* block */ are both skipped by the parser and are valid anywhere, including inside
when and then.
A complete file
Section titled “A complete file”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.
What a file may contain
Section titled “What a file may contain”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.
Rule sets
Section titled “Rule sets”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 load | What is read |
|---|---|
| A single file path | That file, whatever its extension |
| A directory | Only the *.grl entries directly inside it |
| A directory, recursively | Every *.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:
| Surface | Rule-set name |
|---|---|
kis rules | Always rules; every --rules path loads into that one set, and --ruleset is not read |
kis ocr page / kis ocr doc | The value of --ruleset |
rules.svc | The 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.
How a rule set runs
Section titled “How a rule set runs”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
whenis 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.
Ending an execution
Section titled “Ending an execution”| Ends when | Outcome |
|---|---|
| No rule qualifies | Normal 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 5000 | Execution fails with an error and returns nothing at all |
| The caller’s context is cancelled or times out | Execution fails |
Complete() is a hard stop for the whole execution, not just the current cycle.
Retract is how a rule stops re-firing
Section titled “Retract is how a rule stops re-firing”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.
Give every rule a distinct salience
Section titled “Give every rule a distinct salience”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.
Reads of out inside when are memoised
Section titled “Reads of out inside when are memoised”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.
Continue with
Section titled “Continue with”- The rule language — expressions, operators, literals, types and what the grammar does not have
- Built-in functions —
in,out,auditand the helper namespaces - Rule patterns — chaining, fallbacks, guards and staging values
- CLI — running a rule set over files
- Running the service — how
rules.svcreceives and reloads definitions