Skip to content
Talk to our solutions team

Rules engine API

This is the type-by-type reference for the rule engine that the kis CLI and rules.svc both run. Every signature here is the one in the shipped source. For what a rule may call, see Built-in functions; for how a rule set is written, see The rule language.

Both surfaces drive the same engine with the same defaults, so the limits and behaviours below apply identically to a CLI run and to a POST /rule request.

TypeReachable from a rule asShows up in
OutputoutThe result; rules.svc returns Output.All() as the response body
FindingsfindingsResult.Findings — see the caution under Findings
AuditauditResult.Audit
Input / Memoryin, in.Mem(name)Scratch state for the duration of one execution
ResultReturned by Execute
Config, Option, Plugin, EngineSet up before any rule runs
RuleTracker, DataContextAccessor, transform registrySee their sections

Neither surface exposes a knob for any of these. The values are the same on both.

LimitValueEffect
Cycle cap5000Execution aborts with an error past this many firing cycles; no partial Result
Rule set version1.0.0The only version Execute looks up
Rules fired per cycle1The runnable rule with the highest salience
Abort on failed evaluationalways onOne rule’s evaluation error discards the whole run
Output append separator", "Changeable per execution with out.SetSeparator

The interface every backend implements. The grule backend is the implementation both surfaces construct.

type Engine interface {
AddRules(ctx context.Context, rulesetName, version string, content string) error
Load(ctx context.Context, rulesetName, version, path string, recursive bool) error
RegisterPlugin(ctx context.Context, p Plugin) error
Execute(ctx context.Context, facts map[string]any, ruleset string, pluginData map[string]any) (*Result, error)
Rulesets() []string
Close() error
}
MethodReturnsBehaviour
AddRuleserrorCompiles GRL text into (rulesetName, version). An empty version becomes the default. Parse and compile errors surface here, never from Execute
LoaderrorA file path loads that one file whatever its extension. A directory path loads every .grl file in it — recursive also walks subdirectories. Every file lands in the same rule set
RegisterPluginerrorCalls the plugin’s Init immediately and merges its facts into an engine-wide map bound into every later execution. Fails with plugin <name> init: <err>
Execute(*Result, error)Runs ruleset against facts. Returns (nil, err) on any failure — there is no partial result
Rulesets[]stringDistinct rule set names, in Go map order. Includes rule sets stored under an unreachable version
CloseerrorNo-op returning nil. There is no unload: rule sets only accumulate

Rule names must be unique inside a rule set. Loading a directory pools every file into one namespace, so two files that both define rule ValidateTotal fail the load with got N error(s) in grl the script.

Loading is not concurrency safe. Execute is: each call clones a fresh knowledge base and allocates its own data context, Output, Findings and Audit.

The concrete backend is a struct with unexported fields that satisfies Engine:

type Engine struct { /* unexported */ }

It implements every method in the interface above — AddRules, Load, RegisterPlugin, Execute, Rulesets and Close — with the behaviour described in that table. The backend is selected by the literal name "grule". Both the CLI and rules.svc build it the same way, with the shipped defaults and the document and utility namespaces registered, so every value below is the effective one on every run.

type Config struct {
MaxCycles int
DefaultVersion string
}
FieldTypeValueEffect
MaxCyclesint5000Cycles allowed before the execution aborts. A cycle counts only when at least one rule is runnable
DefaultVersionstring"1.0.0"Substituted for an empty version on load, and the only version Execute resolves

Both values are fixed. There is no CLI flag, bootstrap key or request field on either surface that changes the cycle cap or the rule-set version — a runaway rule set is fixed in the rules, normally by adding the missing Retract.

A plugin publishes named facts into executions. Three methods, two of them lifecycle.

Both surfaces register the same set — the document namespace and the utility namespaces — before they serve anything, so there is nothing to enable and no configuration for it. What varies is per run: the document namespace binds bbox only for a run that carried document input, which is kis rules --bbox / kis ocr on the CLI and the reserved bbox object on the POST /rule body. See Documents in rules.

type Plugin interface {
Name() string
Init(ctx context.Context) (map[string]any, error)
Prepare(ctx context.Context, data any) (map[string]any, error)
}
MethodCalledFacts live forOn error
NameWhenever the engine needs the plugin’s key
InitOnce, during RegisterPluginEvery execution from then onplugin <name> init: <err>, registration fails
PrepareOnce per Execute, and only when pluginData[Name()] is non-nilThat execution onlyplugin <name> prepare: <err>, the execution aborts before any rule runs

Prepare is skipped, not called with nil, when the caller supplies no data under the plugin’s name. A fact that a plugin publishes only from Prepare is therefore absent in that run, and a rule naming it fails with got non existent key <name> — which aborts everything.

