Skip to content
Talk to our solutions team

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 2

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 Property

application.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"
}

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:

DetectorResult on this document
CheckboxesPurchase checked, Refinance unchecked — both bracket, both confidence 0.85, page 1
Checkbox groupsLoan Purpose, two options, Selected = [Purchase]
FieldsLoan Number1042-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.

Two mechanisms read marks, and they see different things.

MechanismSeesReached byOn this form
Structural detection[X], [ ], (x), , , small square blocksIsCheckboxChecked, GetCheckbox, GetCheckboxGroupResultpage 1 brackets
Marker readingA glyph immediately left of, or merged into, the option labelCheckedOption, x-checkbox in a schemapage 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 keyDefaultEffect on mark detection
x-checkbox: trueOptions and values are the property’s enum
x-checkbox.optionsExplicit labels, or {label, value} pairs
x-checkbox.markers@ ® © ■ • ✓ ✔ ☑ █Which glyphs count as checked for this property
x-checkbox.near.anchorResolve options in the text near this anchor
x-checkbox.near.locatorrightDirection from the anchor
x-checkbox.near.extent400Pixels from the anchor; raised to at least 900 when reading right
x-pageany pageWhich pages the whole schema applies to

The full keyword list is on fields, forms and checkboxes.

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
}
}
}

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.

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.Get on an unknown name returns nil and the next call panics, which aborts the run and discards everything already collected.
  • ExtractFormScored, never ExtractForm. 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. ExtractForm omits 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, not 0. NeedsReview takes a float64; a whole-number literal is an int64 and fails the call. Comparison operators coerce, function arguments do not.
  • Distinct salience. RouteOccupancyToReview (60) and FlagMissingCitizenship (50) both write review_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.

Terminal window
kis ocr page -r rules/ --schemas schemas/ application

application 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) successfully

Add --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.

Terminal window
kis ocr page -r rules/ --schemas schemas/ --audit application

The 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.90

FlagMissingCitizenship never appears: its when was false, so it never fired.

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.

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 @request.json

The 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.

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 enum

The 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 label

Those are two different kinds of number and the difference matters when you set a threshold.

PropertyConfidenceWhere it comes from
Citizenship0.865Flat 0.85 checkbox_marker, then type_fit closes 10% of the gap to 1.0
Occupancy0.865Identical — same two factors
Judgments0.865Identical
BorrowerName0.921Seven spatial and OCR factors, blended by weight

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.

MechanismWhereEffect
x-page: [1] / x-page: [2]schema, top levelThe whole schema resolves only against those pages
ExtractFormScoredOnPage(name, page)rule textOverrides the schema’s own scope for one call
x-region.page_rangeschema, top levelA 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:

Terminal window
kis ocr doc -r rules/ --schemas schemas/ --bbox-name page -v applicant_id=APP-1042 page1 page2

Two differences from kis ocr page are worth knowing before you use it.

  • The merged document’s default name is doc. --bbox-name page keeps the rule text above unchanged.
  • Per-page facts are collected into an array under pages rather than staying at the top level, so applicant_id is no longer reachable as a bare fact. -v applicant_id=APP-1042 injects 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.

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.

ConditionPathReads a mark merged into the option text
x-checkbox.near.anchor is setAnchor matched with edit tolerance 2/4, options resolved in the text it pulledyes
Scope resolves to exactly one pageOptions matched with phrase fuzz on that pageyes
NeitherWhole-document scan: exact option text, mark must be a separate block within 30px to the leftno

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.

SymptomCauseFix
Option resolves on one page, not anotherWhole-document scan; mark merged into the textScope the schema with x-page, or call ExtractFormScoredOnPage
Nothing resolves, "" for every optionThe scanner’s glyph is not in the marker setAdd it to x-checkbox.markers
The wrong copy of a repeated option resolvesYes/No appear dozens of timesAnchor it with x-checkbox.near.anchor
[X] boxes never resolve through x-checkboxMarker reading does not see bracketsUse IsCheckboxChecked / the checkbox group, or add X to markers
A whole group reads as unselectedElimination only fires when some option is positively uncheckedTreat "" 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:

Terminal window
kis bbox phrase -p "Primary Residence" --fuzzy --max-dist-word 2 application.bbox
Found 1 match(es) for phrase "Primary Residence" (fuzzy)
=== Match 1 (Page 2) ===
Location: (72.0, 160.0) - (229.0, 178.0)
Line: 7
Edit Distance: 2
Matched Words: @Primary Residence
Are there any outstanding judgments against you? @Yes O No
>> @Primary Residence
OInvestment Property

Matched 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.

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");
}
SurfaceAudit trailOutput
kis ocr page--audit, plus the always-written *-hist-audit.json sidecarstdout, or the file written by -o / --update
rules.svcThe same entries, returned under audit when the body sets "audit": trueThe 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.