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.
The stages
Section titled “The stages”| Stage | Salience band | What it does |
|---|---|---|
| Terminal guard | 110 | Rejects outright and ends the run before anything else looks at the application |
| Derivation | 100 | Turns the raw figures into a debt-to-income ratio |
| Classification | 95–93 | Puts the ratio into one of three bands |
| Annotation | 90 | Records a concern without changing the routing |
| Routing | 85–84 | Picks the decision, with the catch-all lowest |
| Close-out | 10 | Stamps 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.
Lay out the project
Section titled “Lay out the project”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-auditEvery 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.
Assign the salience
Section titled “Assign the salience”Salience is the only ordering control the language has. Give every rule a distinct value.
| Rule | Salience | Reads | Writes |
|---|---|---|---|
RejectIneligible | 110 | credit_score | decision, reason, stage |
DeriveDebtRatio | 100 | annual_income, monthly_debt, stage | debt_to_income, stage |
BandLow | 95 | stage, debt_to_income | dti_band, stage |
BandMedium | 94 | stage, debt_to_income | dti_band, stage |
BandHigh | 93 | stage, debt_to_income | dti_band, stage |
FlagThinFile | 90 | stage, credit_score | warnings |
RouteAutoApprove | 85 | stage, dti_band, credit_score | decision, stage |
RouteManualReview | 84 | stage | decision, stage |
CloseOut | 10 | stage | stage |
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.
The rule set
Section titled “The rule set”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.
| Element | Why 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 when | Sequences the stages and guards the values that stage produces |
A distinct salience per rule | The only ordering control |
Retract("SelfName") in every then except the terminal one | Takes 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.
Run it
Section titled “Run it”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) successfullyThree 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.
Watch the chain fire
Section titled “Watch the chain fire”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) successfullyThe 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.
How a later rule sees an earlier write
Section titled “How a later rule sees an earlier write”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) successfullyChanged 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:
| Call | Effect |
|---|---|
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.
Why the state lives in out
Section titled “Why the state lives in out”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.
Terminating cleanly
Section titled “Terminating cleanly”| Mechanism | Effect | Used here by |
|---|---|---|
Retract("SelfName") | Skips that rule for the rest of this execution; reset next execution | every rule except RejectIneligible |
A state write that falsifies the when | Removes the rule from the runnable set without retracting it | the losing branch in each pair |
Complete() | Ends the whole execution immediately, however high the remaining salience | RejectIneligible |
| No rule qualifies | Normal exit; the result is returned | after 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.
When the set never terminates
Section titled “When the set never terminates”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");}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 variableProcessed 0/1 file(s) successfully1 file(s) failed to processNot 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.
| Failure | Fix |
|---|---|
A stage rule that does not advance stage | Retract("SelfName") in its then |
A stage rule that advances stage | Advance it and retract; either alone leaves an edge case |
| A condition that looks self-limiting | It 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.
When one rule aborts the whole run
Section titled “When one rule aborts the whole run”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");}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_scoreProcessed 0/1 file(s) successfully1 file(s) failed to processAPP-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 same pipeline on rules.svc
Section titled “The same pipeline on rules.svc”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, unchangedOnly 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:
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 rules | POST /rule | |
|---|---|---|
| Rule-set selection | Fixed. --ruleset is accepted and never read | The body’s name field, required |
| Facts | Top-level keys of each input file, plus -f and -v | Every top-level key of the request body |
| Nested objects as facts | Readable with bracket access | Readable with bracket access |
Chaining through out + Changed | Works | Works |
| Chaining through a map fact | Works | Works |
| Audit trail | --audit, plus the sidecar | Returned under audit when the body sets "audit": true — the same entries --audit prints |
| Findings | Not surfaced — no rule can record one | Not surfaced |
| Cycle-cap and abort failures | ERROR: line, exit 1, no result | 400, 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.
Related
Section titled “Related”| You want to | Page |
|---|---|
| The full chaining vocabulary, including the map-fact idiom | Rule patterns |
| Salience, retraction and the cycle in detail | Writing rules |
Every call available in a then block | Functions |
| The collectors and the execution result | Engine API |
| The findings model, and why no rule can write to it | Findings API |
Every flag on kis rules | The CLI |
| Another end-to-end scenario | Examples |