Concepts
Rulesets
Work with the parsed, reusable policy program consumed by the solver.A ruleset is the in-memory, parsed form of one policy source. It is what the
solver consumes. It is not the raw .dl text, and it is not the manifest — it
is the result of parsing the verified source into a structured program the
engine can evaluate.
Three things must exist before a ruleset can be solved:
Ruleset = parsed policy program (facts, rules, identity, stratification)
EDB = runtime input facts supplied by the caller
Solve = evaluation of ruleset + EDB → derived IDB factsThe ruleset is loaded once and reused across evaluations. The EDB changes with every request. The solver reads both and produces a result.
For programming details, see C Stable rulesets, Python rulesets and TypeScript rulesets.
Why rulesets exist
Rulesets exist to separate three concerns that must not be mixed.
Human-authored Datalog text. Useful for authoring, review, and version control. Not the shape the solver wants to execute.
Parsed, frozen program. Carries the policy facts, rules, identity, stratification order, symbol table, and predicate registry. Immutable after parsing. Reused across evaluations.
Runtime request facts. This input changes from one evaluation to the next and must stay outside the ruleset. See Runtime EDB.
Without this separation, a single policy evaluation would require re-parsing the source text on every request. The ruleset is the parsed form — parse once, solve many times.
Where rulesets fit
The ruleset is the output of loading and the input to solving. It provides the selected domain's vocabulary and parsed logic. The public input buffer can be built independently; solving validates it against the selected policy.
Load sourcemanifest or inline
Rulesetvalidated immutable program
Prepare sessionreuse the selected policy
Supply request inputcomplete EDB snapshot
Solverules + request facts
- Load source → Ruleset
- Ruleset → Prepare session
- Prepare session → Supply request input
- Supply request input → Solve
See Manifest Loading for how the manifest governs source selection and identity. See Registries for how the domain vocabulary is installed before parsing. See Runtime EDB for how to supply the complete request input. See Solving for what the solver does with a ruleset.
Example policy source
The following policy continues the document_access example from
Registries. The domain declares five predicates:
owns/2 (EDB), shared_with/2 (EDB), sensitivity_level/2 (EDB),
blocked/1 (POLICY_FACT), and allow/2 (IDB|QUERY). It also declares
"mallory" in its source atoms because the policy names that constant.
Text only. Parsed into a ruleset, not executed directly.
The two rules are deliberate. They show that one ruleset can hold multiple rules with the same head, that authorization can be derived through more than one positive path, and that both paths can share the same numeric guard while remaining fail-closed.
What is inside a ruleset
Parsing transforms the source text into these structures.
Source textpolicy facts + rules
Validate and parsevocabulary, safety, dependencies
Rulesetstructured immutable program
- Source text → Validate and parse
- Validate and parse → Ruleset
| Stored information | What it describes |
|---|---|
| Identity | Policy identifier, selected domain and source fingerprint. |
| Facts | blocked("mallory"), a POLICY_FACT base fact. |
| Rules | Two rules deriving allow/2 with the same threshold guard. |
| Strata | Completed lower relations precede their negated or aggregated uses. |
| Support | Predicate declarations, policy symbols and validated comparison guards. |
Lifecycle
Select domaininstall and freeze vocabulary
Parse sourcevalidate rules + strata
Prepare sessionretain compiled policy
Evaluate requestcomplete EDB → result
Release resultrepeat with the same session
- Select domain → Parse source
- Parse source → Prepare session
- Prepare session → Evaluate request
- Evaluate request → Release result
The loader performs installation, freezing and parsing. The application does not manipulate the internal ruleset structures. Keep the prepared session for reuse, and release each result before the next solve on that session. Releasing the original policy-loading handle does not invalidate an existing session. The engine manages the compiled program's lifetime.
Policy source language
For a guided tour of the syntax itself — facts, rules, wildcards, negation, comparisons, aggregates, Why-true and Why-false — see the language introduction. This section covers what the parser enforces when it turns that source into a ruleset.
Clause kinds
A .dl policy source may contain two kinds of clauses.
| Clause | Example | Meaning |
|---|---|---|
| Direct policy fact | blocked("mallory"). | A ground fact declared by the policy. Cannot be supplied by the runtime caller. |
| Rule | allow(User, Document) :- owns(User, Document), not(blocked(User)), sensitivity_level(User, Level), Level >= 3. | A Datalog derivation rule with negation and a numeric guard. |
Direct policy facts
Direct facts must use predicates declared as POLICY_FACT in the domain.
blocked("mallory"). /* OK — blocked/1 is POLICY_FACT */
Direct facts must be ground. Variables and anonymous variables are rejected:
blocked(X). /* rejected — variable in fact */
blocked(_). /* rejected — anonymous variable in fact */
Rules
Rules derive IDB facts from EDB, POLICY_FACT and other IDB relations. QUERY is an optional observation permission, not a separate fact origin.
allow(User, Document) :- /* IDB|QUERY head */
owns(User, Document), /* EDB body atom */
not(blocked(User)), /* POLICY_FACT, negated */
sensitivity_level(User, Level),
Level >= 3. /* numeric guard */
Rule heads must target IDB predicates. EDB and POLICY_FACT predicates cannot appear in a rule head:
owns(User, Document) :- blocked(User). /* rejected — owns/2 is EDB */
blocked(User) :- owns(User, Document). /* rejected — blocked/1 is POLICY_FACT */
Terms
| Kind | Example | Notes |
|---|---|---|
| Named variable | User, Document | Uppercase. A head variable must be bound by a positive body atom or an aggregate result. |
| Anonymous variable | _ | Allowed in positive body atoms only. Each occurrence is a fresh variable. |
| String atom | "mallory" | A symbol constant; must be in the domain's source atoms unless manifest loading explicitly permits otherwise. |
| Integer | 42 | Integer term. |
| Boolean | true | Boolean term. |
Anonymous variables
| Location | Allowed |
|---|---|
| Direct fact | no |
| Rule head | no |
| Positive body atom | yes |
Negated atom not(...) | no |
| Comparison | no |
Rule body literals
| Kind | Example |
|---|---|
| Positive atom | sensitivity_level(User, Level) |
| Negated atom | not(blocked(User)) |
| Comparison | Level >= 3 |
| Aggregate | count(Document, owns(User, Document), N) |
Disjunction (or)
A rule body may use a bounded positive disjunction: the operator or joins two
or more positive atoms and offers alternative ways to satisfy that part of the
body. It is pure syntactic sugar — the parser desugars it into ordinary rules
before anything else runs.
eligible(User, File) :-
account(User),
owns(User, File) or admin(User),
allowed(File).
This single source rule is lowered into the two ordinary rules below, in this
order. Nothing downstream — the solver, the rule representation, the canonical
hash, the proof tree — ever observes an or construct:
eligible(User, File) :- account(User), owns(User, File), allowed(File).
eligible(User, File) :- account(User), admin(User), allowed(File).
Grammar. Only positive atoms may be joined:
body := body_element ("," body_element)*
body_element := ordinary_literal | positive_disjunction
positive_disjunction := positive_atom ("or" positive_atom)+or binds more tightly than the comma. A group holds two or more alternatives,
and a body may contain several or groups. Expansion is the bounded cartesian
product of the groups, taken left to right with the first group varying slowest:
h(X) :- a(X) or b(X), c(X) or d(X).
expands, in order, to:
h(X) :- a(X), c(X).
h(X) :- a(X), d(X).
h(X) :- b(X), c(X).
h(X) :- b(X), d(X).
Bounds. Each expanded rule must fit the same static limits as a hand-written rule: the expanded body length may not exceed the body-literal limit, and the total number of expanded rules must fit the remaining rule capacity. An overflow is rejected with a bounded error, and the disjunction is published atomically — a clause whose expansion overflows leaves no partially-expanded rule behind.
or is contextual, not a reserved word. The operator is recognized only
between two positive atoms with no comma in between. A predicate legitimately
named or stays usable wherever an atom is expected — for example after a
comma:
h(X) :- a(X), or(X). /* or(X) is an ordinary atom here, not the operator */
OR, Or and other casings are never operators.
Comparison operators
Operators are restricted by term type. The type lattice is closed — only homogeneous comparisons are valid.
| Type | Supported operators | Rejected operators |
|---|---|---|
SYMBOL (interned string) | = != | < <= > >= |
INT (64-bit signed) | = != < <= > >= | — |
BOOL | = != | < <= > >= |
| Operator | Meaning |
|---|---|
= | equality |
!= | inequality |
< | less than — integers only |
<= | less than or equal — integers only |
> | greater than — integers only |
>= | greater than or equal — integers only |
Symbol ordering and bool ordering are not supported. Cross-type comparisons
("alice" = 42, true != 0) are always rejected.
Parse-time enforcement — if both sides of a comparison are ground literals
and the comparison is invalid for the type, the parser rejects the clause with
PARSER_INVALID_COMPARISON. This catches most invalid comparisons before any
evaluation runs.
Runtime enforcement — if a variable binds to a type incompatible with the comparison, solving fails with an invalid-field status and returns no result. The comparison diagnostic identifies the left and right term kinds and the operator that caused the error. This is the DENY path for comparisons that cannot be caught statically.
Comparisons are used in rule bodies to constrain which bindings produce a
derived fact. For example, a domain that adds a sensitivity_level/2 EDB
predicate (user, integer level) could write:
/* allow only if the user's sensitivity level is >= 3 */
allow(User, Document) :-
owns(User, Document),
not(blocked(User)),
sensitivity_level(User, Level),
Level >= 3.
The variable Level is bound by the positive atom sensitivity_level(User, Level)
before it appears in the comparison. A rule where Level appeared only in the
comparison would be rejected as unsafe.
If the EDB fact supplies an incorrect type — for example sensitivity_level("alice", "high")
instead of sensitivity_level("alice", 4) — the parser cannot catch this at
load time because the EDB is supplied at runtime. The solver detects the
type mismatch when evaluating Level >= 3 and returns an invalid-field error.
Arithmetic expression filters
Comparison literals may contain bounded integer arithmetic expressions. These expressions are filters only: they evaluate a boolean condition over values that are already bound by positive body atoms. They never create new terms and never bind variables.
allow(User) :-
score(User, Score),
Score + 1 >= 10.
allow(Project) :-
rollback_frames(Project, Frames),
tick_ms(Tick),
Frames * Tick > 150.
Supported arithmetic operators are +, -, and *. Multiplication has higher
precedence than addition and subtraction, and parentheses may be used to group
expressions. Arithmetic is integer-only; strings and booleans cannot be used as
arithmetic operands.
Arithmetic filters are non-generative. Every variable inside the arithmetic expression must already appear in a positive body atom:
next(X, Y) :- value(X), Y = X + 1. /* rejected: Y is not bound */
allow(U) :- score(U, S), S + T < 10. /* rejected: T is not bound */
Division, modulo, floating point, string concatenation, and wall-clock access are not part of the ruleset language. Arithmetic expressions are also rejected in rule heads, facts, and atom arguments.
Aggregates
Aggregates summarize a completed relation instead of deriving one output for every matching input fact.
document_count(User, N) :-
user(User),
count(Document, owns(User, Document), N).
has_multiple_documents(User) :-
document_count(User, N),
N >= 2.
For each user supplied by user/1, the count reads that user's owns/2
facts and binds N to the number of different documents. The domain declares
user/1 and owns/2 as EDB and both heads as IDB.
| Aggregate | What it computes | Known group with no matches |
|---|---|---|
count | Number of distinct typed projected values. | Zero. |
min | Smallest integer value. | No aggregate result. |
max | Largest integer value. | No aggregate result. |
sum | Sum over distinct complete source facts, not distinct projected numbers. | Zero. |
For example, two different source facts carrying the same number can both
contribute to sum. Repeating one identical fact does not contribute twice.
Numeric aggregates accept integers from 0 to 2,147,483,647 and reject overflow;
strings and booleans are not numeric inputs.
The projection variable is local to the aggregate. Other source variables must already be bound by ordinary positive atoms; the result variable may then be used in the head or later conditions. A relation being counted or summed cannot depend back on its own aggregate result. Positive recursion may finish in a lower layer before that layer is aggregated.
See the language guide for grouping, zero versus absent groups, distinct values, arithmetic limits and explanation examples.
Variable safety
The parser enforces Datalog safety before accepting any rule.
| Safety rule | Meaning |
|---|---|
| Head variables must be bound | A positive body atom or a valid aggregate result must supply each head variable's value. |
| Negated variables must be bound | Each variable in not(...) must have a value supplied by a positive body atom or an earlier aggregate result. |
Safe rule:
allow(User, Document) :-
owns(User, Document), /* binds User and Document */
not(blocked(User)), /* User is already bound */
sensitivity_level(User, Level),
Level >= 3.
Unsafe rule — Other is not bound:
allow(User, Document) :-
owns(User, Document),
not(blocked(Other)). /* Other appears only in not() */
Safety of disjunctions
When a body uses or, every expanded rule is validated
independently against these safety rules — the parser never unions variables
across branches. A variable needed by the head, a comparison, or a negated atom
must have a valid binding in each branch that needs it.
eligible(User, File) :- owns(User, File) or admin(User).
is rejected: the admin(User) branch expands to
eligible(User, File) :- admin(User)., where File appears in the head but is
bound by no positive body atom. Adding a common conjunct that binds File in
both branches makes the rule safe:
eligible(User, File) :- owns(User, File) or admin(User), allowed(File).
Here both expanded rules end with allowed(File), so File is bound in each.
Negation and stratification
Negation and aggregates both require ordered evaluation layers. A relation must be complete before a higher layer tests its absence or summarizes it. The dependency constraints are:
positive dependency: stratum(head) >= stratum(body)
negative dependency: stratum(head) > stratum(body)
aggregate dependency: stratum(head) > stratum(source)For the document_access example:
blocked/1 → stratum 0 (POLICY_FACT, no dependencies)
allow/2 → stratum 1 (depends negatively on blocked/1; sensitivity_level/2 is EDB)| Condition | Behavior |
|---|---|
| Positive dependencies only | Positive recursion may run within one layer. |
| Aggregate of a lower relation | Accepted when that relation can be completed first. |
| Stratified negation | Accepted. |
| Recursion through negation or an aggregate | Rejected — not stratifiable. |
| Stratum limit exceeded | Rejected. |
Static bounds
The parser is bounded at compile time.
| Bound | Meaning |
|---|---|
| Variables per rule | Maximum named plus anonymous variables per rule. |
| Terms per atom | Maximum terms per atom. |
| Direct policy facts | Maximum direct policy facts. |
| Expanded rules | Maximum rules. |
| Body literals | Maximum literals in a rule body. |
| Evaluation layers | Maximum stratification depth. |
Exceeding a parser capacity rejects loading instead of returning a partial policy. Capacities depend on the build profile; see C Stable limits and errors.
What you have learned
KEEP THIS MODEL
Keep reusable logic separate from request data- A policy supplies facts and rules
Rules derive new facts from existing ones. Policy facts stay with the policy; runtime EDB facts are supplied for each request.
- The domain sets the vocabulary
Predicate names, arities, origins and policy-source atoms must agree with the selected domain.
- Complete a relation before reading its absence or aggregate
Positive recursion may finish within a lower layer. Negation and aggregates read completed lower relations; a cycle through either is rejected.
- Aggregates answer questions about groups
count counts distinct typed values; min, max and sum operate on integer values under their documented binding and empty-group rules.
- Loading is bounded and fail-closed
Invalid rules or exceeded parser capacities reject the load instead of returning a partial policy.