Processing a form
A filled form is the case the document library was built for: a handful of marks, a handful of
labelled values, and a decision that depends on both. This page runs one two-page application
from OCR words to a JSON result on the kis CLI, runs the same rule set
unchanged over rules.svc, and then breaks the hardest
part of it — a checkbox that detects on one run and not the next.
Every value printed below came out of the files on this page. Copy them and you get the same result.
forms/ application.bbox OCR word boxes, both pages application.json facts for this document rules/ form.grl schemas/ borrower.schema page 1 declarations.schema page 2The form
Section titled “The form”Two pages. Page 1 carries a name, a loan number, a bracket-style pair of boxes and a marker-style pair. Page 2 carries a yes/no declaration and an occupancy choice whose marks the scanner glued onto the option text.
page 1 page 2 Borrower Name Jane Q. Doe Are there any outstanding Loan Number: 1042-77 judgments against you? @Yes O No [X] Purchase [ ] Refinance @ U.S. Citizen @Primary Residence O Permanent Resident Alien OInvestment Propertyapplication.bbox is the word-box JSON described in
OCR ingestion — the format kis hocr2bbox writes.
{ "pages": [ { "number": 1, "width": 612, "height": 792, "words": [ { "text": "Borrower", "box": { "x0": 72, "y0": 100, "x1": 144, "y1": 118 }, "confidence": 0.98, "page": 1 }, { "text": "Name", "box": { "x0": 147, "y0": 100, "x1": 192, "y1": 118 }, "confidence": 0.98, "page": 1 }, { "text": "Jane", "box": { "x0": 240, "y0": 100, "x1": 276, "y1": 118 }, "confidence": 0.96, "page": 1 }, { "text": "Q.", "box": { "x0": 279, "y0": 100, "x1": 297, "y1": 118 }, "confidence": 0.93, "page": 1 }, { "text": "Doe", "box": { "x0": 300, "y0": 100, "x1": 327, "y1": 118 }, "confidence": 0.96, "page": 1 },
{ "text": "Loan", "box": { "x0": 72, "y0": 130, "x1": 108, "y1": 148 }, "confidence": 0.98, "page": 1 }, { "text": "Number:", "box": { "x0": 111, "y0": 130, "x1": 174, "y1": 148 }, "confidence": 0.97, "page": 1 }, { "text": "1042-77", "box": { "x0": 177, "y0": 130, "x1": 240, "y1": 148 }, "confidence": 0.94, "page": 1 },
{ "text": "[X]", "box": { "x0": 72, "y0": 170, "x1": 92, "y1": 188 }, "confidence": 0.91, "page": 1 }, { "text": "Purchase", "box": { "x0": 100, "y0": 170, "x1": 172, "y1": 188 }, "confidence": 0.97, "page": 1 }, { "text": "[ ]", "box": { "x0": 180, "y0": 170, "x1": 200, "y1": 188 }, "confidence": 0.90, "page": 1 }, { "text": "Refinance", "box": { "x0": 208, "y0": 170, "x1": 289, "y1": 188 }, "confidence": 0.97, "page": 1 },
{ "text": "@", "box": { "x0": 72, "y0": 210, "x1": 87, "y1": 228 }, "confidence": 0.88, "page": 1 }, { "text": "U.S.", "box": { "x0": 102, "y0": 210, "x1": 142, "y1": 228 }, "confidence": 0.95, "page": 1 }, { "text": "Citizen", "box": { "x0": 146, "y0": 210, "x1": 204, "y1": 228 }, "confidence": 0.95, "page": 1 },
{ "text": "O", "box": { "x0": 72, "y0": 250, "x1": 87, "y1": 268 }, "confidence": 0.88, "page": 1 }, { "text": "Permanent", "box": { "x0": 102, "y0": 250, "x1": 192, "y1": 268 }, "confidence": 0.95, "page": 1 }, { "text": "Resident", "box": { "x0": 196, "y0": 250, "x1": 272, "y1": 268 }, "confidence": 0.95, "page": 1 }, { "text": "Alien", "box": { "x0": 276, "y0": 250, "x1": 321, "y1": 268 }, "confidence": 0.94, "page": 1 } ] }, { "number": 2, "width": 612, "height": 792, "words": [ { "text": "Are", "box": { "x0": 72, "y0": 100, "x1": 99, "y1": 118 }, "confidence": 0.97, "page": 2 }, { "text": "there", "box": { "x0": 103, "y0": 100, "x1": 148, "y1": 118 }, "confidence": 0.97, "page": 2 }, { "text": "any", "box": { "x0": 152, "y0": 100, "x1": 179, "y1": 118 }, "confidence": 0.97, "page": 2 }, { "text": "outstanding", "box": { "x0": 183, "y0": 100, "x1": 282, "y1": 118 }, "confidence": 0.96, "page": 2 }, { "text": "judgments", "box": { "x0": 286, "y0": 100, "x1": 367, "y1": 118 }, "confidence": 0.96, "page": 2 }, { "text": "against", "box": { "x0": 371, "y0": 100, "x1": 434, "y1": 118 }, "confidence": 0.97, "page": 2 }, { "text": "you?", "box": { "x0": 438, "y0": 100, "x1": 474, "y1": 118 }, "confidence": 0.95, "page": 2 }, { "text": "@Yes", "box": { "x0": 500, "y0": 100, "x1": 536, "y1": 118 }, "confidence": 0.82, "page": 2 }, { "text": "O", "box": { "x0": 550, "y0": 100, "x1": 565, "y1": 118 }, "confidence": 0.86, "page": 2 }, { "text": "No", "box": { "x0": 578, "y0": 100, "x1": 596, "y1": 118 }, "confidence": 0.95, "page": 2 },
{ "text": "@Primary", "box": { "x0": 72, "y0": 160, "x1": 144, "y1": 178 }, "confidence": 0.80, "page": 2 }, { "text": "Residence", "box": { "x0": 148, "y0": 160, "x1": 229, "y1": 178 }, "confidence": 0.95, "page": 2 },
{ "text": "OInvestment", "box": { "x0": 72, "y0": 200, "x1": 171, "y1": 218 }, "confidence": 0.79, "page": 2 }, { "text": "Property", "box": { "x0": 175, "y0": 200, "x1": 247, "y1": 218 }, "confidence": 0.95, "page": 2 } ] } ]}application.json holds the facts for this document. Anything at the top level is a fact a rule
can read by name.
{ "applicant_id": "APP-1042"}What analysis makes of it
Section titled “What analysis makes of it”Words are glued into blocks before any detector runs, and the blocks decide what every later call can see. For these words analysis produces seventeen blocks:
p1 "Borrower Name" (72,100)-(192,118)p1 "Jane Q. Doe" (240,100)-(327,118)p1 "Loan Number: 1042-77" (72,130)-(240,148)p1 "[X]" (72,170)-(92,188)p1 "Purchase" (100,170)-(172,188)p1 "[ ]" (180,170)-(200,188)p1 "Refinance" (208,170)-(289,188)p1 "@" (72,210)-(87,228)p1 "U.S. Citizen" (102,210)-(204,228)p1 "O" (72,250)-(87,268)p1 "Permanent Resident Alien" (102,250)-(321,268)p2 "Are there any outstanding judgments against you?" (72,100)-(474,118)p2 "@Yes" (500,100)-(536,118)p2 "O" (550,100)-(565,118)p2 "No" (578,100)-(596,118)p2 "@Primary Residence" (72,160)-(229,178)p2 "OInvestment Property" (72,200)-(247,218)Two things in that list decide everything below. On page 1 the mark is a separate block from
the option text — @ and U.S. Citizen. On page 2 it is inside the option’s first token —
@Primary Residence, OInvestment Property. The same form, two different shapes, because word
merging glues a mark to the label whenever the gap is under half the median character width. See
the document model for the merge rule.
The always-on detectors then produce:
| Detector | Result on this document |
|---|---|
| Checkboxes | Purchase checked, Refinance unchecked — both bracket, both confidence 0.85, page 1 |
| Checkbox groups | Loan Purpose, two options, Selected = [Purchase] |
| Fields | Loan Number → 1042-77 (inline), plus two spurious pairs (below) |
Nothing on page 2 reaches the structural checkbox detector at all: @ and O are not in its
symbol set, and a mark merged into a word is not a small square block. Page 2 is read by marker
detection instead, which is what the schemas below use.
Detection configuration
Section titled “Detection configuration”Two mechanisms read marks, and they see different things.
| Mechanism | Sees | Reached by | On this form |
|---|---|---|---|
| Structural detection | [X], [ ], (x), ☑, □, small square blocks | IsCheckboxChecked, GetCheckbox, GetCheckboxGroupResult | page 1 brackets |
| Marker reading | A glyph immediately left of, or merged into, the option label | CheckedOption, x-checkbox in a schema | page 1 @/O, page 2 |
Neither reads the other’s marks. CheckedOption("Purchase", "Refinance") returns "" on this
document — X is not a marker glyph — while IsCheckboxChecked("Purchase") returns true.
Structural detection never fires on page 2. Pick the mechanism per group, not per form.
The structural detector’s own settings — pattern lists, label side, grouping distance, the
CommonGroupLabels list that decides what may be grouped — are compile-time defaults. No flag or
bootstrap key changes them. What you do configure is the schema.
| Schema key | Default | Effect on mark detection |
|---|---|---|
x-checkbox: true | — | Options and values are the property’s enum |
x-checkbox.options | — | Explicit labels, or {label, value} pairs |
x-checkbox.markers | @ ® © ■ • ✓ ✔ ☑ █ | Which glyphs count as checked for this property |
x-checkbox.near.anchor | — | Resolve options in the text near this anchor |
x-checkbox.near.locator | right | Direction from the anchor |
x-checkbox.near.extent | 400 | Pixels from the anchor; raised to at least 900 when reading right |
x-page | any page | Which pages the whole schema applies to |
The full keyword list is on fields, forms and checkboxes.
schemas/borrower.schema
Section titled “schemas/borrower.schema”Page 1. One labelled field read to the right of its anchor, one marker checkbox resolved from the
declared enum.
{ "title": "Borrower", "type": "object", "x-page": [1], "x-property-order": ["BorrowerName", "Citizenship"], "required": ["BorrowerName"], "properties": { "BorrowerName": { "type": "string", "x-anchors": ["Borrower Name"], "x-locator": "right", "x-max-extent": 200 }, "Citizenship": { "type": "string", "enum": ["U.S. Citizen", "Permanent Resident Alien"], "x-checkbox": true } }}schemas/declarations.schema
Section titled “schemas/declarations.schema”Page 2. The yes/no answer is anchored on its question, because Yes and No repeat on a real
form and the question is what distinguishes this copy. The occupancy choice is resolved by marker.
{ "title": "Declarations", "type": "object", "x-page": [2], "x-property-order": ["Judgments", "Occupancy"], "properties": { "Judgments": { "type": "string", "enum": ["Yes", "No"], "x-checkbox": { "options": ["Yes", "No"], "near": { "anchor": "Are there any outstanding judgments against you?", "locator": "right", "extent": 900 } } }, "Occupancy": { "type": "string", "enum": ["Primary Residence", "Investment Property"], "x-checkbox": true } }}x-page: [2] is load-bearing, not decoration. Section
When a checkbox detects inconsistently removes it and
shows what breaks.
The rules
Section titled “The rules”Six rules, distinct salience on every one. Four read the page — one labelled pair, one bracket group, two schemas — and two act on what was read.
rule ReadIdentity "Borrower name and loan number" salience 100 { when bbox.Has("page") then out.Set("applicant_id", applicant_id); out.Set("borrower_name", bbox.Get("page").Field("Borrower Name").AsText()); out.Set("loan_number", bbox.Get("page").Field("Loan Number").AsText()); audit.Log("ReadIdentity", "read identity fields from page 1"); Retract("ReadIdentity");}
rule ReadLoanPurpose "Bracket checkboxes on page 1" salience 90 { when bbox.Has("page") then out.Set("loan_purpose", bbox.Get("page").GetCheckboxGroupResult("Loan Purpose").SelectedValue()); out.Set("purchase", bbox.Get("page").IsCheckboxChecked("Purchase")); out.Set("refinance_state", bbox.Get("page").GetCheckboxValue("Refinance")); Retract("ReadLoanPurpose");}
rule ReadCitizenship "Marker checkbox through the page-1 schema" salience 80 { when bbox.Has("page") then out.Set("citizenship", bbox.Get("page").ExtractFormScored("borrower")["Citizenship"].AsText()); out.Set("citizenship_confidence", bbox.Get("page").ExtractFormScored("borrower")["Citizenship"].Confidence()); Retract("ReadCitizenship");}
rule ReadDeclarations "Page-2 answers through the page-2 schema" salience 70 { when bbox.Has("page") then out.Set("judgments", bbox.Get("page").ExtractFormScored("declarations")["Judgments"].AsText()); out.Set("occupancy", bbox.Get("page").ExtractFormScored("declarations")["Occupancy"].AsText()); out.Set("occupancy_confidence", bbox.Get("page").ExtractFormScored("declarations")["Occupancy"].Confidence()); Retract("ReadDeclarations");}
rule RouteOccupancyToReview "Threshold the detected state" salience 60 { when bbox.Has("page") && bbox.Get("page").ExtractFormScored("declarations")["Occupancy"].NeedsReview(0.90) then out.Set("review_reason", "occupancy below threshold"); audit.Log("RouteOccupancyToReview", "occupancy confidence under 0.90"); Retract("RouteOccupancyToReview");}
rule FlagMissingCitizenship "No option carried a checked marker" salience 50 { when bbox.Has("page") && bbox.Get("page").ExtractFormScored("borrower")["Citizenship"].AsText() == "" then out.Set("review_reason", "citizenship unanswered"); audit.Log("FlagMissingCitizenship", "no citizenship option carried a checked marker"); Retract("FlagMissingCitizenship");}Five things in that file are deliberate.
- Every rule guards on
bbox.Has("page").bbox.Geton an unknown name returns nil and the next call panics, which aborts the run and discards everything already collected. ExtractFormScored, neverExtractForm. The scored call returns an entry for every declared property, so indexing one that was not found yields an empty result instead of killing the run.ExtractFormomits missed properties entirely.- The call result is indexed directly.
doc.ExtractFormScored("borrower")["Citizenship"]works; assigning the map to a variable first and then indexing it does not. 0.90, not0.NeedsReviewtakes afloat64; a whole-number literal is anint64and fails the call. Comparison operators coerce, function arguments do not.- Distinct salience.
RouteOccupancyToReview(60) andFlagMissingCitizenship(50) both writereview_reason. Equal salience gives a different firing order run to run, so the winner would be a coin toss; distinct salience makes the lower one — which fires later — the winner, every time.
GetCheckboxGroupResult is safe on a group that does not exist: it returns nil and
SelectedValue() on nil returns "". That is not true of bbox.Get, which is why the guard is
still there.
Run it on the CLI
Section titled “Run it on the CLI”kis ocr page -r rules/ --schemas schemas/ applicationapplication is a prefix: application.json supplies the facts and application.bbox supplies
the words. kis ocr page binds the document under the name page, which is what the rules call
it, and registers each *.schema file in schemas/ under its filename minus the extension.
Result for /work/forms/application.json:{ "applicant_id": "APP-1042", "borrower_name": "Jane Q. Doe", "citizenship": "U.S. Citizen", "citizenship_confidence": 0.865, "judgments": "Yes", "loan_number": "1042-77", "loan_purpose": "Purchase", "occupancy": "Primary Residence", "occupancy_confidence": 0.865, "purchase": true, "refinance_state": "No", "review_reason": "occupancy below threshold"}Processed 1/1 prefix(es) successfullyAdd --audit to see which rules fired and what they recorded. The engine adds an executed
entry for every rule that fires; the rest are the rules’ own audit.Log calls.
kis ocr page -r rules/ --schemas schemas/ --audit applicationThe audit block prints before the result block, which is still printed:
--- Audit for /work/forms/application.json --- 1. [ReadIdentity] executed 2. [ReadIdentity] read identity fields from page 1 3. [ReadLoanPurpose] executed 4. [ReadCitizenship] executed 5. [ReadDeclarations] executed 6. [RouteOccupancyToReview] executed 7. [RouteOccupancyToReview] occupancy confidence under 0.90FlagMissingCitizenship never appears: its when was false, so it never fired.
Run it on the service
Section titled “Run it on the service”rules.svc runs the same engine over the same rule language, and it runs this form end to end — the
four reading rules and the two deciding rules, all six unchanged. The rule text is identical on both
surfaces. What changes is where the document and the schemas come from: on the CLI they are files
found by -r, --schemas and the input prefix; over HTTP they travel on the request.
Publish the rule set to the metadata plane under the name a caller sends as name. Its rule: body
is form.grl above, verbatim — six rules, the same salience on each, the same bbox calls.
The request carries the facts and the document together. bbox is a reserved top-level key, and
because these rules resolve schemas by name — ExtractFormScored("borrower") — send its two-key
form: __docs maps a document name to its word-box JSON, __schemas maps a schema name to the
schema. Both sets of names are exactly the ones the rule text uses, so the document is page and the
schemas are borrower and declarations.
{ "name": "form-checks", "applicant_id": "APP-1042", "bbox": { "__docs": { "page": { "pages": [ { "number": 1, "width": 612, "height": 792, "words": [] }, { "number": 2, "width": 612, "height": 792, "words": [] } ] } }, "__schemas": { "borrower": { "title": "Borrower", "type": "object", "x-page": [1] }, "declarations": { "title": "Declarations", "type": "object", "x-page": [2] } } }}The words arrays and the properties blocks are elided above for length only. Send
application.bbox and the two *.schema files from this page whole and every value below matches
the CLI run key for key. Documents travel inline: the service has no access to your filesystem, so a
path string under bbox fails to load.
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 @request.jsonThe response is the out collector and nothing else — the facts you sent are not echoed back, but
applicant_id appears because ReadIdentity writes it there:
{ "applicant_id": "APP-1042", "borrower_name": "Jane Q. Doe", "citizenship": "U.S. Citizen", "citizenship_confidence": 0.865, "judgments": "Yes", "loan_number": "1042-77", "loan_purpose": "Purchase", "occupancy": "Primary Residence", "occupancy_confidence": 0.865, "purchase": true, "refinance_state": "No", "review_reason": "occupancy below threshold"}The same words produce the same blocks, the same detectors run over them, and the same rules fire in
the same salience order, so the confidences are the 0.865 the CLI printed and review_reason is set
by RouteOccupancyToReview for the same reason. Everything below about page scoping, marker sets and
merged marks applies to this surface exactly as it does to the CLI.
The output shape, including confidence
Section titled “The output shape, including confidence”Every scored value carries where it came from and how much of the extraction was inference.
Explain() renders it.
The marker checkbox on page 1:
[schema.checkbox] "Citizenship" -> "U.S. Citizen" conf=0.86 (page 0, dist=0px) checkbox_marker 0.85 (w=1.0) enum selected via checkbox marker type_fit 1.00 (w=1.0) matches enumThe labelled field beside it:
[field.right_of] "Borrower Name" -> "Jane Q. Doe" conf=0.92 (page 1, dist=48px) ocr 0.98 (w=1.0) OCR engine confidence: 0.98 label_format 0.90 (w=0.5) label has expected format fuzzy_match 0.92 (w=0.7) Edit distance: 1, similarity: 0.92 ocr 0.95 (w=1.0) OCR engine confidence: 0.95 alignment 1.00 (w=0.8) Spatial alignment score: 1.00 proximity 0.76 (w=0.6) Distance: 48.0 (max: 200.0) position 0.85 (w=0.6) value right of labelThose are two different kinds of number and the difference matters when you set a threshold.
| Property | Confidence | Where it comes from |
|---|---|---|
Citizenship | 0.865 | Flat 0.85 checkbox_marker, then type_fit closes 10% of the gap to 1.0 |
Occupancy | 0.865 | Identical — same two factors |
Judgments | 0.865 | Identical |
BorrowerName | 0.921 | Seven spatial and OCR factors, blended by weight |
The multi-page part
Section titled “The multi-page part”application.bbox is one document with two pages, so a single kis ocr page run covers both. What
makes it work is that each schema declares the page it belongs to.
| Mechanism | Where | Effect |
|---|---|---|
x-page: [1] / x-page: [2] | schema, top level | The whole schema resolves only against those pages |
ExtractFormScoredOnPage(name, page) | rule text | Overrides the schema’s own scope for one call |
x-region.page_range | schema, top level | A page window instead of a page list |
Scoping is not only about looking in the right place. A schema whose scope resolves to exactly one page takes a different, more OCR-tolerant code path for marker checkboxes than an unscoped one — see below.
x-page also takes negative indices: -1 is the last page, which is how you target a signature
page on a document whose length varies.
If your pages arrive as one file each, kis ocr doc merges them into a single document before
running the rules once:
kis ocr doc -r rules/ --schemas schemas/ --bbox-name page -v applicant_id=APP-1042 page1 page2Two differences from kis ocr page are worth knowing before you use it.
- The merged document’s default name is
doc.--bbox-name pagekeeps the rule text above unchanged. - Per-page facts are collected into an array under
pagesrather than staying at the top level, soapplicant_idis no longer reachable as a bare fact.-v applicant_id=APP-1042injects it as a top-level string fact instead.
Merging renumbers pages sequentially from 1 across the whole merge, so x-page refers to the
merged numbering, not to any source file’s page numbers.
When a checkbox detects inconsistently
Section titled “When a checkbox detects inconsistently”This is the failure you will actually hit. Take declarations.schema and delete one line:
"x-page": [2],Nothing else changes — same document, same rules, same words, both properties still declared.
Result for /work/forms/application.json:{ ... "judgments": "Yes", "occupancy": "", "occupancy_confidence": 0, "review_reason": "occupancy below threshold"}Judgments still resolves — it is anchored on its question, and the anchored path does not care
about page scope. Occupancy returns an empty string with confidence 0. The run succeeds, the
output is well-formed, and the answer is silently gone.
Three code paths resolve an x-checkbox property, and they are not equally tolerant of what OCR
did to the mark.
| Condition | Path | Reads a mark merged into the option text |
|---|---|---|
x-checkbox.near.anchor is set | Anchor matched with edit tolerance 2/4, options resolved in the text it pulled | yes |
| Scope resolves to exactly one page | Options matched with phrase fuzz on that page | yes |
| Neither | Whole-document scan: exact option text, mark must be a separate block within 30px to the left | no |
Page 2’s marks are merged — @Primary Residence is one block. The whole-document scan looks for
the exact phrase Primary Residence and does not find it, because the block starts with @. The
page-scoped path fuzzy-matches the phrase, then checks both the text to the left and the first
character of the matched block, finds @, and returns the option.
Restore x-page: [2] and occupancy comes back as Primary Residence at 0.865.
Diagnosing it
Section titled “Diagnosing it”| Symptom | Cause | Fix |
|---|---|---|
| Option resolves on one page, not another | Whole-document scan; mark merged into the text | Scope the schema with x-page, or call ExtractFormScoredOnPage |
Nothing resolves, "" for every option | The scanner’s glyph is not in the marker set | Add it to x-checkbox.markers |
| The wrong copy of a repeated option resolves | Yes/No appear dozens of times | Anchor it with x-checkbox.near.anchor |
[X] boxes never resolve through x-checkbox | Marker reading does not see brackets | Use IsCheckboxChecked / the checkbox group, or add X to markers |
| A whole group reads as unselected | Elimination only fires when some option is positively unchecked | Treat "" as unknown, never as no |
DiagnoseForm names the reason per property rather than leaving you to guess. For the unscoped
Occupancy above it reports value_not_near_anchor — the option text was found, no mark was
found near it. The full reason list is on
fields, forms and checkboxes.
To see what the scanner actually wrote, search the words:
kis bbox phrase -p "Primary Residence" --fuzzy --max-dist-word 2 application.bboxFound 1 match(es) for phrase "Primary Residence" (fuzzy)
=== Match 1 (Page 2) ===Location: (72.0, 160.0) - (229.0, 178.0)Line: 7Edit Distance: 2Matched Words: @Primary Residence
Are there any outstanding judgments against you? @Yes O No >> @Primary Residence OInvestment PropertyMatched Words is the diagnosis: the text on the page is @Primary Residence, not
Primary Residence, and the edit distance of 2 is the glued marker. A merged mark always shows up
that way — as a non-zero edit distance whose whole cost sits on the first token.
Recording the validation result
Section titled “Recording the validation result”A form that fails a check needs a record of what failed, not only a value. The engine has a
findings model — Finding with a rule id, a severity, a category and a message; Findings with
Passed() and HasSeverity() — and it is bound into every execution as findings.
Severities are INFO, WARNING, ERROR and CRITICAL, and Passed() is false once an ERROR
or CRITICAL finding exists — the exact shape a form check wants.
Use the pair the rules above already use: audit.Log for the trail and out.Set for anything a
caller must act on. This rule runs unchanged on both surfaces and writes the same two keys either
way.
rule FlagMissingCitizenship "No option carried a checked marker" salience 50 { when bbox.Has("page") && bbox.Get("page").ExtractFormScored("borrower")["Citizenship"].AsText() == "" then out.Set("review_reason", "citizenship unanswered"); out.Set("severity", "ERROR"); audit.Log("FlagMissingCitizenship", "no citizenship option carried a checked marker"); Retract("FlagMissingCitizenship");}| Surface | Audit trail | Output |
|---|---|---|
kis ocr page | --audit, plus the always-written *-hist-audit.json sidecar | stdout, or the file written by -o / --update |
rules.svc | The same entries, returned under audit when the body sets "audit": true | The 200 response body |
A check whose result the caller must act on belongs in out on both surfaces — that is the value
a caller branches on, which is why review_reason is an output and not only an audit line. Keep the
key names stable: they are the contract your caller reads.
Continue with
Section titled “Continue with”- Fields, forms and checkboxes — every schema keyword and every checkbox call
- The document model — word merging, blocks, analysis order
- Matching and confidence — fuzzy matching, factors, calibrating a threshold
- Running rules over documents — the
bboxsurface and its failure modes - The CLI —
kis ocr page,kis ocr doc,kis bbox, every flag - The rules service —
POST /rule, auth, tenancy headers - The extraction API — the typed surface behind these calls