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.thin_file |
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.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:
{ "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": { "thin_file": "credit score below 620" }}Four things in that file carry the pipeline.
| Element | Why 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 when | Sequences 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 rule | The only ordering control |
Complete() in RejectIneligible | Ends 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.
Run it
Section titled “Run it”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) 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.
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.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) 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. --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.
How a later rule sees an earlier write
Section titled “How a later rule sees an earlier write”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 as | Because |
|---|---|
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 default | An 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 |
Why the state lives in out
Section titled “Why the state lives in out”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.
Terminating cleanly
Section titled “Terminating cleanly”| Mechanism | Effect | Used here by |
|---|---|---|
| Firing | A rule that fires is retracted for the rest of the execution, automatically | every rule |
A state write that falsifies the when | Removes a rule that has not fired yet from the runnable set | the losing branch in each pair |
Complete() | Ends the whole execution immediately, however high the remaining salience | RejectIneligible |
Retract("Other") | Takes another rule out of the pool for the rest of the execution | not needed here |
| 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.
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.
When a stage repeats
Section titled “When a stage repeats”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();}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 variableProcessed 0/1 input(s) successfully1 input(s) failedNot 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 shape | Behaviour |
|---|---|
No agenda.Repeat() | Fires at most once, whatever its when |
agenda.Repeat() and a then that falsifies its own when | Repeats until the condition goes false, then stops |
agenda.Repeat() and a when that stays true | Runs 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.
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.Str("stage", "") == "banded" then out.Set("decision", "auto_approve_joint"); out.Set("stage", "routed");}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_scoreProcessed 0/1 input(s) successfully1 input(s) failedAPP-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 same pipeline on rules.svc
Section titled “The same pipeline on rules.svc”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.grlrules.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:
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 rules | rules.svc | |
|---|---|---|
| Which rules | -r names files or folders | scope names the product’s folder, or a static scope of its rules/rules.yaml |
| Facts | Top-level keys of each input file, plus -f and -v | input’s top-level keys, plus named facts |
| Nested objects as facts | Readable with bracket access or in.* dotted paths | The same |
Chaining through out | Works | Works |
| Audit and history | --audit, --history, the sidecar | options.audit, options.history |
| Findings | Raised with findings.*; not printed | Returned as findings |
| Cycle cap | 5000 | rules.exec.maxcycles, or a static scope’s limits.maxcycles |
| Cycle-cap and abort failures | ERROR: line, exit 1, no result | 422 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.
Related
Section titled “Related”| You want to | Page |
|---|---|
| The full chaining vocabulary | 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 | Rule API reference |
| The findings model and how a rule raises one | Findings API |
Every flag on kis rules | The CLI |
| Another end-to-end scenario | Examples |