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.
What you need
Section titled “What you need”| You need | Note |
|---|---|
The kis CLI | The entry point for every local command here. |
| The rules plugin | kis rules and kis bbox are proxy commands: see below. |
| OCR output for the invoice | An 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 invoice
Section titled “The invoice”The scan carries a header block, a line-item table and a totals block:
NORTHWIND SUPPLY CO12 Harbour Road, Bristol BS1 4RN
Invoice Number: INV-2024-0042Invoice Date: 2024-03-14Currency: EURBill To: Acme Robotics GmbH
Line ItemsDescription Qty Unit Price AmountWidget A 10 45.00 450.00Widget B 5 60.00 300.00Cable Set 20 12.50 250.00
Subtotal: 1,000.00 Tax 20%: 200.00 Total Due: 1,200.00
Payment terms: 30 daysIts 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.
Step 1: convert the OCR output
Section titled “Step 1: convert the OCR output”kis bbox create -i pages/invoice.hocr -l info2026-07-27T17:46:41+05:30 INF successfully converted hocr to bbox app=kis.ai input=pages/invoice.hocr output=pages/invoice.bbox words=56kis bbox create: converted 1 file → pages/invoice.bboxThe 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:
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 columnsHeaders: Description | Qty | Unit Price | Amount
Description | Qty | Unit Price | Amount------------+-----+------------+-------Widget A | 10 | 45.00 | 450.00Widget B | 5 | 60.00 | 300.00Cable Set | 20 | 12.50 | 250.00
=== Table 3 (Page 1) ===Dimensions: 3 rows x 3 columns
| Subtotal: | 1,000.00Tax | 20%: | 200.00Total | Due: | 1,200.00Three “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:
kis bbox table -i pages/invoice.bbox -a "Line Items" -H Description -H Qty -H Amount=== Table (Page 1) ===Dimensions: 4 rows x 4 columnsHeaders: Description | Qty | Unit Price | Amount
Description | Qty | Unit Price | Amount------------+-----+------------+-------Widget A | 10 | 45.00 | 450.00Widget B | 5 | 60.00 | 300.00Cable Set | 20 | 12.50 | 250.00Every -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:
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 daysOne 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.
Step 3: the facts
Section titled “Step 3: the facts”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.
Step 4: extract
Section titled “Step 4: extract”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");}| Call | Returns | Why this one |
|---|---|---|
FieldValue(label) | string | Label lookup: resolves Number to the value beside it |
CurrencyRightOf(anchor) | float64 | Parses 1,000.00 to a number, so the check below is arithmetic |
SafeTableBelow(anchor) | nil-safe table | Anchor selection; an unmatched anchor yields an empty table, not a panic |
RowsAsMaps() | []map[string]string | One map per data row, keyed by the detected column header |
Three details in that file decide whether it works at all:
bbox.Hasguards every rule.bbox.Geton 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.docholds a document reference, which survives assignment. Maps and slices do not: assignRowsAsMaps()to a variable and passing it onward fails withreflect: Call using zero Value argument. Keep those inline, asExtractLineItemsdoes.- Salience is distinct on every rule. Equal salience gives a different firing order run to run.
Step 5: validate
Section titled “Step 5: validate”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() > 0evaluates to false and runs cleanly; theGetis never reached. That is what lets one expression both guard and query.math.Abs(...)gets afloat64.CurrencyRightOfreturnsfloat64and the arithmetic staysfloat64, so the argument type matches. Function arguments do not coerce: an integer literal where afloat64is declared fails withreflect: Call using int64 as type float64.
Step 6: run it
Section titled “Step 6: run it”kis rules -r rules/ --with-bbox -i pages/ invoiceResult 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:
kis rules -r rules/ -i pages/ -p invoice.json -b page=pages/invoice.bboxThat 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.
Step 7: see what fired
Section titled “Step 7: see what fired”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 acceptedSix 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.
Step 8: an invoice that fails
Section titled “Step 8: an invoice that fails”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):
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.
Recording a failed check
Section titled “Recording a failed check”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:
| Need | Call | Where it lands |
|---|---|---|
| A machine-readable verdict the caller must see | out.Set("check_totals", "fail") | The result: CLI output, and out in a rules.svc response |
| A graded issue with a category | findings.Error("TotalsMismatch", "totals", "…") | findings in a rules.svc response; kis rules does not print findings |
| A human-readable trail of what each rule decided | audit.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.
Step 9: the checks over HTTP
Section titled “Step 9: the checks over HTTP”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:
kis rules -r rules/extract.grl --with-bbox -i pages/ -o extracted/ invoiceProcessed 1/1 input(s) successfullyextracted/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}The checks as a facts-only rule set
Section titled “The checks as a facts-only rule set”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:
{ "currency": "EUR", "invoice_date": "2024-03-14", "subtotal": 1000, "tax": 200, "total": 1200, "allowed_currencies": "EUR,GBP,USD"}{ "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"}):
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) successfullyDo 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.
Deliver the rule set
Section titled “Deliver the rule set”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.grlrules.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.
Call it
Section titled “Call it”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 }}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.json200, 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.
What changed between the two runs
Section titled “What changed between the two runs”kis rules | rules.svc | |
|---|---|---|
| Rule source | -r rules/, compiled at start-up | The product’s rules/ folder, reloaded when it changes |
| Rule set selected by | -r | scope, required |
| Facts | top-level keys of the input .json, plus -f and -v | input’s top-level keys, plus named facts |
| Document input | -b name=file.bbox, --with-bbox, or the .bbox beside the input | none: send extracted values |
| Result | printed, or written with -o / --update | out in the 200 response |
| Findings | not printed | findings in the response |
| Audit trail | --audit and the -hist-audit.json sidecar | audit when the request sets "options": {"audit": true} |
| Failure | ERROR: <path>: … on stderr, non-zero exit | a 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.
What this page relied on
Section titled “What this page relied on”| Behaviour | Consequence for your rule set |
|---|---|
| One rule fires per cycle, highest salience first | Give 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 cycle | Make competing rules mutually exclusive |
| Evaluation failure aborts the whole execution | One bad rule discards every extracted value; guard with bbox.Has and in.Has |
&& short-circuits | A guard and a query can share one expression |
| Function arguments do not coerce | Match every literal to the parameter type; 1 is int64, 1.0 is float64 |
| Map and slice values die on assignment | Keep RowsAsMaps(), Split() and grid rows inline |
Table cell accessors take Go int | Cell(0, 1) aborts with reflect: Call using int64 as type int; read whole rows |
A later rule’s when sees earlier writes to out | Validation 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 run | Guard it with in.Has, or read it through in.Str / in.Double with a default |
bbox is bound only by kis rules | rules.svc serves no documents; send it the extracted values |
| A label binds by score, not by printed text | FieldValue("Total") returns the subtotal here; probe every label |
Continue with
Section titled “Continue with”- The document model: what
Analyze()builds from those 56 words - Extraction: labels, anchors, scored fields, schemas
- Tables: detection settings and the full table surface
- Documents in rules: every
bboxmethod and factory - Rule patterns: guard, extract, publish, chain
- Built-in functions:
math,strings,array,in,out - The CLI:
kis rulesandkis bboxin full - Running rules.svc:
POST /rules/executeend to end - Document model API: the exact signature of every call above
- Rule API reference: the collectors, the plugin contract, the limits
- Findings API: raising and reading findings
- Errors: the code table behind every message above