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.
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 /rules/execute | A 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:
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 folderrx-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.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:
| Element | Why 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 45 | Comparison 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 ReleaseDefault | A rule’s when sees what earlier rules wrote to out, so the fallback fires only when no hold rule did. |
Distinct salience on all five | Equal 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.
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 = "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) 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. Each rule fires once, and 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 fallback reads what the holds wrote
Section titled “The fallback reads what the holds wrote”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");}{ "patient": { "renal_clearance": 38.0 }, "drug": { "class": "biguanide" } }{ "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.
Recording the validation result
Section titled “Recording the validation result”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.
| Need | Use |
|---|---|
| A machine-readable result the caller acts on | out.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 category | findings.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 what | audit.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.
The audit sidecar
Section titled “The audit sidecar”--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.
Write the results
Section titled “Write the results”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.
kis rules -r rules/ -i input/ -o results/ -k screeningProcessed 2/2 input(s) successfullyresults/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.
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 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.grlThe 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:
curl -sS https://rules.example.com/readyready:trueThen 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.
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 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 the input file, plus -f and -v | input’s top-level keys, plus named facts |
| A fact named like a helper or collector | A 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 lost | Refused: 400 rules-facts-invalid |
| Audit and history | --audit, --history, the sidecar | options.audit, options.history |
| Findings | Raised with findings.*, not printed | Returned as findings |
| A rule that fails | ERROR: line, exit 1, no result | 422 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.
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 bbox create, 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, repeating a rule, 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 |