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:

CODE
Ruleset          =  parsed policy program (facts, rules, identity, stratification)
EDB              =  runtime input facts supplied by the caller
Solve            =  evaluation of ruleset + EDB → derived IDB facts

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

Policy source (.dl)

Human-authored Datalog text. Useful for authoring, review, and version control. Not the shape the solver wants to execute.

Ruleset

Parsed, frozen program. Carries the policy facts, rules, identity, stratification order, symbol table, and predicate registry. Immutable after parsing. Reused across evaluations.

EDB

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.

ARCHITECTUREFrom loading to one request evaluation
    • Load sourcemanifest or inline
    • Rulesetvalidated immutable program
    • Prepare sessionreuse the selected policy
    • Supply request inputcomplete EDB snapshot
    • Solverules + request facts

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.

policies/document_access.dl

Text only. Parsed into a ruleset, not executed directly.

What the example shows
POLICY_FACT blocked("mallory") — loaded from source, not from runtime caller
Rule 1 allow/2 derived from owns/2 (EDB) if not blocked and sensitivity_level >= 3
Rule 2 allow/2 derived from shared_with/2 (EDB) if not blocked and sensitivity_level >= 3
Negation both rules fail-closed when blocked — stratified negation
Sensitivity caller state must also satisfy the numeric guard
Two paths one head predicate, multiple derivation paths, same guard

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.

ARCHITECTUREFrom source text to a reusable ruleset
    • Source textpolicy facts + rules
    • Validate and parsevocabulary, safety, dependencies
    • Rulesetstructured immutable program
Stored informationWhat it describes
IdentityPolicy identifier, selected domain and source fingerprint.
Factsblocked("mallory"), a POLICY_FACT base fact.
RulesTwo rules deriving allow/2 with the same threshold guard.
StrataCompleted lower relations precede their negated or aggregated uses.
SupportPredicate declarations, policy symbols and validated comparison guards.

Lifecycle

ARCHITECTUREPrepare once, evaluate many requests
    • Select domaininstall and freeze vocabulary
    • Parse sourcevalidate rules + strata
    • Prepare sessionretain compiled policy
    • Evaluate requestcomplete EDB → result
    • Release resultrepeat with the same session

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.

ClauseExampleMeaning
Direct policy factblocked("mallory").A ground fact declared by the policy. Cannot be supplied by the runtime caller.
Ruleallow(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.

DATALOG
blocked("mallory").   /* OK — blocked/1 is POLICY_FACT */

Direct facts must be ground. Variables and anonymous variables are rejected:

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

DATALOG
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:

DATALOG
owns(User, Document) :- blocked(User).   /* rejected — owns/2 is EDB */
blocked(User) :- owns(User, Document).   /* rejected — blocked/1 is POLICY_FACT */

Terms

KindExampleNotes
Named variableUser, DocumentUppercase. 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.
Integer42Integer term.
BooleantrueBoolean term.

Anonymous variables

LocationAllowed
Direct factno
Rule headno
Positive body atomyes
Negated atom not(...)no
Comparisonno

Rule body literals

KindExample
Positive atomsensitivity_level(User, Level)
Negated atomnot(blocked(User))
ComparisonLevel >= 3
Aggregatecount(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.

DATALOG
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:

DATALOG
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:

CODE
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:

DATALOG
h(X) :- a(X) or b(X), c(X) or d(X).

expands, in order, to:

DATALOG
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:

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

TypeSupported operatorsRejected operators
SYMBOL (interned string)= !=< <= > >=
INT (64-bit signed)= != < <= > >=—
BOOL= !=< <= > >=
OperatorMeaning
=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:

DATALOG
/* 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.

DATALOG
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:

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

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

AggregateWhat it computesKnown group with no matches
countNumber of distinct typed projected values.Zero.
minSmallest integer value.No aggregate result.
maxLargest integer value.No aggregate result.
sumSum 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 ruleMeaning
Head variables must be boundA positive body atom or a valid aggregate result must supply each head variable's value.
Negated variables must be boundEach variable in not(...) must have a value supplied by a positive body atom or an earlier aggregate result.

Safe rule:

DATALOG
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:

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

DATALOG
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:

DATALOG
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:

CODE
positive dependency:  stratum(head) >= stratum(body)
negative dependency:  stratum(head) >  stratum(body)
aggregate dependency: stratum(head) >  stratum(source)

For the document_access example:

CODE
blocked/1  →  stratum 0  (POLICY_FACT, no dependencies)
allow/2    →  stratum 1  (depends negatively on blocked/1; sensitivity_level/2 is EDB)
ConditionBehavior
Positive dependencies onlyPositive recursion may run within one layer.
Aggregate of a lower relationAccepted when that relation can be completed first.
Stratified negationAccepted.
Recursion through negation or an aggregateRejected — not stratifiable.
Stratum limit exceededRejected.

Static bounds

The parser is bounded at compile time.

BoundMeaning
Variables per ruleMaximum named plus anonymous variables per rule.
Terms per atomMaximum terms per atom.
Direct policy factsMaximum direct policy facts.
Expanded rulesMaximum rules.
Body literalsMaximum literals in a rule body.
Evaluation layersMaximum 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.