Screening a dispense request end to end
One rule set decides whether a prescription is released or held, and records why. Two inputs take the two paths. Everything below is shown in full — no step assumes a file you have not seen — and every console block is what the commands print for these exact inputs.
The scenario exercises five things the quickstart does not: nested map facts, an array fact, guarding an optional fact, accumulating a reason string across rules, and the working-memory trap that silently inverts a decision.
What you need
Section titled “What you need”| Surface | Requirement |
|---|---|
kis rules | The kis CLI plus the rules plugin. No database, no service, no network. |
POST /rule | A reachable rules.svc, a tenant token, and the four tenancy headers. |
Either surface runs this example on its own. kis rules, kis ocr, kis bbox and kis hocr2bbox
are proxy commands — kis forwards your arguments to a separately installed plugin executable and
contains none of the rules logic itself. Check the plugin is present first:
kis rules --helpIf it is missing the command fails immediately:
Error: 'rules' is not installed.A second line names the install command for the plugin package. The plugin is looked for in the
directory holding the kis binary, then ~/.kisai/bin, then every entry of $KIS_PLUGIN_PATH,
then $PATH; the first executable match wins. Every flag: the CLI.
Lay out the files
Section titled “Lay out the files”dispense/ rules/ dispense.grl # the rule set input/ rx-88214.json # holds — three separate reasons rx-88215.json # releases audit/ # sidecars land here, out of the input globrx-88214.json:
{ "rx_id": "RX-88214", "patient": { "age": 71, "renal_clearance": 38.0 }, "drug": { "name": "Metformin", "strength_mg": 1000, "class": "biguanide" }, "allergies": ["penicillin", "sulfa"], "prescriber_npi": "1548392017"}rx-88215.json:
{ "rx_id": "RX-88215", "patient": { "age": 34, "renal_clearance": 96.0 }, "drug": { "name": "Amoxicillin", "strength_mg": 500, "class": "aminopenicillin" }, "allergies": [], "prescriber_npi": "1548392017"}Each top-level key becomes its own fact under its own name: rx_id, patient, drug, allergies
and prescriber_npi. There is no wrapper object and no schema. Neither file carries a prior_auth
key — one rule below is built around that absence.
The rule set
Section titled “The rule set”rule RenalHold "Reduced renal clearance blocks a biguanide" salience 100 { when patient["renal_clearance"] < 45.0 && drug["class"] == "biguanide" then out.Set("decision", "hold"); out.Append("reasons", "renal clearance below 45 for a biguanide"); audit.Log("RenalHold", "renal clearance below 45 for a biguanide"); Changed("out"); Retract("RenalHold");}
rule AllergyHold "A recorded allergy blocks the dispense" salience 90 { when array.Contains(allergies, "sulfa") then out.Set("decision", "hold"); out.Append("reasons", "sulfa allergy on file"); audit.Log("AllergyHold", "sulfa allergy on file"); Changed("out"); Retract("AllergyHold");}
rule PriorAuthHold "High-strength items need a prior authorisation on file" salience 80 { when drug["strength_mg"] >= 1000 && !in.Has("prior_auth") then out.Set("decision", "hold"); out.Append("reasons", "no prior authorisation supplied"); audit.Log("PriorAuthHold", "prior_auth fact absent"); Changed("out"); Retract("PriorAuthHold");}
rule ReleaseDefault "Anything not held is released" salience 50 { when !out.Has("decision") then out.Set("decision", "release"); Retract("ReleaseDefault");}
rule StampRx "Carry the identifier onto the result" salience 40 { when strings.HasPrefix(rx_id, "RX-") then out.Set("rx_id", rx_id); Retract("StampRx");}Seven elements carry the file:
| Element | Why it is written that way |
|---|---|
patient["renal_clearance"] | Bracket access. patient.renal_clearance aborts the whole execution — map facts are not structs. |
45.0, not 45 | Comparison operators coerce, so 45 also works here; a function parameter typed float64 does not, and rejects an integer literal. Write the decimal point everywhere and the distinction stops mattering. |
array.Contains(allergies, "sulfa") | A JSON array decodes to []any, which is what array.Contains takes. array.ContainsStr takes []string and will not accept it. |
!in.Has("prior_auth") | The only safe way to test an absent fact. Naming an unbound fact directly in when aborts the execution and discards output already written. |
out.Append | Concatenates onto the existing string with ", ", so three rules build one reason list without any rule knowing about the others. |
Changed("out") | Makes the engine re-evaluate every cached expression mentioning out. Without it ReleaseDefault overwrites the hold — see below. |
Distinct salience on all five | Equal salience gives a different firing order run to run. Higher fires first. |
The grammar has no if/else, no loops and no array, map or struct literals; branching is two rules
at different salience with the fallback guarded by !out.Has(key). Full grammar:
the rule language. The callable surface —
strings, array, map, math, num, time, util — is on
functions.
Run it
Section titled “Run it”kis rules -r rules/ -i input/ --history --audit --history-audit ./audit/--- History for /path/to/dispense/input/rx-88214.json --- 1. [RenalHold] decision = "hold" 2. [RenalHold] reasons = "renal clearance below 45 for a biguanide" 3. [AllergyHold] decision = "hold" 4. [AllergyHold] reasons = "renal clearance below 45 for a biguanide, sulfa al"... 5. [PriorAuthHold] decision = "hold" 6. [PriorAuthHold] reasons = "renal clearance below 45 for a biguanide, sulfa al"... 7. [StampRx] rx_id = "RX-88214"
--- Audit for /path/to/dispense/input/rx-88214.json --- 1. [RenalHold] executed 2. [RenalHold] renal clearance below 45 for a biguanide 3. [AllergyHold] executed 4. [AllergyHold] sulfa allergy on file 5. [PriorAuthHold] executed 6. [PriorAuthHold] prior_auth fact absent 7. [StampRx] executed
Result for /path/to/dispense/input/rx-88214.json:{ "decision": "hold", "reasons": "renal clearance below 45 for a biguanide, sulfa allergy on file, no prior authorisation supplied", "rx_id": "RX-88214"}
--- History for /path/to/dispense/input/rx-88215.json --- 1. [ReleaseDefault] decision = "release" 2. [StampRx] rx_id = "RX-88215"
--- Audit for /path/to/dispense/input/rx-88215.json --- 1. [ReleaseDefault] executed 2. [StampRx] executed
Result for /path/to/dispense/input/rx-88215.json:{ "decision": "release", "rx_id": "RX-88215"}Processed 2/2 file(s) successfullyPaths are printed absolute; /path/to/ stands in for wherever you put the directory. History values
longer than 50 characters are truncated with a trailing ... — the stored value is not.
Read the two runs against each other:
- On
rx-88214all three hold rules fire. Salience picks the order; it does not suppress anything. Every rule that has not retracted is re-evaluated on the next cycle, so a lower-salience rule still fires later unless its ownwhenis false. - On
rx-88215no hold rule matches,!out.Has("decision")is true, andReleaseDefaultsupplies the fallback.PriorAuthHolddoes not fire becausedrug["strength_mg"]is 500 — the missingprior_authfact alone is not enough. audit.Loglines are interleaved with anexecutedline the engine adds for every rule that fires. Only theexecutedlines are automatic.
The last line is the exit contract: a failed input prints ERROR: <path>: <err>, processing
continues with the remaining inputs, and the command exits 1.
The trap this example is built around
Section titled “The trap this example is built around”Delete the three Changed("out") calls and run rx-88214 again. The rules are otherwise identical:
Result for /path/to/dispense/input/rx-88214.json:{ "decision": "release", "reasons": "renal clearance below 45 for a biguanide, sulfa allergy on file, no prior authorisation supplied", "rx_id": "RX-88214"}Three reasons to hold, and a release decision.
Two safer habits, either of which removes the need: make competing rules mutually exclusive in their
conditions, or keep collector reads out of when entirely. More failure shapes:
troubleshooting.
Recording the validation result
Section titled “Recording the validation result”Nothing above uses the findings collector, because no rule can reach it.
Record the verdict yourself instead. This is what the example already does, and it needs nothing that is not shipped:
| Need | Use |
|---|---|
| A machine-readable result the caller acts on | out.Set / out.Append — the output collector is the response body over HTTP |
| A human-readable record of which rule decided what | audit.Log(ruleID, message) — printed with --audit and written to the sidecar on the CLI, returned under audit when the request body sets "audit": true |
| The severity of a failure | Encode it in the output — a second out.Set("severity", "error") in the rule that holds |
A hold rule therefore carries its severity in out and its reasoning in audit, and never touches
findings:
rule RenalHold "Reduced renal clearance blocks a biguanide" salience 100 { when patient["renal_clearance"] < 45.0 && drug["class"] == "biguanide" then out.Set("decision", "hold"); out.Set("severity", "ERROR"); out.Append("failed_checks", "clinical: renal clearance below 45 for a biguanide"); audit.Log("RenalHold", "renal clearance below 45 for a biguanide"); Changed("out"); Retract("RenalHold");}The audit sidecar
Section titled “The audit sidecar”Every run in which a rule fires also writes <basename>-hist-audit.json. --history and --audit
only control whether the same data is printed. audit/rx-88214-hist-audit.json holds both
collections; its audit array is:
{ "audit": [ { "rule_id": "RenalHold", "message": "executed" }, { "rule_id": "RenalHold", "message": "renal clearance below 45 for a biguanide" }, { "rule_id": "AllergyHold", "message": "executed" }, { "rule_id": "AllergyHold", "message": "sulfa allergy on file" }, { "rule_id": "PriorAuthHold", "message": "executed" }, { "rule_id": "PriorAuthHold", "message": "prior_auth fact absent" }, { "rule_id": "StampRx", "message": "executed" } ]}The history array alongside it carries the same seven entries the --history block printed, as
rule_id / key / value objects with the values untruncated.
Write the results
Section titled “Write the results”Printing is the default. -o alone writes nothing; --update turns writing on.
kis rules -r rules/ -i input/ -o results/ -k screening --update --history-audit ./audit/Processed 2/2 file(s) successfullyresults/rx-88214.json:
{ "screening": { "decision": "hold", "reasons": "renal clearance below 45 for a biguanide, sulfa allergy on file, no prior authorisation supplied", "rx_id": "RX-88214" }}Because -o differs from -i, only the rule results are written and the source files are untouched.
--update without -o merges the results back into each source file instead.
The same run over HTTP
Section titled “The same run over HTTP”rules.svc runs the same engine over the same rule text. Two things differ: the rule set arrives as
product metadata rather than from -r, and it is selected by name in the request body. The GRL below
is rules/dispense.grl unchanged — nested map facts, bracket access and all.
Publish it under the product’s rules metadata. Each entry’s name becomes a rule-set name and
rule carries the GRL source — one entry holds all five rules:
rules: - name: dispense-screening rule: | rule RenalHold "Reduced renal clearance blocks a biguanide" salience 100 { when patient["renal_clearance"] < 45.0 && drug["class"] == "biguanide" then out.Set("decision", "hold"); out.Append("reasons", "renal clearance below 45 for a biguanide"); audit.Log("RenalHold", "renal clearance below 45 for a biguanide"); Changed("out"); Retract("RenalHold"); }
rule AllergyHold "A recorded allergy blocks the dispense" salience 90 { when array.Contains(allergies, "sulfa") then out.Set("decision", "hold"); out.Append("reasons", "sulfa allergy on file"); audit.Log("AllergyHold", "sulfa allergy on file"); Changed("out"); Retract("AllergyHold"); }
rule PriorAuthHold "High-strength items need a prior authorisation on file" salience 80 { when drug["strength_mg"] >= 1000 && !in.Has("prior_auth") then out.Set("decision", "hold"); out.Append("reasons", "no prior authorisation supplied"); audit.Log("PriorAuthHold", "prior_auth fact absent"); Changed("out"); Retract("PriorAuthHold"); }
rule ReleaseDefault "Anything not held is released" salience 50 { when !out.Has("decision") then out.Set("decision", "release"); Retract("ReleaseDefault"); }
rule StampRx "Carry the identifier onto the result" salience 40 { when strings.HasPrefix(rx_id, "RX-") then out.Set("rx_id", rx_id); Retract("StampRx"); }Only name and rule are read; any other key in an entry is ignored. A rule set that fails to
compile is logged and skipped rather than being fatal, so a typo removes that one rule set and leaves
the service running. Refreshes land within about 15 seconds of the metadata server reporting a
change. Delivery and tenancy: running rules.svc.
Confirm the listener is up. /health and /ready bypass tenancy and auth:
curl -sS https://rules.example.com/readyready:trueThen post the facts. The entire body becomes the fact map, plus the required name selector.
A nested object arrives as a map fact, the same shape encoding/json hands the CLI, so
patient["renal_clearance"] and drug["class"] read identically on both surfaces. A JSON array
decodes to []any on both. The body below is rx-88214.json verbatim; the only addition is the
name selector. Fact access syntax:
the rule language.
curl -sS -X POST https://rules.example.com/rule \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "X-Customer: acme" -H "X-Product: pharmacy" -H "X-Env: dev" \ -H "X-Tenant: acme:pharmacy:dev:main" \ -d '{"name":"dispense-screening","rx_id":"RX-88214","patient":{"age":71,"renal_clearance":38.0},"drug":{"name":"Metformin","strength_mg":1000,"class":"biguanide"},"allergies":["penicillin","sulfa"],"prescriber_npi":"1548392017"}'The response body is the output collector and nothing else — the same three keys the CLI printed:
{ "decision": "hold", "reasons": "renal clearance below 45 for a biguanide, sulfa allergy on file, no prior authorisation supplied", "rx_id": "RX-88214"}Differences from the local run, for this rule set:
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 the input file, plus -f and -v | Every top-level key of the request body |
| Audit trail | Printed with --audit, written to the sidecar | Returned under audit when the body sets "audit": true |
| Findings | Not surfaced | Not surfaced |
An execution failure comes back as 400 with a kiserr envelope. The two you will meet first are
ruleset dispense-screening not found: specified knowledge base name and version not exist — the
metadata has not arrived — and rule engine not initialized, which is what a service that has never
loaded any rule set looks like from the caller’s side. Codes and messages:
errors.
The rest of this section
Section titled “The rest of this section”Each page is a complete scenario with its own inputs, in the order they build on each other.
| Example | What it adds |
|---|---|
| Validating a record | Five checks with severities over flat facts, producing a pass/fail plus a list of what failed |
| From hOCR to extracted data | A scanned page converted with kis hocr2bbox, inspected with kis bbox, then read by rules |
| Reading an invoice | Header fields and a line-item table off one scan, checked against the stated totals |
| Processing a form | Checkbox and mark detection, labelled fields, page-scoped schemas |
| Multi-step rule pipelines | One rule’s output as another’s input, the cycle cap, and what one bad rule costs |
Continue with
Section titled “Continue with”| You want to | Page |
|---|---|
| Copy a rule shape that is known to work | Patterns |
| Look up an operator, a literal form or fact-access syntax | The rule language |
Find the right helper in strings, map, array, time, math | Functions |
| Run rules over scanned pages instead of JSON | OCR input |
| See every flag on every local command | The CLI |
| Read the engine and findings types | API reference |