Skip to content
Talk to our solutions team

The rules service

rules.svc runs a product’s own rules for every tenant it serves. It reads the product’s rules/ folder, checks every rule in it when it loads, keeps it current as the folder changes, and answers calls that name a scope: a set of the product’s rules, called by name. A caller sends facts and gets back what the rules decided. With a static scope that reads data, the rules can also read records and propose writes to them; see Rules over data.

The rules are the same GRL that kis rules runs from the command line, so a rule set behaves the same on both. What differs is where the rules come from (the product, never the request), who may run them, and what comes back.

RouteWhat it doesWritesNeeds
POST /rules/executeA decision: runs a scope over the facts you sendnever; proposed writes come back as actionsexecute
POST /rules/evaluateDecisions over records: runs a static scope once per record you nameonly with apply: trueevaluate, and apply to write
POST /rules/sweeps and GET /rules/sweeps/…Every record a static scope matches, a page at a time, in the backgroundonly with apply: trueevaluate, and apply to write
POST /rules/runA watched run: the output, every write, the rules that fired and, with explain, why the others did notneverrun; draft when you send rule text
GET /rulesetsWhat the tenant serves: its folders of rules, its static scopes, whether they loaded—read
POST /ruleThe earlier call: {name, …facts}, answering out alone. Kept for one release; use POST /rules/executeneverexecute
POST /rules/eventsWhere data.svc delivers record changes, for scopes that react to themyesa signed delivery, no token

The last column is the access action each route asks for; Access says who holds each one by default and how a product changes that. Evaluate, sweeps and events have pages of their own under Rules over data.

Every route except POST /rules/events takes the same headers:

HeaderValue
AuthorizationBearer <token> from iam, or the tenant’s API key
X-Customer, X-Env, X-Product, X-Tenantthe tenant, one segment each: acme, prod, erp, eu1
Content-Typeapplication/json on a POST

A token’s tenant must be the one the headers name. The caller the token names is the principal of the call: the rules run as them, every source the scope reads checks them, and the response says who they were.

A body is at most rules.body.maxbytes (1 MiB by default); a larger one answers 413 rules-body-too-large. execute, evaluate and sweeps refuse a body key they do not read (400 rules-json-request-parse-failed), so a misspelt option fails instead of being ignored.

scope names one of:

  • a static scope of the product’s rules/rules.yaml, which can bound its executions, declare the facts it takes and read data (Scopes);
  • or a top folder of rules/, called by its name. invoices runs every rule in rules/invoices/ and the folders under it.

A static scope wins when a folder has the same name. A name that is neither answers 404 rules-scope-not-found.

input is a map; each of its top-level keys is a fact, as each top-level key of an input file is under kis rules. facts is a list of named facts, {"name": "channel", "value": {…}}, added after input; a fact without a name has its keys merged at the top. A call takes at most 20 named facts.

A fact may not take a name the engine binds itself: act, address, agenda, array, audit, date, delivery, findings, in, log, map, math, name, num, out, strings, time, to, util, vocab. Bound as a fact, one would replace that helper or collector for the whole run, so the call is refused instead:

{"error":{"code":"rules-facts-invalid","context":null,"message":"map: the engine binds these names itself (act, address, agenda, array, audit, date, delivery, findings, in, log, map, math, name, num, out, strings, time, to, util, vocab); send the facts under other names","meta":null}}

A static scope that declares its facts holds every call to them: an undeclared fact under strict, a required one missing, or one of the wrong type answers 400 rules-facts-invalid, naming it.

A decision over the facts you send. It never writes.

Terminal window
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-Env: dev' -H 'X-Product: shop' -H 'X-Tenant: main' \
-d '{"scope": "invoice-review",
"input": {"invoice": {"id": "inv-7", "total": 1500}},
"options": {"audit": true, "history": true}}'

With these rules in rules/invoices/review.grl:

rule Review "large invoices go to a senior reviewer" salience 10 {
when
in.Double("invoice.total", 0.0) > 1000.0
then
out.Set("tier", "senior");
out.Set("product", "shop");
findings.Warn("Review", "amount", "large invoice");
}
rule Small "small invoices are approved" salience 5 {
when
in.Double("invoice.total", 0.0) <= 1000.0
then
out.Set("tier", "auto");
out.Set("product", "shop");
}

it answers 200:

{
"scope": "invoice-review",
"stamp": "sha256:e073e3b2320ab656c295ed22a4c645875a999db2bf30d02dc5f92d23e80f2bee",
"principal": { "id": "u-shop-reader", "ptyp": "user" },
"out": { "product": "shop", "tier": "senior" },
"findings": [
{ "rule": "Review", "severity": "WARNING", "category": "amount", "message": "large invoice" }
],
"actions": [],
"audit": [ { "rule": "Review", "message": "executed", "cycle": 1 } ],
"history": [
{ "key": "tier", "value": "senior", "rule": "Review", "cycle": 1 },
{ "key": "product", "value": "shop", "rule": "Review", "cycle": 1 }
]
}
FieldWhat it is
scopethe scope that ran
stampa digest of the exact rule text that decided. It changes when any file of the scope changes, so a caller can record which rules made a decision
principalwho the rules ran as: the caller, with their principal type (user, bot, service, …)
outwhat the rules wrote with out.*: the decision
findingswhat the rules raised with findings.*, each with its rule, severity, category, message, and the fields and details it was given. Always present, empty when none
actionsthe writes the rules proposed with act.*, each with its rule and cycle. execute applies none of them; see act
auditwith options.audit: what each rule logged, in order, starting with an executed line per rule that fired
historywith options.history: every write to out, in order, with the rule and cycle that made it

