Skip to content
Talk to our solutions team

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.

SurfaceRequirement
kis rulesThe kis CLI plus the rules plugin. No database, no service, no network.
POST /ruleA 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:

Terminal window
kis rules --help

If 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.

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 glob

rx-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.

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:

ElementWhy 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 45Comparison 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.AppendConcatenates 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 fiveEqual 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.

Terminal window
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) successfully

Paths 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-88214 all 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 own when is false.
  • On rx-88215 no hold rule matches, !out.Has("decision") is true, and ReleaseDefault supplies the fallback. PriorAuthHold does not fire because drug["strength_mg"] is 500 — the missing prior_auth fact alone is not enough.
  • audit.Log lines are interleaved with an executed line the engine adds for every rule that fires. Only the executed lines 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.

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.

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:

NeedUse
A machine-readable result the caller acts onout.Set / out.Append — the output collector is the response body over HTTP
A human-readable record of which rule decided whataudit.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 failureEncode 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");
}

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.

Printing is the default. -o alone writes nothing; --update turns writing on.

Terminal window
kis rules -r rules/ -i input/ -o results/ -k screening --update --history-audit ./audit/
Processed 2/2 file(s) successfully

results/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.

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:

Terminal window
curl -sS https://rules.example.com/ready
ready:true

Then 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.

Terminal window
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 rulesPOST /rule
Rule-set selectionFixed. --ruleset is accepted and never read.The body’s name field, required
FactsTop-level keys of the input file, plus -f and -vEvery top-level key of the request body
Audit trailPrinted with --audit, written to the sidecarReturned under audit when the body sets "audit": true
FindingsNot surfacedNot 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.

Each page is a complete scenario with its own inputs, in the order they build on each other.

ExampleWhat it adds
Validating a recordFive checks with severities over flat facts, producing a pass/fail plus a list of what failed
From hOCR to extracted dataA scanned page converted with kis hocr2bbox, inspected with kis bbox, then read by rules
Reading an invoiceHeader fields and a line-item table off one scan, checked against the stated totals
Processing a formCheckbox and mark detection, labelled fields, page-scoped schemas
Multi-step rule pipelinesOne rule’s output as another’s input, the cycle cap, and what one bad rule costs
You want toPage
Copy a rule shape that is known to workPatterns
Look up an operator, a literal form or fact-access syntaxThe rule language
Find the right helper in strings, map, array, time, mathFunctions
Run rules over scanned pages instead of JSONOCR input
See every flag on every local commandThe CLI
Read the engine and findings typesAPI reference