Rules Troubleshooting
Every entry below is a symptom you can observe, what it means, a command that confirms it, and the fix. Failures in this block are quiet in several places. An empty result is far more common than an error, so read the “confirm” step before changing rules.
Symptom index
Section titled “Symptom index”| What you see | Section |
|---|---|
got N error(s) in grl the script | Rule sets that will not load |
<ns> has no method <M>, <call> takes N argument(s) | Rule sets that will not load |
<call> returns <type>, which a rule variable cannot hold | Rule sets that will not load |
<call> is deprecated; use <replacement> | Rule sets that will not load |
| An edited rule set still runs its old rules | Rule sets that will not load |
| A rule fires once and never again | Rules that never fire |
A rule gated on findings.* never fires | Rules that never fire |
| Output differs between identical runs | Rules that never fire |
got non existent key <fact> | Executions that abort |
this node identified as "<fact>" is not referencing to an object | Executions that abort |
<fact>[string-><key>] is not an array nor map | Executions that abort |
reflect: call of reflect.Value.Type on zero Value | Executions that abort |
reflect: Call using int64 as type float64 | Executions that abort |
this code part should not be reached | Executions that abort |
the GruleEngine successfully selected rule candidate ... after 5000 cycles | Executions that abort |
out.Append turned a number into text | Accessors that return nothing |
Error: 'rules' is not installed. | Command line |
no inputs resolved | Command line |
recovered : runtime error: invalid memory address or nil pointer dereference | Command line |
got non existent key bbox | Command line |
503 rules-tenant-not-loaded | The deployed service |
404 rules-scope-not-found | The deployed service |
400 rules-facts-invalid | The deployed service |
400 rules-json-request-parse-failed | The deployed service |
403 rules-access-denied | The deployed service |
422 rules-execution-failed | The deployed service |
504 rules-execution-timeout | The deployed service |
Rule sets that will not load
Section titled “Rule sets that will not load”got N error(s) in grl the script
Section titled “got N error(s) in grl the script”loading rules: /srv/rules/b.grl: got 1 error(s) in grl the scriptMeans: the rule text failed to parse. Two unrelated causes produce the identical message: a syntax error, or a rule name that is already registered in that rule set.
Confirm: the count is the only detail you get: no line, no token. Bisect by loading one file at a time:
kis rules -r ./rules/a.grl --validateFix: rule names must be unique across every .grl file loaded in one run, because a folder is
pooled into a single rule set and rules are keyed by name. Rename the collision, or fix the syntax.
A # comment (the marker is //) and a struct literal are the common syntax traps, see
struct literals.
A call that does not resolve
Section titled “A call that does not resolve”R:3: error: strings has no method Trim (did you mean Join?) then out.Set("v", strings.Trim("x"));R:4: error: in.Str takes 2 argument(s), called with 1 out.Set("w", in.Str("x"));Means: every call a rule makes is checked when the rule set loads. A method that does not exist,
or a wrong number of arguments, is an error naming the rule and the line, with a did-you-mean when
one is close. in.Transform does not exist: a rule calling it fails with
in has no method Transform.
Confirm: kis rules --validate prints the diagnostics above without running anything; add
--strict to exit non-zero on an error. kis rules check <product> checks a product’s rules/
folder the way rules.svc loads it, naming the file:
rules "invoice/**" do not validate:rules/invoice/b3.grl: rule R, line 3: strings has no method Trim (did you mean Join?)rules.svc refuses to load a folder with such an error and keeps what it served. A plain
kis rules run does not check: it runs, and the rule fails when it fires, with
got this node identified as "strings" have no function named Trim.
Fix: correct the call. For string clean-up use the string’s own methods (s.Trim(),
s.ToUpper(), s.Replace(old, new)) or the strings helpers; the transform names trim, upper,
lower, normalize_spaces and trim_currency are what bbox.PostprocessRows takes for table
rows. Argument types are not checked at load: see
reflect: Call using int64 as type float64.
A variable that cannot hold its value
Section titled “A variable that cannot hold its value”A:3: error: map.New returns map[string]interface {}, which a rule variable cannot hold; "m" will read back as a zero value and the next call using it aborts the page. Inline the call, or stage it with out.Set and read it back with out.GetMapMeans: a rule assigned a map or a slice to a local variable. Such a variable reads back as a zero value, and the next call using it aborts the execution.
Confirm: kis rules --validate or kis rules check reports it. A plain kis rules run aborts
when the rule fires, with this code part should not be reached for a map, or
reflect: Call using zero Value argument for a slice passed onward.
Fix: pass the call straight to the next one, or stage the value with out.Set and read it back
with the typed reader: out.Map("m"), or out.Value("rows").Rows() for rows. Build a map with
map.Of(k1, v1, k2, v2, …) passed directly to the call that takes it.
Deprecation warnings
Section titled “Deprecation warnings”B:7: warning: out.Append is deprecated; use out.SetMeans: the call works and the rule set loads, but the name is on its way out.
| Deprecated | Use |
|---|---|
in.GetStr, in.GetInt, in.GetFloat, in.GetBool, in.GetMap, in.Get | in.Str, in.Int, in.Double, in.Bool, in.Map, in.Value |
in.MemSet, in.MemGet, in.MemGetStr, in.MemGetRows, in.MemDelete, in.MemClear, in.GetMemAsMap | out.Set, and the out readers |
in.NewMapWithValues | map.Of |
out.GetStr, out.GetInt, out.GetFloat, out.GetBool, out.GetMap | out.Str, out.Int, out.Double, out.Bool, out.Map |
out.Append | out.Set, one key per entry (out.Set("failed.total_mismatch", "ERROR")) |
bbox.Get | bbox.Doc |
Confirm: kis rules --validate groups the warnings by kind.
An edited rule set still runs its old rules
Section titled “An edited rule set still runs its old rules”Means: the load that the edit triggered did not build, so the tenant kept serving the rules it
had. A load builds the tenant’s whole rules/ folder, every folder and static scope, or none of it.
Confirm: GET /rulesets lists what the tenant serves and, after a failed load, says why under
error, naming the file:
curl -sS https://rules.example.com/rulesets -H 'Authorization: Bearer <token>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1'Run kis rules check over the same folder to reproduce the error offline.
Fix: fix the file. The next change under rules/ loads again.
Rules that never fire
Section titled “Rules that never fire”A rule fires once and never again
Section titled “A rule fires once and never again”Means: a rule that fires is retracted for the rest of the execution, so each rule fires at most
once per run, however long its when stays true. A Retract("SelfName") at the end of a then is
harmless and unnecessary.
Confirm: --audit shows one executed entry per rule.
Fix: a rule that must fire again calls agenda.Repeat() in its then, and its then must
falsify its own when, or it runs to the cycle cap:
rule Count salience 10 { when out.Int("n", 0) < 3 then out.Set("n", out.Int("n", 0) + 1); agenda.Repeat();}{}{ "n": 3 }A rule gated on findings.* never fires
Section titled “A rule gated on findings.* never fires”Means: a rule’s when sees every earlier write to out, but raising a finding is not such a
write. When the rule that raises a finding writes nothing to out, a later
when !findings.Passed() can keep the answer it computed before the finding existed.
Confirm: --audit shows the raising rule and not the gated one.
Fix: write the verdict to out in the rule that raises the finding, out.Set("decision", "reject") beside findings.Critical(...), and gate later rules on out. Inside a then,
findings.Count() and findings.Passed() read the findings raised so far correctly.
The same rule set produces different output on different runs
Section titled “The same rule set produces different output on different runs”Means: rules of equal salience have no defined firing order. Running the same three default-salience rules twelve times produced five distinct firing orders.
Confirm: run the same input twice with --audit and compare the order of the audit entries.
Fix: give every rule whose position matters a distinct salience. Exactly one rule fires per
cycle, highest salience first, so distinct salience is the only ordering guarantee. The lower rule
fires last and its write stands:
rule High "high" salience 100 { when in.Has("x") then out.Set("tier", "high");}
rule Low "low" salience 1 { when in.Has("x") then out.Set("tier", "low");}{ "x": 1 }{ "tier": "low" }A run reports success but writes nothing
Section titled “A run reports success but writes nothing”Means: the rule output map was empty. The run skips an empty output, so --update leaves the
source file untouched, -o writes nothing, and nothing is printed except
Processed N/M input(s) successfully. There is no “0 rules fired” diagnostic at the default log
level.
Confirm: --audit prints the decision trail; an empty trail means no rule matched.
Fix: check the fact names your conditions reference. Input .json top-level keys become
top-level fact identifiers, so a rule reads docid == "d1" directly, or in.Str("docid", "").
A transform appears to do nothing
Section titled “A transform appears to do nothing”Means: bbox.PostprocessRows(rows, key, fn) returns the rows untouched when fn is a name it
does not know, and a malformed ExtractRegex pattern behaves the same way. There is no error and no
log line.
Confirm: emit the rows straight after the transform and compare them with the input.
Fix: check the name against trim, upper, lower, normalize_spaces, trim_currency and
ExtractRegex("pattern", "default"); see tables.
Executions that abort and discard everything
Section titled “Executions that abort and discard everything”One failing rule ends the whole execution. There is no per-rule isolation. kis rules prints
ERROR: <path>: <err> for that input and no result. rules.svc answers
422 rules-execution-failed, and POST /rules/execute returns what the rules had written so far
under the error’s meta.partial.
got non existent key <fact>
Section titled “got non existent key <fact>”A missing fact aborts the run from either clause, and the two wrap the message differently. From
when:
executing ruleset invoice: evaluating expression in rule 'CheckTotal' the when raised an error. got left hand expression error. got non existent key orderFrom then:
executing ruleset invoice: error while executing rule CheckTotal. got non existent key vendorMeans: a rule named a fact the caller did not supply. The rule does not have to be relevant to
the request: a bare fact name in any rule’s when is evaluated whether or not that rule could fire,
and the load-time check does not catch it.
Confirm: both forms name the rule and the fact. Compare that fact name against the keys of the
request’s input or the input file.
Fix: read optional facts through in.* with a default, which never aborts:
rule CheckTotal salience 20 { when in.Double("order.Total", 0.0) > 1000.0 then out.Set("review", true);}{ "order": { "Total": 1500.0 } }{ "review": true }A guard in the same expression works too, in.Has("order") && order["Total"] > 1000.0, because
&& short-circuits and the bare name is never evaluated when the guard is false.
this node identified as "<fact>" is not referencing to an object
Section titled “this node identified as "<fact>" is not referencing to an object”executing ruleset invoice: evaluating expression in rule 'D' the when raised an error. got left hand expression error. got this node identified as "order" is not referencing to an objectMeans: dot access was used on a map fact. Only structs and pointers to structs support dot access, and a JSON object arrives as a map.
Confirm: the message names the fact. Any order.Total form against decoded JSON hits this.
Fix: read through a dotted path, in.Double("order.Total", 0.0), or use bracket access,
order["Total"].
<fact>[string-><key>] is not an array nor map
Section titled “<fact>[string-><key>] is not an array nor map”Means: two-level indexing into decoded JSON. The intermediate value is untyped, so it cannot be indexed again.
Confirm: doc["header"] alone resolves; doc["header"]["total"] fails.
Fix: read nested values through a dotted path:
rule ReadTotal salience 20 { when in.Has("doc") then out.Set("total", in.Double("doc.header.total", 0.0));}{ "doc": { "header": { "total": 12.5 } } }{ "total": 12.5 }reflect: call of reflect.Value.Type on zero Value
Section titled “reflect: call of reflect.Value.Type on zero Value”executing ruleset invoice: error while evaluating rule Second ! recovered : reflect: call of reflect.Value.Type on zero ValueMeans: out.Get("k") == "v" compared a key no rule has written yet. out.Get answers nothing for
an unset key, and the comparison aborts.
Fix: use the typed reader with a default: out.Str("k", "") == "v", out.Double("k", 0.0),
out.Has("k").
reflect: Call using int64 as type float64
Section titled “reflect: Call using int64 as type float64”Means: an integer literal was passed to a float64 parameter: math.Min(10, 20),
in.Double("total", 0), NeedsReview(1). The load-time check counts arguments, not their types,
so this surfaces only when the rule runs. reflect: Call using float64 as type int64 is the mirror
image.
Fix: write a float literal with a decimal point, 10.0, 0.0, wherever the parameter is a
float64, and a whole number wherever it is an int64.
this code part should not be reached
Section titled “this code part should not be reached”Means: a local variable held a map, m = map.New(); m["k"] = 1;. kis rules --validate
reports it at load; see a variable that cannot hold its value.
Fix: build the map with map.Of(k1, v1, k2, v2, …) passed straight to the call.
after 5000 cycles
Section titled “after 5000 cycles”executing ruleset loop: the GruleEngine successfully selected rule candidate for execution after 5000 cycles, this could possibly caused by rule entry(s) that keep added into execution pool but when executed it does not change any data in context. Please evaluate your rule entries "When" and "Then" scope. You can adjust the maximum cycle using GruleEngine.MaxCycle variableMeans: a rule that calls agenda.Repeat() kept qualifying until the cycle cap tripped. Without
agenda.Repeat() a rule fires at most once and cannot loop.
Confirm: the rule at fault calls agenda.Repeat() and its then does not falsify its own
when. On rules.svc, meta.partial shows the writes the loop kept making.
Fix: make the repeating rule falsify its own condition, as the counter in
a rule fires once and never again does, or drop
agenda.Repeat(). Complete() ends the execution outright.
A rule that writes a finding never loads
Section titled “A rule that writes a finding never loads”loading rules: /srv/rules/checks.grl: got 3 error(s) in grl the scriptMeans: a struct literal in a rule body. The rule language has no struct-literal syntax, so
findings.Add(Finding{...}) produces a burst of parse errors. Only the count reaches you.
Confirm: delete the findings.Add(Finding{...}) line and load again. If the count drops to zero,
that was it.
Fix: write the string form: findings.Add(ruleID, severity, category, message, fields), or
the graded verbs.
Accessors that return nothing
Section titled “Accessors that return nothing”in.Str returns the default for a fact that is present
Section titled “in.Str returns the default for a fact that is present”rule Probe salience 10 { when in.Has("doctype") then out.Set("direct", doctype); out.Set("in_has", in.Has("doctype")); out.Set("in_str", in.Str("doctype", "")); out.Set("wrong_path", in.Str("doc_type", "none"));}{ "doctype": "invoice", "pages": 3 }{ "direct": "invoice", "in_has": true, "in_str": "invoice", "wrong_path": "none" }Means: you are reading a fact by a path that does not resolve. The typed readers return their default rather than failing, so an unexpected default usually means a wrong path, not a broken reader.
Confirm: check the path with in.Has(path). Nested values are dotted paths into the fact,
in.Str("invoice.currency", "").
A value staged with in.MemSet is missing from the result
Section titled “A value staged with in.MemSet is missing from the result”Means: only out is returned. in.MemSet and the other in.Mem* calls are deprecated and
never reach the result.
Fix: stage with out.Set and read back with the out readers. A later rule’s when and a later
statement in the same then both see the write.
out.Append turned a number into text
Section titled “out.Append turned a number into text”rule SetCount salience 20 { when in.Has("x") then out.Set("k", 5);}
rule AppendNote salience 10 { when in.Has("x") then out.Append("k", "x");}{ "x": 1 }{ "k": "5, x" }Means: out.Append stringifies whatever the key holds and joins with ", ". The separator is
fixed; there is no out.SetSeparator.
Fix: out.Append is deprecated. Give each entry its own key, out.Set("notes.first", "x"),
which writes a nested object, and keep numbers in keys of their own.
Command line
Section titled “Command line”Error: 'rules' is not installed.
Section titled “Error: 'rules' is not installed.”Error: 'rules' is not installed.Install with: kvm install kis-rulesMeans: kis rules and kis bbox are proxy commands; the kis binary carries only the stubs,
and the implementation is a separately installed plugin executable.
Confirm: the lookup order is the directory holding the kis binary, then ~/.kisai/bin, then
every entry of $KIS_PLUGIN_PATH, then $PATH. First executable match wins.
echo $KIS_PLUGIN_PATHls ~/.kisai/binFix: install the plugin, or point $KIS_PLUGIN_PATH at it. See the CLI reference.
no inputs resolved (no positional argument matched, and no file matched pattern ...)
Section titled “no inputs resolved (no positional argument matched, and no file matched pattern ...)”Means: kis rules found nothing to process. This is a hard error, not a no-op. Input discovery
globs -p (default *.json) in -i, not recursively; -R/--recursive affects rule-file discovery
only. Under --require-bbox, every candidate .json lacked its .bbox.
Confirm: read the warning: skipped N input(s) with no .bbox sidecar: ... line printed just
above it, which names what was dropped. Files matching *-hist-audit.json are always skipped.
ls pages/*.json pages/*.bboxFix: produce the missing sidecars with kis bbox create, or correct -i, -p or the path.
recovered : runtime error: invalid memory address or nil pointer dereference
Section titled “recovered : runtime error: invalid memory address or nil pointer dereference”executing rules: executing ruleset rules: error while executing rule B. got rule engine execute panic on rule B ! recovered : runtime error: invalid memory address or nil pointer dereferenceMeans: a rule called a document method on a document name that is not loaded. There is no
“no such document” message; bbox.Get returns nothing and the method call on it panics. In a when
the wording is error while evaluating rule B ! recovered : ….
Confirm: compare the name in the rule against the injected name:
| Command | Document name | Override |
|---|---|---|
kis rules -b <file> | bbox | write -b <name>=<file> |
kis rules --with-bbox | page | --bbox-name |
kis rules --with-bbox --mode doc | doc | --bbox-name |
Fix: align the two, and guard with bbox.Has("<name>"). bbox.Doc() with no argument resolves
the only loaded document, or the one named page.
got non existent key bbox
Section titled “got non existent key bbox”Means: the run carried no document. The bounding-box plugin is registered only when something
could load a document: -b, --with-bbox, --with-image, --image, --schema, --schemas, or
--validate; and a --with-bbox input with no .bbox beside it runs without one. Either way the
bbox name is never bound and the first rule that touches it aborts the run.
Fix: add --with-bbox (sidecar beside each input) or -b [name=]<file>, and check the sidecar
exists. --require-bbox drops an input that has none instead of running it.
*-hist-audit.json files keep appearing, and counts keep growing
Section titled “*-hist-audit.json files keep appearing, and counts keep growing”Means: --history, --audit or --history-audit <dir> writes a sidecar for every processed
input in which a rule fired. The writer reads the existing file and appends, so repeated runs
accumulate duplicates.
Confirm: run the same command twice and count the entries.
Fix: redirect them out of the data directory with --history-audit <dir>, and delete stale ones
between runs. *-hist-audit.json is always excluded from input matching, so a sidecar is never
re-processed as an input.
A numeric comparison against a --vars value never matches
Section titled “A numeric comparison against a --vars value never matches”Means: -v/--vars values are always injected as strings. -v threshold=10 gives a rule the
string "10". --vars also splits its own value on commas, so a value containing one fails with
invalid var format.
Fix: put numeric or comma-carrying facts in a --facts JSON file instead, where the JSON type
is preserved.
A batch run exited 1 but some files were written
Section titled “A batch run exited 1 but some files were written”Means: batch runs are best-effort. Every input is attempted, each failure prints
ERROR: <path>: <err>, and the command exits 1 after reporting Processed N/M. With -o or
--update the successful results are already written.
Confirm: the ERROR: lines go to stderr and the run finishes with
Processed N/M input(s) successfully on stdout. Redirect both streams.
The deployed service
Section titled “The deployed service”Every error answers with a status and a code, in the body
({"error":{"code","message","meta"}}) and in the X-Kis-Error-Code header. Match on the code, not
the message. The full table is on the error reference.
503 rules-tenant-not-loaded
Section titled “503 rules-tenant-not-loaded”Means: the service’s configuration names the calling tenant, but no load of its rules/ folder
has succeeded yet: the first load is still running, or it failed. The response carries
Retry-After: 5.
Confirm: GET /rulesets answers "loaded": false, and, when the load failed, says why under
error, naming the file. One tenant’s broken folder does not take the service out of rotation for
the others.
Fix: retry after the interval. If error names a file, fix it; the next change under rules/
loads again.
400 rules-tenant-not-served
Section titled “400 rules-tenant-not-served”Means: the request names no tenant (the request names no tenant), or names one the service’s
configuration does not (tenant <key> is not served by rules).
Fix: send the tenancy headers, and add the tenant to the service’s configuration; see configuration.
404 rules-scope-not-found
Section titled “404 rules-scope-not-found”the product has no scope invoice: no static scope of rules.yaml and no folder rules/invoice/Means: the calling tenant’s product has no static scope of that name in rules/rules.yaml and
no folder of that name under rules/. POST /rule, the earlier call, words it
ruleset invoice not found: the product has no rules/invoice/ folder of rules.
Confirm: GET /rulesets lists the folders and static scopes the tenant serves.
Fix: correct the scope, or add the folder.
400 rules-facts-invalid
Section titled “400 rules-facts-invalid”Means: the facts were refused before any rule ran. The message says which case:
| Message | Cause |
|---|---|
out: the engine binds these names itself (act, address, agenda, …); send the facts under other names | A fact named like a helper or a collector. Bound as a fact it would replace the helper, or lose its value to the collector |
the facts do not meet scope <name>'s contract: … | A static scope declares facts and the request does not meet them |
<names>: scope <name> reads these itself as knowledge; send the facts under other names | The static scope fills those names from its own sources |
at most 20 facts | Too many named facts entries |
Fix: rename the facts, or send what the scope’s contract asks for; see static scopes.
400 rules-json-request-parse-failed
Section titled “400 rules-json-request-parse-failed”the body is not an execute request: json: unknown field "audit"Means: the body did not decode as the call’s request. POST /rules/execute refuses a key it does
not read, so a misplaced option is an error rather than silently ignored: audit and history
belong under options.
Fix: send {"scope", "input", "facts", "options": {"audit", "history"}}. A body over
rules.body.maxbytes (1 MiB by default) answers 413 rules-body-too-large instead.
403 rules-access-denied
Section titled “403 rules-access-denied”Means: the tenant’s access rule for the action refused the caller, or could not be evaluated.
By default executing a scope asks can("rules.scopes", "read") of the caller; a product refines the
rules in rules/extend/access.yaml.
Fix: grant the privilege, or check the product’s access rules; see access.
422 rules-execution-failed
Section titled “422 rules-execution-failed”Means: a rule failed while the rules ran: a missing fact, dot access on a map, an argument of the
wrong type, the cycle cap. The message is the engine’s, the same text kis rules prints, and
executions that abort walks each one. On
POST /rules/execute, meta.partial holds what the rules had written before the failure.
Confirm: run the same input through kis rules against a local copy of the folder:
kis rules -r <product> --scope <name> loads it as rules.svc does and holds each input to the
scope’s facts contract.
504 rules-execution-timeout
Section titled “504 rules-execution-timeout”Means: an execution outlived its bound: rules.exec.timeout (10s by default), or a static
scope’s limits.timeout, which rules.exec.maxtimeout (30s by default) caps. kis rules bounds an
execution at five minutes.
Fix: find the rule that runs long, usually one that repeats with agenda.Repeat(), or raise the
scope’s limits.timeout within the ceiling; see
configuration.
The response has no audit trail
Section titled “The response has no audit trail”Means: the request did not ask for one. The trail is opt-in.
Fix: set "options": {"audit": true} on POST /rules/execute; add "history": true for every
write with the rule that made it. POST /rule, the earlier call, answers out alone.
A 404 with an empty body
Section titled “A 404 with an empty body”Means: the path is not one of the service’s routes. Unknown paths answer 404 with no body at
all, not the JSON envelope. The decision call is POST /rules/execute.
Continue with
Section titled “Continue with”- Running the service: boot sequence, probes, the
rules/folder - Configuration: every key and its default
- Error reference: the complete code and message table
- Rule language: what compiles and what does not
- Patterns: rule shapes that avoid the traps above