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.
Four ways to run it
Section titled “Four ways to run it”| How | Over | Writes | Runs as |
|---|---|---|---|
POST /rules/execute | the facts you send, plus the scope’s knowledge | never: proposals come back | the caller |
POST /rules/evaluate | the records you name, up to 100 | with apply: true | the caller |
| Sweeps | every record the scope matches, in the background | with apply: true | the system user, once started |
| Events | the record a data change names | always | the system user |
Sources
Section titled “Sources”A source is the tenant’s own product data, named in the scope:
sources: shop: { kind: data, datastore: main }data | seshat | |
|---|---|---|
| Through | the tenant’s data.svc and its API | rules.svc’s own read pool on the same tables, in-process |
| Reads | yes | yes |
| Writes | yes, through data.svc’s whole write path: its validation, hooks, versions, events and its record of who wrote | no: a scope writes through a data source |
| Change events | yes, for events | no |
| Access | data.svc’s own checks for the caller’s token | the product’s access rules and row-level security, run in-process for the caller |
| Encrypted or tokenized fields | as data.svc serves them to the caller | refused |
| Needs | the tenant’s data endpoint; a system user for the checks at load | the 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
Section titled “Knowledge”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"));}{ "invoice": { "customer_id": "c1" }, "customer": { "segment": "gold" } }{ "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.
The records a scope decides over
Section titled “The records a scope decides over”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] }Who the work runs as
Section titled “Who the work runs as”Every read and write runs as a principal, fixed when the work starts and returned with the result:
| Work | Principal |
|---|---|
execute, evaluate, a watched run, starting a sweep | the caller: the user, bot, service or agent their token names |
| a sweep once it is running | the system user; the caller who started it is kept as startedby |
| a data change a scope reacts to | the 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.
Writes
Section titled “Writes”Rules propose; rules.svc applies, and only when all of these hold:
- the call asked to apply (
apply: true, or an event); - the principal may
applythe scope (Access); - the scope’s
writesallow the source, entity, operation and fields; - 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.
Continue with
Section titled “Continue with”- Evaluating records
- Sweeps
- Events
- Scopes: the full
rules.yamlreference.