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
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.GetStr("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");
Changed("out");
Retract("DeriveDebtRatio");
}
rule BandLow "Ratio at or below 0.30" salience 95 {
when
out.GetStr("stage") == "scored" && out.GetFloat("debt_to_income") <= 0.30
then
out.Set("dti_band", "low");
out.Set("stage", "banded");
Changed("out");
Retract("BandLow");
}
rule BandMedium "Ratio above 0.30 and at or below 0.45" salience 94 {
when
out.GetStr("stage") == "scored" && out.GetFloat("debt_to_income") > 0.30 && out.GetFloat("debt_to_income") <= 0.45
then
out.Set("dti_band", "medium");
out.Set("stage", "banded");
Changed("out");
Retract("BandMedium");
}
rule BandHigh "Ratio above 0.45" salience 93 {
when
out.GetStr("stage") == "scored" && out.GetFloat("debt_to_income") > 0.45
then
out.Set("dti_band", "high");
out.Set("stage", "banded");
Changed("out");
Retract("BandHigh");
}
rule FlagThinFile "A marginal score is recorded, not rejected" salience 90 {
when
out.GetStr("stage") == "banded" && credit_score < 620.0
then
out.Append("warnings", "credit score below 620");
audit.LogWithData("FlagThinFile", "credit score below 620",
in.NewMapWithValues("category", "credit", "severity", "WARNING"));
Retract("FlagThinFile");
}
rule RouteAutoApprove "A low ratio with a strong score clears without an underwriter" salience 85 {
when
out.GetStr("stage") == "banded" && out.GetStr("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");
Changed("out");
Retract("RouteAutoApprove");
}
rule RouteManualReview "Everything else that reached a band goes to an underwriter" salience 84 {
when
out.GetStr("stage") == "banded"
then
out.Set("decision", "manual_review");
out.Set("stage", "routed");
audit.Log("RouteManualReview", "auto-approve conditions not met");
Changed("out");
Retract("RouteManualReview");
}
rule CloseOut "Nothing is runnable after this" salience 10 {
when
out.GetStr("stage") == "routed"
then
out.Set("stage", "complete");
Changed("out");
Retract("CloseOut");
}

Five things in that file carry the pipeline.

