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.
type Finding struct { RuleID string Severity Severity Category string Message string Fields map[string]any}
type Findings struct { // unexported}
type Severity intFinding fields
Section titled “Finding fields”| Field | Type | Meaning |
|---|---|---|
RuleID | string | Which check raised it. Free text — not validated against the loaded rule names |
Severity | Severity | Grade. Zero value is SeverityInfo, so an unset severity never fails an execution |
Category | string | Free-text grouping, e.g. validation, completeness |
Message | string | The human-readable statement |
Fields | map[string]any | Structured detail. nil unless set; nothing populates it for you |
All five fields are exported and settable. None carries a JSON tag, so a marshalled finding uses the Go field names verbatim.
Severity
Section titled “Severity”const ( SeverityInfo Severity = iota SeverityWarning SeverityError SeverityCritical)
func (s Severity) String() string| Constant | Value | String() | Fails Passed() |
|---|---|---|---|
SeverityInfo | 0 | INFO | no |
SeverityWarning | 1 | WARNING | no |
SeverityError | 2 | ERROR | yes |
SeverityCritical | 3 | CRITICAL | yes |
Any other value prints UNKNOWN. Severity is a named int type, not a string enum — the numbers
above are the values, and there is no parser from "ERROR" back to a Severity.
Findings
Section titled “Findings”func NewFindings() *Findings
func (f *Findings) Add(finding Finding)func (f *Findings) All() []Findingfunc (f *Findings) Count() intfunc (f *Findings) HasSeverity(s Severity) boolfunc (f *Findings) Passed() bool| Signature | Behaviour |
|---|---|
NewFindings() *Findings | A collector with an empty, non-nil backing slice |
Add(finding Finding) | Appends. No validation, no deduplication, no ordering by severity |
All() []Finding | The backing slice in insertion order — the slice itself, not a copy |
Count() int | Number of findings held |
HasSeverity(s Severity) bool | True when at least one finding has exactly that severity |
Passed() bool | true when no SeverityError and no SeverityCritical finding exists |
Passed is the pass/fail decision and it has only two failing grades. INFO and WARNING findings
never fail an execution however many of them accumulate, and an empty collector passes.
Findings has no exported field and no JSON method, so marshalling the collector directly yields
{}. Marshal All() instead.
What a rule can do
Section titled “What a rule can do”The collector is bound into every execution as findings, but only part of its surface is reachable
from rule text.
| Call from a rule | Result |
|---|---|
findings.Passed() | Works. Returns true |
findings.Count() | Works. Returns 0 |
findings.All() | Works, but a rule can only take .Len() of the returned slice |
findings.Add(Finding{...}) | Parse error. The whole rule set fails to load |
findings.HasSeverity(2) | Runtime panic: reflect: Call using int64 as type Severity |
Record a validation result instead
Section titled “Record a validation result instead”Two bindings do work from rule text, and together they cover what the findings model would have
carried: audit for the trail, out for the verdict the caller reads.
rule ValidateEmail "Flag an unparseable customer email" salience 80 { when !strings.MatchRegex("^[^@ ]+@[^@ ]+\\.[a-zA-Z]{2,}$", customer_email) then out.Set("validation_status", "FAILED"); out.Set("validation_severity", "ERROR"); audit.LogWithData("ValidateEmail", "email failed the format check", in.NewMapWithValues("category", "validation", "severity", "ERROR")); Retract("ValidateEmail");}customer_email is a bare fact identifier — a top-level key of the payload the caller supplies.
Carry the severity as your own string or integer under an out key. out is the result map, so a
caller reads the verdict the same way it reads every other extracted value, and a later rule can
branch on it with out.GetStr("validation_status") == "FAILED" — a real read of a real value, which
findings.Passed() is not.
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.
type Audit struct { // unexported}
type AuditEntry struct { RuleID string Message string Data map[string]any Timestamp time.Time}
func NewAudit() *Audit
func (a *Audit) Log(ruleID, message string)func (a *Audit) LogWithData(ruleID, message string, data map[string]any)func (a *Audit) All() []AuditEntryfunc (a *Audit) Count() intfunc (a *Audit) ForRule(ruleID string) []AuditEntryfunc (a *Audit) JSON() ([]byte, error)| Signature | Behaviour | Callable from a rule |
|---|---|---|
Log(ruleID, message string) | Appends an entry stamped with the current time | yes |
LogWithData(ruleID, message string, data map[string]any) | Same, carrying a structured payload | yes — pass in.NewMapWithValues(...) directly |
All() []AuditEntry | Every entry in append order | only .Len() is useful |
Count() int | Number of entries | yes |
ForRule(ruleID string) []AuditEntry | Entries whose RuleID matches exactly. nil when none do | only .Len() is useful |
JSON() ([]byte, error) | Marshals the entries | no — two return values are rejected outright |
AuditEntry fields
Section titled “AuditEntry fields”| Field | Type | Meaning |
|---|---|---|
RuleID | string | The first argument to Log / LogWithData. Free text — the engine’s own entries carry the firing rule’s name |
Message | string | The second argument. The engine writes executed |
Data | map[string]any | The third argument to LogWithData. nil for entries written by Log |
Timestamp | time.Time | time.Now() at the moment of the call. Set for you; not settable through either method |
All four fields are exported and untagged, so JSON() emits the Go field names verbatim.
Audit itself holds one unexported slice — like Findings, marshalling the collector directly
yields {}. Marshal All(), or call JSON().
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:
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.
Related
Section titled “Related”- 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 API —
Result,Output,Inputand 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