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.
Routes
Section titled “Routes”| Route | What it does | Writes | Needs |
|---|---|---|---|
POST /rules/execute | A decision: runs a scope over the facts you send | never; proposed writes come back as actions | execute |
POST /rules/evaluate | Decisions over records: runs a static scope once per record you name | only with apply: true | evaluate, and apply to write |
POST /rules/sweeps and GET /rules/sweeps/… | Every record a static scope matches, a page at a time, in the background | only with apply: true | evaluate, and apply to write |
POST /rules/run | A watched run: the output, every write, the rules that fired and, with explain, why the others did not | never | run; draft when you send rule text |
GET /rulesets | What the tenant serves: its folders of rules, its static scopes, whether they loaded | — | read |
POST /rule | The earlier call: {name, …facts}, answering out alone. Kept for one release; use POST /rules/execute | never | execute |
POST /rules/events | Where data.svc delivers record changes, for scopes that react to them | yes | a 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 call
Section titled “Every call”Every route except POST /rules/events takes the same headers:
| Header | Value |
|---|---|
Authorization | Bearer <token> from iam, or the tenant’s API key |
X-Customer, X-Env, X-Product, X-Tenant | the tenant, one segment each: acme, prod, erp, eu1 |
Content-Type | application/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.
What a call runs
Section titled “What a call runs”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.invoicesruns every rule inrules/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.
POST /rules/execute
Section titled “POST /rules/execute”A decision over the facts you send. It never writes.
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 } ]}| Field | What it is |
|---|---|
scope | the scope that ran |
stamp | a 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 |
principal | who the rules ran as: the caller, with their principal type (user, bot, service, …) |
out | what the rules wrote with out.*: the decision |
findings | what 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 |
actions | the writes the rules proposed with act.*, each with its rule and cycle. execute applies none of them; see act |
audit | with options.audit: what each rule logged, in order, starting with an executed line per rule that fired |
history | with 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.
POST /rules/run
Section titled “POST /rules/run”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 } ]}| Key | What it takes |
|---|---|
scope | a static scope or top folder the tenant serves |
paths, nested | instead of scope: folders or .grl files under rules/, with or without the folders under them |
input, facts | the facts, as for execute |
files | a 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 |
explain | each 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.
GET /rulesets
Section titled “GET /rulesets”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).
POST /rule
Section titled “POST /rule”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.
Documents
Section titled “Documents”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.
Tenants
Section titled “Tenants”rules.svc serves the tenants its configuration lists, each with its own product (Configuration).
| The tenant | The answer |
|---|---|
| not in the configuration | 400 rules-tenant-not-served |
| configured, its rules not loaded yet | 503 rules-tenant-not-loaded, with Retry-After |
| loaded | served |
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.
Health and readiness
Section titled “Health and readiness”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.
Continue with
Section titled “Continue with”- 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.