Skip to content
Talk to our solutions team

Multi-step rule pipelines

A pipeline is a rule set where one rule’s output is another rule’s input: an early rule derives a value, later rules branch on what it derived, and the set stops when there is nothing left to decide. This example builds one for loan-application triage, runs it over three applications on the kis CLI and over rules.svc, then breaks it in the two ways a pipeline breaks.

Nothing here is hidden. Three input files, one .grl file, one command.

StageSalience bandWhat it does
Terminal guard110Rejects outright and ends the run before anything else looks at the application
Derivation100Turns the raw figures into a debt-to-income ratio
Classification95–93Puts the ratio into one of three bands
Annotation90Records a concern without changing the routing
Routing85–84Picks the decision, with the catch-all lowest
Close-out10Stamps the terminal state; after it, nothing is runnable

Each stage writes a stage key that the next stage’s when reads. That key is both the sequencer and the guard: a rule that has not reached its stage never evaluates the values that stage produces.

pipeline/
rules/
triage.grl # the rule set
inbox/
APP-4417.json # low ratio, strong score
APP-4418.json # below the score floor
APP-4501.json # high ratio, marginal score
audit/ # created by --history-audit

Every top-level key of an input file becomes its own fact under its own name. There is no wrapper object and no schema.

{
"application_id": "APP-4417",
"annual_income": 84000.0,
"monthly_debt": 1750.0,
"credit_score": 712
}
{
"application_id": "APP-4418",
"annual_income": 52000.0,
"monthly_debt": 1900.0,
"credit_score": 545
}
{
"application_id": "APP-4501",
"annual_income": 60000.0,
"monthly_debt": 2400.0,
"credit_score": 604
}

All three carry the same four facts. Nothing carries pipeline state. The rules build that themselves.

Salience is the only ordering control the language has. Give every rule a distinct value.

RuleSalienceReadsWrites
RejectIneligible110credit_scoredecision, reason, stage
DeriveDebtRatio100annual_income, monthly_debt, stagedebt_to_income, stage
BandLow95stage, debt_to_incomedti_band, stage
BandMedium94stage, debt_to_incomedti_band, stage
BandHigh93stage, debt_to_incomedti_band, stage
FlagThinFile90stage, credit_scorewarnings.thin_file
RouteAutoApprove85stage, dti_band, credit_scoredecision, stage
RouteManualReview84stagedecision, stage
CloseOut10stagestage

Leave gaps between stages, the stage bands above start ten apart and then five, so a rule can be inserted between two stages without renumbering the set. Rules competing inside one stage sit one apart, which is all that ordering them against each other requires.

rules/triage.grl
rule RejectIneligible "A score below the floor ends the run immediately" salience 110 {
when
credit_score < 580.0
then
out.Set("application_id", application_id);
out.Set("decision", "reject");
out.Set("reason", "credit score below 580");
out.Set("stage", "terminated");
audit.Log("RejectIneligible", "terminal reject; no later rule runs");
Complete();
}
rule DeriveDebtRatio "Derive the ratio the later rules branch on" salience 100 {
when
out.Str("stage", "") == "" && annual_income > 0.0
then
ratio = (monthly_debt * 12.0) / annual_income;
out.Set("application_id", application_id);
out.Set("debt_to_income", ratio);
out.Set("stage", "scored");
audit.Log("DeriveDebtRatio", "ratio derived from annual income and monthly debt");
}
rule BandLow "Ratio at or below 0.30" salience 95 {
when
out.Str("stage", "") == "scored" && out.Double("debt_to_income", 0.0) <= 0.30
then
out.Set("dti_band", "low");
out.Set("stage", "banded");
}
rule BandMedium "Ratio above 0.30 and at or below 0.45" salience 94 {
when
out.Str("stage", "") == "scored" && out.Double("debt_to_income", 0.0) > 0.30 && out.Double("debt_to_income", 0.0) <= 0.45
then
out.Set("dti_band", "medium");
out.Set("stage", "banded");
}
rule BandHigh "Ratio above 0.45" salience 93 {
when
out.Str("stage", "") == "scored" && out.Double("debt_to_income", 0.0) > 0.45
then
out.Set("dti_band", "high");
out.Set("stage", "banded");
}
rule FlagThinFile "A marginal score is recorded, not rejected" salience 90 {
when
out.Str("stage", "") == "banded" && credit_score < 620.0
then
out.Set("warnings.thin_file", "credit score below 620");
audit.LogWithData("FlagThinFile", "credit score below 620",
map.Of("category", "credit", "severity", "WARNING"));
}
rule RouteAutoApprove "A low ratio with a strong score clears without an underwriter" salience 85 {
when
out.Str("stage", "") == "banded" && out.Str("dti_band", "") == "low" && credit_score >= 700.0
then
out.Set("decision", "auto_approve");
out.Set("stage", "routed");
audit.Log("RouteAutoApprove", "band low, score at or above 700");
}
rule RouteManualReview "Everything else that reached a band goes to an underwriter" salience 84 {
when
out.Str("stage", "") == "banded"
then
out.Set("decision", "manual_review");
out.Set("stage", "routed");
audit.Log("RouteManualReview", "auto-approve conditions not met");
}
rule CloseOut "Nothing is runnable after this" salience 10 {
when
out.Str("stage", "") == "routed"
then
out.Set("stage", "complete");
}

