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 findings.Warn(...).
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 a rule does not need
Section titled “What a rule does not need”Two calls that rules sometimes end with do nothing a rule needs. Existing rules that still call them load and run unchanged.
Retract of itself
Section titled “Retract of itself”A rule leaves the agenda when it fires, so it does not re-enter the next cycle’s candidate scan:
// not neededRetract("ClassifyInvoice");Retract("OtherRule") still takes another rule off the agenda. For the rare rule that should
fire repeatedly, call agenda.Repeat() in its then block, see
Rule patterns.
Changed and Forget
Section titled “Changed and Forget”A later rule reading out in its when sees the earlier write without any signal:
// not neededChanged("out");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
\..
Reads of nested facts take the same dotted paths through in: in.Str("customer.address.city", "").
Facts decoded from JSON are maps, and dot access on a map fact (customer.address) aborts the
execution; see Reading facts. 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. It must be a .grl file or a vocabulary file |
| A directory | The *.grl files and vocabulary files directly inside it |
| A directory, recursively | Every *.grl file and vocabulary 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 |
|---|---|
kis rules | Every --rules path loads into one set, named by --ruleset (default rules). --scope <name> instead runs a product’s scope, as rules.svc does |
rules.svc | A scope: a top folder of the product’s rules/ with the folders under it, or a static scope of rules/rules.yaml. A caller names it as scope on POST /rules/execute |
See CLI, Scopes and The rules service.
A rule set that reads 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
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 still on the agenda. 2. If no rule qualifies -> stop, execution is complete. 3. Fire the `then` of the single qualifying rule with the highest salience. That rule leaves the agenda, unless its `then` called agenda.Repeat(). 4. If that rule called Complete() -> stop. 5. Go to 1.Exactly one rule fires per cycle. Every remaining rule’s condition is then evaluated again before the next one fires, so each rule sees what the rules before it wrote.
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 still on the agenda 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, 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 the cap | Execution fails. The cap is 5000 under kis rules; on rules.svc it is rules.exec.maxcycles (5000 by default) or a static scope’s limits.maxcycles |
| The time bound passes | Execution fails. rules.svc: rules.exec.timeout (10 seconds by default) or a static scope’s limits.timeout; kis rules: five minutes |
Complete() is a hard stop for the whole execution, not just the current cycle. When an execution
fails, rules.svc answers with what was written before the failure under the error’s
meta.partial; kis rules prints the error for that input and no result.
A rule fires once
Section titled “A rule fires once”A rule that fires leaves the agenda for the rest of the current execution, so it fires at most once
per run. This lives on a per-execution copy of the rule set, so it never leaks into the next
execution. A rule that must fire again calls agenda.Repeat() in its then, and keeps firing while
its when holds; a repeating rule whose when never turns false runs into the cycle cap. See
Rule patterns.
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.
A later rule sees an earlier rule’s output
Section titled “A later rule sees an earlier rule’s output”A rule’s when that reads out sees what earlier rules wrote:
rule First salience 10 { when !out.Has("tier") then out.Set("tier", "gold");}
rule Second salience 5 { when out.Str("tier", "") == "gold" then out.Set("bonus", 10);}{}{ "tier": "gold", "bonus": 10 }Second needs no out.Has("tier") && guard: out.Str returns its default 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. Read
such facts through in with a default, or put a guard first: && short-circuits, so the unsafe
side is never 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