Skip to content
Talk to our solutions team

Findings

A finding is one severity-graded validation issue produced during a rule execution: which check raised it, how bad it is, what it says, and any structured detail. Findings are collected in one list per execution, and that list answers a single question — did this execution pass?

Every execution allocates three collectors — Output, Findings, Audit, in that order. Findings is the second, bound into the run under the name findings and returned as Result.Findings; Audit is bound as audit and returned as Result.Audit. Both are allocated per call, so two concurrent executions never share a list. See Result for the struct that carries them.

The engine never inspects a finding: it allocates the collector, exposes it, and hands it back. Severity is the only thing that carries meaning, and only through Passed.

PartSet byMeaning
Rule idFirst argument to every raiseWhich check raised it. Free text — not validated against the loaded rule names
SeverityAdd’s second argument, or the verb you usedThe grade. Unset means INFO, which never fails an execution
CategoryAdd’s third argument, or the verb’s secondFree-text grouping, such as validation or completeness
MessageThe human-readable statementWhat went wrong
FieldsAdd’s comma-separated fifth argument, or .Field(name)Which field keys the finding concerns
Details.Detail(key, value)Extra key/value context. Absent until you add some
TimestampThe engineWhen the finding was raised

Findings come back in the execution result, not through out — a caller reads them from the response’s findings list.

Four grades, named by string everywhere a rule touches them:

SeverityFails Passed()
INFOno
WARNINGno
ERRORyes
CRITICALyes

Severity names are resolved case-insensitively, and the short forms rules actually write are accepted:

AcceptedResolves to
info, information, informationalSeverityInfo
warn, warningSeverityWarning
error, errSeverityError
critical, fatalSeverityCritical

An unrecognised name resolves to INFO, so a misspelling silently downgrades an error to information — prefer the graded verbs, where the severity is in the function name and cannot be misspelled.

SignatureBehaviour
New() *FindingsAn empty collector
Add(ruleID, severity, category, message, fields)Raises a finding from five strings. fields is a comma-separated list of field keys; empty means none
Info / Warn / Error / Critical (ruleID, category, message)Raise at a fixed grade. Severity is in the verb, so it cannot be misspelled
Detail(key, value)Attaches one key/value to a raised finding. Chainable
Field(name)Records another field key on a raised finding. Chainable
All()The findings in insertion order
Count()How many were raised
CountOf(severity)How many carry that severity name
HasSeverity(severity)Whether any carries that severity name
Has(category)Whether any carries that category
Passed()true when nothing at ERROR or above was raised
JSON()Marshals the findings

Every raise returns the *Finding, which is what makes the detail calls chainable.

Passed has only two failing grades. INFO and WARNING findings never fail an execution however many accumulate, and an empty collector passes.

The collector is bound into every execution as findings, and the whole raise-and-query surface is reachable from rule text. Every parameter is a string, so there is nothing a rule cannot express.

rule ValidateLoanAmount "loan amount must parse as a number" salience 80 {
when
!strings.MatchRegex("^[0-9,.]+$", LELoanAmount)
then
findings.Add("validate_le_loan_amount_numeric", "Error", "invalid_format",
"LELoanAmount does not parse as a valid number", "LELoanAmount");
}

The four graded verbs are shorter and cannot carry a misspelled severity:

findings.Error("check_totals", "totals", "line items do not sum to the invoice total");
findings.Warn("check_dates", "completeness", "no issue date found");
findings.Info("classify", "routing", "document classified as a closing disclosure");
findings.Critical("check_signature", "integrity", "signature block is missing");

Attach detail by chaining, and gate later rules on what has been raised:

findings.Error("check_totals", "totals", "line items do not sum")
.Detail("expected", "12000.00")
.Detail("actual", "11750.00")
.Field("invoice_total");
// in a later rule
when findings.HasSeverity("error") && !findings.Has("routing")
Call from a ruleResult
findings.Add(r, sev, cat, msg, fields)Raises. Five strings, no struct
findings.Error(r, cat, msg) and the other three verbsRaise at a fixed grade
.Detail(k, v) / .Field(n)Chain onto the returned finding
findings.Count() / findings.CountOf("error")Counts
findings.HasSeverity("error") / findings.Has("totals")Presence
findings.Passed()The pass/fail gate. A real read of real values
findings.All()Works, but a rule can only take .Len() of the returned slice
findings.JSON()Rejected — two return values. Read the findings from the result instead

Carrying the verdict in the output as well

Section titled “Carrying the verdict in the output as well”

Findings are the validation channel, but a caller that reads only the output map still needs the verdict there. Setting both costs one line and makes the result readable either way:

rule ValidateEmail "Flag an unparseable customer email" salience 80 {
when
!strings.MatchRegex("^[^@ ]+@[^@ ]+\\.[a-zA-Z]{2,}$", customer_email)
then
findings.Error("ValidateEmail", "validation", "customer email failed the format check")
.Field("customer_email");
out.Set("validation_status", "FAILED");
}

customer_email is a bare fact identifier — a top-level key of the payload the caller supplies. A later rule can branch on either channel: findings.HasSeverity("error") or out.GetStr("validation_status") == "FAILED".

Audit is the timestamped decision trail, allocated per execution alongside Findings and bound as audit. It is the only write path a rule has for diagnostics.

SignatureBehaviourCallable from a rule
audit.Log(ruleID, message)Appends an entry stamped with the current timeyes
audit.LogWithData(ruleID, message, data)Same, carrying a structured payloadyes — pass in.NewMapWithValues(...) directly
audit.All()Every entry in append orderonly .Len() is useful
audit.Count()Number of entriesyes
audit.ForRule(ruleID)Entries whose rule id matches exactly. Empty when none doonly .Len() is useful
audit.JSON()Marshals the entriesno — two return values are rejected outright
PartMeaning
Rule idThe first argument to Log / LogWithData. Free text — the engine’s own entries carry the firing rule’s name
MessageThe second argument. The engine writes executed
DataThe third argument to LogWithData. Absent for entries written by Log
TimestampWhen the call was made. Set for you; not settable through either method

Every rule that fires also appends an automatic entry with the message executed before its then block runs, so a rule’s own audit.Log lands immediately after its executed marker:

Terminal window
kis ocr page -r classify.grl -i pages --history --audit page1
--- Audit for pages/page1.json ---
1. [DetectInvoice] executed
2. [DetectInvoice] matched the phrase 'Invoice Number'

The automatic entry is written by the engine’s listener, not by the rule, so Count() is the number of firings plus the number of explicit Log / LogWithData calls. A rule that fires in three cycles contributes three executed entries.

Both surfaces write the same entries, and both can return them. On a kis run --audit prints them and the <prefix>-hist-audit.json sidecar records them; over HTTP a body carrying "audit": true gets them back under a reserved audit key beside the output — see the rules service for the response shape.

  • Built-in functions — the complete call surface of a rule, including which of these methods rule text can reach
  • The rule language — why there is no struct literal
  • Rule patterns — recording a decision trace
  • Rules engine APIResult, Output, Input and the rest of the engine surface these collectors arrive on
  • Documents — the page and document facts the audited example runs against
  • Errors and limits — what a failed load or a panicking call does to the rest of the execution