Skip to content
Talk to our solutions team

Reading an invoice

One scanned invoice, one rule set, one result. This page runs the whole block end to end: convert the OCR output, inspect it, extract the header fields and the line-item table with rules, validate what was extracted, and then run the checks again over HTTP against rules.svc, on the extracted values. Every command, rule and output below was executed against the shipped CLI; the console blocks are what it printed, with paths shortened.

You needNote
The kis CLIThe entry point for every local command here.
The rules pluginkis rules and kis bbox are proxy commands: see below.
OCR output for the invoiceAn hOCR file from your recogniser. The block ingests OCR; it does not run it.

kis rules and kis bbox are dispatched to a separately installed plugin binary. Without it every command on this page prints Error: '<command>' is not installed. and exits non-zero. The command name you typed is interpolated. Search order and flags: the CLI.

The project:

invoice/
rules/
extract.grl # header fields, totals, line items
validate.grl # the three checks, against the page
pages/
invoice.hocr # OCR output for the scan
invoice.bbox # written by kis bbox create
invoice.json # the facts for this page
invoice-b.hocr # a second scan, used in step 8
invoice-b.json
checks.grl # the same three checks, against facts (step 9)
policy.json # the currency allow list (step 9)

checks.grl sits outside rules/ on purpose: -r rules/ loads every .grl in the directory into one rule set, and the two versions of the checks would both fire. extracted/ appears in step 9, written by the CLI.

The scan carries a header block, a line-item table and a totals block:

NORTHWIND SUPPLY CO
12 Harbour Road, Bristol BS1 4RN
Invoice Number: INV-2024-0042
Invoice Date: 2024-03-14
Currency: EUR
Bill To: Acme Robotics GmbH
Line Items
Description Qty Unit Price Amount
Widget A 10 45.00 450.00
Widget B 5 60.00 300.00
Cable Set 20 12.50 250.00
Subtotal: 1,000.00
Tax 20%: 200.00
Total Due: 1,200.00
Payment terms: 30 days

Its hOCR is ordinary recogniser output, a page box, then lines, then words with a per-word box and confidence, the first line of the fifteen:

<div class='ocr_page' id='page_1' title='image "invoice.png"; bbox 0 0 1275 1650; ppageno 0'>
<div class='ocr_carea' id='block_1_1' title='bbox 100 110 720 145'>
<p class='ocr_par' id='par_1_1' lang='eng' title='bbox 100 110 720 145'>
<span class='ocr_line' id='line_1_1' title='bbox 100 110 720 145; baseline 0 -6'>
<span class='ocrx_word' id='word_1_1' title='bbox 100 110 420 145; x_wconf 96'>NORTHWIND</span>
<span class='ocrx_word' id='word_1_2' title='bbox 440 110 620 145; x_wconf 95'>SUPPLY</span>
<span class='ocrx_word' id='word_1_3' title='bbox 640 110 720 145; x_wconf 96'>CO</span>
</span>
</p>
</div>
<!-- fourteen more ocr_carea blocks, one per printed line -->
</div>

The remaining fourteen lines follow the same shape. Every word carries its own bbox and x_wconf; the words are laid out in three x-bands, labels near x 100, the line-item columns ending at x 470, 790 and 1010, and the totals labels near x 700 with their amounts right-aligned at x 1010.

Terminal window
kis bbox create -i pages/invoice.hocr -l info
2026-07-27T17:46:41+05:30 INF successfully converted hocr to bbox app=kis.ai input=pages/invoice.hocr output=pages/invoice.bbox words=56
kis bbox create: converted 1 file → pages/invoice.bbox

The word count is logged at info, and the default log level is error, so without -l info the command prints only the converted 1 file line. It writes pages/invoice.bbox beside the input. One page, 56 words, each with its box and confidence:

{
"pages": [
{
"number": 1,
"width": 1275,
"height": 1650,
"words": [
{
"text": "NORTHWIND",
"box": { "x0": 100, "y0": 110, "x1": 420, "y1": 145 },
"confidence": 0.96,
"page": 1,
"line_id": 1
}
]
}
]
}

