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 |
| None: sets the starting verdict | INFO | — | InitVerdict |
WARNING never rejects. That is the same threshold findings.Passed() applies, false only for ERROR
and CRITICAL, and this rule set reproduces it explicitly in output keys, for the reasons in
why this rule set hand-rolls its verdict.
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: with bracket access, supplier["id"], or a dotted path,
in.Str("supplier.id", ""). Dot access on a map fact aborts the execution. 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.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");}On the first record, INV-2214.json, the rule set writes:
{ "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"}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 joins each new entry to the value already there with ", " |
Every rule ends in Retract("<own name>") | Harmless and no longer needed: the engine retracts a rule once it fires, so each fires at most once per run. A rule that must fire again calls agenda.Repeat() |
| 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 input(s) successfully--history-audit ./audit/ records the history and audit of each record in
audit/<basename>-hist-audit.json; without it, and without --history or --audit, nothing is
written. Input discovery always skips *-hist-audit.json, so a sidecar is never read back as a
record.
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_settle"... 7. [CheckCurrencySettleable] severity = "ERROR" 8. [CheckCurrencySettleable] decision = "reject" 9. [CheckTotalAddsUp] failed_checks = "WARNING late_submission, ERROR currency_not_settle"... 10. [CheckTotalAddsUp] severity = "ERROR" 11. [CheckTotalAddsUp] decision = "reject" 12. [RequirePurchaseOrder] failed_checks = "WARNING late_submission, ERROR currency_not_settle"... 13. [RequirePurchaseOrder] severity = "CRITICAL" 14. [RequirePurchaseOrder] decision = "reject"
--- 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 input(s) successfully line as
above; only the two trace sections are shown here.
The history lists every write, attributed to the rule that made it, in firing order: the last write
to severity is the highest severity reached. In the audit, the executed 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. 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": "CheckTotalAddsUp", "key": "severity", "value": "ERROR" }, { "rule_id": "CheckTotalAddsUp", "key": "decision", "value": "reject" }, { "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" }, { "rule_id": "RequirePurchaseOrder", "key": "decision", "value": "reject" } ], "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: findings.Error(...) and its
siblings raise a graded issue, findings.Passed() is false once an ERROR or CRITICAL one exists, and
rules.svc returns every finding the rules raised under findings. That is the same threshold the
rule set above builds by hand.
It builds it by hand because out is the one channel every caller reads: kis rules prints and
writes out and does not print findings, and a caller that gates on out.decision needs nothing
else. Raise findings and set the status keys when you need both.
findings.Count(), findings.Passed() and findings.HasSeverity(...) read what the rules have raised
so far in the execution, inside a then. Do not gate a later rule’s when on them alone: a rule that
raises a finding and writes nothing to out does not make the engine re-evaluate the conditions it
has already evaluated, so a when !findings.Passed() can keep its first answer.
The two channels side by side
Section titled “The two channels side by side”| Channel | Carries | Reaches the caller |
|---|---|---|
out.Set / out.Append | The verdict: decision, severity, failed_checks | out on both surfaces |
findings.* | One graded entry per failed check, with category and detail | findings in the rules.svc response; not printed by kis rules |
audit.Log | Per-check reasoning and the offending values | --audit and the sidecar on the CLI, an audit array in the response when the request sets "options": {"audit": true} |
Each check above maps onto a finding directly:
| This rule set writes | The finding equivalent |
|---|---|
out.Append("failed_checks", "CRITICAL missing_purchase_order") plus out.Set("severity", "CRITICAL") | findings.Critical("RequirePurchaseOrder", "missing_purchase_order", "no purchase order quoted") |
out.Set("decision", "reject") on ERROR and CRITICAL | findings.Passed() is false |
no decision write on WARNING | findings.Warn(...) leaves findings.Passed() true |
the second argument of audit.Log(rule, msg) | the finding’s message |
audit.LogWithData(rule, msg, map.Of("net", net_amount, …)) | .Detail("net", …) on the finding |
The same rule set over HTTP
Section titled “The same rule set over HTTP”rules.svc runs identical GRL. Put the file in the product’s rules/ folder, in a folder of its
own; the folder’s name is the scope a call runs:
<product>/rules/ invoice-validation/ invoice.grl # the rule set above, unchangedrules.svc loads the folder when the tenant’s product changes, checks every call the rules make against what the engine binds, and refuses the whole load, naming the file, the rule and the line, when one does not resolve: a typo never reaches a request. Ask for the decision:
curl -sS -X POST https://rules.example.com/rules/execute \ -H 'Content-Type: application/json' -H 'Authorization: Bearer <token>' \ -H 'X-Customer: acme' -H 'X-Env: prod' -H 'X-Product: erp' -H 'X-Tenant: eu1' \ -d '{"scope":"invoice-validation","input":{"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"}}'{ "scope": "invoice-validation", "stamp": "sha256:…", "principal": { "id": "u-…", "ptyp": "user" }, "out": { "decision": "reject", "failed_checks": "WARNING late_submission, ERROR currency_not_settleable, ERROR total_mismatch, CRITICAL missing_purchase_order", "invoice_id": "INV-2214", "severity": "CRITICAL" }, "findings": [], "actions": []}input is the record: each of its top-level keys is a fact, as each top-level key of an input file
is under kis rules. out is what the rules wrote; stamp names the exact rule text that decided.
Add "options": {"audit": true} for the rules that fired, and "history": true for every write
with the rule that made it. The full surface is the rules service.
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? | out.decision == "accept" |
| How bad is it? | out.severity |
| What failed? | out.failed_checks, split on , . Absent when nothing failed |
| Why did it fail? | out.failed_checks names the checks; options.audit returns what each rule logged |
A failed call answers with a status and code of its own — no access, an unknown scope, a fact the
engine binds itself, a rule that failed to evaluate — never a 200 with an empty out. Codes:
errors.
What differs between the two surfaces
Section titled “What differs between the two surfaces”kis rules | rules.svc | |
|---|---|---|
| Which rules | -r names files or folders | scope names the product’s folder (or a static scope of its rules/rules.yaml) |
| Facts | Top-level keys of each input file, plus -f and -v | input’s top-level keys, plus named facts |
| A fact named like a helper or collector | A helper name (map, strings, …) replaces that helper for the run; a collector name (out, in, findings, audit) is replaced by the collector and the value is lost | Refused (400 rules-facts-invalid) |
| Audit and history | --audit, --history, the sidecar | options.audit, options.history |
| Findings | Raised with findings.*; not printed | Raised the same way; returned as findings |
| Batching | Every file matching -p, one execution each | One record per request; many records with evaluate |
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 | A fact named in any rule’s when is evaluated whether or not that rule is relevant, 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 and no accept
baseline:
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 input(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 input(s) successfullyMore failure shapes and their exact messages: troubleshooting.
Persisting the verdict
Section titled “Persisting the verdict”Printing is the default. -o writes each verdict to a directory instead, and --update without
-o merges it back into the source.
kis rules -r rules/ -i input/ -o results/ -k validation --history-audit ./audit/A writing run prints no Result for blocks, only Processed 2/2 input(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 |
| Raise graded findings from a rule | Findings |
| See the whole rule API | Rule API reference |
| Validate a scanned document instead of a JSON record | Documents in rules |
| Deploy the rule set | Running rules.svc |