Validating a record
A supplier invoice arrives as a flat JSON record. Five rules check it, each with its own severity, and
the rule set writes back a verdict a caller can gate on: decision, severity, and the list of
checks that failed. Every command, rule and output on this page was run end to end; the console blocks
are what the commands printed.
What the rule set decides
Section titled “What the rule set decides”| Check | Severity | Rejects? | Rule |
|---|---|---|---|
| Received more than 14 days after the invoice date | WARNING | no | WarnLateSubmission |
| Currency is not one the entity settles in | ERROR | yes | CheckCurrencySettleable |
| Net plus tax does not equal the stated total | ERROR | yes | CheckTotalAddsUp |
| No purchase order quoted | CRITICAL | yes | RequirePurchaseOrder |
| — sets the starting verdict | INFO | — | InitVerdict |
WARNING never rejects. That threshold is the one the Go findings model uses — Passed() is false only
for ERROR and CRITICAL — and this rule set reproduces it explicitly, because
no rule can reach that model.
Project layout
Section titled “Project layout”validation/ rules/ invoice-validation.grl # the five rules, one rule set input/ INV-2214.json # fails four checks INV-2215.json # clean audit/ # sidecar destination, created by the runkis rules is a proxy command dispatched to a separately installed plugin; if it is missing you get
Error: 'rules' is not installed. and nothing on this page runs. See
the CLI.
The records
Section titled “The records”Every top-level key becomes its own fact under its own name. A nested object arrives as a map fact and
reads the same way on both surfaces — one level, with bracket access: supplier["id"]. Dot access on
a map fact aborts the execution, so the flat record below keeps every check to a single unqualified
fact reference.
{ "invoice_id": "INV-2214", "supplier_id": "SUP-118", "currency": "CHF", "net_amount": 4820.00, "tax_amount": 916.00, "total_amount": 5636.50, "po_number": "", "invoice_date": "2026-07-02", "received_date": "2026-07-24"}The second record is the same shape and passes everything:
{ "invoice_id": "INV-2215", "supplier_id": "SUP-118", "currency": "EUR", "net_amount": 4820.00, "tax_amount": 916.00, "total_amount": 5736.00, "po_number": "PO-9931", "invoice_date": "2026-07-18", "received_date": "2026-07-24"}The rule set
Section titled “The rule set”rule InitVerdict "Start every invoice from an accepted verdict" salience 100 { when in.Has("invoice_id") then out.SetSeparator(" | "); out.Set("invoice_id", invoice_id); out.Set("decision", "accept"); out.Set("severity", "INFO"); Retract("InitVerdict");}
rule WarnLateSubmission "Received more than 14 days after the invoice date" salience 90 { when in.Has("invoice_date") && in.Has("received_date") && time.DaysBetween(time.ParseTime("2006-01-02", invoice_date), time.ParseTime("2006-01-02", received_date)) > 14 then out.Append("failed_checks", "WARNING late_submission"); out.Set("severity", "WARNING"); audit.Log("WarnLateSubmission", "received more than 14 days after the invoice date"); Retract("WarnLateSubmission");}
rule CheckCurrencySettleable "Currency must be one the entity settles in" salience 80 { when !in.Has("currency") || !currency.In("EUR", "USD", "GBP") then out.Append("failed_checks", "ERROR currency_not_settleable"); out.Set("severity", "ERROR"); out.Set("decision", "reject"); audit.Log("CheckCurrencySettleable", strings.Sprintf("currency %s is not settleable", currency)); Retract("CheckCurrencySettleable");}
rule CheckTotalAddsUp "Net plus tax must equal the invoice total" salience 70 { when !in.Has("net_amount") || !in.Has("tax_amount") || !in.Has("total_amount") || math.Abs(net_amount + tax_amount - total_amount) > 0.005 then out.Append("failed_checks", "ERROR total_mismatch"); out.Set("severity", "ERROR"); out.Set("decision", "reject"); audit.Log("CheckTotalAddsUp", strings.Sprintf("net %.2f plus tax %.2f does not equal total %.2f", net_amount, tax_amount, total_amount)); Retract("CheckTotalAddsUp");}
rule RequirePurchaseOrder "Every invoice must quote a purchase order" salience 60 { when !in.Has("po_number") || strings.TrimSpace(po_number) == "" then out.Append("failed_checks", "CRITICAL missing_purchase_order"); out.Set("severity", "CRITICAL"); out.Set("decision", "reject"); audit.Log("RequirePurchaseOrder", "no purchase order quoted"); Retract("RequirePurchaseOrder");}Why it is shaped this way
Section titled “Why it is shaped this way”| Decision | Reason |
|---|---|
InitVerdict sets accept / INFO first, at the highest salience | There is no if/else. A default written first and overwritten by whichever checks fail is the whole branching mechanism |
| Salience descends as severity ascends: 90 WARNING, 80 and 70 ERROR, 60 CRITICAL | The most severe rule fires last, so the last write to severity is the highest severity reached. No comparison is needed |
Only ERROR and CRITICAL rules write decision | Reproduces the ERROR threshold. A warning-only invoice stays accept |
Every optional fact is guarded with in.Has(...) and && / || | Both operators short-circuit, so the guarded term is never evaluated when the fact is absent |
| A missing field is treated as a failed check, not an error | !in.Has("po_number") || strings.TrimSpace(po_number) == "" fires on absent and on blank — absence is exactly what a validation set exists to catch |
out.Append accumulates failed_checks | There is no array literal. Append concatenates with the separator set once by InitVerdict |
Every rule ends in Retract("<own name>") | Every non-retracted rule is re-evaluated each cycle; without it the rule re-fires until the 5000-cycle cap fails the run |
| Every rule has a distinct salience | Equal salience means the firing order changes between runs |
The full call surface is on functions; the grammar is on the rule language.
Run it
Section titled “Run it”kis rules -r rules/ -i input/ --history-audit ./audit/Result for /path/to/validation/input/INV-2214.json:{ "decision": "reject", "failed_checks": "WARNING late_submission | ERROR currency_not_settleable | ERROR total_mismatch | CRITICAL missing_purchase_order", "invoice_id": "INV-2214", "severity": "CRITICAL"}Result for /path/to/validation/input/INV-2215.json:{ "decision": "accept", "invoice_id": "INV-2215", "severity": "INFO"}Processed 2/2 file(s) successfully--history-audit ./audit/ is not optional housekeeping. The sidecar is written on every run in which
a rule fires, and without a destination it lands in input/, where the default *.json pattern picks
it up as a record on the next run — and a sidecar carries none of the nine facts, so the run aborts.
Reading the verdict
Section titled “Reading the verdict”| Key | Present | Read as |
|---|---|---|
decision | always | accept or reject. The gate |
severity | always | Highest severity reached: INFO, WARNING, ERROR or CRITICAL |
failed_checks | only when something failed | <SEVERITY> <check_id> entries joined with | |
invoice_id | whenever the record carries one | Echoed so a batched result identifies itself |
“Always” here means “whenever InitVerdict fired”, which is whenever the record has an invoice_id;
the failure modes cover the record that does not. A
clean record has no failed_checks key at all — out.Append never ran. Treat the missing key as
“nothing failed”, not as an error.
What fired, and why
Section titled “What fired, and why”kis rules -r rules/ -i input/ -p 'INV-2214.json' --history --audit --history-audit ./audit/--- History for /path/to/validation/input/INV-2214.json --- 1. [InitVerdict] invoice_id = "INV-2214" 2. [InitVerdict] decision = "accept" 3. [InitVerdict] severity = "INFO" 4. [WarnLateSubmission] failed_checks = "WARNING late_submission" 5. [WarnLateSubmission] severity = "WARNING" 6. [CheckCurrencySettleable] failed_checks = "WARNING late_submission | ERROR currency_not_settl"... 7. [CheckCurrencySettleable] severity = "ERROR" 8. [CheckCurrencySettleable] decision = "reject" 9. [CheckTotalAddsUp] failed_checks = "WARNING late_submission | ERROR currency_not_settl"... 10. [RequirePurchaseOrder] failed_checks = "WARNING late_submission | ERROR currency_not_settl"... 11. [RequirePurchaseOrder] severity = "CRITICAL"
--- Audit for /path/to/validation/input/INV-2214.json --- 1. [InitVerdict] executed 2. [WarnLateSubmission] executed 3. [WarnLateSubmission] received more than 14 days after the invoice date 4. [CheckCurrencySettleable] executed 5. [CheckCurrencySettleable] currency CHF is not settleable 6. [CheckTotalAddsUp] executed 7. [CheckTotalAddsUp] net 4820.00 plus tax 916.00 does not equal total 5636.50 8. [RequirePurchaseOrder] executed 9. [RequirePurchaseOrder] no purchase order quotedThe run then prints the same Result for block and Processed 1/1 file(s) successfully line as
above; only the two trace sections are shown here.
Two things to read out of that history. The executed audit lines are written by the engine, one per
firing; the others are the audit.Log calls in the rules, and they are where the values that failed
a check are recorded. And the gaps show the duplicate-call caching: CheckTotalAddsUp fired but wrote
only failed_checks, because its out.Set("severity", "ERROR") and out.Set("decision", "reject")
parse identically to calls CheckCurrencySettleable had already run; RequirePurchaseOrder
lost its decision write the same way. History values are truncated at 50 characters for display
only; the sidecar holds them in full.
The same two collections are written to audit/INV-2214-hist-audit.json, appended to on each run
(shown compacted below — the file itself is indented one field per line):
{ "history": [ { "rule_id": "InitVerdict", "key": "invoice_id", "value": "INV-2214" }, { "rule_id": "InitVerdict", "key": "decision", "value": "accept" }, { "rule_id": "InitVerdict", "key": "severity", "value": "INFO" }, { "rule_id": "WarnLateSubmission", "key": "failed_checks", "value": "WARNING late_submission" }, { "rule_id": "WarnLateSubmission", "key": "severity", "value": "WARNING" }, { "rule_id": "CheckCurrencySettleable", "key": "failed_checks", "value": "WARNING late_submission | ERROR currency_not_settleable" }, { "rule_id": "CheckCurrencySettleable", "key": "severity", "value": "ERROR" }, { "rule_id": "CheckCurrencySettleable", "key": "decision", "value": "reject" }, { "rule_id": "CheckTotalAddsUp", "key": "failed_checks", "value": "WARNING late_submission | ERROR currency_not_settleable | ERROR total_mismatch" }, { "rule_id": "RequirePurchaseOrder", "key": "failed_checks", "value": "WARNING late_submission | ERROR currency_not_settleable | ERROR total_mismatch | CRITICAL missing_purchase_order" }, { "rule_id": "RequirePurchaseOrder", "key": "severity", "value": "CRITICAL" } ], "audit": [ { "rule_id": "InitVerdict", "message": "executed" }, { "rule_id": "WarnLateSubmission", "message": "executed" }, { "rule_id": "WarnLateSubmission", "message": "received more than 14 days after the invoice date" }, { "rule_id": "CheckCurrencySettleable", "message": "executed" }, { "rule_id": "CheckCurrencySettleable", "message": "currency CHF is not settleable" }, { "rule_id": "CheckTotalAddsUp", "message": "executed" }, { "rule_id": "CheckTotalAddsUp", "message": "net 4820.00 plus tax 916.00 does not equal total 5636.50" }, { "rule_id": "RequirePurchaseOrder", "message": "executed" }, { "rule_id": "RequirePurchaseOrder", "message": "no purchase order quoted" } ]}Why this rule set hand-rolls its verdict
Section titled “Why this rule set hand-rolls its verdict”The engine carries a validation channel built for exactly this job — a Finding is
{RuleID, Severity, Category, Message, Fields}, severity is 0 INFO, 1 WARNING, 2 ERROR,
3 CRITICAL, and Passed() is false only when an ERROR or CRITICAL finding exists. That is the same
threshold the rule set above builds by hand, and it builds it by hand because no rule can reach the
channel. The model is described on the findings API.
Two calls on the binding are live, and both are honest about the state of things:
findings.Count() returns 0 and findings.Passed() returns true, on every execution, because
nothing can add to the list. Do not gate on findings.Passed() — it is true for an invoice that
failed every check.
What to use instead
Section titled “What to use instead”The rule set at the top of this page is the working alternative, and it is two mechanisms:
| Channel | Carries | Reaches the caller |
|---|---|---|
out.Set / out.Append | The verdict: decision, severity, failed_checks | Yes — out is the result on both surfaces |
audit.Log | Per-check reasoning and the offending values | Yes — --audit and the sidecar on the CLI, an audit array in the response when the body sets "audit": true |
If you are porting a rule set written against the findings model, the mapping is mechanical. The left
column is the Finding you would have recorded, not a call you can make:
| Finding you would record | Working form |
|---|---|
Severity 3 CRITICAL, category <cat> | out.Append("failed_checks", "CRITICAL <cat>") plus out.Set("severity", "CRITICAL") |
Severity 2 ERROR or 3 CRITICAL | also out.Set("decision", "reject") |
Severity 0 INFO or 1 WARNING | no decision write |
Message | the second argument of audit.Log(rule, msg) |
Fields | audit.LogWithData(rule, msg, in.NewMapWithValues("net", net_amount, ...)) |
Passed() | out.GetStr("decision") == "accept", read by the caller after the run |
Count() | count the | separators in failed_checks, or keep a counter key |
The same rule set over HTTP
Section titled “The same rule set over HTTP”rules.svc runs identical GRL. The rule text does not change; the delivery and the selector do.
Publish the rule set under the product’s rules metadata. Each entry’s name becomes a rule-set
name and rule carries the source:
rules: - name: invoice-validation rule: | rule InitVerdict "Start every invoice from an accepted verdict" salience 100 { when in.Has("invoice_id") then out.SetSeparator(" | "); out.Set("invoice_id", invoice_id); out.Set("decision", "accept"); out.Set("severity", "INFO"); Retract("InitVerdict"); }
rule WarnLateSubmission "Received more than 14 days after the invoice date" salience 90 { when in.Has("invoice_date") && in.Has("received_date") && time.DaysBetween(time.ParseTime("2006-01-02", invoice_date), time.ParseTime("2006-01-02", received_date)) > 14 then out.Append("failed_checks", "WARNING late_submission"); out.Set("severity", "WARNING"); audit.Log("WarnLateSubmission", "received more than 14 days after the invoice date"); Retract("WarnLateSubmission"); }
rule CheckCurrencySettleable "Currency must be one the entity settles in" salience 80 { when !in.Has("currency") || !currency.In("EUR", "USD", "GBP") then out.Append("failed_checks", "ERROR currency_not_settleable"); out.Set("severity", "ERROR"); out.Set("decision", "reject"); audit.Log("CheckCurrencySettleable", strings.Sprintf("currency %s is not settleable", currency)); Retract("CheckCurrencySettleable"); }
rule CheckTotalAddsUp "Net plus tax must equal the invoice total" salience 70 { when !in.Has("net_amount") || !in.Has("tax_amount") || !in.Has("total_amount") || math.Abs(net_amount + tax_amount - total_amount) > 0.005 then out.Append("failed_checks", "ERROR total_mismatch"); out.Set("severity", "ERROR"); out.Set("decision", "reject"); audit.Log("CheckTotalAddsUp", strings.Sprintf("net %.2f plus tax %.2f does not equal total %.2f", net_amount, tax_amount, total_amount)); Retract("CheckTotalAddsUp"); }
rule RequirePurchaseOrder "Every invoice must quote a purchase order" salience 60 { when !in.Has("po_number") || strings.TrimSpace(po_number) == "" then out.Append("failed_checks", "CRITICAL missing_purchase_order"); out.Set("severity", "CRITICAL"); out.Set("decision", "reject"); audit.Log("RequirePurchaseOrder", "no purchase order quoted"); Retract("RequirePurchaseOrder"); }Only name and rule are read. A rule set that fails to compile is logged and skipped rather than
being fatal, so a typo removes this rule set and leaves the service serving the others.
Post the record. The whole body is the fact map, so the facts are the record’s own keys plus name:
curl -sS -X POST https://rules.example.com/rule -H 'Content-Type: application/json' -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d '{"name":"invoice-validation","invoice_id":"INV-2214","supplier_id":"SUP-118","currency":"CHF","net_amount":4820.00,"tax_amount":916.00,"total_amount":5636.50,"po_number":"","invoice_date":"2026-07-02","received_date":"2026-07-24"}'{ "decision": "reject", "failed_checks": "WARNING late_submission | ERROR currency_not_settleable | ERROR total_mismatch | CRITICAL missing_purchase_order", "invoice_id": "INV-2214", "severity": "CRITICAL"}The response body is the output collector. Key order is not stable. Add "audit": true to the body
and the response carries the trail beside the output, under a reserved audit key holding the same
{"rule_id", "message"} entries the CLI prints with --audit and writes to the sidecar.
How a caller reads it
Section titled “How a caller reads it”| Question | Read |
|---|---|
| Did the call succeed? | HTTP 200. A rejected invoice is still a 200 — validation failure is not a transport failure |
| Is the record valid? | decision == "accept" |
| How bad is it? | severity |
| What failed? | failed_checks, split on |. Absent when nothing failed |
| Why did it fail? | failed_checks names the checks; audit carries the offending values when the body sets "audit": true |
What differs between the two surfaces
Section titled “What differs between the two surfaces”kis rules | POST /rule | |
|---|---|---|
| Rule-set selection | Fixed; --ruleset is accepted and never read | The body’s name field, required |
| Facts | Top-level keys of each input file, plus -f and -v | Every top-level key of the request body |
| Nested facts | Readable with bracket access, one level | The same — bracket access, one level |
| Audit trail | Printed with --audit, written to the sidecar | Returned under audit when the body sets "audit": true |
| Findings | Always empty — no rule can record one | Always empty, and not in the response |
| Batching | Every file matching -p, one execution each | One record per request |
Failure modes this rule set is built against
Section titled “Failure modes this rule set is built against”| Input | Without the guard | With the guard as written |
|---|---|---|
po_number key absent | evaluating expression in rule 'RequirePurchaseOrder' the when raised an error. got left hand expression error. got non existent key po_number, and the whole run is discarded | CRITICAL missing_purchase_order, decision: reject |
invoice_id absent | — | InitVerdict never fires; see below |
| Any single unguarded fact reference | Every rule is evaluated every cycle, so one missing fact discards all output for that record | — |
invoice_id is the one fact this rule set does not defend, and it is the interesting failure. Nothing
crashes; the verdict quietly degrades. An otherwise-clean record produces no output at all, and a
failing record still reports its checks — but with no invoice_id to identify it, no accept
baseline, and joined with the collector’s default ", " because InitVerdict never ran to call
out.SetSeparator:
Result for /path/to/probe/no-id.json:{ "decision": "reject", "failed_checks": "WARNING late_submission, ERROR currency_not_settleable, ERROR total_mismatch, CRITICAL missing_purchase_order", "severity": "CRITICAL"}Processed 1/1 file(s) successfullyGuard the identifier the same way as everything else, or check caller-side that invoice_id came
back.
The guarded run over a record with no po_number at all:
Result for /path/to/probe/INV-2216.json:{ "decision": "reject", "failed_checks": "CRITICAL missing_purchase_order", "invoice_id": "INV-2216", "severity": "CRITICAL"}Processed 1/1 file(s) successfullyMore failure shapes and their exact messages: troubleshooting.
Persisting the verdict
Section titled “Persisting the verdict”Printing is the default; --update turns on writing, and -o sends it somewhere other than the
source.
kis rules -r rules/ -i input/ -o results/ -k validation --update --history-audit ./audit/--update prints no Result for blocks — only Processed 2/2 file(s) successfully. The verdict is
in results/INV-2214.json:
{ "validation": { "decision": "reject", "failed_checks": "WARNING late_submission | ERROR currency_not_settleable | ERROR total_mismatch | CRITICAL missing_purchase_order", "invoice_id": "INV-2214", "severity": "CRITICAL" }}With a separate -o, only the rule results are written and the source record is untouched. Without
-o, --update merges the verdict back into each source file — under -k validation if given, at
the root otherwise. Full flag behaviour: the CLI.
Continue with
Section titled “Continue with”| You want to | Page |
|---|---|
| Chain one check’s result into another’s condition | Rule patterns |
| Look up an operator or a literal form | The rule language |
| Find a function for a check you need to write | Functions |
| Understand the findings model and why no rule reaches it | Findings |
| See the whole execution contract | Engine |
| Validate a scanned document instead of a JSON record | Documents in rules |
| Deploy the rule set | Running rules.svc |