A rule that fails mid-run answers 422 rules-execution-failed, naming the rule and why; what the rules had written before it is under the error’s meta.partial. A run past its time answers 504 rules-execution-timeout. See Errors.

A watched run, for authoring and debugging: what the rules wrote, each write with its rule and cycle, the rules that fired in order, what they logged, and with explain, every rule’s when cycle by cycle, so a rule that did not fire says so.

{ "scope": "invoice-review", "input": { "invoice": { "total": 200 } }, "explain": true }
{
"scope": { "name": "invoice-review", "paths": ["invoices"], "limits": { "timeout": "2s", "maxcycles": 100 } },
"draft": false,
"rules": [
{ "rule": "Review", "file": "rules/invoices/review.grl" },
{ "rule": "Small", "file": "rules/invoices/review.grl" }
],
"output": { "product": "shop", "tier": "auto" },
"writes": [
{ "key": "tier", "value": "auto", "rule": "Small", "cycle": 1 },
{ "key": "product", "value": "shop", "rule": "Small", "cycle": 1 }
],
"fired": [ { "rule": "Small", "cycle": 1 } ],
"actions": [],
"log": [],
"matched": 1,
"cycles": 2,
"durationms": 0.08,
"evaluations": [
{ "rule": "Review", "cycle": 1, "matched": false },
{ "rule": "Small", "cycle": 1, "matched": true },
{ "rule": "Review", "cycle": 2, "matched": false }
]
}
KeyWhat it takes
scopea static scope or top folder the tenant serves
paths, nestedinstead of scope: folders or .grl files under rules/, with or without the folders under them
input, factsthe facts, as for execute
filesa draft: the scope’s files sent whole, [{"path": "rules/invoices/review.grl", "text": "…"}], run instead of what is loaded. At most 200 files and 4 MiB. A draft needs the draft action, because it executes rule text the caller sends
explaineach rule’s evaluation, cycle by cycle

A run never writes and never changes what is loaded. It runs on an engine of its own, bounded at 10 seconds and 2000 cycles whatever the scope’s limits say. A run of a static scope that reads data reads it as the caller. A rule that fails or does not load is part of the answer: 200 with error set, because the person watching needs to see it. The call itself is refused only for access, an unknown scope, or facts the scope does not take.

What the tenant serves: each top folder with its files, the rules in each, its vocabulary files and whether folders under it hold rules; each static scope as rules.yaml declares it; loaded; and error when the latest load of the folder failed.

{
"folders": [
{ "name": "invoices", "files": [ { "path": "rules/invoices/review.grl", "rules": ["Review", "Small"] } ], "vocab": [], "nested": false }
],
"scopes": [
{ "name": "invoice-review", "description": "what an invoice needs before it is paid", "paths": ["invoices"],
"limits": { "timeout": "2s", "maxcycles": 100 },
"facts": { "strict": true, "fields": { "invoice": { "type": "object", "required": true }, "channel": { "type": "string" } } } }
],
"loaded": true
}

The listing holds only the folders and scopes the caller may read; a product can hide one (Access).

The call an earlier rules.svc served, kept for one release so callers can move: the body’s name picks a top folder, every other key is a fact, and the answer is out alone.

{ "name": "invoices", "invoice": { "total": 5000 } }
{ "product": "shop", "tier": "senior" }

It takes the same access, facts checks and errors as execute. It returns no findings, actions, stamp or principal. Move to POST /rules/execute: name becomes scope, and the facts go under input.

rules.svc serves no documents today: a request has no bbox key, and rules that query a document (bbox.*) run through kis rules only. See Documents.

rules.svc serves the tenants its configuration lists, each with its own product (Configuration).

The tenantThe answer
not in the configuration400 rules-tenant-not-served
configured, its rules not loaded yet503 rules-tenant-not-loaded, with Retry-After
loadedserved

A tenant’s rules load again whenever a file under the product’s rules/ changes. A load that succeeds is served whole; one that fails keeps what was served before, and GET /rulesets says why in error. One tenant never sees another’s rules, scopes or loads.

The chassis probes answer as for every platform block. Readiness lists each tenant as a dependency (tenant:<key>) without gating on it, so one tenant’s broken rules/ folder never takes the instance out of rotation for the others.

  • Configuration: the rules: block, tenants, sweeps and events.
  • Access: who may execute, evaluate, apply and author.
  • Scopes: rules/rules.yaml.
  • Rules over data: sources, evaluate, sweeps and events.
  • Errors: every code and its status.