Skip to content
Talk to our solutions team

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.

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
| ! atom

That is the complete list of forms. There is no other statement kind, no other operator and no other way to construct a value.

PositionConstraint
whenMust evaluate to a boolean. when 1 fails with the when is not a boolean expression : 1
Right side of an assignmentAny expression
A call argumentAny expression, including a nested call
Inside [ ]Any expression — rows[1 - 1] works. One level of indexing only
salienceNot an expression. Integer literal only

Tightest first. All binary operators are left-associative.

LevelOperatorsNotes
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.

&& 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.

FormExampleEvaluates to
Double-quoted string"invoice"string
Single-quoted string'invoice'string
Decimal integer42, -5int64
Hexadecimal integer0x1F (31), -0x10 (−16)int64
Octal integer010 (8)int64
Decimal float1.5, .5, 1.5e2, -1.5float64
Hexadecimal float0x1p-2 (0.25)float64
Booleantrue, falsebool
Nilniluntyped 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 follows the literal types, and the results surprise people coming from Go.

ExpressionResultType
2 + 35int64
3 * 412int64
7 / 23.5float64
1 / 30.3333333333333333float64
1 / 0+Inffloat64
1 + 2.53.5float64
7 % 21int64
9223372036854775807 + 1-9223372036854775808int64
"n=" + 5"n=5"string
"n=" + 2.5"n=2.500000"string

Four things follow from that table:

  • Division always yields a float. 7 / 2 is 3.5, never 3. 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 +Inf and the rule carries on, so a zero denominator surfaces later as a nonsense output rather than an error.
  • % is int64-only. 7 % 2.0 aborts the execution with can not use data type of float64 in modulo; "a" % 2 aborts with the string form 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.

OperandsBehaviour
Two numbersCompared numerically across int64 and float64. 1 == 1.0 is true
Two stringsLexicographic. "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.

&&, || and ! take booleans and nothing else. There is no truthiness:

when 1 && true // can not use data type of int64 in Logical AND comparison
when !1 // the when is not a boolean expression : !1

when accepts exactly one expression. Two conditions on consecutive lines is a load error, not an implicit AND — join them with &&.

Which accessor is legal depends on the Go type the fact decoded to, not on how the value looks.

Fact typeAccessorExample
Struct or pointer to structDot, exported fields only — no surface supplies oneorder.Total > 1000
Object fact — a -f fact file, or a nested object on the POST /rule bodyBracketorder["Total"] > 1000
Slice or arrayBracket, integer indexrows[0]
Result of a callBracket, chained directly"a,b".Split(",")[1]
A value reached through one bracket alreadyNeither. Both accessors abortdoc["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.

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.

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>.

Each of these fails at load with a parse error. None is a runtime limitation you can work around with a flag.

You might writeWrite 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 : bTwo 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 variable0 - x
when x = 1when x == 1
salience 1.5An integer literal. salience -5 is legal, salience 1.5 is not
A then statement without a trailing ;Terminate every then statement with ;
An empty thenAt 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.

  • 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