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 HTTPNoDefinitions arrive only from the metadata plane
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 setsNoOne process-wide engine, shared by every tenant

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) and register the config-refresh hook
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
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 stop the per-tenant metadata monitors

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.

The rule engine is not part of boot. It is created lazily on the first definition refresh, which means a freshly started instance serves requests before it has any rules.

MethodPathAuthTenancy headersPurpose
POST/ruleTenant JWTRequiredExecute a rule set
GET/healthNoneNoneLiveness
GET/readyNoneNoneReadiness
anyunmatchedRequired404, 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:product:env: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 Go 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, engine not initialised, unknown rule set, execution failure
401Unauthorized (plain text)Token missing, invalid, expired, or its tenant claim does not match X-Tenant
404emptyUnmatched path
405Wrong 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)rule engine not initializedNo definitions have ever been loaded
(empty)ruleset <name> not found: specified knowledge base name and version not existRule set is not loaded
(empty)ruleset <name> not found: expression … is not on the clone table …The rule set was corrupted by a republished edit — see Refresh behaviour
(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"}'

There is one loading path. Per tenant, the service builds a metadata client from that tenant’s meta.url, calls the metadata service’s per-product service-configuration endpoint for client name rules, and reads the string list at product.rules.data in the response. Each string is parsed as a YAML document and its entries are registered with the engine.

rules:
- name: invoice-checks
rule: |
rule ApproveSmallInvoice "auto-approve small invoices" salience 10 {
when
total < 5000
then
out.Set("approved", true);
Retract("ApproveSmallInvoice");
}
KeyTypeMeaning
ruleslist of mapsTop-level key; non-map entries are dropped silently
rules[].namestringThe rule-set name — the value a caller sends as name on POST /rule
rules[].rulestringGRL source. One entry may hold several rule blocks; they all land in that one rule set

Any other key in an entry is ignored. name names the rule set, not a rule — individual rule identifiers live inside the GRL text. See Rule definitions for the authoring format.

There is no local rules directory, no config key pointing at one, no CLI flag to preload rules into a running instance, and no upload endpoint. The service also embeds a datastore directory, but it ships containing only a placeholder and nothing reads it.

When a tenant’s configuration changes, the service performs a blocking first load — retrying the metadata call every second, indefinitely, until one succeeds — and then starts a background monitor that polls every 15 seconds. Each response carries a load flag; the monitor skips the definition update entirely when it is false. From the first load and from every load: true response the service records lastupdated.service and lastupdated.product, and echoes them back as servicelastupdated and productlastupdated on the next poll, which is how the metadata service decides whether anything moved.

A published rule change is therefore picked up within roughly 15 seconds of the metadata service reporting a newer timestamp — never on request, and never instantly.

Definition failures degrade silently. YAML that does not parse and GRL that does not compile are both logged and skipped; the refresh reports success and the service keeps serving whatever was already registered. There is no metric, health signal or route that reports which rule sets loaded, so the service log is the only evidence. See Troubleshooting.

Requests are tenant-scoped. The engine is not.

Loading is also not thread-safe. Definition refresh runs on background goroutines while request handlers read the same unguarded map, so a refresh landing during traffic can trigger the Go runtime’s fatal concurrent map access, which terminates the process and cannot be recovered by the panic handler. Publish definition changes during a quiet window, or restart to apply them.

RouteSuccessFailure
GET /ready200, plain text ready:true500, ready:false
GET /health200, JSON500, same JSON shape
{
"healthy": true,
"dependencies": {},
"memstats": {
"Alloc": 12, "HeapAlloc": 12, "HeapSys": 19, "HeapIdle": 5,
"HeapInUse": 13, "TotalAlloc": 40, "Sys": 27, "NumGC": 4
},
"version": ""
}

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.