The other 55 words are elided, and so are the page’s lines and paragraphs arrays, which the converter writes from the ocr_line and ocr_par nesting. That file is the document input for everything that follows. Format details and the cleanup flags are on OCR ingestion; the field-by-field reference is the document API.

Step 2: look at the page before writing a rule

Section titled “Step 2: look at the page before writing a rule”

kis bbox reads a .bbox file and prints what the detectors found. No rules are loaded, nothing is written back. Start with the tables:

Terminal window
kis bbox tables -i pages/invoice.bbox
=== Table 1 (Page 1) ===
Dimensions: 4 rows x 5 columns
Invoice | Number: | INV-2024-0042 | |
Invoice | Date: | 2024-03-14 | |
| Currency: | EUR | |
Bill | To: | Acme | Robotics | GmbH
=== Table 2 (Page 1) ===
Dimensions: 4 rows x 4 columns
Headers: Description | Qty | Unit Price | Amount
Description | Qty | Unit Price | Amount
------------+-----+------------+-------
Widget A | 10 | 45.00 | 450.00
Widget B | 5 | 60.00 | 300.00
Cable Set | 20 | 12.50 | 250.00
=== Table 3 (Page 1) ===
Dimensions: 3 rows x 3 columns
| Subtotal: | 1,000.00
Tax | 20%: | 200.00
Total | Due: | 1,200.00

Three “tables”, because detection is text-alignment clustering over blocks and nothing else, the label/value header and the totals block cluster just as readily as the real table, and the label column splits wherever the word gaps happen to fall. Only Table 2 has a detected header row, and only Table 2 is a table you would have called one. Table indexes are an artefact of that clustering: select by anchor instead, and confirm the anchor and the headers up front:

Terminal window
kis bbox table -i pages/invoice.bbox -a "Line Items" -H Description -H Qty -H Amount
=== Table (Page 1) ===
Dimensions: 4 rows x 4 columns
Headers: Description | Qty | Unit Price | Amount
Description | Qty | Unit Price | Amount
------------+-----+------------+-------
Widget A | 10 | 45.00 | 450.00
Widget B | 5 | 60.00 | 300.00
Cable Set | 20 | 12.50 | 250.00

Every -H value must be present or the command fails with Header validation failed: missing headers: … and exits non-zero, which makes this a usable regression check over a fixture set before any rule exists.

Check the anchors the rules will use the same way:

Terminal window
kis bbox phrase -i pages/invoice.bbox -p "Total Due"
Found 1 match(es) for phrase "Total Due"
=== Match 1 (Page 1) ===
Location: (700.0, 980.0) - (846.0, 1015.0)
Line: 14
Cable Set 20 12.50 250.00
Subtotal: 1,000.00
Tax 20%: 200.00
>> Total Due: 1,200.00
Payment terms: 30 days

One match, at line 14, and the reported box covers the matched words only, Total through Due:, not the amount. So Due: is unambiguous on this page and a rule can anchor on it. Detection rules, the column-spec language and the rest of the table surface are on tables; anchors and label matching are on extraction and the tables and forms API.

kis rules runs one execution per <prefix>.json + <prefix>.bbox pair, so the page needs a facts file even when the document carries most of the input. Everything the rules need that is not on the page goes here:

{
"source": "invoice-scan-001.png",
"allowed_currencies": "EUR,GBP,USD"
}

Two facts: source and allowed_currencies. Both are flat scalars, deliberately. A rule reads a fact by its bare name, allowed_currencies, or through in.Str("allowed_currencies", ""), which answers the default when the fact is absent instead of aborting. Nesting costs you a little: a nested fact read by bracket access (invoice["currency"]) comes back untyped and cannot be passed to a typed parameter, and dot access on it (invoice.currency) aborts the run. in.Str("invoice.currency", "") reads the same value through a dotted path and hands back a string. Those costs are the rule language’s, not the wire’s: a nested fact behaves the same way under kis rules and on rules.svc.