ElementWhy it matters
out.Set("stage", …)The chaining channel. out is also the result, so the state needs no separate fact
Changed("out")Tells the engine the collector moved. Without it the next stage never sees the write
out.GetStr("stage") == "<previous>" first in every whenSequences the stages and guards the values that stage produces
A distinct salience per ruleThe only ordering control
Retract("SelfName") in every then except the terminal oneTakes the rule out of the pool; Complete() replaces it in RejectIneligible

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/ --history-audit ./audit/
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": "credit score below 620"
}
Processed 3/3 file(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.

--history-audit ./audit/ is not optional hygiene. A <basename>-hist-audit.json sidecar is written next to every input on every run in which a rule fires, whether or not you asked for it, and the default *.json input pattern picks it up as an input on the next run. Send it elsewhere.

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 = "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": "credit score below 620"
}
Processed 1/1 file(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.

Sub-expression results are memoised in a working memory that is invalidated only when the engine observes a change to the data context. A method call on a collector is not observed — so out.Set("stage", "scored") in one rule is invisible to the next rule’s when, which keeps the answer it computed in the first cycle.

Changed("out") in the writing rule clears every cached expression whose text mentions out, which is what makes the chain move. Drop it from DeriveDebtRatio and run the same input:

Result for /path/to/pipeline/inbox/APP-4417.json:
{
"application_id": "APP-4417",
"debt_to_income": 0.25,
"stage": "scored"
}
Processed 1/1 file(s) successfully

Changed and Forget are one function under two names. Both clear every cached expression whose text contains the argument, so the argument is the only difference:

CallEffect
Changed("out")Clears every cached expression containing out. Use this
Forget("out.GetStr(\"stage\")")Clears only expressions whose text contains that snippet

Swapping Forget("out.GetStr(\"stage\")") for Changed("out") in DeriveDebtRatio runs this pipeline to exactly the same result. That is not a licence to use it. It works only because every stage condition leads with the stage read, so && short-circuits and the second collector call in each condition is never evaluated — and therefore never cached — before the reset. Reverse the terms in one rule:

when
out.GetFloat("debt_to_income") > 0.45 && out.GetStr("stage") == "scored"

Now cycle 1 evaluates out.GetFloat("debt_to_income") and caches 0. The snippet reset does not match that expression, and the cache is keyed on expression text and shared across every rule that reads it — so BandLow sees the same stale 0, and APP-4501 comes back banded "low" on a ratio of 0.48. No error and no warning; only the wrong band. Changed("out") on the identical rule set still bands it "high".

Use Changed("out"). The narrow form buys nothing here and fails silently.

There is a second chaining idiom: index assignment on a caller-supplied map fact (state["stage"] = "scored") is a tracked data-context change and re-triggers evaluation with no explicit signal. It is the shorter form, and rule patterns covers it.

It runs the same way on both surfaces. A nested JSON object on the POST /rule body arrives as a map fact under its own top-level key, exactly as -f state=state.json supplies one on the CLI, and a rule reaches into it with the same bracket access — state["stage"], never state.stage, which aborts the execution. What chaining through out gains instead is 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.

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
Retract("SelfName")Skips that rule for the rest of this execution; reset next executionevery rule except RejectIneligible
A state write that falsifies the whenRemoves the rule from the runnable set without retracting itthe losing branch in each pair
Complete()Ends the whole execution immediately, however high the remaining salienceRejectIneligible
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. A rule that calls Complete() does not also need Retract.

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 retracted 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 working form is the one in the rule set above — the audit trail for the trace, out for the verdict the caller reads:

rule FlagThinFile "A marginal score is recorded, not rejected" salience 90 {
when
out.GetStr("stage") == "banded" && credit_score < 620.0
then
out.Append("warnings", "credit score below 620");
audit.LogWithData("FlagThinFile", "credit score below 620",
in.NewMapWithValues("category", "credit", "severity", "WARNING"));
Retract("FlagThinFile");
}

out.Append joins repeated warnings with ", ", so a second annotation stage adds to the same key. Keep that key string-valued from its first write — if it ever holds a number or a map, the next Append replaces the accumulated value instead of extending it. in.NewMapWithValues is how a rule builds the structured payload for audit.LogWithData; the language has no map literal.

The response body over HTTP is the output collector, so 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 request setting "audit": true gets that trace back under audit — read it, do not branch on it.

Add a formatting stage between banding and routing, and forget to retract it:

rule AddPercent "Also express the ratio as a percentage" salience 92 {
when
out.GetStr("stage") == "banded"
then
out.Set("debt_to_income_pct", out.GetFloat("debt_to_income") * 100.0);
Changed("out");
}
Terminal window
kis rules -r rules/ -i inbox/ -p "APP-4417.json" --history-audit ./audit/
ERROR: /path/to/pipeline/inbox/APP-4417.json: executing rules on /path/to/pipeline/inbox/APP-4417.json: 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 file(s) successfully
1 file(s) failed to process

Not a hang — a hard failure at cycle 5001, exit status 1, and no result at all. Every out.Set the earlier stages performed is discarded. Over HTTP the same run comes back as 400 carrying the same message and an empty result.

The rule does not falsify its own condition: stage is still banded after it fires, and its own Changed("out") re-invalidates the cache every cycle, so it re-qualifies forever. Chaining makes this easier to hit than a plain rule set does, because Changed("out") is exactly the call that keeps a stale condition fresh.

FailureFix
A stage rule that does not advance stageRetract("SelfName") in its then
A stage rule that advances stageAdvance it and retract; either alone leaves an edge case
A condition that looks self-limitingIt is not. when !out.Has("x") then out.Set("x", 1); burns all 5000 cycles, because the condition is cached and never re-evaluated as false

The cap is fixed at 5000 cycles and neither kis rules nor rules.svc exposes a setting for it. A runaway set is fixed in the rules. The counter also advances only when a rule actually fires, so it is not a timeout — the only wall-clock bound is the caller’s context.

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.GetStr("stage") == "banded"
then
out.Set("decision", "auto_approve_joint");
out.Set("stage", "routed");
Changed("out");
Retract("RouteCoApplicant");
}
Terminal window
kis rules -r rules/ -i inbox/ -p "APP-4417.json" --history-audit ./audit/
ERROR: /path/to/pipeline/inbox/APP-4417.json: executing rules on /path/to/pipeline/inbox/APP-4417.json: 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 file(s) successfully
1 file(s) failed to process

