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, collecting reasons from several rules into one object, and a fallback rule that reads what the rules before it wrote.

SurfaceRequirement
kis rulesThe kis CLI plus the rules plugin. No database, no service, no network.
POST /rules/executeA reachable rules.svc, a tenant token, and the four tenancy headers.

Either surface runs this example on its own. kis rules and kis bbox 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 folder

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.Set("reasons.renal", "renal clearance below 45 for a biguanide");
audit.Log("RenalHold", "renal clearance below 45 for a biguanide");
}
rule AllergyHold "A recorded allergy blocks the dispense" salience 90 {
when
array.Contains(allergies, "sulfa")
then
out.Set("decision", "hold");
out.Set("reasons.allergy", "sulfa allergy on file");
audit.Log("AllergyHold", "sulfa allergy on file");
}
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.Set("reasons.prior_auth", "no prior authorisation supplied");
audit.Log("PriorAuthHold", "prior_auth fact absent");
}
rule ReleaseDefault "Anything not held is released" salience 50 {
when
!out.Has("decision")
then
out.Set("decision", "release");
}
rule StampRx "Carry the identifier onto the result" salience 40 {
when
strings.HasPrefix(rx_id, "RX-")
then
out.Set("rx_id", rx_id);
}

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. in.Double("patient.renal_clearance", 0.0) reads the same value through a dotted path
45.0, not 45Comparison operators coerce, so 45 also works here; a function parameter typed float64 does not, and an integer literal passed to one aborts the run. 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")Tests an absent fact without naming it. Naming an unbound fact directly in when aborts the execution and discards output already written; the in.* readers never do.
out.Set("reasons.renal", …)A dotted key writes into a nested object, so each hold rule adds its own reason under reasons without knowing about the others.
!out.Has("decision") in ReleaseDefaultA rule’s when sees what earlier rules wrote to out, so the fallback fires only when no hold rule did.
Distinct salience on all fiveEqual salience gives a different firing order run to run. Higher fires first.

Each rule fires at most once per execution: a rule that fires is retracted for the rest of the run, so none of them needs a Retract. 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 = "renal clearance below 45 for a biguanide"
3. [AllergyHold] decision = "hold"
4. [AllergyHold] reasons.allergy = "sulfa allergy on file"
5. [PriorAuthHold] decision = "hold"
6. [PriorAuthHold] reasons.prior_auth = "no prior authorisation supplied"
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": {
"allergy": "sulfa allergy on file",
"prior_auth": "no prior authorisation supplied",
"renal": "renal clearance below 45 for a biguanide"
},
"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 input(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. Each rule fires once, and 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.

ReleaseDefault sits below every hold rule and asks one question: has anything set decision yet? A rule’s when sees every write made to out before it is evaluated, so the answer reflects the holds that already fired. Cut down to the two rules that matter, on the renal facts of rx-88214:

rule RenalHold "Reduced renal clearance blocks a biguanide" salience 100 {
when
patient["renal_clearance"] < 45.0 && drug["class"] == "biguanide"
then
out.Set("decision", "hold");
}
rule ReleaseDefault "Anything not held is released" salience 50 {
when
!out.Has("decision")
then
out.Set("decision", "release");
}
facts
{ "patient": { "renal_clearance": 38.0 }, "drug": { "class": "biguanide" } }
out
{ "decision": "hold" }

The hold stands: ReleaseDefault never fires, because by its turn decision is set. Two habits keep a fallback like this honest as the rule set grows: give it the lowest salience of the rules it backs up, and guard it on the key it would write. More failure shapes: troubleshooting.

The rule set above records its verdict in out and its reasoning in audit. The engine also carries a findings channel built for validation: a finding has a rule, a severity, a category and a message, and rules.svc returns every finding the rules raised in each response, under findings.

NeedUse
A machine-readable result the caller acts onout.Set. The output is out in the rules.svc response and the printed result on the CLI
A graded issue with a severity and a categoryfindings.Error/Warn/Info/Critical(ruleID, category, message), or findings.Add(ruleID, severity, category, message, fields). Returned under findings by rules.svc; kis rules does not print them
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 sets "options": {"audit": true}