On APP-4501 it writes:

facts
{ "application_id": "APP-4501", "annual_income": 60000.0, "monthly_debt": 2400.0, "credit_score": 604 }
out
{
"application_id": "APP-4501",
"debt_to_income": 0.48,
"decision": "manual_review",
"dti_band": "high",
"stage": "complete",
"warnings": { "thin_file": "credit score below 620" }
}

Four things in that file carry the pipeline.

ElementWhy it matters
out.Set("stage", …)The chaining channel. A later rule’s when sees what an earlier rule wrote to out, and out is also the result, so the state needs no separate fact
out.Str("stage", "") == "<previous>" first in every whenSequences the stages and guards the values that stage produces. The typed reader returns its default for a key not yet written
A distinct salience per ruleThe only ordering control
Complete() in RejectIneligibleEnds the execution outright; nothing after it runs

Each rule fires at most once per execution: a rule that fires is retracted for the rest of the run, so no stage needs a Retract of its own.

RouteManualReview is the catch-all. It qualifies in the same cycle as RouteAutoApprove and loses on salience, but salience does not suppress it, so it would fire on the next cycle. What stops it is RouteAutoApprove advancing stage to routed, which falsifies the catch-all’s own condition. Order the branches with salience; falsify them with state.

Terminal window
kis rules -r rules/ -i inbox/
Result for /path/to/pipeline/inbox/APP-4417.json:
{
"application_id": "APP-4417",
"debt_to_income": 0.25,
"decision": "auto_approve",
"dti_band": "low",
"stage": "complete"
}
Result for /path/to/pipeline/inbox/APP-4418.json:
{
"application_id": "APP-4418",
"decision": "reject",
"reason": "credit score below 580",
"stage": "terminated"
}
Result for /path/to/pipeline/inbox/APP-4501.json:
{
"application_id": "APP-4501",
"debt_to_income": 0.48,
"decision": "manual_review",
"dti_band": "high",
"stage": "complete",
"warnings": {
"thin_file": "credit score below 620"
}
}
Processed 3/3 input(s) successfully

Three applications, three paths through the same nine rules. BandMedium never fires on this input set; its condition is the gap between the other two bands.

Terminal window
kis rules -r rules/ -i inbox/ -p "APP-4501.json" --history --audit --history-audit ./audit/
--- History for /path/to/pipeline/inbox/APP-4501.json ---
1. [DeriveDebtRatio] application_id = "APP-4501"
2. [DeriveDebtRatio] debt_to_income = 0.48
3. [DeriveDebtRatio] stage = "scored"
4. [BandHigh] dti_band = "high"
5. [BandHigh] stage = "banded"
6. [FlagThinFile] warnings.thin_file = "credit score below 620"
7. [RouteManualReview] decision = "manual_review"
8. [RouteManualReview] stage = "routed"
9. [CloseOut] stage = "complete"
--- Audit for /path/to/pipeline/inbox/APP-4501.json ---
1. [DeriveDebtRatio] executed
2. [DeriveDebtRatio] ratio derived from annual income and monthly debt
3. [BandHigh] executed
4. [FlagThinFile] executed
5. [FlagThinFile] credit score below 620 {"category":"credit","severity":"WARNING"}
6. [RouteManualReview] executed
7. [RouteManualReview] auto-approve conditions not met
8. [CloseOut] executed
Result for /path/to/pipeline/inbox/APP-4501.json:
{
"application_id": "APP-4501",
"debt_to_income": 0.48,
"decision": "manual_review",
"dti_band": "high",
"stage": "complete",
"warnings": {
"thin_file": "credit score below 620"
}
}
Processed 1/1 input(s) successfully

The history is the pipeline read back: every value is attributed to the rule that wrote it, in firing order. Five rules fired across five cycles, one per cycle, the executed audit lines are added by the engine before each then runs; the rest are the audit.Log and audit.LogWithData calls in the rule bodies. --history-audit ./audit/ also records both sections in audit/APP-4501-hist-audit.json, appending on every run; without it, --history and --audit write the sidecar beside the input.

