Running rules over documents
A rule never touches OCR words. The surface you run rules on loads OCR JSON, analyses it, and binds
the analysed document into the rule data context under an identifier a rule can name. That
identifier is bbox, and bbox is what a rule author writes.
Everything on this page describes the bridge — the contract, the payload shape each surface supplies, and every method the document plugin exposes to rule text. What you can ask a document once you hold one is covered in the document model, extraction, tables and matching.
Where document rules run
Section titled “Where document rules run”| Surface | bbox bound | How |
|---|---|---|
kis CLI | yes | kis ocr page, kis ocr doc, kis rules --bbox / --with-bbox |
rules.svc over HTTP | yes | POST /rule carries a reserved bbox object alongside the facts |
Both surfaces reach the same document model, the same detectors and the same bbox methods. A rule
set that extracts under kis ocr page runs unchanged on the service; the only difference is where
the OCR JSON comes from — a file on disk for the CLI, the request body for the service. See
the service page for the rest of the HTTP surface.
The plugin contract
Section titled “The plugin contract”A plugin is three methods. Nothing else.
| Method | When it runs | What it produces |
|---|---|---|
Name | — | The registry key and the per-run payload key |
Init | Once, when the engine is built | Facts bound into every execution |
Prepare | Once per execution | Facts bound into that execution only |
Each key of a returned map becomes a top-level identifier in rule text. The split is what makes the
document bridge safe under concurrency: stateless helper namespaces come from Init and are shared,
per-document state comes from Prepare and is private to one run.
The document plugin returns nothing from Init. It publishes bbox only from Prepare, and
Prepare constructs a brand-new plugin instance for that execution rather than mutating the
registered one — so parallel workers never share the document map, and bbox.Remove or bbox.Clear
inside a rule can never affect another in-flight execution.
Enabling the plugin
Section titled “Enabling the plugin”The document plugin is enabled per run, not by static configuration.
| Surface | Enabled when |
|---|---|
kis ocr page, kis ocr doc | Always |
kis rules | Only with --bbox or --with-bbox |
rules.svc | The request body carries a bbox object |
The utility namespaces (util, num, strings, time, math, map, array) are always
registered, on both surfaces, whether or not a document is present.
What each surface supplies
Section titled “What each surface supplies”The per-run payload is keyed by plugin name. The document plugin accepts two shapes under bbox.
Plain shape — a map of document name to content. Each value is either parsed OCR JSON or, where
the surface reads files, a path to a .bbox file.
Schema shape — two reserved keys. __docs holds the same map as above; __schemas holds parsed
JSON Schemas by name. When __schemas is present the plugin calls SetSchemas on every loaded
document, which is what makes doc.ExtractForm("name") resolve. __schemas is read only when
__docs is present, so a payload carrying __schemas alone is treated as a document named
__schemas and fails to load.
The CLI assembles that payload from your flags. Over HTTP it is the value of the reserved bbox key
on the request body, so you write one shape or the other yourself: the plain shape when no schema is
in play, the schema shape when one is.
The inner keys are the names a rule passes to bbox.Has and bbox.Get. The CLI picks them for you:
| Command | Document name | Flag |
|---|---|---|
kis ocr page | page | --bbox-name |
kis ocr doc | doc | --bbox-name |
kis rules --with-bbox | bbox | --with-bbox-name |
kis rules -b key=path | key | — (defaults to bbox) |
Over HTTP
Section titled “Over HTTP”POST /rule carries the document alongside the facts. bbox is a reserved top-level key: it maps a
document name to that document’s OCR JSON, and those names are exactly what a rule passes to
bbox.Has and bbox.Get.
{ "name": "invoice-checks", "bbox": { "page": { "pages": [ { "number": 1, "width": 612, "height": 792, "words": [ { "text": "Name", "box": {"x0": 72, "y0": 100, "x1": 120, "y1": 115}, "confidence": 0.98, "page": 1 } ] } ] } }, "currency": "EUR"}When the rule set calls ExtractForm or SafeExtractForm, send the schema shape instead: bbox
holds __docs — the same document map as above — and __schemas, mapping schema name to a JSON
Schema document. The service loads and analyses every entry before the first rule is evaluated, so a
document that will not parse or analyse fails the request with 400 and the same
plugin bbox prepare: detail the CLI prints.
{ "name": "closing-disclosure", "bbox": { "__docs": {"page": {"pages": []}}, "__schemas": {"closing_disclosure": {"type": "object"}} }}The response is unchanged from any other execution: 200 with exactly the key/value pairs the rules
wrote to out.
Loading a document
Section titled “Loading a document”For each entry in the payload the plugin parses the OCR JSON, wraps it, and runs Analyze().
Analyze groups words into blocks and lines per page, aggregates document statistics, builds a
spatial index, then runs the detectors — tables, fields, checkboxes and checkbox groups, page
regions, font sizes, and special regions such as signatures, handwriting and barcodes. Every query a
rule makes is a query over that analysed model. Failure to read, parse or analyse aborts the
execution before any rule runs, with plugin bbox prepare: <detail>.
The input format is described in OCR ingestion.
The bbox surface
Section titled “The bbox surface”Twenty methods, in three groups.
Lifecycle
Section titled “Lifecycle”| Method | Signature | Callable from a rule |
|---|---|---|
Get | Get(name string) *Document | yes |
Has | Has(name string) bool | yes |
Names | Names() []string | yes |
Remove | Remove(name string) | yes |
Clear | Clear() | yes |
MustLoadFromFile | MustLoadFromFile(name, path string) *Document | yes — its panic aborts the run |
LoadFromFile | LoadFromFile(name, path string) (*Document, error) | no |
LoadFromReader | LoadFromReader(name string, r io.Reader) (*Document, error) | no |
LoadFromBytes | LoadFromBytes(name string, b []byte) (*Document, error) | no |
LoadFromMap | LoadFromMap(name string, m map[string]any) (*Document, error) | no |
LoadFromEnv | LoadFromEnv(name, envVar string) (*Document, error) | no |
GetOrLoad | GetOrLoad(name, path string) (*Document, error) | no |
Get returns nil for an unknown name; it never loads and never errors. Chaining a method onto that
nil is fatal:
rule engine execute panic on rule X ! recovered : runtime error: invalid memory address or nil pointer dereferenceAssigning it to a variable first only changes the wording — reflect: call of reflect.Value.Type on zero Value — not the outcome. The panic is recovered but the execution still aborts and every value
already collected is discarded. Always guard with bbox.Has in the when.
Type factories
Section titled “Type factories”Rule text can only produce string, int64, float64 and bool. Any document method whose
parameter is a typed Go value is unreachable unless a factory manufactures it. These six factories
are that bridge.
| Factory | Signature | Feeds |
|---|---|---|
Box | Box(x0, y0, x1, y1 float64) BoundingBox | BlocksInBox, BlocksIntersectingBox, NearestBlockIn |
Pt | Pt(x, y float64) Point | NearestBlocks, BlocksNearPoint |
Dir | Dir(name string) Direction | FuzzyTextNear*, FieldNear*, CheckedOptionNear*, NearestBlockIn |
RelDir | RelDir(name string) RelativeDir | ExtractAfter |
GeoBox | GeoBox(x0, y0, x1, y1 float64) geometry.Box | BlocksInRegion, BlocksIntersecting, HasPhraseInBox, FindNextFuzzy, FindNextFuzzyWithNgram |
GeoDir | GeoDir(name string) geometry.Direction | NearestBlockInDirection, FindNextFuzzy, FindNextFuzzyWithNgram |
Every direction factory accepts left, right, above / up, below / down.
Grid helpers
Section titled “Grid helpers”These two are the only document-level operations the plugin re-exposes on itself, so a rule does not have to fetch the document first.
| Method | Signature | Behaviour |
|---|---|---|
ExtractColumnAlignedSection | ExtractColumnAlignedSection(docName, sectionStart, sectionEnd, columnHeaders string) []map[string]any | Looks the document up by name; returns nil for an unknown name |
PostprocessRows | PostprocessRows(rows []map[string]any, key, fn string) []map[string]any | Applies fn to the string at key in every row; chainable |
fn accepts trim, upper, lower, normalize_spaces, trim_currency, and the parameterised
form ExtractRegex("pattern", "default"). An unknown name returns the rows unchanged, silently —
a typo in trim_currency produces untrimmed values, not an error.
The column-spec mini-language is documented in tables.
One extraction, end to end
Section titled “One extraction, end to end”What the rule author writes. Guard on Has, bind the document to a variable, query it, publish
with out.Set, Retract so the rule does not re-fire.
rule ExtractInvoiceVendor "Vendor name and total from the invoice" salience 100 { when bbox.Has("page") then doc = bbox.Get("page"); out.Set("vendor", doc.TextRightOf("Name")); out.Set("total", doc.FuzzyTextNear("Total", bbox.Dir("right"), 1, 2, 0.3)); out.Set("lines", doc.LineCount()); Retract("ExtractInvoiceVendor");}What the CLI does.
kis ocr page -r rules/ invoiceThe positional argument is a prefix: both invoice.json (the facts) and invoice.bbox (the OCR
JSON) must exist, and either file’s path may be given instead — the extension is stripped.
kis ocr page builds the engine with the document plugin enabled, reads both files, and passes
{"bbox": {"page": <content>}} as the per-run payload.
What the service does. The same rule set, no edits, with the OCR JSON in the request instead of on disk.
curl -X POST https://rules.example.com/rule -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d @invoice-request.jsoninvoice-request.json holds name, the facts, and bbox keyed by the same document name the rule
uses — {"name":"invoice-checks","bbox":{"page":{"pages":[…]}},"currency":"EUR"}. The service
passes that object through as the per-run payload unchanged.
What the plugin resolves. Prepare sees a non-nil entry under bbox, constructs a fresh
instance, loads page from the supplied content — parsing the OCR JSON and running Analyze() —
and returns {"bbox": instance}. The engine binds that under the identifier bbox for this
execution only.
What the document model computes. bbox.Has("page") is a map lookup. bbox.Get("page") hands
the rule the analysed document, on which the whole query surface is callable. TextRightOf runs an
anchor lookup against the spatial index; FuzzyTextNear fuzzy-matches the label, expands a search
region to the right, and returns the text found there; LineCount reads the line model built by
Analyze.
What comes back. Each out.Set writes into the Output collector, stamped with the firing
rule’s name. On a two-row document holding Name / John and Total / $1,250.00 the result is
{vendor: "John", total: "$1,250.00", lines: 2} — printed by the CLI, and returned as the 200
JSON body by the service.
Schemas instead of rule code
Section titled “Schemas instead of rule code”When the run supplies schemas, extraction can live in a schema instead of rule code and the rule just
names it. From the CLI the schema name is the filename without its extension; register schemas with
kis ocr page --schema closing_disclosure.schema or --schemas schemas/ — both are repeatable, and
neither flag exists on kis rules. Over HTTP the name is the key you use under __schemas in the
request body’s bbox object.
rule ExtractClosingForm salience 90 { when bbox.Has("page") then out.Set("borrower", map.GetStr(bbox.Get("page").SafeExtractForm("closing_disclosure"), "borrower_name", "")); Retract("ExtractClosingForm");}Use SafeExtractForm, not ExtractForm, and keep the whole chain in one expression. See the two
sections below for why.
Reading a columnar section
Section titled “Reading a columnar section”rule OriginationCharges salience 80 { when bbox.Has("page") then out.Set("homeowner_fee", map.GetStr(array.FindRowBy(bbox.PostprocessRows(bbox.ExtractColumnAlignedSection("page", "Origination Charges", "Services Borrower Did Not Shop For", "At Closing,Before Closing,Paid By Others"), "At Closing", "trim_currency"), "description", "homeowner"), "At Closing", "")); Retract("OriginationCharges");}array.FindRowBy and map.GetStr come from the always-on utility namespaces
(functions). If the section is absent the extraction
returns nil and the next call in the chain aborts the run.
Rows and maps must stay inline
Section titled “Rows and maps must stay inline”Assign a map or a slice to a rule variable and you can never pass that variable to another function:
the argument arrives as a zero reflect.Value and the run aborts.
rows = bbox.ExtractColumnAlignedSection("page", "Origination Charges", "", "At Closing");row = array.FindRowBy(rows, "description", "homeowner");That second line always fails with reflect: Call using zero Value argument, on a full section as
readily as on a missing one, and it fails identically for strings.Split, bbox.Names and every
Safe* method that returns a map or a slice. Nest the calls instead — one expression, no
intermediate variable. Document references (doc = bbox.Get("page")) and primitives are unaffected;
only values you pass onward are.
Nil results are fatal, so use the Safe* family
Section titled “Nil results are fatal, so use the Safe* family”When a document method returns a nil slice or map, the rule variable holds an untyped nil.
| What you write | What happens |
|---|---|
x.Len() on a nil result | is not referencing an object thus function Len call is not supported. Kind invalid |
| passing a nil result straight into another call | reflect: Call using zero Value argument |
a method call chained onto bbox.Get("unknown") | runtime error: invalid memory address or nil pointer dereference |
All three abort the entire execution and discard every value already collected — one unguarded call
destroys the output of every rule that already fired. The Safe* family exists for exactly this:
SafeTable, SafeTableBelow, SafeTableAbove, SafeTableBetween, SafeTableBelowFuzzy,
SafeTableAboveFuzzy, SafeTableBetweenFuzzy, SafeExtractForm, SafeExtractFormOnPage,
SafeExtractTable and SafeExtractTableOnPage all return non-nil empties where their unsafe
counterparts return nil. Prefer them wherever the section may be absent.
Failure modes
Section titled “Failure modes”| Message | Where | Cause |
|---|---|---|
got non existent key bbox | rule evaluation | The run carried no document — no --bbox flag, or no bbox key |
bbox plugin expects map[string]any, got %T | input | bbox was not an object |
__docs: expected map[string]any, got %T | input | Schema-shaped payload malformed |
__schemas: expected map[string]*jsonschema.Schema, got %T | input | A schema entry was not a JSON Schema |
document %s: expected map[string]any or string, got %T | input | A document value was neither OCR JSON nor a path |
plugin bbox prepare: loading document %s: ... | before any rule runs | Bad path, or OCR JSON that will not parse or analyse |
calling function <Name> which … multiple value … | rule evaluation | A two-value loader method was called from rule text |
reflect: Call using zero Value argument | rule evaluation | A map- or slice-valued variable, or a nil result, was passed as an argument |
reflect: Call using int64 as type float64 | rule evaluation | Whole-number literal where a float64 is wanted |
reflect: Call using float64 as type int64 | rule evaluation | Decimal literal where an int64 is wanted |
reflect: Call using string as type document.Direction | rule evaluation | Bare string where bbox.Dir(...) is required |
reflect: Call using int as type document.Direction | rule evaluation | A bbox.Dir(...) result was assigned to a variable before being passed |
Over HTTP every one of these comes back as 400 with the detail in the error envelope. Every
rule-evaluation failure above is fatal to the whole run, not to the one rule —
abort-on-failed-evaluation is unconditional and every non-retracted rule is evaluated each cycle.
Troubleshooting walks the diagnosis.
Continue with
Section titled “Continue with”- The document model — what
Analyze()builds - Extraction — fields, checkboxes, anchors, schemas
- Tables — table detection and the column-spec language
- Matching — fuzzy matching and confidence
- Rule patterns — guard, extract, publish
- The rules service —
POST /rule, auth, tenancy headers - CLI —
kis ocrandkis rulesflags in full