Skip to content
Talk to our solutions team

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.

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.

CapabilityIn rules.svcWhere it is
Execute a named rule setYes: POST /rule—
Document, OCR and bounding-box rules (bbox.*)Yes: bbox on the POST /rule bodyDocument input
Create, list, update or delete rule sets over HTTPNoRule sets are files in the product’s rules/ folder
Ask which rule sets are loadedNoNo introspection route exists
The audit trail in the responseYes. Set "audit": true on the bodyResponse
Rule-match count and duration in the responseNoCollected by the engine, not serialised
Findings in the responseNoNothing can add one: see below
Nested objects in the fact payloadYes: bracket access, one levelRequest
Asynchronous, queued or batched executionNoExecution is synchronous on the request goroutine
Multiple rule sets per requestNoOne request runs exactly one rule set
log.* inside a rule bodyNoBound on no surface. The CLI does not have it either
Per-tenant isolation of rule setsYesEach 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.

1. register the service logger
2. initialise tenancy (multi-tenant)
3. parse command-line flags
4. 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 folder
6. apply boot config — warm the tenants listed in loadtenants, load timeout tables
7. install SIGINT/SIGTERM handlers
8. build the router, mount the probes and /rule, set healthy and ready to true,
resolve the TLS config, bind the listener
9. block until a stop signal arrives
10. shut down the HTTP server, then every tenant's watch and meta client

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

MethodPathAuthTenancy headersPurpose
POST/ruleTenant JWTRequiredExecute a rule set
GET/healthNoneNoneLiveness
GET/readyNoneNoneReadiness
anyunmatched—Required404, 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.

HeaderRequiredValue
Content-TypeNoNever inspected; the body is decoded as JSON whatever you send
AuthorizationYesBearer <tenant-jwt>
X-CustomerYesCustomer segment of the tenant key
X-ProductYesProduct segment
X-EnvYesEnvironment segment
X-TenantYesTenant segment
X-Api-TimeoutNoGo duration; imposes a per-request deadline
X-Debug-LogNoLogs 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.

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.

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"}}
}
}

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"}
]
}
CodeBodyCause
200Output JSON object, plus audit when the body asked for itExecution completed
400invalid tenant (plain text)Missing header, or tenant unknown to the config plane
400config client not initialised (plain text)Broken boot; every request fails this way
400Error envelopeBad JSON, missing name, unknown rule set, execution failure
401Unauthorized (plain text)Token missing, invalid, expired, or its tenant claim does not match X-Tenant
404emptyUnmatched path
405—Wrong method on /rule; carries an Allow header
408context-middleware-timed-outX-Api-Timeout was supplied and the handler exceeded it
500500 - Internal errorA panic escaped the handler
503emptyX-Api-Timeout was in force and the request context was cancelled for some other reason: normally the client disconnecting
{
"error": {
"message": "name of rule to execute not given",
"code": "",
"context": null,
"meta": null
}
}
codemessageMeaning
rules-json-request-parse-failedjson request parse failed due to {{err}}Body was not a decodable JSON object
(empty)name of rule to execute not givenname was missing or empty
(empty)ruleset <name> not found: the product has no rules/<name>/ folderThe tenant’s product has no such rule set
(empty)ruleset <name> not found: the tenant's rules have not loadedNo 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.

Terminal window
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"}'

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

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

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: ….

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.

RouteSuccessFailure
GET /ready200, plain text ready:true500, ready:false
GET /health200, JSON500, 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.

BoundDefaultSet by
Server write timeout15stimeouts.write
Per-request deadlinenoneThe caller’s X-Api-Timeout header
Engine cycle cap5000Fixed. 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.