Every write to out is visible to every rule evaluated after it. DeriveDebtRatio sets stage to scored, and on the next cycle the band rules’ conditions are evaluated against that value. Nothing in the writing rule has to announce the change.

Two details decide whether a condition reads what you expect:

Write the read asBecause
out.Str("stage", ""), out.Double("debt_to_income", 0.0)A typed reader with a default answers for a key no rule has written yet. out.Get("stage") == "scored" on an unset key aborts the whole execution with reflect: call of reflect.Value.Type on zero Value
0.0, not 0, as a float defaultAn integer literal passed to a float64 parameter aborts the run with reflect: Call using int64 as type float64. The load-time check does not catch it

Chaining through out buys visibility: the stage state is part of the result the caller reads back, and every write is attributed to the rule that made it under --history, or under history when a rules.svc request sets "options": {"history": true}.

The cost is that the scratch key stage ends up in the result. That is usually worth having: out is the channel the caller reads on both surfaces, printed by the CLI, returned by the service.

MechanismEffectUsed here by
FiringA rule that fires is retracted for the rest of the execution, automaticallyevery rule
A state write that falsifies the whenRemoves a rule that has not fired yet from the runnable setthe losing branch in each pair
Complete()Ends the whole execution immediately, however high the remaining salienceRejectIneligible
Retract("Other")Takes another rule out of the pool for the rest of the executionnot needed here
No rule qualifiesNormal exit; the result is returnedafter CloseOut

Complete() is a hard stop for the execution, not for the cycle. RejectIneligible fires in cycle 1 for APP-4418 and nothing else runs, DeriveDebtRatio qualified in that same cycle and lost on salience, and never got another chance.

After CloseOut sets stage to complete, no rule’s condition is true and the engine stops on its own. That is the normal end of a pipeline: every stage fired or falsified, nothing runnable, the result returned.

Recording a validation result mid-pipeline

Section titled “Recording a validation result mid-pipeline”

FlagThinFile is an annotation stage. It records a concern and lets the routing continue.

The form in the rule set above writes the concern where every caller reads it, out, and the trace behind it to the audit trail:

rule FlagThinFile "A marginal score is recorded, not rejected" salience 90 {
when
out.Str("stage", "") == "banded" && credit_score < 620.0
then
out.Set("warnings.thin_file", "credit score below 620");
audit.LogWithData("FlagThinFile", "credit score below 620",
map.Of("category", "credit", "severity", "WARNING"));
}

A dotted key writes into a nested object, so a second annotation stage adds its own entry beside this one, out.Set("warnings.high_ratio", …), without reading or rewriting the first. map.Of is how a rule builds the structured payload for audit.LogWithData; the language has no map literal. To raise a graded finding as well, add findings.Warn("FlagThinFile", "credit", "credit score below 620"); to the same then.

Anything the caller branches on has to be in out. The warnings key is. The audit.LogWithData entry is the trace behind it, and a rules.svc request setting "options": {"audit": true} gets that trace back under audit. Read it, do not branch on it.

A rule fires once per execution unless its then calls agenda.Repeat(), which puts it back in the pool. Add a formatting stage between banding and routing that repeats:

rule AddPercent "Also express the ratio as a percentage" salience 92 {
when
out.Str("stage", "") == "banded"
then
out.Set("debt_to_income_pct", out.Double("debt_to_income", 0.0) * 100.0);
agenda.Repeat();
}
Terminal window
kis rules -r rules/ -i inbox/ -p "APP-4417.json"
ERROR: /path/to/pipeline/inbox/APP-4417.json: executing rules: executing ruleset rules: 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 variable
Processed 0/1 input(s) successfully
1 input(s) failed

Not a hang, a hard failure at the cycle cap and exit status 1. The rule does not falsify its own condition: stage is still banded after it fires, and agenda.Repeat() puts it straight back, so it re-qualifies until the cap. The CLI prints no result for that input. rules.svc answers 422 rules-execution-failed and keeps what was written: the error’s meta.partial holds the output as the cap found it.

Delete the agenda.Repeat(); line and the same stage fires once, writes debt_to_income_pct: 25, and the pipeline completes.

Rule shapeBehaviour
No agenda.Repeat()Fires at most once, whatever its when
agenda.Repeat() and a then that falsifies its own whenRepeats until the condition goes false, then stops
agenda.Repeat() and a when that stays trueRuns to the cycle cap and fails the execution

