Skip to content
Talk to our solutions team

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.

CheckSeverityRejects?Rule
Received more than 14 days after the invoice dateWARNINGnoWarnLateSubmission
Currency is not one the entity settles inERRORyesCheckCurrencySettleable
Net plus tax does not equal the stated totalERRORyesCheckTotalAddsUp
No purchase order quotedCRITICALyesRequirePurchaseOrder
None: sets the starting verdictINFO—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.

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 run

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

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"
}
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:

facts
{
"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"
}
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"
}
DecisionReason
InitVerdict sets accept / INFO first, at the highest salienceThere 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 CRITICALThe 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 decisionReproduces 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_checksThere 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 salienceEqual salience means the firing order changes between runs

The full call surface is on functions; the grammar is on the rule language.

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

KeyPresentRead as
decisionalwaysaccept or reject. The gate
severityalwaysHighest severity reached: INFO, WARNING, ERROR or CRITICAL
failed_checksonly when something failed<SEVERITY> <check_id> entries joined with ,
invoice_idwhenever the record carries oneEchoed 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.

Terminal window
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 quoted

The 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" }
]
}

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.

ChannelCarriesReaches the caller
out.Set / out.AppendThe verdict: decision, severity, failed_checksout on both surfaces
findings.*One graded entry per failed check, with category and detailfindings in the rules.svc response; not printed by kis rules
audit.LogPer-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 writesThe 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 CRITICALfindings.Passed() is false
no decision write on WARNINGfindings.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

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, unchanged

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

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

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

kis rulesrules.svc
Which rules-r names files or foldersscope names the product’s folder (or a static scope of its rules/rules.yaml)
FactsTop-level keys of each input file, plus -f and -vinput’s top-level keys, plus named facts
A fact named like a helper or collectorA 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 lostRefused (400 rules-facts-invalid)
Audit and history--audit, --history, the sidecaroptions.audit, options.history
FindingsRaised with findings.*; not printedRaised the same way; returned as findings
BatchingEvery file matching -p, one execution eachOne 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”
InputWithout the guardWith the guard as written
po_number key absentevaluating 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 discardedCRITICAL missing_purchase_order, decision: reject
invoice_id absent—InitVerdict never fires; see below
Any single unguarded fact referenceA 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) successfully

Guard 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) successfully

More failure shapes and their exact messages: troubleshooting.

Printing is the default. -o writes each verdict to a directory instead, and --update without -o merges it back into the source.

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

You want toPage
Chain one check’s result into another’s conditionRule patterns
Look up an operator or a literal formThe rule language
Find a function for a check you need to writeFunctions
Raise graded findings from a ruleFindings
See the whole rule APIRule API reference
Validate a scanned document instead of a JSON recordDocuments in rules
Deploy the rule setRunning rules.svc