Rule language reference
Everything you may write inside a when or a then statement comes from one small expression
grammar. This page is that grammar in full: what parses, what it evaluates to, and what the language
has no syntax for at all.
The rule header, the execution cycle and rule-set loading are on
Writing rules. The callable surface — in, out, audit and
the helper namespaces — is on Built-in functions.
The whole grammar
Section titled “The whole grammar”rule Name ["description"] [salience N] { when expression then statement; ... }
statement = target = expression // also += -= *= /= | expression // in practice, a method call
expression = expression * / % expression | expression + - & | expression | expression == != > < >= <= expression | expression && expression | expression || expression | [!] ( expression ) | atom
atom = literal | name | atom . name // member access | atom [ expression ] // index | name ( args ) // function call | atom . name ( args ) // method call | ! atomThat is the complete list of forms. There is no other statement kind, no other operator and no other way to construct a value.
Where expressions appear
Section titled “Where expressions appear”| Position | Constraint |
|---|---|
when | Must evaluate to a boolean. when 1 fails with the when is not a boolean expression : 1 |
| Right side of an assignment | Any expression |
| A call argument | Any expression, including a nested call |
Inside [ ] | Any expression — rows[1 - 1] works. One level of indexing only |
salience | Not an expression. Integer literal only |
Operators and precedence
Section titled “Operators and precedence”Tightest first. All binary operators are left-associative.
| Level | Operators | Notes |
|---|---|---|
| 1 | . [ ] ( ) | Member access, index, call. Postfix, applied left to right |
| 2 | ! | Applies to a single atom or a parenthesised expression |
| 3 | * / % | |
| 4 | + - & | | Bitwise & and | sit here, tighter than comparison |
| 5 | == != > < >= <= | |
| 6 | && | Short-circuits |
| 7 | || | Short-circuits |
1 + 2 * 3 == 7 is true. false && false || true is true. 6 & 3 == 2 is true — the & binds
first, unlike C where the comparison would win. Parenthesise bitwise operands if you use them at all.
! binds to one atom, not to a comparison: !true == false parses as (!true) == false. To negate
a comparison, parenthesise it — !(1 > 2).
There is no ternary, no in operator, no between, no + that produces anything but a number or a
string, and no unary minus outside a numeric literal. The - in -5 belongs to the literal, so -x
where x is a variable fails the load; write 0 - x.
Short-circuiting is load-bearing
Section titled “Short-circuiting is load-bearing”&& and || short-circuit, and that is the only way to make a conditional access safe:
when out.Has("tier") && out.Get("tier") == "gold"The guard is required, not defensive. Without it the right-hand comparison runs against a value that is not there and aborts the whole execution — see Comparison below.
A guard that reads out is only half the job: the rule that wrote the value must also call
Changed("out"), or a later rule’s when keeps the reading it memoised on the first cycle and never
fires. Rule patterns has the full idiom.
Literals
Section titled “Literals”| Form | Example | Evaluates to |
|---|---|---|
| Double-quoted string | "invoice" | string |
| Single-quoted string | 'invoice' | string |
| Decimal integer | 42, -5 | int64 |
| Hexadecimal integer | 0x1F (31), -0x10 (−16) | int64 |
| Octal integer | 010 (8) | int64 |
| Decimal float | 1.5, .5, 1.5e2, -1.5 | float64 |
| Hexadecimal float | 0x1p-2 (0.25) | float64 |
| Boolean | true, false | bool |
| Nil | nil | untyped nil |
Strings take backslash escapes: "say \"hi\"" and "a\nb" both work. The doubled-quote form
"a""b" lexes as one token but fails the load when it is unquoted.
true, false and nil are matched case-insensitively, like the keywords.
Arithmetic
Section titled “Arithmetic”Arithmetic follows the literal types, and the results surprise people coming from Go.
| Expression | Result | Type |
|---|---|---|
2 + 3 | 5 | int64 |
3 * 4 | 12 | int64 |
7 / 2 | 3.5 | float64 |
1 / 3 | 0.3333333333333333 | float64 |
1 / 0 | +Inf | float64 |
1 + 2.5 | 3.5 | float64 |
7 % 2 | 1 | int64 |
9223372036854775807 + 1 | -9223372036854775808 | int64 |
"n=" + 5 | "n=5" | string |
"n=" + 2.5 | "n=2.500000" | string |
Four things follow from that table:
- Division always yields a float.
7 / 2is3.5, never3. There is no integer division operator and no truncation. Anything you divide is float64 from that point on, including as a function argument. - Division by zero does not fail. It produces
+Infand the rule carries on, so a zero denominator surfaces later as a nonsense output rather than an error. %is int64-only.7 % 2.0aborts the execution withcan not use data type of float64 in modulo;"a" % 2aborts with thestringform of the same message.- Integer overflow wraps silently at the int64 boundary.
Float equality is exact-bit equality: 0.1 + 0.2 == 0.3 is false. Compare with a tolerance instead —
math.Abs(0.1 + 0.2 - 0.3) < 0.000001 is true.
+ concatenates when either side is a string, formatting a number operand as it goes. A float
concatenates in Go’s %f form — "n=" + 2.5 is "n=2.500000", not "n=2.5". Use
strings.Sprintf rather than + when the exact text matters.
Comparison
Section titled “Comparison”| Operands | Behaviour |
|---|---|
| Two numbers | Compared numerically across int64 and float64. 1 == 1.0 is true |
| Two strings | Lexicographic. "abc" < "abd" is true |
| Two booleans | == and != only |
String on the left, number on the right, with == / != | No error. "5" == 5 is simply false |
Number on the left, string on the right, with == / != | Aborts: can not use data type of string in EQ comparison |
String vs number, with > < >= <= | Aborts either way — "5" > 5 gives can not compare data type of string to int64 in GT comparison, 5 > "5" gives can not use data type of string in GT comparison |
Mixed-type equality is not symmetric: which side the string is on decides whether you get false or a
dead execution. Never rely on == to compare across types — convert first.
Reading a key that is not in a map fact fails the same way, with
this node identified as "<fact>" have no selector with specified key.
Logical joining
Section titled “Logical joining”&&, || and ! take booleans and nothing else. There is no truthiness:
when 1 && true // can not use data type of int64 in Logical AND comparisonwhen !1 // the when is not a boolean expression : !1when accepts exactly one expression. Two conditions on consecutive lines is a load error, not an
implicit AND — join them with &&.
Member access and indexing
Section titled “Member access and indexing”Which accessor is legal depends on the Go type the fact decoded to, not on how the value looks.
| Fact type | Accessor | Example |
|---|---|---|
| Struct or pointer to struct | Dot, exported fields only — no surface supplies one | order.Total > 1000 |
Object fact — a -f fact file, or a nested object on the POST /rule body | Bracket | order["Total"] > 1000 |
| Slice or array | Bracket, integer index | rows[0] |
| Result of a call | Bracket, chained directly | "a,b".Split(",")[1] |
| A value reached through one bracket already | Neither. Both accessors abort | doc["header"]["total"] |
Both forms also work on the left of an assignment, and both mutate the caller’s fact in place:
then order.Approved = true; page["reviewed"] = "yes";The index may be any expression, including a variable: k = "Total"; order[k].
Strings are not indexable. "abc"[0] panics with a nil pointer dereference.
.Len(), .ToUpper(), .In(...) and the rest are methods on a value, not grammar — see
Methods on values.
Variables
Section titled “Variables”name = expression; in a then block creates or overwrites a top-level name. +=, -=, *= and
/= also parse; /= follows the division rule, so x = 7; x /= 2 leaves x at 3.5. Assignment is
a then-only construct — when x = 1 fails the load. Write when x == 1.
Variables hold scalars — strings, numbers, booleans — and survive across rules within one execution.
Identifiers
Section titled “Identifiers”A name starts with a letter (including a Unicode letter) and continues with letters, digits or _.
Step_1_Do is valid; 1Step fails the load.
Names are case-sensitive everywhere, including the string you pass to Retract. A name written in a
when that no fact binds aborts the entire execution with got non existent key <name>.
What the language does not have
Section titled “What the language does not have”Each of these fails at load with a parse error. None is a runtime limitation you can work around with a flag.
| You might write | Write instead |
|---|---|
if c { ... } else { ... } | Two rules — the second carries the negated condition at a lower salience |
for i = 0; i < 3; ... { } | One rule that re-fires, driven by a counter fact; see Rule patterns |
c ? a : b | Two rules |
"a" in "a","b" | "a".In("a","b") — a method on a string value, not an operator |
["a", "b"] | Nothing. Supply the array as a top-level fact — a key in a -f file, or a top-level key on the POST /rule body |
{"a": 1} | in.NewMapWithValues("a", 1), usable only as a direct call argument |
Finding{RuleID: "x"} | Nothing — see below |
-x on a variable | 0 - x |
when x = 1 | when x == 1 |
salience 1.5 | An integer literal. salience -5 is legal, salience 1.5 is not |
A then statement without a trailing ; | Terminate every then statement with ; |
An empty then | At least one statement |
There is also no return, break or continue; no variable declaration or type annotation; no
function, import or include; and no string interpolation. A .grl file contains rule declarations
and comments, nothing else.
Branching is expressed as a second rule with the opposite condition at a lower salience — Rule patterns has the idiom.
Continue with
Section titled “Continue with”- Writing rules — the rule header,
when/then, rule sets and the execution cycle - Built-in functions — every callable binding and which signatures the type rules rule out
- Rule patterns — guards, chaining, fallbacks and staging collections
- Troubleshooting — the same errors, indexed by symptom