APP-4417 has no co_applicant_score. The new rule is irrelevant to it — its second term would have been false in cycle 1 anyway — but abort-on-failed-evaluation is unconditional and every non-retracted rule is evaluated every cycle, so the reference to a fact the caller did not supply kills the execution before any rule fires. There is no per-rule isolation and no skip-on-error mode.

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.

Guard the fact and put the guard first:

when
out.GetStr("stage") == "banded" && in.Has("co_applicant_score") && co_applicant_score >= 700.0

&& short-circuits, so the bare fact name is never evaluated when the fact is absent. in.Has is the only in accessor that reads a fact correctly — the typed getters beside it return zero values. Order the terms cheapest-and-safest first: the stage check is always evaluable, the presence check is always evaluable, the value read is not.

Over HTTP this is a 400 with the same message. Every fact a rule set touches must be on the request body of every call to it.

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

Publish the rule set under the product’s rules metadata. Each entry’s name becomes a rule-set name and rule carries the GRL source — paste the whole of rules/triage.grl under it as a block scalar:

rules:
- name: application-triage
rule: |
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();
}
// the remaining eight rules follow here, unchanged

Only name and rule are read; anything else in the document is ignored. A rule set that fails to compile is logged and skipped rather than fatal, so a typo removes one rule set and leaves the service running.

Post the facts. The entire body becomes the fact map, so the facts are the same four top-level keys the input file carried, plus the selector:

Terminal window
curl -sS -X POST https://rules.example.com/rule -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 '{"name":"application-triage","application_id":"APP-4501","annual_income":60000.0,"monthly_debt":2400.0,"credit_score":604}'
{
"application_id": "APP-4501",
"debt_to_income": 0.48,
"decision": "manual_review",
"dti_band": "high",
"stage": "complete",
"warnings": "credit score below 620"
}

The response body is the output collector and nothing else — the same map the CLI printed, minus the per-file framing.

kis rulesPOST /rule
Rule-set selectionFixed. --ruleset is accepted and never readThe body’s name field, required
FactsTop-level keys of each input file, plus -f and -vEvery top-level key of the request body
Nested objects as factsReadable with bracket accessReadable with bracket access
Chaining through out + ChangedWorksWorks
Chaining through a map factWorksWorks
Audit trail--audit, plus the sidecarReturned under audit when the body sets "audit": true — the same entries --audit prints
FindingsNot surfaced — no rule can record oneNot surfaced
Cycle-cap and abort failuresERROR: line, exit 1, no result400, same message, no result

Delivery, refresh timing, tenancy headers and the readiness caveat are on running rules.svc; the exact error envelopes are in errors.

You want toPage
The full chaining vocabulary, including the map-fact idiomRule patterns
Salience, retraction and the cycle in detailWriting rules
Every call available in a then blockFunctions
The collectors and the execution resultEngine API
The findings model, and why no rule can write to itFindings API
Every flag on kis rulesThe CLI
Another end-to-end scenarioExamples