rules/extract.grl. Three rules, distinct salience, each firing once:

rule ExtractHeader "invoice number, date and currency" salience 100 {
when
bbox.Has("page")
then
doc = bbox.Get("page");
out.Set("invoice_number", doc.FieldValue("Number"));
out.Set("invoice_date", doc.FieldValue("Date:"));
out.Set("currency", doc.FieldValue("Currency:"));
audit.Log("ExtractHeader", "header fields read");
}
rule ExtractTotals "the three printed money amounts" salience 90 {
when
bbox.Has("page")
then
doc = bbox.Get("page");
out.Set("subtotal", doc.CurrencyRightOf("Subtotal:"));
out.Set("tax", doc.CurrencyRightOf("20%:"));
out.Set("total", doc.CurrencyRightOf("Due:"));
audit.Log("ExtractTotals", "totals read");
}
rule ExtractLineItems "the table under the Line Items heading" salience 80 {
when
bbox.Has("page")
then
out.Set("line_items", bbox.Get("page").SafeTableBelow("Line Items").RowsAsMaps());
out.Set("line_item_count", bbox.Get("page").SafeTableBelow("Line Items").DataRowCount());
audit.Log("ExtractLineItems", "line-item table read");
}
CallReturnsWhy this one
FieldValue(label)stringLabel lookup: resolves Number to the value beside it
CurrencyRightOf(anchor)float64Parses 1,000.00 to a number, so the check below is arithmetic
SafeTableBelow(anchor)nil-safe tableAnchor selection; an unmatched anchor yields an empty table, not a panic
RowsAsMaps()[]map[string]stringOne map per data row, keyed by the detected column header

Three details in that file decide whether it works at all:

  • bbox.Has guards every rule. bbox.Get on a name that was never loaded returns nothing, and the next call in the chain dereferences it. That panic aborts the execution and discards all output.
  • doc holds a document reference, which survives assignment. Maps and slices do not: assign RowsAsMaps() to a variable and passing it onward fails with reflect: Call using zero Value argument. Keep those inline, as ExtractLineItems does.
  • Salience is distinct on every rule. Equal salience gives a different firing order run to run.

rules/validate.grl. Each check is a pass rule and a fail rule with mutually exclusive conditions and adjacent, distinct salience:

rule TotalsReconcile "subtotal + tax equals the printed total" salience 70 {
when
bbox.Has("page") &&
math.Abs(bbox.Get("page").CurrencyRightOf("Subtotal:") + bbox.Get("page").CurrencyRightOf("20%:") - bbox.Get("page").CurrencyRightOf("Due:")) <= 0.01
then
out.Set("check_totals", "pass");
audit.Log("TotalsReconcile", "subtotal + tax = total");
}
rule TotalsMismatch "the printed total does not reconcile" salience 69 {
when
bbox.Has("page") &&
math.Abs(bbox.Get("page").CurrencyRightOf("Subtotal:") + bbox.Get("page").CurrencyRightOf("20%:") - bbox.Get("page").CurrencyRightOf("Due:")) > 0.01
then
out.Set("check_totals", "fail");
audit.Log("TotalsMismatch", "subtotal + tax does not equal the printed total");
}
rule DatePresent "the invoice carries a date" salience 60 {
when
bbox.Has("page") && bbox.Get("page").FieldValue("Date:") != ""
then
out.Set("check_date", "pass");
audit.Log("DatePresent", "invoice date present");
}
rule DateMissing "no invoice date was read" salience 59 {
when
bbox.Has("page") && bbox.Get("page").FieldValue("Date:") == ""
then
out.Set("check_date", "fail");
audit.Log("DateMissing", "no invoice date on the page");
}
rule CurrencyAllowed "the currency is on the allow list" salience 50 {
when
bbox.Has("page") &&
array.ContainsStr(strings.Split(allowed_currencies, ","), bbox.Get("page").FieldValue("Currency:"))
then
out.Set("check_currency", "pass");
audit.Log("CurrencyAllowed", "currency accepted");
}
rule CurrencyRejected "the currency is not on the allow list" salience 49 {
when
bbox.Has("page") &&
array.ContainsStr(strings.Split(allowed_currencies, ","), bbox.Get("page").FieldValue("Currency:")) == false
then
out.Set("check_currency", "fail");
audit.Log("CurrencyRejected", "currency not on the allow list");
}