A hold rule that raises a finding as well as writing the decision:

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("reasons.renal", "renal clearance below 45 for a biguanide");
findings.Error("RenalHold", "clinical", "renal clearance below 45 for a biguanide");
audit.Log("RenalHold", "renal clearance below 45 for a biguanide");
}

Keep the decision in out even when you raise findings: out is what every caller reads, on both surfaces. The findings API, including .Detail(k, v) and .Field(name) for structured detail, is on Findings.

--history, --audit or --history-audit <dir> also writes <basename>-hist-audit.json for every input in which a rule fired; without one of them nothing is written. 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.

Without --history-audit <dir> the sidecar lands next to its input. Input discovery always skips *-hist-audit.json, so a sidecar is never read back as an input, but sending it to audit/ keeps the input directory clean, which is why the layout above has that folder.

Printing is the default. -o <dir> writes each result to that directory instead, mirroring the input’s path; --update without -o merges the results back into each source file.

Terminal window
kis rules -r rules/ -i input/ -o results/ -k screening
Processed 2/2 input(s) successfully

results/rx-88214.json:

{
"screening": {
"decision": "hold",
"reasons": {
"allergy": "sulfa allergy on file",
"prior_auth": "no prior authorisation supplied",
"renal": "renal clearance below 45 for a biguanide"
},
"rx_id": "RX-88214"
}
}

Because -o differs from -i, only the rule results are written and the source files are untouched. -k nests the result under that key; without it the result keys sit at the root.

rules.svc runs the same engine over the same rule text. Two things differ: the rule set is a folder of the product rather than a path given to -r, and a call names it as its scope. Put rules/dispense.grl unchanged, nested map facts, bracket access and all, in the product’s rules/dispense-screening/ folder; the folder’s name is the scope a call runs:

rules/
dispense-screening/
dispense.grl

The change reaches the service by itself. rules.svc checks every call the rules make when it loads the folder; a load that does not build keeps the rules the tenant had, and GET /rulesets says why under error. Delivery, tenancy and static scopes: 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 ask for the decision. input is the record: each of its top-level keys is a fact, exactly as each top-level key of rx-88214.json is under kis rules. A nested object arrives as a map fact, so patient["renal_clearance"] and drug["class"] read identically on both surfaces, and a JSON array decodes to []any on both. Fact access syntax: the rule language.

Terminal window
curl -sS -X POST https://rules.example.com/rules/execute \
-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:dev:pharmacy:main" \
-d '{"scope":"dispense-screening","input":{"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"}}'
{
"scope": "dispense-screening",
"stamp": "sha256:…",
"principal": { "id": "u-…", "ptyp": "user" },
"out": {
"decision": "hold",
"reasons": {
"allergy": "sulfa allergy on file",
"prior_auth": "no prior authorisation supplied",
"renal": "renal clearance below 45 for a biguanide"
},
"rx_id": "RX-88214"
},
"findings": [],
"actions": []
}

out is the same three keys the CLI printed. stamp names the exact rule text that decided, and findings holds whatever the rules raised: nothing, for this rule set. Add "options": {"audit": true, "history": true} for the audit trail and every write with the rule that made it. The full surface is on the rules service.

Differences from the local run, for this rule set:

kis rulesrules.svc
Which rules-r names files or foldersscope names the product’s folder, or a static scope of its rules/rules.yaml
FactsTop-level keys of the input file, plus -f and -vinput’s top-level keys, plus named facts
A fact named like a helper or collectorA helper name (map, strings, …) replaces that helper for the run; a collector name (out, in, findings, audit) is replaced by the collector and the value is lostRefused: 400 rules-facts-invalid
Audit and history--audit, --history, the sidecaroptions.audit, options.history
FindingsRaised with findings.*, not printedReturned as findings
A rule that failsERROR: line, exit 1, no result422 rules-execution-failed, with what was written so far under the error’s meta.partial

The body above is safe on the naming count: name inside drug is nested, so it never becomes a fact of its own.

The errors you will meet first: 404 rules-scope-not-found (the product has no scope dispense-screening: no static scope of rules.yaml and no folder rules/dispense-screening/), and 503 rules-tenant-not-loaded with a Retry-After header while the tenant’s first load has not completed. Every error carries its code in the body and in the X-Kis-Error-Code header. 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 bbox create, 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, repeating a rule, 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