type Result struct {
Ruleset string
RulesMatched int
Duration time.Duration
Output *Output
Findings *Findings
Audit *Audit
}
FieldTypeWhat it holds
RulesetstringThe rule set name passed to Execute, echoed back
RulesMatchedintCount of firings, not distinct rules. A rule that fires in three cycles counts three
Durationtime.DurationWall clock across the whole Execute, including fact binding and every plugin’s Prepare
Output*OutputEverything the rules wrote to out
Findings*FindingsSeverity-graded validation list
Audit*AuditOne entry per firing plus anything rules logged

Result is non-nil only on success. Any evaluation error, action error, cycle-cap trip or context cancellation returns (nil, error) and discards every value already collected.

rules.svc returns Output.All() as the response body and drops RulesMatched, Duration, Findings and Audit.

The key/value result collector, bound as out.

type Output struct { /* unexported */ }
func NewOutput() *Output
MethodBehaviour
Set(key string, val any)Stores the value and appends an OutputChange stamped with the firing rule
Append(key string, val string)Concatenates onto an existing string value using the separator; otherwise behaves as Set
SetSeparator(sep string)Separator used by later Append calls. Default ", "
SetCurrentRule(rule string)Sets the rule name stamped onto subsequent changes. The engine calls this before each firing
Get(key string) anyRaw value, nil if absent
Has(key string) boolKey presence
GetStr(key string) string"" if absent or not a string
GetInt(key string) int640 if absent or not int, int64 or float64
GetFloat(key string) float640 if absent or not float64, int or int64
GetBool(key string) boolfalse if absent or not a bool
GetMap(key string) map[string]anynil if absent or not a map[string]any
All() map[string]anyThe live map — this is the result payload
History() []OutputChangeEvery change, in order
HistoryFor(key string) []OutputChangeChanges for one key. The built-in provenance mechanism
JSON() ([]byte, error)JSON of the data map
UnwrapColumn(input map[string]interface{}, section, columnName, renameName string, maxN int64)Flattens one column of input[section]["rows"] into maxN keys named "<renameName> <n>", 1-based
UnwrapColumnWithTemplate(input map[string]interface{}, section, columnName, renameName string, maxN int64)Same, but renameName is a format string taking the index

Both unwrap methods emit {"confidence": "", "value": ..., "label": ...} per key, pad missing rows with empty values, and return silently without setting anything if input[section], its rows entry or the row shape does not match.

type OutputChange struct {
Key string
Value any
RuleID string
Timestamp time.Time
}
OutputChange fieldTypeWhat it carries
KeystringThe key that was written
ValueanyThe value as written, not the value now
RuleIDstringThe rule current when the write happened; "" for a write outside a firing
Timestamptime.Timetime.Now() at the write

Every Set appends one entry, and Append records the concatenated result, so History() is a complete write log rather than a set of distinct keys.

Input is bound as in. It reads facts and owns the per-execution scratch memory.

type Input struct { /* unexported */ }
type DataContextAccessor interface {
Get(key string) (any, error)
}
func NewInput(ctx DataContextAccessor) *Input

DataContextAccessor is the single-method seam Input reads facts through; the backend supplies the implementation.

MethodReturns
Has(key string) booltrue when the key exists and its value is non-nil
Get(key string) anyThe raw value from the data context
GetStr(key string) string"" when absent or not a string
GetInt(key string) int640 when absent or not numeric
GetFloat(key string) float640 when absent or not numeric
GetBool(key string) boolfalse when absent or not a bool
GetMap(key string) map[string]anynil when absent or not a map[string]any

Input holds named memory slots. The default slot exists from the start; Mem(name) creates any other slot on first use.

MethodBehaviour
Mem(key string) *MemoryThe named slot, created lazily
MemSet(key string, v any)Sets in the default slot
MemGet(key string) anyReads the default slot
MemGetStr(key string) stringReads the default slot as a concrete string, "" on a type mismatch
MemGetRows(key string) []map[string]anyReads the default slot as rows, nil on a type mismatch
MemDelete(key string)Deletes from the default slot
MemClear()Empties the default slot
GetMemAsMap() map[string]anyAn independent deep copy of the default slot
NewMapWithValues(kv ...any) map[string]anyBuilds a fresh map from alternating key/value pairs
Transform(key string, fn string)Applies a transform and writes the result back to the default slot

GetMemAsMap copies nested map[string]any, []map[string]any, []any and []string recursively, unwraps internal ordered-map nodes to plain maps, and preserves scalar types exactly — notably int64 versus float64, which the rule dispatcher treats as distinct. A value emitted through out.Set(k, in.GetMemAsMap()) is therefore immune to later memory writes.