The checks re-query the document instead of reading back what the extraction rules wrote. Either works: a later rule’s when sees every earlier write to out, so out.Double("total", 0.0) would read the extracted total. Re-querying keeps validate.grl free of any dependency on extract.grl’s key names and salience, and it costs nothing here, the second query hits the same analysed document. See rule patterns for the ways a rule set can chain.

Two things make the guards safe:

  • && short-circuits. bbox.Has("nope") && bbox.Get("nope").LineCount() > 0 evaluates to false and runs cleanly; the Get is never reached. That is what lets one expression both guard and query.
  • math.Abs(...) gets a float64. CurrencyRightOf returns float64 and the arithmetic stays float64, so the argument type matches. Function arguments do not coerce: an integer literal where a float64 is declared fails with reflect: Call using int64 as type float64.
Terminal window
kis rules -r rules/ --with-bbox -i pages/ invoice
Result for /work/invoice/pages/invoice.json:
{
"check_currency": "pass",
"check_date": "pass",
"check_totals": "pass",
"currency": "EUR",
"invoice_date": "2024-03-14",
"invoice_number": "INV-2024-0042",
"line_item_count": 3,
"line_items": [
{
"Amount": "450.00",
"Description": "Widget A",
"Qty": "10",
"Unit Price": "45.00"
},
{
"Amount": "300.00",
"Description": "Widget B",
"Qty": "5",
"Unit Price": "60.00"
},
{
"Amount": "250.00",
"Description": "Cable Set",
"Qty": "20",
"Unit Price": "12.50"
}
],
"subtotal": 1000,
"tax": 200,
"total": 1200
}
Processed 1/1 input(s) successfully

-r rules/ compiles both .grl files into one rule set. The positional invoice is a prefix: pages/invoice.json and pages/invoice.bbox must both exist, and kis rules binds the document under the name page. The name every rule passes to bbox.Has and bbox.Get. Change it with --bbox-name, and change the rules to match; a name mismatch is a nil dereference, not a “no such document” message.

kis rules reaches the same result with the document named explicitly:

Terminal window
kis rules -r rules/ -i pages/ -p invoice.json -b page=pages/invoice.bbox

That form loads the document plugin only because -b is present. Without -b or --with-bbox, bbox is not bound at all and every rule here aborts with got non existent key bbox.

Terminal window
kis rules -r rules/ --with-bbox -i pages/ --audit invoice
--- Audit for /work/invoice/pages/invoice.json ---
1. [ExtractHeader] executed
2. [ExtractHeader] header fields read
3. [ExtractTotals] executed
4. [ExtractTotals] totals read
5. [ExtractLineItems] executed
6. [ExtractLineItems] line-item table read
7. [TotalsReconcile] executed
8. [TotalsReconcile] subtotal + tax = total
9. [DatePresent] executed
10. [DatePresent] invoice date present
11. [CurrencyAllowed] executed
12. [CurrencyAllowed] currency accepted

Six rules fired, in salience order, and the three fail rules never matched. The executed lines are the engine’s, one per firing; the others are the audit.Log calls in the rule bodies. --audit adds this block, it does not replace anything, the Result for … block from step 6 still follows it, and is elided here. --history gives the same run as the list of out.Set writes instead, each attributed to the rule that made it:

