Purpose of this document This specification is intended to be used as a background instruction for an AI assistant. Business analysts and domain experts describe their data structures and business rules (or paste rules from another system), and the AI translates those descriptions into the correct technical artifacts:
- Field Schema YAML files
- Action Schema YAML files
- Rule files (
.ruleDSL)- Manifest YAML file(s)
IMPORTANT — No Hallucination Rule: Every field type, operator, normalizer, action argument type, file key, and DSL keyword used in generated output must come exclusively from the lists in this specification. Do not invent types, operators, keywords, or file keys that are not listed here.
Every rule example in this document is executed by an automated test (
ruleengine-core/src/test/kotlin/ruleengine/docs/SpecExampleTest.kt), which parses, validates and compiles it against the schema examples shown here. If an example ever stops working, that test fails.
The Rule Engine evaluates business rules against structured input data. It does not execute actions itself — it returns a list of matched rules and the actions they declared. The consuming application decides what to do with those actions.
Field Schema (YAML) ──► which data fields exist, their types and allowed operators
Action Schema (YAML) ──► which named outputs a rule can produce
Rule files (.rule) ──► when <conditions> → then <actions>
Manifest (YAML) ──► ties field schema + action schema + rule files together
↓ at startup
[ Engine loads, parses, validates, and compiles everything ]
↓ at runtime (per record)
[ Input data arrives → engine evaluates all rules → returns matched rules + actions ]
rules: order, with matches returned in that same order.When translating a business description into a rule engine configuration, always produce exactly these four artifact types:
| Artifact | File extension | Purpose |
|---|---|---|
| Field Schema | .yaml |
Declares every data field: its type, text normalizers, and allowed operators |
| Action Schema | .yaml |
Declares every named output a rule may produce and the type of its argument |
| Rule file(s) | .rule |
Contains one or more rules: conditions + actions |
| Manifest | .yaml |
Entry point that references the field schema, action schema, and all rule files |
<project-name>/
├── manifest.yaml
├── schemas/
│ ├── <domain>-schema.yaml
│ └── actions.yaml
└── rules/
├── <topic-1>.rule
└── <topic-2>.rule
schema: <schema-name> # optional but recommended; use versioned names, e.g. "transaction-v1"
fields:
<fieldName>: # camelCase identifier, must be unique within the schema
type: <type> # REQUIRED — exactly one value from the type table below
normalizers: # OPTIONAL — only valid on text and string_set fields
- <normalizer> # zero or more values from the normalizer table below, applied in order
operators: # OPTIONAL — if omitted, all operators valid for the type are allowed
- <operator> # one or more values from the operator tables below
Use exactly one of these values for the type key. No other values are valid.
| Canonical name | Accepted aliases | Use for |
|---|---|---|
text |
string |
Free text: descriptions, names, codes, IBANs, etc. |
integer |
int, long |
Whole numbers: counts, years, scores |
decimal |
number, bigdecimal |
Numbers with decimal places: amounts, prices, percentages |
boolean |
bool |
True/false flags |
string_set |
stringset, set |
Multiple string values per field: tags, labels, categories |
date |
— | Calendar dates, compared with quoted literals |
date_time |
datetime, timestamp |
Dates with a time of day, compared at time precision |
collection |
list, array |
A list of objects: transactions, order items, positions |
object |
map |
A single nested record: customer, address, counterparty |
Rule: Always use the canonical name in generated output. Aliases are only accepted as input when a user writes them; always write the canonical form in output files.
Rule:
collectionandobjectare structure types. They are never compared directly — you navigate into them with a dotted path (customer.country) or aggregate over them (sum(transactions.amount)). Writingtransactions equals "x"is an error. See 3.5 Nested Data.
Normalizers apply only to text and string_set fields. They are applied in the listed order before any rule comparison. They are not valid on integer, decimal, boolean, date, date_time, collection, or object fields.
| Name | What it does | Typical use |
|---|---|---|
trim |
Removes leading and trailing whitespace | Almost every text field |
lowercase |
Converts all letters to lowercase | Natural language fields |
uppercase |
Converts all letters to uppercase | Code fields (SEPA codes, country codes) |
collapse_whitespace |
Replaces runs of whitespace with a single space | Free-text descriptions |
remove_punctuation |
Removes all punctuation characters | Free-text descriptions |
german_umlaut_fold |
Replaces German umlauts with ASCII equivalents (ä→ae, ö→oe, ü→ue, ß→ss) | German text fields |
Rule: Do not invent normalizer names. Only the six names above are valid.
| Scenario | Normalizers to use |
|---|---|
| General free-text field | trim, lowercase |
| German free-text field | trim, lowercase, german_umlaut_fold |
| Code / identifier field (uppercase) | trim, uppercase |
| Noisy text with extra spaces | trim, lowercase, collapse_whitespace |
| Text with punctuation noise | trim, lowercase, remove_punctuation |
The operators list restricts which comparison operations rule authors may use for that field. If you omit operators, all operators valid for the type are allowed.
text)| Operator | Meaning | Example in a rule |
|---|---|---|
equals |
Exact match after normalisation | country equals "de" |
contains |
Field value contains the given substring | purpose contains "rent" |
startsWith |
Field value begins with the given text | iban startsWith "DE" |
endsWith |
Field value ends with the given text | purpose endsWith "GmbH" |
in |
Field value matches one entry in a list | sepaCode in ["CCRD", "DCRD"] |
regex |
Field value matches a regular expression | iban regex "^DE[0-9]{20}$" |
integer)| Operator | Symbolic alias | Meaning | Example in a rule |
|---|---|---|---|
equals |
== or = |
Exact equality | count equals 3 |
gt |
> |
Greater than | count gt 10 |
gte |
>= |
Greater than or equal | count >= 5 |
lt |
< |
Less than | count lt 0 |
lte |
<= |
Less than or equal | count <= 100 |
between |
— | Inclusive range | count between 5 20 |
decimal)| Operator | Symbolic alias | Meaning | Example in a rule |
|---|---|---|---|
equals |
== or = |
Exact equality | amount equals 0 |
gt |
> |
Greater than | amount gt 1000 |
gte |
>= |
Greater than or equal | amount >= 500 |
lt |
< |
Less than | amount lt 0 |
lte |
<= |
Less than or equal | amount <= 9999 |
between |
— | Inclusive range | amount between 100 5000 |
string_set)| Operator | Meaning | Example in a rule |
|---|---|---|
containsAny |
At least one listed value is in the set | tags containsAny ["vip", "premium"] |
containsAll |
All listed values are in the set | tags containsAll ["verified", "active"] |
boolean)equals is the only operator for boolean fields. The value is the bare word true or false — never quoted, never "yes" / 1.
| Operator | Meaning | Example in a rule |
|---|---|---|
equals |
Flag has this value | isActive equals true |
isActive equals true
isActive equals false
not isActive equals true # equivalent to `isActive equals false` for a field that is present
Note:
not isActive equals truealso matches records where the field is absent, because a missing value makes the inner condition false. UseisActive equals falsewhen the flag must be present and false.
date, date_time)Both types use the same six operators. There is no separate “before” or “after” operator: lt is
“before” and gt is “after”.
Literals are always quoted. By default they are ISO-8601 — "2024-01-31" for a date,
"2024-06-15T09:30:00" for a date_time — and any other spelling is rejected at load time. A field
that declares a format uses that pattern instead.
A date comparison is by calendar date: a value carrying a time is truncated to its date. A
date_time comparison keeps the time, so bookedAt gt "2024-06-15T09:00:00" is false for a value at
exactly 09:00:00 and true one second later.
| Operator | Symbolic alias | Meaning | Example in a rule |
|---|---|---|---|
equals |
== or = |
Same day (date) / same instant (date_time) |
bookingDate equals "2024-06-15" |
gt |
> |
After | bookingDate gt "2024-01-01" |
gte |
>= |
On or after | bookingDate >= "2024-01-01" |
lt |
< |
Before | bookingDate lt "2020-01-01" |
lte |
<= |
On or before | bookingDate <= "2024-12-31" |
between |
— | Inclusive range | bookingDate between "2024-01-01" "2024-12-31" |
Rule: Do not use text operators (
contains,startsWith, etc.) on numeric, boolean, or date fields. Do not use numeric operators on text fields. Do not usebetweenon text fields. Do not usecontainsAny/containsAllon non-string_setfields. Do not use any operator directly on acollectionorobjectfield.
format keyA date or date_time field may declare a format: a
java.time.format.DateTimeFormatter
pattern such as dd.MM.yyyy. It is the field’s only date format — it governs both the incoming data
value and the literal written in every rule for that field:
schema: invoices-v1
fields:
dueDate:
type: date
format: "dd.MM.yyyy"
paidAt:
type: date_time
format: "dd.MM.yyyy HH:mm"
rule "overdue-invoice" {
when
dueDate lt "31.01.2024"
then
flag "overdue"
}
Rule: A declared
formatreplaces ISO, it does not extend it. With the schema above,dueDate equals "2024-01-31"is an error and the input value"2024-01-31"does not match. Omitformatwhenever the data is ISO-8601 — that is the default and needs no declaration.
| Constraint | Notes |
|---|---|
Valid on date and date_time only |
A format on any other type is rejected when the schema loads |
| Must be a usable pattern | Rejected at load time when it is malformed (QQQQQQ) or cannot represent a complete value (MM-dd on a date, yyyy-MM-dd on a date_time) |
| Applies to text values | An input already typed as a date (a LocalDate, LocalDateTime or Instant handed to the engine by the host application) carries no text, so no pattern applies to it |
| Prefer separators | An all-digit pattern such as yyyyMMdd works in a hand-written rule but is not round-trip-safe in the visual Builder, which cannot tell that value from a number |
Note: A
formaton a nested member of acollection/objectis accepted and checked, but has no effect at runtime: nested paths are compared as raw text (see 3.5 Nested Data).
Both spellings exist, and they do not take the same path through the engine:
| Comparison | Write | Why |
|---|---|---|
| A field against a literal | Named: equals, gt, gte, lt, lte, between, contains, … |
Fully validated: the field’s declared operators: list is enforced, the literal type is checked, and text normalizers are applied to the literal as well as the field |
| A value expression (aggregate or arithmetic) | Symbolic: ==, !=, >, >=, <, <= |
Required — the engine only routes a condition through the expression engine for symbolic operators |
Rule: For a plain field-vs-literal comparison, prefer the named operator.
==and!=always route to the expression engine, which does not enforce the field’s declaredoperators:list and does not normalize the literal. On a field with alowercasenormalizer,counterparty equals "ACME"matches the value"acme", whilecounterparty == "ACME"does not.>,>=,<,<=on a plain field are equivalent to their named forms.
schema: transaction-v1
fields:
# Free-text payment description
purpose:
type: text
normalizers:
- trim
- lowercase
- german_umlaut_fold
operators:
- equals
- contains
- startsWith
- endsWith
- in
- regex
# IBAN — kept uppercase for format checks
iban:
type: text
normalizers:
- trim
- uppercase
operators:
- equals
- startsWith
- regex
# SEPA transaction code
sepaCode:
type: text
normalizers:
- trim
- uppercase
operators:
- equals
- in
# Signed transaction amount (negative = outgoing)
amount:
type: decimal
operators:
- equals
- gt
- gte
- lt
- lte
- between
# Number of transactions in a time window
count:
type: integer
operators:
- equals
- gt
- gte
- lt
- lte
- between
# Customer tags / labels
tags:
type: string_set
normalizers:
- trim
- lowercase
operators:
- containsAny
- containsAll
# Yes/no flag
isActive:
type: boolean
operators:
- equals
# Calendar date — ISO literals, because no `format` is declared
bookingDate:
type: date
operators:
- equals
- gt
- gte
- lt
- lte
- between
# Date with a time of day
bookedAt:
type: date_time
operators:
- equals
- gt
- gte
- lt
- lte
- between
Input data is often not flat: a record may carry a list of transactions, or a nested customer object.
Declare those with collection (a list) or object (a single record) and a nested fields: block.
fields: is recursive — a nested member may itself be a collection or object with its own
fields:, so nesting depth is unlimited.
schema: orders-v1
fields:
orders:
type: collection # a list of order objects
fields:
status:
type: text
total:
type: decimal
customer:
type: object # an object inside a collection
fields:
country:
type: text
items:
type: collection # a collection inside a collection
fields:
sku:
type: text
price:
type: decimal
Given that schema, rules can navigate and aggregate to any declared depth:
orders.customer.country == "DE"
count(orders) > 3
sum(orders.total) > 1000
sum(orders[status == "paid"].items[price > 0].price) > 500
Nested fields: declared |
What you get |
|---|---|
| Yes | Member names are validated, wrong nesting is an error at load time, leaf types are known, and the visual Builder offers the members in its dropdowns |
| No | Paths still work at runtime, but nothing below the declared field is checked — a typo like orders.totl goes unnoticed |
Rule: When the business description mentions a list of records or a nested record, declare it as
collection/objectwith its members. Only omit the members when the data shape is genuinely unknown.
Rule:
normalizers:andoperators:are not valid on acollectionorobjectfield itself. Declare them on the nested scalar members instead.
actions:
<actionName>: # lowercase hyphenated or camelCase identifier, must be unique
argTypes: [<argType>] # REQUIRED — one element from the argType table, or [] for no argument
Important: An action takes at most one argument.
argTypesholds exactly one entry for an action that takes a value, or is empty ([]) for an action that is just a signal.
| Type | Accepted values in rules | Example |
|---|---|---|
string |
Any text in double quotes | label "rent" |
integer |
A whole number (no quotes) | score 10 |
decimal |
A number with decimal places (no quotes) | threshold 0.75 |
| (none) | argTypes: [] — the rule writes the bare action name |
suppress |
Rule: Only
string,integer, anddecimalare valid argument types. No other types exist for actions.
Rule: The number of arguments in a rule must match
argTypesexactly. An action declaredargTypes: [string]must be given one quoted value; an action declaredargTypes: []must be given none.argTypesnever holds more than one entry.
actions:
label:
argTypes: [string]
category:
argTypes: [string]
flag:
argTypes: [string]
score:
argTypes: [integer]
alert:
argTypes: [string]
reject:
argTypes: [string]
notify:
argTypes: [string]
suppress:
argTypes: [] # a signal with no argument
You may define any action names that fit the domain. These are widely used conventions:
| Action | Arg type | Purpose |
|---|---|---|
label |
string |
Assign a classification label (e.g. "rent", "salary") |
category |
string |
Assign a broader category (e.g. "housing", "income") |
flag |
string |
Mark for review or attention (e.g. "review", "compliance") |
score |
integer |
Contribute a numeric score (e.g. risk score) |
alert |
string |
Trigger an alert with a named reason |
reject |
string |
Signal that the item should be rejected, with a reason |
notify |
string |
Trigger a notification |
suppress |
(none) | Drop the record from downstream processing |
.rule.rule file may contain one or more rules.# are comments and are ignored.rules: order), and matches are returned in that order.rule "<rule-id>" {
when
<condition>
then
<action>
<action>
...
}
| Part | Required | Notes |
|---|---|---|
rule "<id>" |
✅ | ID must be unique across all loaded rule files. Use lowercase-hyphenated or UPPER_UNDERSCORE identifiers. |
when |
✅ | Keyword, followed by one or more conditions. |
then |
✅ | Keyword, followed by one or more actions. |
A condition compares a field from the schema to a literal value using an operator:
<fieldName> <operator> <value>
The field name must exist in the field schema. The operator must be in the field’s allowed operators list.
purpose contains "rent"
iban startsWith "DE"
sepaCode equals "SALA"
sepaCode in ["CCRD", "DCRD", "PMNT"]
iban regex "^DE[0-9]{20}$"
counterparty endsWith "GmbH"
amount >= 500
amount between 100 5000
count gt 10
amount equals 0
amount < -10000
For
between, write both bounds separated by a space:amount between 100 5000means100 ≤ amount ≤ 5000(both inclusive).
tags containsAny ["vip", "premium"]
tags containsAll ["verified", "active"]
isActive equals true
isActive equals false
bookingDate equals "2024-06-15"
bookingDate >= "2024-01-01"
bookingDate between "2024-01-01" "2024-12-31"
bookedAt gt "2024-06-15T09:00:00"
dueDate lt "31.01.2024"
Date values are always quoted. Without a
formatthey are ISO —YYYY-MM-DDfor adate,YYYY-MM-DDTHH:MM:SSfor adate_time— sobookingDate > 20240101andbookingDate equals "15.06.2024"are both rejected at load time. A field that declares aformatuses that pattern instead, which is whydueDateabove is written"31.01.2024".
When a field is declared collection or object, navigate into it with a dotted path:
orders.customer.country == "DE"
For anything that aggregates over a collection, see 5.8 Value Expressions.
ignoreCase modifierFor text operators (equals, contains, startsWith, endsWith, regex) and string-set operators, append ignoreCase after the value to make the comparison case-insensitive:
counterparty equals "Netflix" ignoreCase
This is useful when a field does not have a lowercase or uppercase normalizer but you still need case-insensitive matching.
Rule:
ignoreCaseonly applies totextandstring_setconditions. On a numeric, boolean, or date condition it is accepted but does nothing — do not write it there. It also cannot be combined with a symbolic operator:counterparty == "Netflix" ignoreCaseis a parse error. Use the named operator (equals) when you needignoreCase.
Use the and keyword between conditions:
rule "high-risk" {
when
country equals "ng"
and amount >= 10000
then
flag "review"
}
Conditions on consecutive lines are also joined with AND, with no keyword needed. These two rules are identical:
rule "high-risk-implicit" {
when
country equals "ng"
amount >= 10000
then
flag "review"
}
Rule: Prefer the explicit
andin generated output. It reads unambiguously and survives reformatting; the implicit form depends on the line break.
rule "vip-customer" {
when
tags containsAny ["vip"]
or tags containsAny ["premium"]
then
label "vip"
}
rule "non-dach-iban" {
when
not iban regex "^(DE|AT|CH)"
then
flag "foreign-iban"
}
From highest to lowest:
notandorSo A or B and C is interpreted as A or (B and C).
Use parentheses to make complex logic unambiguous:
rule "chargeback" {
when
(purpose contains "chargeback"
or purpose contains "cancellation"
or purpose contains "reversal")
and amount < 0
then
label "chargeback"
flag "review"
}
Actions appear in the then block. Each line is one action:
then
label "rent"
category "housing"
score 10
argTypes: [] are written as the bare name, with nothing after it:
then
suppress
tag "noise"
rent-payment, fraud-keyword-purposeLEGAL_1, AML_HIGH_RISK# transaction-classification.rule
# Classifies bank transactions by purpose and amount
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
(purpose contains "miete"
or purpose contains "rent"
or purpose 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
}
Value expressions allow conditions to aggregate data from nested lists of objects. Use them when a rule must reason about a collection (e.g. all transactions on an account) rather than a single field.
aggregateFunction(fieldPath) comparisonOperator valueExpression
Both sides of the comparison can be aggregate function calls, arithmetic expressions, or numeric literals.
| Operator | Meaning |
|---|---|
== |
Equal |
!= |
Not equal |
> |
Greater than |
>= |
Greater than or equal |
< |
Less than |
<= |
Less than or equal |
Important: Use symbolic operators (
==,>, etc.) for value expression comparisons. The legacy named operators (equals,gt, etc.) are only valid for plain field comparisons.
All functions take exactly one argument — a field path that resolves to a collection.
| Function | Description |
|---|---|
count(path) |
Number of elements |
sum(path) |
Sum of numeric values |
subtract(path) |
First element minus all subsequent elements |
avg(path) |
Arithmetic mean |
median(path) |
Median value |
max(path) |
Maximum value |
min(path) |
Minimum value |
A dot-separated path projects a field from each element of a list:
transactions.amount → [100.00, 90.00, ...]
Paths may be any depth, following the nested fields: you declared in the schema
(3.5):
sum(orders.items.price)
orders.customer.country
Important — projection flattens.
sum(orders.items.price)is the sum across all items of all orders, not a per-order total. Every level is flattened into one list of values. The engine has no grouping construct; if you need a per-parent figure, the input data must supply it as a field.
A filter in [...] selects only matching elements. Each segment of a path may carry its own
filter:
transactions[label == "risk"].amount
transactions[amount > 0]
orders[status == "paid"].items[price > 0].price
Field names inside [...] refer to fields of the element being filtered, not to top-level fields.
A filter is a single comparison. This is narrower than a normal condition:
Allowed inside [...] |
Not allowed inside [...] |
|---|---|
==, !=, >, >=, <, <= |
and, or, not |
named gt, gte, lt, lte |
equals, contains, in, between, regex, containsAny, containsAll |
Rule: Inside a filter, write equality as
==, never asequals. To require two conditions on the same element, chain filters:transactions[label == "risk"][amount > 100]. Violations of this table are reported when the rules are compiled, not as validation warnings.
count(transactions) > 2
sum(transactions.amount) > 1000
avg(transactions.amount) >= 25
max(transactions[label == "risk"].amount) > 500
sum(transactions[label == "risk"].amount) > sum(transactions[amount > 0].amount) * 0.03
count, sum, subtract return 0 for an empty array — comparisons work normally.avg, median, max, min return a missing value for an empty array — the comparison always evaluates to false.Arithmetic can be applied to any value expression: +, -, *, /.
Standard precedence applies (*// before +/-); use parentheses to override.
sum(transactions.amount) * 0.03
(sum(a.amount) + sum(b.amount)) / count(transactions)
contains, startsWith, etc. used with a value expression → error.For the full reference including all edge cases see docs/expressions.md.
name: <project-name> # optional; human-readable name for the project
entries:
- id: <entry-id> # REQUIRED; unique identifier for this entry; appears in logs and results
schema: <path> # optional; relative path to the field schema YAML file
actions: <path> # optional; relative path to the action schema YAML file
rules: # REQUIRED; list of relative paths to .rule files
- <path-to-rule-file.rule>
- <path-to-another-rule-file.rule>
All paths are relative to the manifest file itself. This means the entire project folder can be moved without changing any paths.
| Field | Required | Description |
|---|---|---|
name |
optional | Human-readable project name |
entries |
✅ | List of one or more rule set entries |
| Field | Required | Description |
|---|---|---|
id |
✅ | Unique identifier for this entry (lowercase-hyphenated recommended) |
schema |
optional | Relative path to the field schema YAML file |
actions |
optional | Relative path to the action schema YAML file |
rules |
✅ | List of relative paths to .rule files |
No other keys are valid at the entry level. Do not add keys like
version,description,priority, orenabled.
name: transaction-rules
entries:
- id: transactions
schema: schemas/transaction-schema.yaml
actions: schemas/actions.yaml
rules:
- rules/classification.rule
- rules/fraud-detection.rule
Use multiple entries when different parts of the system operate on different data models or rule sets. Each entry is completely independent.
name: banking-rules
entries:
- id: transaction-classification
schema: schemas/transaction-schema.yaml
actions: schemas/transaction-actions.yaml
rules:
- rules/transactions/classification.rule
- rules/transactions/chargebacks.rule
- id: customer-risk
schema: schemas/customer-schema.yaml
actions: schemas/customer-actions.yaml
rules:
- rules/customers/risk-scoring.rule
- rules/customers/vip-detection.rule
This section describes how to map common business descriptions to the technical artifacts.
| Business description | Field type to use |
|---|---|
| A text description, name, code, reference | text |
| A numeric amount, price, percentage | decimal |
| A count, year, quantity, integer score | integer |
| A yes/no flag, enabled/disabled state | boolean |
| A list of tags, labels, or categories | string_set |
| A date | date |
| A date with a time of day (“booked at 09:30”) | date_time |
| A date in a non-ISO format (“31.01.2024”) | date / date_time + format: |
| A list of records (“each transaction has an amount and a label”) | collection + nested fields: |
| A single nested record (“the customer has a country and an IBAN”) | object + nested fields: |
Ask yourself for each field:
textdecimal, otherwise integerbooleandate, or date_time when the time of day mattersformat: on that fieldstring_setcollection, and declare those fieldsobject, and declare those fieldsDistinguishing
string_setfromcollection: a list of bare strings (["vip", "premium"]) is astring_set. A list of objects ([{"amount": 100, "label": "risk"}]) is acollection. Rules aggregate over acollection; they only test membership on astring_set.
| Business statement | DSL condition |
|---|---|
| “purpose exactly matches ‘SALA’” | purpose equals "SALA" |
| “purpose contains the word ‘rent’” | purpose contains "rent" |
| “IBAN starts with DE, AT, or CH” | iban regex "^(DE\|AT\|CH)" |
| “SEPA code is one of CCRD, DCRD, or PMNT” | sepaCode in ["CCRD", "DCRD", "PMNT"] |
| “amount is at least 500” | amount >= 500 |
| “amount is between 100 and 5000 (inclusive)” | amount between 100 5000 |
| “amount is negative (outgoing payment)” | amount < 0 |
| “count is more than 10” | count gt 10 |
| “customer has at least one of: vip, premium” | tags containsAny ["vip", "premium"] |
| “customer has all of: verified AND active” | tags containsAll ["verified", "active"] |
| “IBAN does NOT start with DE” | not iban startsWith "DE" |
| “purpose contains ‘rent’ case-insensitively” | purpose contains "rent" ignoreCase |
| “both condition A and condition B” | conditionA + newline + and conditionB |
| “either condition A or condition B” | conditionA + newline + or conditionB |
| Business outcome | Suggested action |
|---|---|
| “classify as X” / “label it as X” | label "X" (arg type: string) |
| “put it in category X” | category "X" (arg type: string) |
| “mark for review” / “flag it” | flag "review" (arg type: string) |
| “assign a risk score of N” | score N (arg type: integer) |
| “send an alert” | alert "alert-name" (arg type: string) |
| “reject / block” | reject "reason" (arg type: string) |
| “send notification” | notify "notification-name" (arg type: string) |
Define every action the business needs in the Action Schema before referencing it in a rule.
When data originates from German-language systems, always add these normalizers to free-text fields:
normalizers:
- trim
- lowercase
- german_umlaut_fold
This makes rule comparisons work correctly for inputs like “Miete” (which becomes “miete”) and “Müller” (which becomes “mueller”).
Group rules by business topic, one file per topic. Examples:
| Business domain | Suggested file name |
|---|---|
| Transaction classification | classification.rule |
| Fraud / AML detection | fraud-detection.rule |
| Chargeback detection | chargebacks.rule |
| Customer risk scoring | customer-risk.rule |
| VIP customer detection | vip-detection.rule |
We process bank transactions. Each transaction has:
purpose: a free-text payment description (German or English)iban: the counterparty IBANsepaCode: a SEPA transaction code (always uppercase, e.g. “SALA”, “DMCT”)amount: the signed transaction amount (negative = outgoing)count: number of similar transactions in the last 30 daystags: a list of customer tagsWe want to:
- Label transactions as “rent” if the purpose contains “miete”, “rent”, or “pacht”, and amount is at least 300.
- Label transactions as “salary” if sepaCode is “SALA” and amount is positive.
- Flag transactions as “foreign-iban” if the IBAN does not start with DE, AT, or CH.
- Flag and alert on transactions that look like structuring: count between 5 and 20, amount between 8000 and 9999.
- Block transactions from customers with tags “blocked” or “sanctioned”.
schemas/transaction-schema.yamlschema: transaction-v1
fields:
purpose:
type: text
normalizers:
- trim
- lowercase
- german_umlaut_fold
operators:
- equals
- contains
- startsWith
- endsWith
- in
- regex
iban:
type: text
normalizers:
- trim
- uppercase
operators:
- equals
- startsWith
- regex
sepaCode:
type: text
normalizers:
- trim
- uppercase
operators:
- equals
- in
amount:
type: decimal
operators:
- equals
- gt
- gte
- lt
- lte
- between
count:
type: integer
operators:
- equals
- gt
- gte
- lt
- lte
- between
tags:
type: string_set
normalizers:
- trim
- lowercase
operators:
- containsAny
- containsAll
schemas/actions.yamlactions:
label:
argTypes: [string]
flag:
argTypes: [string]
alert:
argTypes: [string]
reject:
argTypes: [string]
rules/classification.rule# classification.rule — transaction classification rules
rule "rent-payment" {
when
(purpose contains "miete"
or purpose contains "rent"
or purpose contains "pacht")
and amount >= 300
then
label "rent"
}
rule "salary-credit" {
when
sepaCode equals "SALA"
and amount > 0
then
label "salary"
}
rules/fraud-detection.rule# fraud-detection.rule — fraud and AML detection rules
rule "non-dach-iban" {
when
not iban regex "^(DE|AT|CH)"
then
flag "foreign-iban"
}
rule "structuring-suspicion" {
when
count between 5 20
and amount between 8000 9999
then
flag "structuring"
alert "aml-structuring-suspicion"
}
rule "blocked-customer" {
when
tags containsAny ["blocked", "sanctioned"]
then
flag "compliance"
alert "flagged-customer-transaction"
reject "aml-block"
}
manifest.yamlname: transaction-rules
entries:
- id: transactions
schema: schemas/transaction-schema.yaml
actions: schemas/actions.yaml
rules:
- rules/classification.rule
- rules/fraud-detection.rule
The engine validates everything at load time and rejects the following. Never generate output that violates these constraints.
| Constraint | Example of invalid usage |
|---|---|
| Unknown field type | type: number_list (not a valid type) |
| Unknown normalizer name | normalizers: [strip] (strip does not exist; use trim) |
| Unknown operator name | operators: [greaterThan] (greaterThan does not exist; use gt) |
Nested fields: on a scalar type |
amount: {type: decimal, fields: {...}} — only collection and object may nest |
format on a non-date field |
purpose: {type: text, format: "dd.MM.yyyy"} — only date and date_time accept it |
Malformed format pattern |
format: "QQQQQQ" (not a valid DateTimeFormatter pattern) |
format that cannot represent the value |
format: "MM-dd" on a date (no year), format: "yyyy-MM-dd" on a date_time (no time) |
Empty format |
format: "" — omit the key instead to get ISO |
Operator names must be canonical. Write the spelling from the operator tables in 3.3:
startsWith, notstarts_withorstartswith;regex, notmatches. The engine accepts those variants as aliases so older schemas keep loading, but generated output should always use the canonical name. A name the engine has no implementation for —greaterThan,not_contains,isEmpty— is rejected when the schema loads.A declared
operators:list is a whitelist, not a type check. Listing an operator the field’s type does not support (operators: [contains]on aninteger) still loads; the error surfaces on the rule that uses it. Declaring only a subset restricts the field to that subset, so a rule using any other operator is rejected even though the type supports it.
| Constraint | Example of invalid usage |
|---|---|
| Unknown argument type | argTypes: [bool] (use string, integer, or decimal) |
| More than one argument type | argTypes: [string, integer] (at most one is allowed) |
| Argument count mismatch in a rule | suppress "x" when suppress is declared argTypes: [], or a bare label when label expects a string |
| Constraint | Example of invalid usage |
|---|---|
| Unknown field name in condition | purpse contains "rent" (typo — field does not exist in schema) |
| Operator not allowed for field | amount contains "500" (contains is not valid for decimal) |
| Wrong literal type | score "high" when score expects integer |
| Duplicate rule ID | Two rules in any loaded file sharing the same ID |
| Action not defined in action schema | notify "x" when notify is not in the action schema |
between on a text field |
purpose between "a" "z" (not valid) |
List literal on a non-in / non-containsAny/All operator |
purpose equals ["a", "b"] |
| Quoted or non-boolean value on a boolean field | isActive equals "true", isActive equals 1 |
Non-ISO date literal on a field with no format |
bookingDate equals "15.06.2024" (use "2024-06-15") |
ISO literal on a field that declares a format |
dueDate equals "2024-01-31" when dueDate declares format: "dd.MM.yyyy" (use "31.01.2024") |
Date-only literal on a date_time field |
bookedAt equals "2024-06-15" (use "2024-06-15T00:00:00") |
| Comparing a structure directly | transactions equals "x" — navigate into it or aggregate over it |
| Unknown member of a declared structure | orders.totl when orders declares total |
and / or inside a filter |
transactions[a > 1 and b > 2] — chain filters instead |
Named equals inside a filter |
transactions[label equals "risk"] (use ==) |
| Text operator inside a filter | transactions[label contains "risk"] |
ignoreCase after a symbolic operator |
name == "Acme" ignoreCase (use equals) |
One warning, not an error: a multi-segment path whose root is not declared in the schema produces a warning and the rule still loads, because the root may be an undeclared structure read straight from the input.
sum(unknownThing.amount) > 1is therefore accepted with a warning, while a single-segmentunknownThing > 1is an error. Declare the structure to get real checking.
| Constraint | Example of invalid usage |
|---|---|
Missing entries key |
A manifest YAML with no entries list |
Missing id in an entry |
An entry without an id field |
Missing rules in an entry |
An entry with no rules list |
| Unknown key in an entry | Adding priority: 1 or enabled: true to an entry |
| Non-existent file path | A schema: path that does not resolve to an actual file |
fields:.type: from the valid types list.collection, nested records are object, and their members are declared
under a nested fields:.text or string_set fields — never to a collection or
object itself.operators: or normalizers: on a collection / object field.date_time, not a date.format: appears only on date / date_time fields, and only when the data is not ISO-8601.schema: name (e.g. my-schema-v1).argTypes:, or [] when it takes no argument.string, integer, or decimal.equals, gt, …) are used for field-vs-literal comparisons; symbolic
operators (==, >, …) only where a value expression is involved.between is only used on integer, decimal, date or date_time fields — two numeric bounds
without quotes, or two quoted date values.true / false; date values are quoted, in the field’s
declared format when it has one and YYYY-MM-DD / YYYY-MM-DDTHH:MM:SS otherwise.in / containsAny / containsAll use a JSON-style list: ["a", "b"].[...] use only ==, !=, >, >=, <, <= and contain no and / or.then block is defined in the action schema, with a matching argument count.and is written explicitly rather than relying on the implicit line-break AND.name: and an entries: list.id:, a schema: path, an actions: path, and a rules: list.rules: list).This specification is complete. Do not add field types, operators, normalizers, action argument types, or manifest keys that are not listed in this document.