Skip to content
Talk to our solutions team

Rules over data

A rule decides over facts. With a static scope, some of those facts come from the product’s own data, read by rules.svc before the rules run, and the rules can propose changes to that data. The rules stay the same GRL: a record arrives as a fact, read with in.*, and a proposal is an act call.

Everything a scope reads and writes is declared in rules/rules.yaml (Scopes), checked when the tenant’s rules load, and done as a principal whose rights are checked where the data lives.

HowOverWritesRuns as
POST /rules/executethe facts you send, plus the scope’s knowledgenever: proposals come backthe caller
POST /rules/evaluatethe records you name, up to 100with apply: truethe caller
Sweepsevery record the scope matches, in the backgroundwith apply: truethe system user, once started
Eventsthe record a data change namesalwaysthe system user

A source is the tenant’s own product data, named in the scope:

sources:
shop: { kind: data, datastore: main }
dataseshat
Throughthe tenant’s data.svc and its APIrules.svc’s own read pool on the same tables, in-process
Readsyesyes
Writesyes, through data.svc’s whole write path: its validation, hooks, versions, events and its record of who wroteno: a scope writes through a data source
Change eventsyes, for eventsno
Accessdata.svc’s own checks for the caller’s tokenthe product’s access rules and row-level security, run in-process for the caller
Encrypted or tokenized fieldsas data.svc serves them to the callerrefused
Needsthe tenant’s data endpoint; a system user for the checks at loadthe tenant’s datastores/dbpools naming data.svc’s schema

Pick data for anything that writes, and when data.svc’s view of the record is the one that matters. seshat saves a network hop for read-heavy knowledge. Both answer the same facts for the same records. Configuration shows the tenant keys each needs.

When the tenant’s rules load, each source a static scope names is opened and held to the scope: every entity and field must exist, every written field must be writable, and every entity it updates or deletes must carry versions unless its write says concurrency: last-write-wins. A scope that does not hold fails the load and the tenant keeps what it served.

Knowledge is read before every execution and bound as facts, so rules read it like anything the caller sent:

data:
knowledge:
- { name: customer, source: shop, entity: customer, match: { id: invoice.customer_id }, select: [segment] }
rule Segment "the invoice's customer segment" salience 1 {
when
true
then
out.Set("segment", in.Str("customer.segment", "none"));
}
facts
{ "invoice": { "customer_id": "c1" }, "customer": { "segment": "gold" } }
out
{ "segment": "gold" }

The facts above are what the rule sees once rules.svc has read the customer: the caller sent only invoice. A match finds one record and binds it (nothing when there is none); a where binds a list of rows, at most max. More rows than that fail the execution rather than binding part of them. A read can be cached for a while with cache, per tenant, rule text and caller.

A scope with data.over decides over records of one entity: each evaluation reads a record, with its related records, binds it under as, runs the rules once, and plans the record’s proposals.

data:
over:
source: shop
entity: invoice
as: invoice
select: [id, total, customer_id, review_tier]
key: id
related:
- { name: customer, source: shop, entity: customer, match: { id: customer_id }, select: [segment] }
writes:
- { source: shop, entity: invoice, ops: [update], fields: [review_tier] }
- { source: shop, entity: task, ops: [create], fields: [id, subject] }

Every read and write runs as a principal, fixed when the work starts and returned with the result:

WorkPrincipal
execute, evaluate, a watched run, starting a sweepthe caller: the user, bot, service or agent their token names
a sweep once it is runningthe system user; the caller who started it is kept as startedby
a data change a scope reacts tothe system user

The system user is a normal user of each tenant, with a password that rules.svc’s configuration holds (Configuration). Grant it what the background work needs, and nothing else: its privileges bound what sweeps and events can do, and a sweep checks them again before every page.

rules.svc’s own service identity never stands in for a principal. A data source hands data.svc the caller’s own token, so data.svc records the caller as the writer; a seshat source runs the product’s access rules and row-level security for the caller.

Rules propose; rules.svc applies, and only when all of these hold:

  1. the call asked to apply (apply: true, or an event);
  2. the principal may apply the scope (Access);
  3. the scope’s writes allow the source, entity, operation and fields;
  4. the source lets the principal write that record.

Applying is safe to repeat. A field set to what it holds is dropped, so a record decided twice writes nothing the second time. An update carries the version it read: when the record changed in between, the record is read and decided again once, then reported as a conflict. A record whose writes span two sources, where the second fails after the first applied, is reported as partial. Evaluating records shows each of these in the response.