--- History for /work/invoice/pages/invoice.json ---
1. [ExtractHeader] invoice_number = "INV-2024-0042"
2. [ExtractHeader] invoice_date = "2024-03-14"
3. [ExtractHeader] currency = "EUR"
4. [ExtractTotals] subtotal = 1000
5. [ExtractTotals] tax = 200
6. [ExtractTotals] total = 1200
7. [ExtractLineItems] line_items = [{"Amount":"450.00","Description":"Widget A","Qty":"10","Unit Price":"45.00"},{"Amount":"300.00","De...
8. [ExtractLineItems] line_item_count = 3
9. [TotalsReconcile] check_totals = "pass"
10. [DatePresent] check_date = "pass"
11. [CurrencyAllowed] check_currency = "pass"

Long values are truncated in that view, as line_items is above.

The same rule set over a second scan whose printed total reads 1,300.00, converted and paired the same way (pages/invoice-b.json beside pages/invoice-b.bbox):

Terminal window
kis rules -r rules/ --with-bbox -i pages/ --audit invoice-b
--- Audit for /work/invoice/pages/invoice-b.json ---
1. [ExtractHeader] executed
2. [ExtractHeader] header fields read
3. [ExtractTotals] executed
4. [ExtractTotals] totals read
5. [ExtractLineItems] executed
6. [ExtractLineItems] line-item table read
7. [TotalsMismatch] executed
8. [TotalsMismatch] subtotal + tax does not equal the printed total
9. [DatePresent] executed
10. [DatePresent] invoice date present
11. [CurrencyAllowed] executed
12. [CurrencyAllowed] currency accepted
{
"check_currency": "pass",
"check_date": "pass",
"check_totals": "fail",
"currency": "EUR",
"invoice_date": "2024-03-14",
"invoice_number": "INV-2024-0043",
"line_item_count": 3,
"subtotal": 1000,
"tax": 200,
"total": 1300
}

TotalsMismatch fired where TotalsReconcile did on the first invoice, and the extraction still completed. A failed check is a value in the result, not an error. The line_items array is identical to the first run and is elided above.

The engine carries a severity-graded findings collector beside out and audit. A rule raises a finding with findings.Error/Warn/Info/Critical(ruleID, category, message), or findings.Add(ruleID, severity, category, message, fields), and findings.Passed() is false once an ERROR or CRITICAL finding exists.

The mismatch rule with a finding beside its verdict:

rule TotalsMismatch "the printed total does not reconcile" salience 69 {
when
bbox.Has("page") &&
math.Abs(bbox.Get("page").CurrencyRightOf("Subtotal:") + bbox.Get("page").CurrencyRightOf("20%:") - bbox.Get("page").CurrencyRightOf("Due:")) > 0.01
then
out.Set("check_totals", "fail");
findings.Error("TotalsMismatch", "totals", "subtotal + tax does not equal the printed total");
audit.Log("TotalsMismatch", "subtotal + tax does not equal the printed total");
}

Where each lands:

NeedCallWhere it lands
A machine-readable verdict the caller must seeout.Set("check_totals", "fail")The result: CLI output, and out in a rules.svc response
A graded issue with a categoryfindings.Error("TotalsMismatch", "totals", "…")findings in a rules.svc response; kis rules does not print findings
A human-readable trail of what each rule decidedaudit.Log("TotalsMismatch", "…")--audit output and the -hist-audit.json sidecar; audit in a response that asks for it

Keep the verdict in out: it is the one channel every caller reads, and the only one kis rules prints.

rules.svc serves no documents today: a request has no document input, and a rule set that names bbox does not load there. Steps 4 to 8 run through kis rules; what crosses to the service is what they extracted. Split the rule set where it already divides: extract.grl reads the page, validate.grl decides. Run extraction where the scan is, send the service the extracted values, and the request body stays small, a page’s word boxes are far larger than the handful of values they produce. That split is why the six checking rules below are rewritten to read facts instead of the page.

First run extraction alone and keep the result. -o writes the rule output to a directory, mirroring the input’s relative path, instead of printing it:

Terminal window
kis rules -r rules/extract.grl --with-bbox -i pages/ -o extracted/ invoice
Processed 1/1 input(s) successfully

extracted/invoice.json is the extraction, and nothing else. The source facts stay behind:

{
"currency": "EUR",
"invoice_date": "2024-03-14",
"invoice_number": "INV-2024-0042",
"line_item_count": 3,
"line_items": [
{ "Amount": "450.00", "Description": "Widget A", "Qty": "10", "Unit Price": "45.00" },
{ "Amount": "300.00", "Description": "Widget B", "Qty": "5", "Unit Price": "60.00" },
{ "Amount": "250.00", "Description": "Cable Set", "Qty": "20", "Unit Price": "12.50" }
],
"subtotal": 1000,
"tax": 200,
"total": 1200
}

checks.grl. Same six rules, same salience ladder, same verdicts. Every bbox query replaced by the fact the extraction produced, and every bbox.Has guard by in.Has:

rule TotalsReconcile "subtotal + tax equals the printed total" salience 70 {
when
in.Has("subtotal") && in.Has("tax") && in.Has("total") &&
math.Abs(subtotal + tax - total) <= 0.01
then
out.Set("check_totals", "pass");
audit.Log("TotalsReconcile", "subtotal + tax = total");
}
rule TotalsMismatch "the printed total does not reconcile" salience 69 {
when
in.Has("subtotal") && in.Has("tax") && in.Has("total") &&
math.Abs(subtotal + tax - total) > 0.01
then
out.Set("check_totals", "fail");
audit.Log("TotalsMismatch", "subtotal + tax does not equal the printed total");
}
rule DatePresent "the invoice carries a date" salience 60 {
when
in.Has("invoice_date") && invoice_date != ""
then
out.Set("check_date", "pass");
audit.Log("DatePresent", "invoice date present");
}
rule DateMissing "no invoice date was read" salience 59 {
when
in.Has("invoice_date") == false || invoice_date == ""
then
out.Set("check_date", "fail");
audit.Log("DateMissing", "no invoice date on the page");
}
rule CurrencyAllowed "the currency is on the allow list" salience 50 {
when
in.Has("currency") && in.Has("allowed_currencies") &&
array.ContainsStr(strings.Split(allowed_currencies, ","), currency)
then
out.Set("check_currency", "pass");
audit.Log("CurrencyAllowed", "currency accepted");
}
rule CurrencyRejected "the currency is not on the allow list" salience 49 {
when
in.Has("currency") && in.Has("allowed_currencies") &&
array.ContainsStr(strings.Split(allowed_currencies, ","), currency) == false
then
out.Set("check_currency", "fail");
audit.Log("CurrencyRejected", "currency not on the allow list");
}

On the extracted values and the allow list:

facts
{
"currency": "EUR", "invoice_date": "2024-03-14", "subtotal": 1000, "tax": 200, "total": 1200,
"allowed_currencies": "EUR,GBP,USD"
}
out
{ "check_currency": "pass", "check_date": "pass", "check_totals": "pass" }

in.Has guards each bare fact name: a fact the caller did not send aborts the run when it is named bare, and && short-circuits so the name is never evaluated when the guard is false. || short-circuits like &&, which is what makes DateMissing safe when the fact is absent entirely. The typed readers do the same job in one call: in.Str("invoice_date", "") != "" answers the default for an absent fact.

Run it locally first, kis rules takes plain facts with no document at all, so the extraction is a valid input file. -f merges a second facts file into the same execution, which is where the allow list comes from (policy.json holds {"allowed_currencies": "EUR,GBP,USD"}):

Terminal window
kis rules -r checks.grl -i extracted/ -p invoice.json -f policy.json --audit
--- Audit for /work/invoice/extracted/invoice.json ---
1. [TotalsReconcile] executed
2. [TotalsReconcile] subtotal + tax = total
3. [DatePresent] executed
4. [DatePresent] invoice date present
5. [CurrencyAllowed] executed
6. [CurrencyAllowed] currency accepted
Result for /work/invoice/extracted/invoice.json:
{
"check_currency": "pass",
"check_date": "pass",
"check_totals": "pass"
}
Processed 1/1 input(s) successfully

Do not reach for -v for that allow list: --vars splits its own value on commas, so -v allowed_currencies=EUR,GBP,USD fails with parsing vars: invalid var format "GBP": expected key=value. A facts file has no such limit.

Put checks.grl in a folder of its own under the product’s rules/ folder; the folder’s name is the scope a call runs:

rules/
invoice-checks/
checks.grl

rules.svc loads the folder when the tenant’s product changes and checks every call the rules make; a load that does not build keeps the rules the tenant had, and GET /rulesets says why under error. kis rules check runs the same check offline. // is the comment marker inside a rule file. # is not: it fails the whole file at load.

scope selects the rule set; input carries the facts. Post the extraction with the allow list added:

{
"scope": "invoice-checks",
"input": {
"allowed_currencies": "EUR,GBP,USD",
"currency": "EUR",
"invoice_date": "2024-03-14",
"invoice_number": "INV-2024-0042",
"line_item_count": 3,
"line_items": [
{ "Amount": "450.00", "Description": "Widget A", "Qty": "10", "Unit Price": "45.00" },
{ "Amount": "300.00", "Description": "Widget B", "Qty": "5", "Unit Price": "60.00" },
{ "Amount": "250.00", "Description": "Cable Set", "Qty": "20", "Unit Price": "12.50" }
],
"subtotal": 1000,
"tax": 200,
"total": 1200
}
}
Terminal window
curl -X POST https://rules.example.com/rules/execute -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 @invoice-request.json

200, carrying what the rules wrote under out:

{
"scope": "invoice-checks",
"stamp": "sha256:…",
"principal": { "id": "u-…", "ptyp": "user" },
"out": {
"check_currency": "pass",
"check_date": "pass",
"check_totals": "pass"
},
"findings": [],
"actions": []
}

invoice_number, line_items and the rest ride along as facts no rule reads; they are not echoed back. If the caller needs them in the response, a rule has to out.Set them. A rule that raised a finding would add an entry to findings, with its rule, severity, category and message.

kis rulesrules.svc
Rule source-r rules/, compiled at start-upThe product’s rules/ folder, reloaded when it changes
Rule set selected by-rscope, required
Factstop-level keys of the input .json, plus -f and -vinput’s top-level keys, plus named facts
Document input-b name=file.bbox, --with-bbox, or the .bbox beside the inputnone: send extracted values
Resultprinted, or written with -o / --updateout in the 200 response
Findingsnot printedfindings in the response
Audit trail--audit and the -hist-audit.json sidecaraudit when the request sets "options": {"audit": true}
FailureERROR: <path>: … on stderr, non-zero exita status and an error code, 422 rules-execution-failed for a rule that fails
Rule text—identical for rule sets that name no document

Headers, tenancy, status codes and the error codes are on running rules.svc; how definitions reach the service is there too.

BehaviourConsequence for your rule set
One rule fires per cycle, highest salience firstGive every rule a distinct salience; equal salience is non-deterministic
Each rule fires at most once; a rule that has not fired is re-evaluated each cycleMake competing rules mutually exclusive
Evaluation failure aborts the whole executionOne bad rule discards every extracted value; guard with bbox.Has and in.Has
&& short-circuitsA guard and a query can share one expression
Function arguments do not coerceMatch every literal to the parameter type; 1 is int64, 1.0 is float64
Map and slice values die on assignmentKeep RowsAsMaps(), Split() and grid rows inline
Table cell accessors take Go intCell(0, 1) aborts with reflect: Call using int64 as type int; read whole rows
A later rule’s when sees earlier writes to outValidation rules may read out; these re-query the document to stay independent of extract.grl
A bare fact name the caller did not send aborts the runGuard it with in.Has, or read it through in.Str / in.Double with a default
bbox is bound only by kis rulesrules.svc serves no documents; send it the extracted values
A label binds by score, not by printed textFieldValue("Total") returns the subtotal here; probe every label