NewMapWithValues returns a brand-new map on every call, so it shares no backing with memory. Non-string keys are stringified; an odd trailing argument is dropped.

Transform prefers a value already in the default slot and falls back to the data context, so chain by calling it again on the same key.

Memory is the slot type returned by Mem. It wraps an ordered map and is created only through Mem — there is no exported constructor:

type Memory struct { /* unexported */ }
MethodBehaviour
Get(key string) anyValue, nil if absent
Set(key string, v any)Stores a value
Delete(key string)Removes a key
Clear()Replaces the slot with an empty one
ToMap() map[string]anyThe slot as a map. Nested containers are shared, not copied — use GetMemAsMap for an independent snapshot
GetRows(key string) []map[string]anyValue as rows, nil on a type mismatch

The severity-graded validation list, bound as findings.

type Findings struct { /* unexported */ }
type Finding struct {
RuleID string
Severity Severity
Category string
Message string
Fields map[string]any
}
func NewFindings() *Findings
Finding fieldTypeWhat it carries
RuleIDstringThe rule that raised it. Never populated by the engine — the caller sets it
SeveritySeverityGrades the finding; drives Passed() and HasSeverity()
CategorystringFree-form grouping label
MessagestringThe human-readable text
Fieldsmap[string]anyStructured detail attached to the finding
MethodBehaviour
Add(finding Finding)Appends a finding
All() []FindingEvery finding, in order
Count() intNumber of findings
Passed() boolfalse only when at least one ERROR or CRITICAL finding exists
HasSeverity(s Severity) boolWhether any finding carries that severity

Severity is a named int with four constants declared in iota order:

type Severity int
const (
SeverityInfo Severity = iota
SeverityWarning
SeverityError
SeverityCritical
)
func (s Severity) String() string
ConstantValueString()
SeverityInfo0INFO
SeverityWarning1WARNING
SeverityError2ERROR
SeverityCritical3CRITICAL

Any value outside that range stringifies as UNKNOWN.

The append-only decision trail, bound as audit.

type Audit struct { /* unexported */ }
type AuditEntry struct {
RuleID string
Message string
Data map[string]any
Timestamp time.Time
}
func NewAudit() *Audit
AuditEntry fieldTypeWhat it carries
RuleIDstringThe firing rule’s name, or the first argument to audit.Log
Messagestringexecuted for an engine-written entry, otherwise the logged text
Datamap[string]anySet only by LogWithData; nil for a plain Log
Timestamptime.Timetime.Now() at the moment the entry was appended
MethodBehaviour
Log(ruleID, message string)Appends an entry stamped time.Now()
LogWithData(ruleID, message string, data map[string]any)Same, carrying a data map
All() []AuditEntryEvery entry, in order
Count() intNumber of entries
ForRule(ruleID string) []AuditEntryEntries for one rule, nil when there are none
JSON() ([]byte, error)JSON of the entry slice. Not callable from a rule

The engine writes one entry per firing with the message executed, so Audit.All() is a complete firing trace even when no rule logs anything, and Count() is RulesMatched plus whatever rules logged explicitly.

type RuleTracker struct { /* unexported */ }
func NewRuleTracker() *RuleTracker
func (r *RuleTracker) Done(name string)
func (r *RuleTracker) IsDone(name string) bool

Done marks a name, IsDone reports whether it was marked.

The registry behind in.Transform(key, fn). An fn string is either a registered name or a call of the form Name("arg", "arg").

fnResult
trimLeading and trailing whitespace removed
upperUppercased
lowerLowercased
normalize_spacesRuns of whitespace collapsed to single spaces
trim_currency$ and , removed, then trimmed
ExtractRegex("pattern", "defaultVal")The whole match when the pattern has no capture groups, otherwise every non-empty group concatenated with no separator. defaultVal on no match, "" when the argument is omitted
var Registry map[string]func(string) string
type ParserFunc func(args []any) (func(string) string, error)
func Register(name string, fn func(string) string)
func RegisterParser(name string, fn ParserFunc)
func Resolve(fn string) func(string) string
func ParseQuotedArg(s string) (string, string, error)
SymbolBehaviour
RegistryThe name → function map holding the five zero-argument transforms above
ParserFuncParses a call’s argument list and returns the transform to apply. The value being transformed is implicit, never an argument
RegisterAdds a zero-argument transform. Panics on an empty name or a name already registered
RegisterParserAdds a parameterised transform under a call name. Panics on an empty name
ResolveReturns the transform for an fn string, or nil when the name is unknown or the arguments are invalid
ParseQuotedArgReads the first double-quoted string from s, honouring \n, \t and \<char> escapes. Returns the value, the remainder and an error

Argument tokens parse as Go literals: quoted strings, true/false, int64, then float64.