The cap is 5000 cycles under kis rules. On rules.svc it is rules.exec.maxcycles (default 5000), and a static scope may set a lower limits.maxcycles; see configuration. The counter advances only when a rule fires, so it is not a timeout; the wall-clock bound on the service is rules.exec.timeout (10s by default), which answers 504 rules-execution-timeout.

Add a stage that routes on a co-applicant, where only some applications have one:

rule RouteCoApplicant "Route on the co-applicant's score when there is one" salience 86 {
when
co_applicant_score >= 700.0 && out.Str("stage", "") == "banded"
then
out.Set("decision", "auto_approve_joint");
out.Set("stage", "routed");
}
Terminal window
kis rules -r rules/ -i inbox/ -p "APP-4417.json"
ERROR: /path/to/pipeline/inbox/APP-4417.json: executing rules: executing ruleset rules: evaluating expression in rule 'RouteCoApplicant' the when raised an error. got left hand expression error. got left hand expression error. got non existent key co_applicant_score
Processed 0/1 input(s) successfully
1 input(s) failed

APP-4417 has no co_applicant_score. The new rule is irrelevant to it, and its second term would have been false in cycle 1 anyway, but a bare fact name the caller did not send aborts the execution from any rule’s when, before any rule fires. There is no per-rule isolation and no skip-on-error mode, and the load-time check does not catch it: a fact the caller may or may not send is not an error until a run lacks it.

In a pipeline the damage is worse than in a flat set: a stage added at the end can discard everything the earlier stages produced, on inputs that never reach it. No sidecar is written for a failed input either, so the audit trail that would show how far the run got does not exist.

Read optional facts through in.* with a default, which never aborts:

rule RouteCoApplicant "Route on the co-applicant's score when there is one" salience 86 {
when
out.Str("stage", "") == "banded" && in.Double("co_applicant_score", 0.0) >= 700.0
then
out.Set("decision", "auto_approve_joint");
out.Set("stage", "routed");
}

A missing score reads as 0.0, the condition is false, and APP-4417 routes exactly as before. in.Has("co_applicant_score") && co_applicant_score >= 700.0 works too, because && short-circuits and the bare name is never evaluated when the fact is absent.

On rules.svc the unguarded rule answers 422 rules-execution-failed with the same message. Every fact a rule names bare must be in the input of every call to it.

The rule text is identical. What changes is delivery and selection.

Put rules/triage.grl, unchanged, in the product’s rules/application-triage/ folder; the folder’s name is the scope a call runs:

rules/
application-triage/
triage.grl

rules.svc loads the folder when the tenant’s product changes, checks every call the rules make, and refuses a load that does not build, naming the file, the rule and the line; the tenant keeps serving what it had, and GET /rulesets says why under error. Check the folder offline the same way with kis rules check.

Ask for the decision. input holds the same four facts the input file carried:

Terminal window
curl -sS -X POST https://rules.example.com/rules/execute -H 'Content-Type: application/json' -H 'Authorization: Bearer <token>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d '{"scope":"application-triage","input":{"application_id":"APP-4501","annual_income":60000.0,"monthly_debt":2400.0,"credit_score":604}}'
{
"scope": "application-triage",
"stamp": "sha256:…",
"principal": { "id": "u-…", "ptyp": "user" },
"out": {
"application_id": "APP-4501",
"debt_to_income": 0.48,
"decision": "manual_review",
"dti_band": "high",
"stage": "complete",
"warnings": { "thin_file": "credit score below 620" }
},
"findings": [],
"actions": []
}

out is the same map the CLI printed, minus the per-file framing. stamp names the exact rule text that decided.

kis rulesrules.svc
Which rules-r names files or foldersscope names the product’s folder, or a static scope of its rules/rules.yaml
FactsTop-level keys of each input file, plus -f and -vinput’s top-level keys, plus named facts
Nested objects as factsReadable with bracket access or in.* dotted pathsThe same
Chaining through outWorksWorks
Audit and history--audit, --history, the sidecaroptions.audit, options.history
FindingsRaised with findings.*; not printedReturned as findings
Cycle cap5000rules.exec.maxcycles, or a static scope’s limits.maxcycles
Cycle-cap and abort failuresERROR: line, exit 1, no result422 rules-execution-failed, partial output under meta.partial

Delivery, tenancy headers and static scopes are on running rules.svc; the exact error codes are in errors.

You want toPage
The full chaining vocabularyRule patterns
Salience, retraction and the cycle in detailWriting rules
Every call available in a then blockFunctions
The collectors and the execution resultRule API reference
The findings model and how a rule raises oneFindings API
Every flag on kis rulesThe CLI
Another end-to-end scenarioExamples