Rules are the core of the rule engine. A rule says: “When these conditions are true, produce these actions.”
Rules are written in plain text files with the .rule extension.
The syntax is intentionally simple and close to natural language, so that business analysts and domain experts can write and review rules without developer support.
.rule file can contain one or more rules.rules: list order first, then the order the rules are declared within each file. Matches are returned in that same order.set clause publishes a value only the rules after it can read, and a branch ending in stop ends the run at its own position, so the rules after it are not evaluated. Reordering the manifest can therefore change the result, not just its sequence.rule "rule-id" {
description "<what this rule is for, in one sentence>"
when
<condition>
then
<action>
<action>
...
else
<action>
...
not_exists
<action>
...
}
| Part | Required | Description |
|---|---|---|
rule "id" |
✅ | Unique identifier for this rule |
description "..." |
⬜ | One sentence explaining what the rule is for. Must come first, before when |
when |
✅ | One or more conditions that must be true |
then |
✅ | One or more actions to return when the rule matches |
else |
⬜ | What to return when the condition is false. Same contents as then |
not_exists |
⬜ | What to return when the record carries no data to decide the condition. Same contents as then, and written after else |
rule "rent-payment" {
when
purpose contains "rent"
and amount >= 500
then
label "rent"
}
A rule may open with a description clause: one sentence, in the reader’s language, saying what the rule is for — not what it technically compares.
rule "rent-payment" {
description "A recurring payment of at least 500 whose purpose mentions rent."
when
purpose contains "rent"
and amount >= 500
then
label "rent"
}
{, before when.Why it matters: the description is the only part of a rule written for a human rather than for the engine. It is what appears in an exported rule overview handed to a customer or published to a wiki, where the reader has never seen the DSL. Without it, such a reader gets only the rule id and the raw condition.
Write it as a statement about the business, not about the syntax:
| ✅ Good | ❌ Avoid |
|---|---|
"A valuable shipment needs a cover note." |
"Checks declaredValue between 1000 and 25000." |
"Gold-tier customers on an express service get the premium assessment." |
"tier equals gold AND service contains express" |
"Two or more parcels from the same hub travel together." |
"A filter may read a nested member of the element it filters." |
Comments are not descriptions. A
#comment is stripped when the file is read and never reaches the engine, so it cannot appear in an export. Use#for notes to other rule authors, anddescriptionfor the sentence the business reader needs.
A condition compares a field to a value using an operator.
fieldName operator value
The fields and allowed operators come from the Field Schema.
| Operator | Example | Meaning |
|---|---|---|
equals |
country equals "de" |
Exact match |
contains |
purpose contains "rent" |
The field contains this text |
startsWith |
iban startsWith "DE" |
The field begins with this text |
endsWith |
purpose endsWith "GmbH" |
The field ends with this text |
in |
sepaCode in ["CCRD", "DCRD"] |
The field matches one value from a list |
regex |
iban regex "^DE[0-9]{20}$" |
The field matches a regular expression |
| Operator | Symbolic form | Example | Meaning |
|---|---|---|---|
equals |
== or = |
amount equals 0 |
Exact numeric equality |
gt |
> |
amount > 1000 |
Greater than |
gte |
>= |
amount >= 500 |
Greater than or equal |
lt |
< |
amount < 0 |
Less than |
lte |
<= |
amount <= 9999 |
Less than or equal |
between |
— | amount between 100 5000 |
Inclusive range (both bounds included) |
| Operator | Example | Meaning |
|---|---|---|
containsAny |
tags containsAny ["vip", "premium"] |
At least one listed value is in the set |
containsAll |
tags containsAll ["verified", "active"] |
All listed values are in the set |
| Operator | Example | Meaning |
|---|---|---|
equals |
isActive equals true |
The flag has this value |
equals is the only operator, and the value is the bare word true or false — never quoted.
isActive equals true
isActive equals false
Note:
not isActive equals truealso matches records where the field is missing, because a missing value makes the inner condition false. WriteisActive equals falsewhen the flag must be present and false.
There is no before or after operator: lt means before and gt means after.
| Operator | Symbolic form | Example | Meaning |
|---|---|---|---|
equals |
== |
bookingDate equals "2024-06-15" |
Same day |
gt |
> |
bookingDate > "2024-01-01" |
After |
gte |
>= |
bookingDate >= "2024-01-01" |
On or after |
lt |
< |
bookingDate < "2020-01-01" |
Before |
lte |
<= |
bookingDate <= "2024-12-31" |
On or before |
between |
— | bookingDate between "2024-01-01" "2024-12-31" |
Inclusive date range |
A date_time field uses the same operators and compares the time of day too:
bookedAt gt "2024-06-15T09:00:00"
bookedAt between "2024-06-15T09:00:00" "2024-06-15T17:00:00"
Dates are always quoted. Without a declared format they are ISO — YYYY-MM-DD for a date,
YYYY-MM-DDTHH:MM:SS for a date_time — so bookingDate > 20240101 and
bookingDate equals "15.06.2024" are both rejected when the rules load.
When the schema declares a format for the field, that pattern is what
the rule must use instead:
dueDate lt "31.01.2024"
When the schema declares a field as an object, a condition navigates into it with a dotted path and
compares it like any other field:
shipment.customer.tier equals "gold"
shipment.transitDays <= 2
A collection holds many records, so a path through it has one value per element and needs a
value expression — a total, an average, or a count of matching elements:
sum(orders[status == "paid"].total) > 1000
count(orders[customer.country == "DE"]) > 0
Most operators can be written as a word (equals, gt) or a symbol (==, >). They are not
interchangeable in one respect:
| Comparison | Write | Why |
|---|---|---|
| A field against a value | Named — equals, gt, contains, … |
Fully checked: the field’s declared operator list applies, the value type is verified, and text normalizers are applied to the value as well as the field |
| An aggregate or calculation | Symbolic — ==, !=, >, >=, <, <= |
Required; see Value Expressions |
Important:
==and!=always go through the value-expression engine, which does not apply normalizers to the value. On a field with alowercasenormalizer,counterparty equals "ACME"matches the stored valueacme, butcounterparty == "ACME"does not. Use the word form for plain field comparisons.>,>=,<and<=behave the same in both spellings.
Rules can use either the full dot-notation path or a field alias to refer to data fields.
Use the complete path to a field as defined in the schema. This is highly explicit and avoids ambiguity.
The schema may declare the path either as a single dotted field id or as nested fields: blocks — both
spellings work with every operator (see Nested Data). A path that reads
through a collection is the one exception: aggregate or filter it instead.
To understand how dot-notation works, consider the following input JSON:
{
"user": {
"profile": {
"email": "user@example.com",
"age": 30
},
"account_info": {
"status": "active"
}
},
"transaction": {
"metadata": {
"vendor_id": "VEND-123",
"location": {
"city": "Berlin",
"country": "DE"
}
}
}
}
The paths to specific fields would be:
user.profile.emailuser.account_info.statustransaction.metadata.vendor_idtransaction.metadata.location.cityTo make rules more readable and maintainable, you can define short aliases for complex paths in your schema configuration.
# If 'user.profile.email' is aliased to 'email'
email contains "@gmail.com"
# If 'transaction.metadata.vendor_id' is aliased to 'vendor'
vendor equals "VEND-123"
An alias whose path reads through a collection is the one exception — write it inside the aggregate or the
filter (sum(orders.orderTotal)), never on its own.
rule "high-risk-country" {
when
country equals "ng"
and amount >= 10000
then
flag "review"
score 100
}
Both country equals "ng" and amount >= 10000 must be true for the rule to match.
Conditions on consecutive lines are joined with AND automatically, so the and keyword can be left
out. These two blocks mean the same thing:
when
country equals "ng"
and amount >= 10000
when
country equals "ng"
amount >= 10000
Writing and explicitly is clearer and does not depend on the line break, so prefer it.
rule "vip-customer" {
when
tags containsAny ["vip"]
or tags containsAny ["premium"]
then
label "vip"
}
The rule matches if the customer has either tag.
rule "non-dach-iban" {
when
not iban regex "^(DE|AT|CH)"
then
flag "foreign-iban"
}
The rule matches when the IBAN does not start with DE, AT, or CH.
When mixing and, or, and not without parentheses, the precedence is:
not (highest)
and
or (lowest)
This means:
A or B and C
is interpreted as:
A or (B and C)
Use parentheses to make grouping explicit.
Parentheses let you create complex conditions that are easy to read and unambiguous.
rule "chargeback-small" {
when
(purpose contains "chargeback"
or purpose contains "cancellation"
or purpose contains "reversal")
and amount < 0
then
label "chargeback"
flag "review"
}
The rule matches when the purpose mentions any of the three words and the amount is negative.
ignoreCase ModifierFor text operators (equals, contains, startsWith, endsWith, regex), you can add ignoreCase after the comparison value to make the match case-insensitive — regardless of whether the field has a lowercase normalizer.
rule "streaming-subscription" {
when
counterparty equals "Netflix" ignoreCase
or counterparty equals "Spotify AB" ignoreCase
then
label "streaming"
category "entertainment"
}
This matches NETFLIX, netflix, Netflix, etc.
If the field already uses a
lowercasenormalizer,ignoreCaseis redundant but harmless.
Actions appear in the then block.
Each line is one action: the action name followed by its argument.
then
label "rent"
category "housing"
score 10
String arguments are always in double quotes. Numeric arguments are written as plain numbers.
An action declared in the schema with argTypes: [] takes no argument and is written as the bare name:
then
suppress
tag "noise"
A rule can have any number of actions. All of them are returned when the rule matches.
else BranchA rule can also say what to produce when its condition is false.
Write it as an else block after the then block:
rule "order-tier" {
description "An order of at least 1000 gets priority handling, anything smaller the standard path."
when
amount >= 1000
then
label "priority"
else
label "standard"
}
The else block is optional. A rule without one behaves exactly as it always has: a false
condition produces nothing at all.
Without else, a business statement with two outcomes over one threshold needs two rules, and the
threshold gets written twice:
rule "order-priority" {
when
amount >= 1000
then
label "priority"
}
rule "order-standard" {
when
amount < 1000
then
label "standard"
}
That works, but the boundary now lives in two places. The first time someone moves one of them and not
the other, orders of exactly 1000 either get both labels or neither. One rule with an else has one
boundary.
else block can containExactly what a then block can: actions, extract clauses and set clauses.
rule "order-tier" {
description "An order of at least 1000 is tier 2, anything smaller is tier 1."
when
amount >= 1000
then
label "priority"
set tierLevel = 2
else
label "standard"
set tierLevel = 1
}
A set in the else block publishes to the rules after it exactly as one in then does — the
variable carries whatever the branch that actually ran assigned.
| Aspect | Behaviour |
|---|---|
| Optional | Omit it and a false condition produces nothing, as before. |
| Position | After the then block, before the closing }. |
| At most once | A second else on the same rule is an error. |
| Never empty | else with nothing in it is an error. Drop the keyword instead. |
| Exclusive | Exactly one branch runs per record. Never both, never neither. |
| Not a match | An else result means the condition was false. It says what to output, not that the rule matched. |
else is a keyword, so an action cannot be named else. If your action schema declares one, the engine
reports an error and the action has to be renamed.
An else result is returned alongside the ordinary matches, tagged with the branch that produced it,
so nothing has to guess which half of the rule ran. See
integration-guide.md.
else fits one condition with two outcomes. An else fires whenever its own condition is
false — including for records another rule already handled — so a chain of rules with else blocks
makes a record collect every band it is not in.
For three or more bands, give each its own rule and no else:
rule "tier-high" {
when
amount >= 1000
then
label "high"
}
rule "tier-mid" {
when
amount >= 100
and amount < 1000
then
label "mid"
}
rule "tier-low" {
when
amount < 100
then
label "low"
}
not_exists BranchA condition needs data on both sides of it. When a record does not carry that data, “the condition is
false” is the wrong answer — the truthful one is “the condition could not be decided”. A rule can say
what to produce in that case, in a not_exists block after then and else:
rule "order-tier" {
description "A large order is priority, a small one standard, an order with no amount neither."
when
amount >= 1000
then
label "priority"
else
label "standard"
not_exists
label "unknown"
flag "no-amount"
}
amount: 5000 → priorityamount: 10 → standardamount at all, or amount: null → unknown and no-amountThe block takes exactly what a then block takes: actions, extract clauses, set and add clauses,
and stop.
| Read | Undecided? |
|---|---|
A field the record does not carry, or carries as null |
yes |
| A field whose value cannot be read as its declared type | yes |
| A variable no earlier rule published | yes |
avg, median, min, max over a missing or empty collection |
yes — they produce no value |
count and sum over a missing collection |
no — they produce 0 |
every over a missing collection |
no — vacuously true |
any over a missing collection |
no — nothing satisfied it |
| A field that is present and simply does not match | no — that is an ordinary false |
and, or and notAn undecided read does not make the whole condition undecided. It only does so when nothing else settles the answer:
amount >= 1000 or country == "de" # country is "de" -> then (one true side is enough)
amount >= 1000 and country == "fr" # country is "de" -> else (one false side settles it)
amount >= 1000 and country == "de" # country is "de" -> not_exists
| Combination | Answer |
|---|---|
false and <undecided> |
false |
true and <undecided> |
undecided |
true or <undecided> |
true |
false or <undecided> |
undecided |
not <undecided> |
undecided, in a rule that declares not_exists |
isAvailable()isAvailable(<value>) asks whether the record carries something at all. It answers a plain true or
false, never undecided, which is what makes it usable as a guard:
when
isAvailable(amount)
and amount >= 1000
That rule takes else for a record with no amount: the guard answered false and settled the and.
Use isAvailable when the rule should treat missing data as a plain no; use not_exists when the
outcome should say the data was missing.
It accepts anything a value expression may hold — a field, a nested path, a whole object or collection, an aggregate, a variable — and can be negated:
isAvailable(transactions)
isAvailable($turnover)
not isAvailable(counterparty)
An empty collection is not “available”: an absent collection and an empty one are the same answer
to does the record carry this at all, so isAvailable(transactions) is false for transactions: [].
isEmpty()isEmpty(<collection>) asks the narrower question: whether the record carries the collection and
it holds no elements. Between them the two name all three states:
| the record carries | isAvailable |
isEmpty |
|---|---|---|
| nothing at all | false |
false |
| an empty collection | false |
true |
| one or more elements | true |
false |
count(transactions) == 0 reaches the same conclusion for a collection the record carries, but it is
undecided for one it does not — an aggregate propagates a missing value, while isEmpty consumes it,
so only isEmpty can guard a rule. The argument must be a path; a decorated one is allowed, so
isEmpty(orders[total > 100]) asks whether the filter selected nothing.
| Aspect | Behaviour |
|---|---|
| Optional | A rule without not_exists behaves as it always did: undecided data reads as false, so the rule takes else, or produces nothing when it has no else either. |
| Position | After then, and after else when the rule has one. An else written after not_exists is an error. |
| At most once | A second not_exists on the same rule is an error. |
| Never empty | A not_exists with no action, no set and no stop is an error — drop the keyword. |
| Exclusive | Exactly one branch produces output per record. |
| Not a match | The condition was neither true nor false, so the rule is not reported as matched. |
| Variables | A set or add here publishes to the following rules exactly as one in then does. |
| Trace | The rule’s trace node records the verdict UNKNOWN and the branch it selected. |
not_exists is a keyword, so an action may not be named not_exists.
Declaring not_exists changes what not means for missing data inside that rule. The
guarded accumulator relies on not $topics contains "billing"
being true while the list is still empty. In a rule that declares not_exists, the same condition
is undecided and takes that branch instead — so a rule that guards on a list it fills should not
declare not_exists.
Use it when “we could not tell” is a business outcome of its own: an assessment that must report
UNKNOWN rather than a failure, a check that has to be recorded as skipped, a missing mandatory field
that deserves a different flag from a wrong one. When missing data simply means the rule does not
apply, leave the block out — that is already what happens.
stop KeywordA branch can end the run. Write stop as the last statement of a then, else or not_exists block:
rule "blocked-country" {
description "A payment to a sanctioned country is rejected outright; nothing else applies."
when
country in ["xx", "yy"]
then
label "rejected"
stop
}
When that branch fires, the rule’s own output is collected and then no rule declared after it is evaluated for that record. This is what makes a guard rule a guard: everything below it is not merely overridden, it never runs.
stop belongs to a branch, not to the rule, so a rule can halt on one verdict and carry on with the
other:
rule "must-be-known-country" {
description "An unknown country is rejected and nothing further is assessed."
when
country in ["de", "at", "ch"]
then
label "known-country"
else
label "rejected"
stop
}
Here a known country continues through the rest of the rule set; an unknown one ends the run. An
else block containing nothing but stop is valid and means exactly that.
| Aspect | Behaviour |
|---|---|
| Position | The last statement in its block. Anything after it is an error. |
| Scope | The remaining rules of the same manifest entry, across file boundaries. |
| Own output | Collected first. stop halts what comes after the rule, not the branch it sits in. |
| Per branch | Valid in then, in else, or both. |
| Variables | Compatible. A variable published before the stop is in the result; the rules that would have read it are simply not reached. |
stop is a keyword, so an action cannot be named stop.
stop must be lastThe lines below a stop would still run — a branch’s output resolves before the halt takes effect — so a
block with stop in the middle would read as if half of it were dead. Requiring it last removes the
question. The visual Builder holds it as a badge pinned to the end of the branch, so it cannot get out of
place there.
EvaluationResult.stoppedBy names the rule that halted the run, or is null when every rule was
evaluated. Without it, a consumer cannot tell “no further rule matched” from “no further rule ran”.
See integration-guide.md.
The Test panel shows the difference directly: rules after the halt are reported as not evaluated rather than as no match.
A rule set using stop depends on its manifest order. Moving a guard rule below the rules it was meant
to guard silently stops guarding them. Say so in a comment next to the rules: list when you write one —
see manifest.md.
set ClauseA rule can publish a named value that the rules after it can read. Use it when several rules need the same computed value: work it out once, then refer to it by name.
Write the assignment in the then block:
then
set <name> = <expression>
Read it anywhere a value can stand, with a $ in front of the name:
when
$<name> >= 1000
The right-hand side of set is a full value expression — a field, a literal, an
aggregate, arithmetic, or another variable.
# totals.rule — listed first in the manifest
rule "account-totals" {
description "Computes the account turnover once for the rules that follow."
when
count(transactions) > 0
then
set turnover = sum(transactions.amount)
}
# tiers.rule — listed after totals.rule
rule "active-account" {
description "An account with meaningful turnover is treated as active."
when
$turnover >= 100
then
label "active"
}
rule "high-turnover-account" {
description "A very high turnover is reviewed by hand."
when
$turnover >= 100000
then
label "manual-review"
flag "high-turnover"
}
A variable can also be an action argument:
rule "turnover-score" {
description "Reports the account turnover as a score."
when
count(transactions) > 0
then
set turnover = sum(transactions.amount)
score $turnover
}
That needs no declaration: score is declared argTypes: [integer] and the engine does not check what
the variable holds. An action whose argument is meant to be a variable can say so instead, with
argTypes: [variable_string] or [variable_list] — see
the action schema. The rule is written exactly the same way; the
declaration is what gets it checked and completed.
| Question | Answer |
|---|---|
Who can read $name? |
Only rules that come after the rule that sets it, within the same manifest entry. |
| What is “after”? | Manifest rule-file order first, then declaration order inside each file. See manifest.md. |
| When does the assignment run? | Only if the rule matched — a set sits in then, like an action. |
| Can the same rule’s actions read it? | Yes. Assignments are applied before the rule’s own actions resolve. |
| What if nothing set it? | The read yields a missing value, so the condition is simply false. Evaluation never fails. |
| Can two rules set the same name? | Yes; the last matching rule wins. The validator warns, because it is usually unintended. |
| Does a variable change the input? | No. The engine never modifies input data; a variable lives only for the duration of one evaluation. |
This makes rule order significant. Without variables or a
stop, order only affects the order of the results. A rule that reads$namedepends on an earlier rule having run and matched, so moving rule files around in the manifest can change the outcome.
The visual editor scopes variables the same way. Editing one rule file of an open manifest, it counts
the set and add clauses of every file listed before it — including ones you have edited but not yet
saved — so reading a variable an earlier file publishes is accepted, while reading one a later file
publishes is still the forward reference the engine rejects.
A variable name is written without the $ in the set clause and with it everywhere else.
It follows the same spelling rules as a field name — letters, digits, _ and -, starting with a
letter or _.
$1, $2, … are not variables; an all-digit name is a capture group of an
extract clause.
| Rule | Severity |
|---|---|
$name must be assigned by an earlier rule of the entry |
❌ error (with a “did you mean” suggestion) |
| A variable must not be named like a schema field | ❌ error |
A set or add name must be written without the $ prefix |
❌ parse error |
| The value expression is checked like any other (unknown fields, bad aggregates) | ❌ error |
Two rules setting the same name |
⚠️ warning |
Two rules adding to the same name |
✅ nothing — that is the point of add |
One name written by both set and add |
❌ error |
The “assigned by an earlier rule” check is deliberately generous: it only asks that some earlier rule assigns the name, not that the rule will actually match at runtime. Catching typos and forward references is the point; proving a variable is always populated is not possible before the data arrives.
add ClauseA set publishes one value. To collect several values across rules, use add:
then
add <value expression> to <name>
and read the list back with contains:
when
$<name> contains "something"
Labelling. Many rules produce the same label from different evidence, and the expensive part of each rule is the text matching. Guarding on the list lets a rule skip that work once the label is recorded:
# topics.rule
rule "billing-from-refund" {
description "A refund request is a billing matter."
when
not $topics contains "billing"
and purpose contains "refund"
then
label "billing"
add "billing" to topics
}
rule "billing-from-invoice" {
description "So is an invoice question — but the topic is only claimed once."
when
not $topics contains "billing"
and purpose contains "invoice"
then
label "billing"
add "billing" to topics
}
# routing.rule — listed after topics.rule, so it reads a finished list
rule "route-billing" {
description "Routing reads the accumulated topics instead of the ticket text again."
when
$topics contains "billing"
then
category "finance-team"
}
For a ticket mentioning both, only the first rule fires. The second stops at its guard — it never
searches the text — and label "billing" is produced once rather than twice. route-billing then
works from the finished list rather than going back to the input.
The guard is cheap on purpose. and stops at its first false condition, and the engine evaluates the
cheapest condition of an and first, so the list lookup runs before the text search whichever order
you write them in. An or is unaffected: a false first operand still lets the second decide.
| Question | Answer |
|---|---|
| What if the value is already in the list? | Nothing is added. The list behaves as a set, so two rules reaching the same conclusion produce one entry. |
| What order do the values come out in? | The order they were added. |
Who can read $name? |
The rules after the add — and the condition of the rule that writes it. That exception is what lets a rule guard on the list it fills in. |
Why is that safe for add but not set? |
An unread list is missing, contains on a missing value is false, and not of that is true — so the guard correctly passes the first time. set total = $total + amount has no such starting point, so reading a set variable before it is assigned stays an error. |
| Can two rules add to the same name? | Yes, and it is not a warning. That is the whole point. |
| Can a name be both? | No. A name written by both a set and an add is an error. |
| What can be added? | Any value expression, as with set. A missing value adds nothing, but the list is still created. |
| How does it arrive in the result? | As a list in EvaluationResult.variables. |
contains on a variablecontains is the one named operator a variable accepts. What it means depends on what the variable
holds: membership for a list, a substring for a text value.
It does not apply normalizers — a variable has no schema entry to take them from — so add values
in the form you will test for. add "Billing" to topics is not found by $topics contains "billing".
addis a keyword. An action may not be namedadd. If an action schema declares one, the engine reports it as an error and the action has to be renamed.
extract ClauseSometimes the argument you want to pass to an action is not a fixed string but a value computed from the input data at the time the rule fires.
The extract clause lets you apply a regular-expression capture group to a text field and use the result as the action argument.
extract <fieldName> regex("<pattern>", <groupIndex>) <actionName> $1
| Part | Description |
|---|---|
fieldName |
The name of a text field in the field schema |
"<pattern>" |
A regular expression with one or more capture groups |
<groupIndex> |
The capture group to extract: 1 for the first group, 2 for the second, 0 for the whole match |
<actionName> |
The action that will receive the extracted value |
$1 |
A placeholder that refers to the extracted value |
rule "tag-transaction-id" {
when
reference regex "TXN-[0-9]+"
then
extract reference regex("TXN-([0-9]+)", 1) label $1
}
When a transaction has reference = "TXN-98765", the rule matches and the result contains:
{ "name": "label", "arguments": ["98765"] }
rule "label-by-username" {
when
user_email regex ".+@.+"
then
extract user_email regex("([a-z0-9._%+\\-]+)@.*", 1) label $1
}
extract with static actionsAn extract clause produces exactly one action.
You can freely mix extract actions and regular static actions in the same then block:
rule "classify-with-id" {
when
reference regex "TXN-[0-9]+"
then
extract reference regex("TXN-([0-9]+)", 1) label $1
category "transactions"
}
This emits two actions: a label with the extracted ID, and a static category "transactions".
If the extraction regex does not find a match in the field value at evaluation time, the action argument is null (empty).
The action is still emitted — the consuming application should handle null arguments defensively.
This is intentional: the rule’s when condition governs whether the rule fires; a failing extraction at the action level does not suppress the action.
Tip: Align the
whencondition and the extraction pattern so the extraction can only fail in practice if the input data is malformed. For example, use the same regex in both:when reference regex "TXN-[0-9]+" then extract reference regex("TXN-([0-9]+)", 1) label $1
| Constraint | Example of invalid usage |
|---|---|
| Source field must exist in the field schema | extract unknownField regex("(.*)", 1) label $1 |
Source field must be of type text |
Using extract on a decimal or integer field |
| Regex pattern must be valid | extract ref regex("[invalid", 1) label $1 |
| Group index must be ≥ 0 | extract ref regex("(.*)", -1) label $1 |
$1 requires an extract clause |
Using $1 in a plain action without extract |
Action argument type must be string |
Using $1 with an integer action like score |
Lines starting with # are comments and are ignored by the engine:
# This rule detects rent payments
rule "rent-payment" {
when
purpose contains "rent"
and amount >= 300
then
label "rent"
}
Use comments to document the intent of a rule, especially for complex conditions.
A single .rule file may contain multiple rules.
It is good practice to group related rules in one file:
# fraud-detection.rule — rules for fraud and AML detection
rule "non-dach-iban" {
when
not iban regex "^(DE|AT|CH)"
then
flag "foreign-iban"
}
rule "high-value-outgoing" {
when
amount < -10000
then
flag "high-value"
alert "payment-above-threshold"
}
rule "zero-amount-probe" {
when
amount equals 0
then
flag "probe"
alert "zero-amount-detected"
}
Rule IDs must be globally unique across all loaded rule files. If two rules have the same ID, the engine reports a validation error and refuses to load.
Good ID conventions:
rent-payment, fraud-keyword-purposeLEGAL_1, AML_HIGH_RISK# sepa-classification.rule
rule "direct-debit" {
when
sepaCode in ["DMCT", "DRNL", "PRCT"]
then
label "direct-debit"
category "banking"
}
rule "salary-credit" {
when
sepaCode equals "SALA"
and amount > 0
then
label "salary"
category "income"
}
rule "rent-payment" {
when
(purposeNorm contains "miete"
or purposeNorm contains "rent"
or purposeNorm contains "pacht")
and amount >= 300
then
label "rent"
category "housing"
}
rule "premium-customer-transfer" {
when
tags containsAll ["premium", "verified"]
and amount > 0
then
label "premium-credit"
score 100
}
# fraud-detection.rule
rule "structuring-suspicion" {
when
count between 5 20
and amount between 8000 9999
then
flag "structuring"
alert "aml-structuring-suspicion"
}
rule "foreign-high-value" {
when
(not iban regex "^(DE|AT|CH)")
and amount < -5000
then
label "fraud"
alert "foreign-high-value-outgoing"
reject "compliance-block"
}
rule "flagged-customer" {
when
tags containsAny ["blocked", "sanctioned", "aml-watch"]
then
flag "compliance"
alert "flagged-customer-transaction"
reject "aml-block"
}
| Mistake | Error message | Fix |
|---|---|---|
| Using a field name that is not in the schema | Unknown field 'purpse' in condition |
Check spelling; the engine may suggest the closest match |
| Using an operator not allowed for a field | Operator 'contains' is not allowed for field 'amount' |
Use a numeric operator like gt, gte, lt, lte, between |
| Using an action not defined in the action schema | Unknown action 'notify' |
Add notify to the action schema YAML |
| Two rules with the same ID | Duplicate rule id: rent-payment |
Give each rule a unique ID |
between used on a text field |
Operator 'between' is not applicable to text field |
Use between only on integer, decimal, date or date_time fields |
| Wrong argument type for an action | Action 'score' argument 0 expects INTEGER |
Use a number, not a quoted string: score 10 not score "10" |
| Quoting a boolean value | Field 'isActive' expects 'true' or 'false' |
Write isActive equals true, without quotes |
| A date in the wrong format | Invalid date '15.06.2024' … expected ISO format YYYY-MM-DD |
Use "2024-06-15" |
| An ISO date on a field that declares a format | Invalid date '2024-01-31' … expected format 'dd.MM.yyyy' (e.g. "31.01.2024") |
Write the value in the field’s own format |
| Comparing a collection or object directly | Field 'orders' is a collection and cannot be compared directly |
Navigate into it (orders.total) or aggregate over it (sum(orders.total)) |
| Misspelling a member of a declared collection | Unknown field 'totl' in 'orders.totl' |
Check the nested fields: block in the schema |
| Comparing a path that reads through a collection | Field 'orders.total' reads through collection 'orders' … |
Aggregate it (sum(orders.total) > 100) or filter it (count(orders[total > 100]) > 0) |
between inside a path filter |
Operator 'between' is not supported in filter segments |
Write the two bounds: orders[total > 10 and total < 50] |
ignoreCase inside a path filter |
The 'ignoreCase' modifier is not supported in filter segments |
Normalize the member in the schema instead |
| Misspelling a member inside a path filter | Unknown field 'stauts' in filter on 'orders' |
Check the collection’s nested fields: block |
ignoreCase after a symbolic operator |
Expected 'then' block |
Use the word form: name equals "Acme" ignoreCase |
| An action given the wrong number of arguments | Action 'suppress' expects 0 arguments but got 1 |
Match the action’s argTypes — a bare name when it is [] |
equals and contains, add regex only when simpler operators are insufficientignoreCase modifier instead of duplicating rules for different capitalizationsin over many or equals — sepaCode in ["CCRD", "DCRD", "PMNT"] is cleaner than three separate conditionsand explicitly — consecutive lines are joined with AND automatically, but the keyword makes the intent obvious and survives reformattingequals rather than ==, so normalizers and the schema’s operator list both applywhen condition — if you use extract ref regex("TXN-([0-9]+)", 1) in the then block, guard the rule with reference regex "TXN-[0-9]+" so the extraction only runs when a match is guaranteed$1 as the argument for string-typed actions; it cannot be used with integer or decimal action arguments