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.

type Finding struct {
RuleID string
Severity Severity
Category string
Message string
Fields map[string]any
}
type Findings struct {
// unexported
}
type Severity int
FieldTypeMeaning
RuleIDstringWhich check raised it. Free text — not validated against the loaded rule names
SeveritySeverityGrade. Zero value is SeverityInfo, so an unset severity never fails an execution
CategorystringFree-text grouping, e.g. validation, completeness
MessagestringThe human-readable statement
Fieldsmap[string]anyStructured 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.

const (
SeverityInfo Severity = iota
SeverityWarning
SeverityError
SeverityCritical
)
func (s Severity) String() string
ConstantValueString()Fails Passed()
SeverityInfo0INFOno
SeverityWarning1WARNINGno
SeverityError2ERRORyes
SeverityCritical3CRITICALyes

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.

func NewFindings() *Findings
func (f *Findings) Add(finding Finding)
func (f *Findings) All() []Finding
func (f *Findings) Count() int
func (f *Findings) HasSeverity(s Severity) bool
func (f *Findings) Passed() bool
SignatureBehaviour
NewFindings() *FindingsA collector with an empty, non-nil backing slice
Add(finding Finding)Appends. No validation, no deduplication, no ordering by severity
All() []FindingThe backing slice in insertion order — the slice itself, not a copy
Count() intNumber of findings held
HasSeverity(s Severity) boolTrue when at least one finding has exactly that severity
Passed() booltrue 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.

The collector is bound into every execution as findings, but only part of its surface is reachable from rule text.

Call from a ruleResult
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

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() []AuditEntry
func (a *Audit) Count() int
func (a *Audit) ForRule(ruleID string) []AuditEntry
func (a *Audit) JSON() ([]byte, error)
SignatureBehaviourCallable from a rule
Log(ruleID, message string)Appends an entry stamped with the current timeyes
LogWithData(ruleID, message string, data map[string]any)Same, carrying a structured payloadyes — pass in.NewMapWithValues(...) directly
All() []AuditEntryEvery entry in append orderonly .Len() is useful
Count() intNumber of entriesyes
ForRule(ruleID string) []AuditEntryEntries whose RuleID matches exactly. nil when none doonly .Len() is useful
JSON() ([]byte, error)Marshals the entriesno — two return values are rejected outright
FieldTypeMeaning
RuleIDstringThe first argument to Log / LogWithData. Free text — the engine’s own entries carry the firing rule’s name
MessagestringThe second argument. The engine writes executed
Datamap[string]anyThe third argument to LogWithData. nil for entries written by Log
Timestamptime.Timetime.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:

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