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. 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, kis ocr, kis bbox and kis hocr2bbox 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, kis ocr, kis bbox and kis hocr2bbox are dispatched to a separately installed plugin binary. Without it every command on this page prints Error: 'ocr' 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 hocr2bbox
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 hocr2bbox -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

Success is logged at info, and the default log level is error, so without -l info the command prints nothing at all. 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 OCR 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 extraction API.

kis ocr page 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, not in.GetStr("allowed_currencies"), which returns the empty string for a fact that is present. Nesting costs you: a nested fact needs bracket access (invoice["currency"]), dot access on it aborts the run, and the value that comes back out cannot be passed to a typed parameter. 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. Flat scalars have none of them.

rules/extract.grl. Three rules, distinct salience, each retracting itself:

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");
Retract("ExtractHeader");
}
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");
Retract("ExtractTotals");
}
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");
Retract("ExtractLineItems");
}
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");
Retract("TotalsReconcile");
}
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");
Retract("TotalsMismatch");
}
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");
Retract("DatePresent");
}
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");
Retract("DateMissing");
}
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");
Retract("CurrencyAllowed");
}
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");
Retract("CurrencyRejected");
}

The checks re-query the document instead of reading back what the extraction rules wrote. That is not a stylistic choice: out.Get in a when is memoised and never sees a later write, so a validation rule cannot read the extracted result out of the output collector. Document queries are pure, so asking the page again is the shape that works, 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 ocr page -r rules/ -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 prefix(es) 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 ocr page 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. kis ocr page always binds it.

Terminal window
kis ocr page -r rules/ -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 ocr page -r rules/ -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, and a rule can read it — findings.Count() and findings.Passed() return 0 and true. No rule can write to it.

The shape the Go model implies, once a rule-facing entry point exists, is four plain strings — a rule id, a severity name, a category and a message — because a rule body can only produce strings, numbers and booleans:

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
findings.Add("TotalsMismatch", "ERROR", "totals",
"subtotal + tax does not equal the printed total");
Retract("TotalsMismatch");
}

What works is the pair this page already uses, and it covers the same ground:

NeedWorking callWhere it lands
A machine-readable verdict the caller must seeout.Set("check_totals", "fail")The result — CLI output and HTTP response body
A human-readable trail of what each rule decidedaudit.Log("TotalsMismatch", "…")--audit output and the -hist-audit.json sidecar

Keep the verdict in out. The audit trail is the human-readable record of what each rule decided; the response body carries what the rules wrote to out, so anything a caller must act on has to be an out.Set.

rules.svc runs the same rule sets on the wire: facts on the request body, documents under the reserved bbox object. Two shapes work, and this step covers both.

Send the page and the rule set from steps 4 and 5 runs unchanged. bbox on the POST /rule body is keyed by document name, and the name is the one every rule already passes to bbox.Has and bbox.Getpage here. The value is the contents of pages/invoice.bbox from step 1, inline:

{
"name": "invoice-extract-and-check",
"allowed_currencies": "EUR,GBP,USD",
"bbox": {
"page": {"pages": [{"number": 1, "width": 1275, "height": 1650, "words": []}]}
}
}

The service parses and analyses each document before any rule runs, so bbox.Has("page"), FieldValue, CurrencyRightOf and SafeTableBelow behave exactly as they did under kis ocr page, and the 200 body carries the whole step 6 result — the extracted header, totals and line items alongside the three checks, because both files write to out. The key under bbox is the same knob as --bbox-name: a name mismatch is a nil dereference, not a “no such document” message. The document-input contract is in full on running rules.svc.

invoice-extract-and-check is a rules entry holding extract.grl and validate.grl verbatim; the metadata shape is the same one below.

The rest of this step takes the other shape: 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 facts, 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 ocr page -r rules/extract.grl -i pages/ -o extracted/ invoice
Processed 1/1 prefix(es) 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");
Retract("TotalsReconcile");
}
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");
Retract("TotalsMismatch");
}
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");
Retract("DatePresent");
}
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");
Retract("DateMissing");
}
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");
Retract("CurrencyAllowed");
}
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");
Retract("CurrencyRejected");
}

in.Has is the guard that works: in.GetStr, in.GetInt, in.GetFloat and in.GetBool return the zero value even for a fact that is present, so a condition built on them never matches. || short-circuits like &&, which is what makes DateMissing safe when the fact is absent entirely.

Run it locally first — kis rules takes plain facts with no document at all, so a request body 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 file(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.

Rules reach rules.svc as product metadata under a rules list. name is what a caller sends as name, and one entry’s rule string holds every rule in the set:

rules:
- name: invoice-checks
rule: |
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");
Retract("TotalsReconcile");
}
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");
Retract("TotalsMismatch");
}
// the remaining four rules from checks.grl, verbatim

// is the comment marker inside a rule file. # is not — it fails the whole file at load.

name selects the rule set; every other top-level key of the body becomes a fact. Post the extraction with the allow list added:

{
"name": "invoice-checks",
"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/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 @invoice-request.json

200, carrying exactly what the rules wrote to out and nothing else:

{
"check_currency": "pass",
"check_date": "pass",
"check_totals": "pass"
}

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.

kis ocr page / kis rulesPOST /rule
Rule source-r rules/, compiled at start-upProduct metadata, polled every 15s
Rule set selected byfixed name rules on kis rules; --ruleset on kis ocrthe body’s name, required
Factstop-level keys of the input .json, plus -f and -vevery top-level key of the body
Document input-b name=file.bbox, --with-bbox, or the .bbox beside the prefixthe reserved bbox object on the body, keyed by document name
Resultprinted, or written with -o / --updatethe 200 body
Audit trail--audit and the -hist-audit.json sidecarreturned under audit when the body sets "audit": true
FailureERROR: <path>: … on stderr, non-zero exit400 and the error envelope
Rule textidentical, documents included

Headers, tenancy, status codes and the error envelope 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
A retracted rule leaves the pool; every other rule is re-evaluatedMake competing rules mutually exclusive, and retract each one in its own then
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
out.Get in a when is memoisedValidation rules re-query the document rather than reading back out
in.GetStr and friends return the zero valueRead facts by their bare name; guard with in.Has
bbox is bound from the host’s document dataThe CLI reads .bbox files; rules.svc reads the bbox object on the body
A label binds by score, not by printed textFieldValue("Total") returns the subtotal here; probe every label