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
— sets the starting verdictINFOInitVerdict

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.

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 — 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"
}
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");
}
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 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 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 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.

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_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 quoted

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

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.

The rule set at the top of this page is the working alternative, and it is two mechanisms:

ChannelCarriesReaches the caller
out.Set / out.AppendThe verdict: decision, severity, failed_checksYes — out is the result on both surfaces
audit.LogPer-check reasoning and the offending valuesYes — --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 recordWorking form
Severity 3 CRITICAL, category <cat>out.Append("failed_checks", "CRITICAL <cat>") plus out.Set("severity", "CRITICAL")
Severity 2 ERROR or 3 CRITICALalso out.Set("decision", "reject")
Severity 0 INFO or 1 WARNINGno decision write
Messagethe second argument of audit.Log(rule, msg)
Fieldsaudit.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

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:

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

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?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
kis rulesPOST /rule
Rule-set selectionFixed; --ruleset is accepted and never readThe body’s name field, required
FactsTop-level keys of each input file, plus -f and -vEvery top-level key of the request body
Nested factsReadable with bracket access, one levelThe same — bracket access, one level
Audit trailPrinted with --audit, written to the sidecarReturned under audit when the body sets "audit": true
FindingsAlways empty — no rule can record oneAlways empty, and not in the response
BatchingEvery file matching -p, one execution eachOne record per request

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 absentInitVerdict never fires; see below
Any single unguarded fact referenceEvery 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) 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 file(s) successfully

More failure shapes and their exact messages: troubleshooting.

Printing is the default; --update turns on writing, and -o sends it somewhere other than the source.

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

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
Understand the findings model and why no rule reaches itFindings
See the whole execution contractEngine
Validate a scanned document instead of a JSON recordDocuments in rules
Deploy the rule setRunning rules.svc