The rules service
rules.svc is a single-endpoint HTTP service that executes a named rule set against a JSON fact
payload, optionally carrying one or more documents, and returns whatever the rules wrote to the
out collector.
That is the entire business surface: one route, POST /rule, plus the two chassis probes. That one
route reaches the same capabilities as the kis CLI: plain fact rules and
document rules alike. A request carries document input alongside its facts, and the rule text that
queries it, the document model, OCR ingest, fields, forms, checkboxes, tables, repeaters, matching
and confidence, is identical to what the CLI runs. What lives only on the CLI is local file
handling and batch iteration over a directory.
What the HTTP surface covers
Section titled “What the HTTP surface covers”Read this table before designing against rules.svc. The gaps below are in the HTTP surface, not in
the engine or the document library, and none of them are configurable.
| Capability | In rules.svc | Where it is |
|---|---|---|
| Execute a named rule set | Yes: POST /rule | — |
Document, OCR and bounding-box rules (bbox.*) | Yes: bbox on the POST /rule body | Document input |
| Create, list, update or delete rule sets over HTTP | No | Rule sets are files in the product’s rules/ folder |
| Ask which rule sets are loaded | No | No introspection route exists |
| The audit trail in the response | Yes. Set "audit": true on the body | Response |
| Rule-match count and duration in the response | No | Collected by the engine, not serialised |
| Findings in the response | No | Nothing can add one: see below |
| Nested objects in the fact payload | Yes: bracket access, one level | Request |
| Asynchronous, queued or batched execution | No | Execution is synchronous on the request goroutine |
| Multiple rule sets per request | No | One request runs exactly one rule set |
log.* inside a rule body | No | Bound on no surface. The CLI does not have it either |
| Per-tenant isolation of rule sets | Yes | Each tenant’s rule sets are its own |
The util helpers (util, num, strings, time, math, map, array) come from the engine’s
init step and are bound on every execution, as are the out, findings, audit and in bindings.
bbox joins them whenever the request carries a document. See
Functions for what each one offers and
Documents in rules for the bbox surface.
Boot order
Section titled “Boot order”1. register the service logger2. initialise tenancy (multi-tenant)3. parse command-line flags4. load the bootstrap YAML (default .rules.yaml, or -f <path>)5. chassis environment init — identity, log level, goroutine pool, TLS, JWT keys, vault, service discovery, config client, watchers; for every configured tenant, a meta client for its product, its rule sets loaded from the product's rules/ folder, and a watch on that folder6. apply boot config — warm the tenants listed in loadtenants, load timeout tables7. install SIGINT/SIGTERM handlers8. build the router, mount the probes and /rule, set healthy and ready to true, resolve the TLS config, bind the listener9. block until a stop signal arrives10. shut down the HTTP server, then every tenant's watch and meta clientSteps 2 and 5 exit with status 1 on failure, and so does step 8 if the TLS config cannot be resolved or the listener cannot bind.
Rule sets load per tenant as each tenant is configured, and a tenant whose first load fails retries
in the background. An instance can serve requests before a tenant’s rule sets have loaded: until they
do, a call for one answers ruleset <name> not found: the tenant's rules have not loaded.
Routes
Section titled “Routes”| Method | Path | Auth | Tenancy headers | Purpose |
|---|---|---|---|---|
POST | /rule | Tenant JWT | Required | Execute a rule set |
GET | /health | None | None | Liveness |
GET | /ready | None | None | Readiness |
| any | unmatched | — | Required | 404, empty body |
The router is built with method-not-allowed handling, automatic OPTIONS, trailing-slash redirect
and fixed-path redirect enabled. GET /rule returns 405 with an Allow header; POST /rule/
redirects to /rule.
POST /rule
Section titled “POST /rule”Headers
Section titled “Headers”| Header | Required | Value |
|---|---|---|
Content-Type | No | Never inspected; the body is decoded as JSON whatever you send |
Authorization | Yes | Bearer <tenant-jwt> |
X-Customer | Yes | Customer segment of the tenant key |
X-Product | Yes | Product segment |
X-Env | Yes | Environment segment |
X-Tenant | Yes | Tenant segment |
X-Api-Timeout | No | Go duration; imposes a per-request deadline |
X-Debug-Log | No | Logs this request at debug level when realtimedebug is on |
The four tenancy headers are joined with : into the tenant key, and that key must resolve in the
config plane, the full customer:env:product:tenant join is tried first, then the bare X-Tenant
value, a missing or unresolvable value returns 400 invalid tenant in plain text, before
authentication and before the handler.
The token may also arrive in a jwt cookie, or as a ?token= query parameter, which is tried as a
fallback after header extraction fails. RS256 realm tokens and EdDSA tokens are both accepted on
separate validation paths, the token’s tenant claim must match X-Tenant; there is no superadmin
bypass.
Request
Section titled “Request”The body is a single JSON object with a flat set of facts. Arrays, bare strings and numbers fail the decode.
{ "name": "invoice-checks", "total": 1200, "currency": "EUR"}name selects the rule set, bbox carries document input and audit asks for the decision trail.
Every other top-level key becomes a fact usable in when and then.
Document input
Section titled “Document input”A rule set that queries a document gets it from the reserved bbox object on the same request. Each
key names a document, and its value is the word-box JSON described in
OCR ingestion, the service parses and analyses each one before
any rule runs, then binds the analysed documents under bbox for that execution.
{ "name": "invoice-extract", "currency": "EUR", "bbox": { "page": {"pages": [{"number": 1, "width": 612, "height": 792, "words": []}]} }}The document name is what the rule passes to bbox.Has and bbox.Get, exactly as under the CLI:
rule ExtractInvoiceVendor "vendor and total from the invoice" salience 100 { when bbox.Has("page") then out.Set("vendor", bbox.Get("page").TextRightOf("Name")); Retract("ExtractInvoiceVendor");}The rule text is unchanged between the two surfaces. kis rules -b page=invoice.bbox reads the same
JSON from disk; the request above sends it inline. Every field, form, checkbox, table, repeater and
matching call on the returned document behaves identically, see
Documents in rules for the full bbox surface.
To resolve ExtractForm("<name>") by schema name, send the two-key form: __docs holds the
document map above, __schemas holds JSON Schemas by name. This is the same payload the CLI builds
from --schema and --schemas.
{ "name": "closing-disclosure", "bbox": { "__docs": {"page": {"pages": []}}, "__schemas": {"closing_disclosure": {"type": "object"}} }}Response
Section titled “Response”200 with a JSON object containing exactly the key/value pairs the rules wrote to out.
{ "approved": true, "tier": "gold"}Key order is not stable. The map is rebuilt from an unordered map on the way out.
Set "audit": true on the request and the trail comes back beside the output, under a reserved
audit key holding the same {"rule_id", "message"} entries kis rules --audit prints and the
<prefix>-hist-audit.json sidecar records, the engine’s automatic executed marker for every
firing included:
{ "approved": true, "tier": "gold", "audit": [ {"rule_id": "TierGold", "message": "executed"}, {"rule_id": "TierGold", "message": "spend above the 10000 threshold"} ]}Status codes
Section titled “Status codes”| Code | Body | Cause |
|---|---|---|
200 | Output JSON object, plus audit when the body asked for it | Execution completed |
400 | invalid tenant (plain text) | Missing header, or tenant unknown to the config plane |
400 | config client not initialised (plain text) | Broken boot; every request fails this way |
400 | Error envelope | Bad JSON, missing name, unknown rule set, execution failure |
401 | Unauthorized (plain text) | Token missing, invalid, expired, or its tenant claim does not match X-Tenant |
404 | empty | Unmatched path |
405 | — | Wrong method on /rule; carries an Allow header |
408 | context-middleware-timed-out | X-Api-Timeout was supplied and the handler exceeded it |
500 | 500 - Internal error | A panic escaped the handler |
503 | empty | X-Api-Timeout was in force and the request context was cancelled for some other reason: normally the client disconnecting |
Error envelope
Section titled “Error envelope”{ "error": { "message": "name of rule to execute not given", "code": "", "context": null, "meta": null }}code | message | Meaning |
|---|---|---|
rules-json-request-parse-failed | json request parse failed due to {{err}} | Body was not a decodable JSON object |
| (empty) | name of rule to execute not given | name was missing or empty |
| (empty) | ruleset <name> not found: the product has no rules/<name>/ folder | The tenant’s product has no such rule set |
| (empty) | ruleset <name> not found: the tenant's rules have not loaded | No load of the tenant’s rule sets has succeeded yet |
| (empty) | executing ruleset <name>: plugin bbox prepare: loading document <name>: … | A document under bbox would not parse or analyse; no rule ran |
| (empty) | executing ruleset <name>: <engine error> | A rule failed evaluation, or the 5000-cycle cap was hit |
The full table, including boot-time codes, is on the error reference.
Worked call
Section titled “Worked call”curl -X POST https://rules.example.com/rule -H 'Content-Type: application/json' -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d '{"name":"invoice-checks","total":1200,"currency":"EUR"}'How rule definitions reach the service
Section titled “How rule definitions reach the service”Rule sets are files in the tenant’s product, read through the tenant’s meta source (meta.url, or
meta.localdir for a product checkout on disk). Each folder in the product’s rules/ folder is one
rule set, named for the folder: the name a caller sends on POST /rule. It holds the set’s .grl
files, at any depth, and the vocabulary files beside them (vocab.yaml, or any *.vocab.yaml,
*.vocab.yml or *.vocab.json).
rules/ invoice-checks/ rule set "invoice-checks" approve.grl flags/ large.grl vocab.yaml the rule set's vocabulary discounts/ rule set "discounts" discount.grlIt is the folder the CLI runs: kis rules -r rules/invoice-checks -R --ruleset invoice-checks
executes the same rule set against local input. See
Rule definitions for the GRL inside the files.
A rule or vocabulary file directly in rules/ belongs to no rule set and fails the load, as does a
rule-set folder with no .grl file. Any other file (a README) is ignored.
Refresh behaviour
Section titled “Refresh behaviour”When a file under rules/ changes, the service builds the tenant’s rule sets again from the folder
and swaps them in whole: an edited rule runs as edited, a removed rule or rule set is gone, and a new
folder is a new rule set. The change reaches the service by itself, through meta’s change stream (the
directory watcher, for meta.localdir).
A load that does not build changes nothing: the tenant keeps serving the rule sets it had, and the
service logs the error with the file it names, reload: rules/<set>/<file>.grl: ….
Tenancy of the engine
Section titled “Tenancy of the engine”Each tenant’s rule sets are its own. They come from the tenant’s product, and a request executes only the calling tenant’s rule sets, by name: two tenants may each have a rule set of the same name, and each runs its own. Every rule set has its own engine, as a CLI run does, so its vocabulary is its own too.
Health and readiness
Section titled “Health and readiness”| Route | Success | Failure |
|---|---|---|
GET /ready | 200, plain text ready:true | 500, ready:false |
GET /health | 200, JSON | 500, same JSON shape |
{ "healthy": true, "dependencies": {}, "version": ""}The response also carries a memstats object. That is runtime diagnostics, not a contract, see
API conventions. version is whatever the service
set at startup, and most set nothing.
Memory values are in MiB. version is always the empty string, rules.svc never populates it, so
it is not a build identifier. Both probes bypass tenancy and authentication, and neither reflects
the rule engine. Use them for process liveness only.
Execution ceilings
Section titled “Execution ceilings”| Bound | Default | Set by |
|---|---|---|
| Server write timeout | 15s | timeouts.write |
| Per-request deadline | none | The caller’s X-Api-Timeout header |
| Engine cycle cap | 5000 | Fixed. No config key or flag changes it |
The cycle cap is 5000 on every surface, the CLI included. When it trips, the run returns
executing ruleset <name>: ... with the whole run’s output discarded. Fix a runaway rule set in the
rules, usually by adding the missing Retract, not in configuration.
Continue with
Section titled “Continue with”- Configuration: every bootstrap key, flag and default
- Troubleshooting: silent boots, empty responses, rules that do nothing
- Rule definitions: the GRL inside a rule file
- Documents in rules: the full
bboxsurface a rule can call - OCR ingestion: the word-box JSON the
bboxobject carries - Error reference: the complete code table
- The
kisCLI: running rules from local files