Skip to content
Talk to our solutions team

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:

KindWhat it isDeclares
A foldera top folder of rules/, with every folder under it, called by its namenothing: rules over the facts a call sends
A static scopean entry of rules/rules.yaml, called by its nameits paths, and optionally limits, a facts contract, sources and what it reads and writes
Pathsfolders 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.

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.grl

Rules 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.

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 }
KeyMeaning
namehow a call names the scope: letters, digits, ., _ and -. Unique in the file
descriptionshown by GET /rulesets
pathswhat the scope runs: folders and .grl files under rules/, without the rules/ prefix. At least one, and each must be there
nestedwith true, each folder in paths brings the folders under it
limitstimeout (a duration such as 2s) and maxcycles for this scope’s executions. The service’s rules.exec.maxtimeout and rules.exec.maxcycles cap them
factsthe facts a call may send (below)
sourceswhere the scope reads and writes data (below)
datawhat it reads, which records it decides over, and what it may write (below)
onthe 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.

KeyMeaning
fields.<name>.typeany, object, array, string, number, integer or boolean
fields.<name>.requiredthe call must send it
stricta 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}}

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 }
kindReads and writes throughAs
datathe tenant’s data.svc and its API; reads, writes, versions and change eventsthe caller, with their own token
seshatthe same tables, read in-process through rules.svc’s own read pool; it only readsthe 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 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]
KeyMeaning
namethe fact the rows are bound as. It may not be a declared fact, or another read’s name
source, entitywhere to read
selectthe fields read: a scope sees the fields it selects and nothing more. Required
wherea 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
maxthe most rows it may bind (at most 1000); more fails the execution rather than binding part of them
cachehow 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).

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] }
KeyMeaning
asthe fact the record is bound as
selectthe fields read; must include key
keya unique, orderable field: how records are named in a call, and the cursor of every sweep page
wherewhich records the scope may decide over; a sweep’s own where can only narrow it
relatedrecords 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

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
KeyMeaning
source, entitywhere the write goes
opsany of create, update, upsert, delete
fieldsthe fields it may set. Required unless the write only deletes
concurrencylast-write-wins for an entity without versions. Without it, an update or delete needs an entity with versions, and carries the version it read
atomicityper-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.

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] }
KeyMeaning
opsany of create, update, delete. A deleted record has nothing to evaluate, so delete triggers nothing
fieldsread 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.

A where is static filter text over the entity’s fields, with literal values only. Each source translates it to its own query.

FormExample
comparisontotal > 1000, status = 'open', status != 'void' (also <>), <, <=, >=
and, or, not, parenthesesstatus = 'open' and (total > 1000 or rush = true)
in, not incurrency in ('EUR', 'CHF')
betweentotal between 100 and 1000
is null, is not nullreview_tier is null
like, not likename like 'ACME%' (% any run, _ one character; case-sensitive)
a nested fieldcustomer.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.

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; no paths, 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; data without sources.
  • A read with no select, no entity, or a where that does not parse or holds template syntax; a match reading a fact facts.fields does not declare; a knowledge or related name used twice or taken by a declared fact.
  • An over with no key, or a key it does not select; a related whose match is not one field the over selects.
  • A write with no entity or ops, an unknown op, no fields (unless it only deletes), a concurrency other than last-write-wins; writes to two sources without atomicity: per-source.
  • on without data.over, or with an unknown op.
  • A literal act.* call that the scope’s writes do 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:

Terminal window
kis rules check products/shop

It 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.