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 | Definitions arrive only from the metadata plane |
| 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 | No | One 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.
Boot order
Section titled “Boot order”1. register the service logger2. initialise tenancy (multi-tenant) and register the config-refresh hook3. 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, watchers6. 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 stop the per-tenant metadata monitorsSteps 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.
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: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.
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 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"} ]}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, engine not initialised, 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) | rule engine not initialized | No definitions have ever been loaded |
| (empty) | ruleset <name> not found: specified knowledge base name and version not exist | Rule 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.
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”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"); }| Key | Type | Meaning |
|---|---|---|
rules | list of maps | Top-level key; non-map entries are dropped silently |
rules[].name | string | The rule-set name — the value a caller sends as name on POST /rule |
rules[].rule | string | GRL 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.
Refresh behaviour
Section titled “Refresh behaviour”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.
Tenancy of the engine
Section titled “Tenancy of the engine”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.
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": {}, "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.
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 YAML envelope and the GRL inside it
- 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