Scopes
A scope is a set of a product’s rules that runs as one: one program, one failure domain, one namespace of rule names. Every call to rules.svc names the scope it runs. There are three kinds:
| Kind | What it is | Declares |
|---|---|---|
| A folder | a top folder of rules/, with every folder under it, called by its name | nothing: rules over the facts a call sends |
| A static scope | an entry of rules/rules.yaml, called by its name | its paths, and optionally limits, a facts contract, sources and what it reads and writes |
| Paths | folders or .grl files a watched run names (POST /rules/run with paths) | nothing |
A static scope wins over a folder of the same name. Everything below is about static scopes.
The folder
Section titled “The folder”rules/ rules.yaml static scopes extend/access.yaml who may do what (Access) invoices/ review.grl limits/large.grl vocab.yaml routing/ route.grl common/ currency.grlRules live in folders under rules/, never directly in it. A folder’s vocabulary files
(vocab.yaml, or any *.vocab.yaml, .yml or .json) apply to the rules in that folder and the
folders under it; a subfolder’s lists override its parent’s.
rules/rules.yaml
Section titled “rules/rules.yaml”scopes: - name: invoice-review description: what an invoice needs before it is paid paths: [invoices, common/currency.grl] nested: true limits: { timeout: 2s, maxcycles: 100 } facts: strict: true fields: invoice: { type: object, required: true } channel: { type: string }| Key | Meaning |
|---|---|
name | how a call names the scope: letters, digits, ., _ and -. Unique in the file |
description | shown by GET /rulesets |
paths | what the scope runs: folders and .grl files under rules/, without the rules/ prefix. At least one, and each must be there |
nested | with true, each folder in paths brings the folders under it |
limits | timeout (a duration such as 2s) and maxcycles for this scope’s executions. The service’s rules.exec.maxtimeout and rules.exec.maxcycles cap them |
facts | the facts a call may send (below) |
sources | where the scope reads and writes data (below) |
data | what it reads, which records it decides over, and what it may write (below) |
on | the data changes it reacts to (below) |
A key the file does not know, or a value of the wrong type, fails the load, naming the line.
facts is the scope’s contract with its callers. Every call that runs the scope is held to it, so a
misspelt key fails the call instead of reaching the rules as a missing fact.
| Key | Meaning |
|---|---|
fields.<name>.type | any, object, array, string, number, integer or boolean |
fields.<name>.required | the call must send it |
strict | a top-level fact the scope does not declare is refused |
A call that breaks the contract answers 400 rules-facts-invalid, naming each fact:
{"error":{"code":"rules-facts-invalid","context":null,"message":"the facts do not meet scope invoice-review's contract: extra is not a fact scope invoice-review takes (facts.strict)","meta":null}}Sources
Section titled “Sources”A scope that reads or writes data names its sources. Each is the tenant’s own product data, under a name the scope’s other keys use:
sources: shop: { kind: data, datastore: main } db: { kind: seshat, datastore: main }kind | Reads and writes through | As |
|---|---|---|
data | the tenant’s data.svc and its API; reads, writes, versions and change events | the caller, with their own token |
seshat | the same tables, read in-process through rules.svc’s own read pool; it only reads | the caller, with the product’s access rules and row-level security run for them |
datastore is the product’s datastore the source reads. When a tenant’s rules load, every source a
static scope names is opened and held to what the scope does with it: every entity and field it
names must exist, a field it writes must be writable, and an entity it updates must carry versions
unless the write says concurrency: last-write-wins. Rules over data
says how each source reads, writes and checks access.
Knowledge
Section titled “Knowledge”Knowledge is data read before every execution and bound as facts: thresholds, rates, the customer an invoice belongs to.
data: knowledge: - name: thresholds source: db entity: review_threshold where: "active = true" select: [currency, amount] max: 500 cache: 5m - name: customer source: db entity: customer match: { id: invoice.customer_id } select: [segment]| Key | Meaning |
|---|---|
name | the fact the rows are bound as. It may not be a declared fact, or another read’s name |
source, entity | where to read |
select | the fields read: a scope sees the fields it selects and nothing more. Required |
where | a filter in the where language: static text only |
match | {field: fact.path}: the entity’s field must equal a fact’s value, bound as a parameter. Each fact it reads must be declared under facts.fields |
max | the most rows it may bind (at most 1000); more fails the execution rather than binding part of them |
cache | how long a read is kept, per tenant, rule text and caller. Without it, every execution reads afresh |
A read with where binds a list of rows. A read with match and no where binds the one record it
finds, or nothing when there is none (and more than one fails the execution). A fact a match
reads that the call did not send binds nothing, without a read.
Knowledge is read as the caller: two callers can see different rows. A call that sends a fact with
a knowledge read’s name is refused (400 rules-facts-invalid).
The records a scope decides over
Section titled “The records a scope decides over”over makes a scope one that evaluates records: it runs once
per record, with the record bound as a fact.
data: over: source: shop entity: invoice as: invoice select: [id, total, customer_id, review_tier] key: id where: "status = 'open'" related: - { name: customer, source: shop, entity: customer, match: { id: customer_id }, select: [segment] }| Key | Meaning |
|---|---|
as | the fact the record is bound as |
select | the fields read; must include key |
key | a unique, orderable field: how records are named in a call, and the cursor of every sweep page |
where | which records the scope may decide over; a sweep’s own where can only narrow it |
related | records read with each one, each bound as a fact under its name. match names one field: {related_field: record_field}, where the record’s field is one over selects |
Writes
Section titled “Writes”Rules never write: they propose with act, and a scope declares what
its rules may propose. A proposal outside the declaration is never applied.
data: writes: - { source: shop, entity: invoice, ops: [update], fields: [review_tier] } - { source: shop, entity: task, ops: [create], fields: [id, subject] } - { source: shop, entity: ticket, ops: [update], fields: [priority], concurrency: last-write-wins } atomicity: per-source| Key | Meaning |
|---|---|
source, entity | where the write goes |
ops | any of create, update, upsert, delete |
fields | the fields it may set. Required unless the write only deletes |
concurrency | last-write-wins for an entity without versions. Without it, an update or delete needs an entity with versions, and carries the version it read |
atomicity | per-source: required when the writes name more than one source, because a record’s writes are atomic within each source and not across them |
act.Set(field, value) proposes a field of the record being evaluated, so its write is an update
of the over entity. A literal act.* call in the scope’s rules, one whose source, entity and
fields are written out, is checked when the scope loads: one its writes do not allow fails the
load, naming the rule. A call that computes a name is checked when its action is applied.
Triggers
Section titled “Triggers”on makes a scope react to changes of its over records in data.svc: each change it lists
evaluates that record, with apply, as the system user.
on: - { ops: [create, update] }| Key | Meaning |
|---|---|
ops | any of create, update, delete. A deleted record has nothing to evaluate, so delete triggers nothing |
fields | read and checked, but it does not narrow a trigger yet: data.svc’s change events do not say which fields changed |
on needs data.over.
The where language
Section titled “The where language”A where is static filter text over the entity’s fields, with literal values only. Each source
translates it to its own query.
| Form | Example |
|---|---|
| comparison | total > 1000, status = 'open', status != 'void' (also <>), <, <=, >= |
| and, or, not, parentheses | status = 'open' and (total > 1000 or rush = true) |
| in, not in | currency in ('EUR', 'CHF') |
| between | total between 100 and 1000 |
| is null, is not null | review_tier is null |
| like, not like | name like 'ACME%' (% any run, _ one character; case-sensitive) |
| a nested field | customer.segment = 'gold' |
Strings are single-quoted, with '' for a quote. A field on the right-hand side, null in a
comparison, or template syntax ({{ }}, {% %}, ${ }) fails the load: a value that comes from a
fact goes through match, bound as a parameter, never into the text.
What fails a load
Section titled “What fails a load”Each failure names the file, the scope and, for a rule, the rule and line. The tenant keeps what it
served, and GET /rulesets says why.
- A name that is not letters, digits,
.,_and-, or a repeated name; nopaths, or a path that is not there. - An unknown key or a wrong type anywhere in the file.
- A source with no kind, an unknown kind, or no datastore; a read or write naming a source that is
not under
sources;datawithoutsources. - A read with no
select, no entity, or awherethat does not parse or holds template syntax; amatchreading a factfacts.fieldsdoes not declare; a knowledge or related name used twice or taken by a declared fact. - An
overwith no key, or a key it does not select; arelatedwhosematchis not one field theoverselects. - A write with no entity or ops, an unknown op, no fields (unless it only deletes), a
concurrencyother thanlast-write-wins; writes to two sources withoutatomicity: per-source. onwithoutdata.over, or with an unknown op.- A literal
act.*call that the scope’swritesdo not allow. - A source that cannot be opened, or that lacks what the scope does with it: an entity or field it
does not have, a field it cannot write, an update without versions or
last-write-wins. - Any rule of the scope that does not load (Validating a rule set).
Check before you push, from the product’s root or naming its rules/ folder:
kis rules check products/shopIt loads the folder offline with the same code rules.svc uses and prints the same errors, for everything except the sources: it opens none, so what a source lacks is found only when rules.svc loads the scope.
Continue with
Section titled “Continue with”- Rules over data: what sources, knowledge and writes do at run time.
act: proposing writes from a rule.- The rules service: calling a scope.