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 bbox.Doc().Top(20).Has("INVOICE") then out.Set("document_type", "invoice");}
rule ClassifyReceipt "Receipt header present" salience 90 { when bbox.Doc().Top(20).HasAny("RECEIPT", "SALES RECEIPT") then out.Set("document_type", "receipt");}
rule ClassifyUnknown "Fallback when nothing matched" salience 1 { when !out.Has("document_type") then out.Set("document_type", "unknown"); out.Set("requires_review", true);}Two things every rule in that file does: it declares a salience distinct from every other rule,
and it writes its result with out.Set. That is all a rule needs.
Documents are read through bbox.Doc() — a scope
(Top(20)), then a verb (Has, HasAny).
Matching is fuzzy and case-insensitive by default, so Has("INVOICE") matches Invoice
and invoice, and the OCR punctuation variants are absorbed. There is nothing to configure and
no tolerance to tune. Opt out with Exact() or MatchCase() when a near-miss would be wrong —
see Matching.
What you no longer write
Section titled “What you no longer write”Rules used to end with two lines of ceremony. Both are now handled by the runtime, and existing rules that still call them keep working.
Retract is automatic
Section titled “Retract is automatic”A rule is retracted after it fires, so it does not re-enter the next cycle’s candidate scan.
// no longer neededRetract("ClassifyInvoice");Explicit Retract calls are no-ops, so nothing breaks. For the rare rule that should fire
repeatedly, call Repeat() in its then block to clear the flag.
Changed is gone from the rule surface
Section titled “Changed is gone from the rule surface”out knows when it has changed and invalidates the memoised condition itself, so a later rule
reading out in its when sees the earlier write:
// no longer neededChanged("out");Changed("in") was manual invalidation of scratch memory. With results staged on out there
is no scratch memory to invalidate, so it does not exist either.
Nested output with dotted keys
Section titled “Nested output with dotted keys”A dot in an output key nests:
out.Set("response.success", true);out.Set("response.message", "All OK!");{ "response": { "success": true, "message": "All OK!" } }Reads use the same path — out.Bool("response.success", false).
Nesting is on by default. Two rules keep it safe on real label text:
- A key whose split would produce an empty segment stays literal, so a trailing dot such as
"Work Phone Ext."needs no escaping. - A key with an interior dot that is meant literally must escape it as
\..
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