API REFERENCE

Rulesets

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_once()     =  the solver: evaluates 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.

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 shared context — symbol table and predicate registry — that the EDB also depends on.

ManifestSHA, identity.dl sourceraw policy textRulesetparsed, frozenEDBruntime factssolve_onceruleset + EDB → IDB

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 connect the EDB to the loaded ruleset. 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).

policies/document_access.dl
blocked("mallory").

allow(User, Document) :-
  owns(User, Document),
  not blocked(User),
  sensitivity_level(User, Level),
  Level >= 3.

allow(User, Document) :-
  shared_with(User, Document),
  not blocked(User),
  sensitivity_level(User, Level),
  Level >= 3.

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.

!

If the solver derives no allow(User, Document) fact for a request, the decision is DENY. The absence of a derived fact is not an error.

What is inside a ruleset

Parsing transforms the source text into these structures.

policies/document_access.dl
blocked("mallory").

allow(User, Document) :-
  owns(User, Document),
  not blocked(User),
  sensitivity_level(User, Level),
  Level >= 3.

allow(User, Document) :-
  shared_with(User, Document),
  not blocked(User),
  sensitivity_level(User, Level),
  Level >= 3.

Raw text.

parse_ruleset_ex()

Validates predicates against the frozen registry.

Parses policy facts, rules, safety checks, and comparison guards.

Computes stratification order for negation.

ruleset
identity policy_id, domain, sha256
facts blocked("mallory") — POLICY_FACT
rules 2 rules deriving allow/2 with a shared threshold guard
strata blocked/1 in stratum 0, allow/2 in stratum 1; sensitivity_level/2 is EDB
support predicate registry, symbol table, comparison guards
!

A ruleset is immutable after parsing. Runtime request data belongs in the EDB, not in the ruleset. The same ruleset is passed unchanged to every call to solve_once.

Lifecycle

1. Initializeruleset_init

Create the ruleset shell and attach policy identity: policy_id, domain, and SHA-256. The predicate registry is initialized with core predicates.

2. Install domaindomain install

Register the selected domain from the Domain Registry. This installs the predicate vocabulary — the set of predicates the parser is allowed to reference.

3. Freeze registryregistry freeze

Lock the predicate registry before parsing. The parser validates every predicate name and arity against the frozen registry. No new predicates may be added after freeze.

4. Parseparse_ruleset_ex

Convert .dl source text into policy facts, rules, safety checks, and stratification metadata. Fails closed on any violation.

5. Solvesolve_once

Evaluate the ruleset against a finalized EDB. Produces an opaque solve result. The ruleset is unchanged — solve_once may be called again with a different EDB. The EDB must share the same symbol table and predicate registry as this ruleset.

6. Clearruleset_clear

Release the ruleset. Safe to call with NULL.

From ruleset to EDB

Once a ruleset is loaded, the next step is initializing the EDB for each request. The EDB must share the same symbol table and predicate registry as the ruleset — these are the two fields that bridge loading and runtime evaluation.

/* A ruleset lives inside policy_set.policies[0] */
maelys_datalog_policy_set_t policy_set;
maelys_datalog_manifest_load_ex("manifest.json", 0, &policy_set, &diag);
 
/* Connect the EDB to the loaded ruleset */
static maelys_datalog_edb_t edb;
static maelys_datalog_fact_t pool[MAELYS_DATALOG_MAX_EDB_FACTS];
 
maelys_datalog_edb_init(
    &edb, pool, MAELYS_DATALOG_MAX_EDB_FACTS,
    &policy_set.policies[0].symbols,    /* shared: same string IDs as the ruleset */
    &policy_set.policies[0].registry);  /* shared: same predicate vocabulary */

The ruleset is loaded once. The EDB is recreated per request. Both refer to the same symbol table so that string values in facts match string values in rules.

See Runtime EDB — initialization for the full EDB lifecycle.

API reference

The ruleset pipeline involves four functions called in a fixed order: ruleset_init → domain install + registry freeze → parse_ruleset_exruleset_clear. High-level loading APIs such as maelys_datalog_load_policy_inline and maelys_datalog_manifest_load_from_text perform all steps automatically. The functions below are exposed for callers that manage the pipeline directly.

Complete example

This example loads the document_access policy from the earlier section. All variables are declared and all values are concrete.

#include "include/maelys_datalog.h"
#include <string.h>
 
maelys_result_t
load_document_access_policy(void)
{
    maelys_result_t rc;
 
    /* --- The policy source --- */
    static const char policy_src[] =
        "blocked(\"mallory\").\n"
        "\n"
        "allow(User, Document) :-\n"
        "    owns(User, Document),\n"
        "    not blocked(User),\n"
        "    sensitivity_level(User, Level),\n"
        "    Level >= 3.\n"
        "\n"
        "allow(User, Document) :-\n"
        "    shared_with(User, Document),\n"
        "    not blocked(User),\n"
        "    sensitivity_level(User, Level),\n"
        "    Level >= 3.\n";
    const size_t src_len = sizeof(policy_src) - 1;   /* byte count, not strlen */
 
    /* SHA-256 of policy_src — computed once at build or load time */
    char sha256[65];
    maelys_sha256_hex(
        (const unsigned char *)policy_src, src_len, sha256);
 
    /* --- Ruleset shell --- */
    maelys_datalog_ruleset_t ruleset = {0};
 
    rc = maelys_datalog_ruleset_init(
        &ruleset,
        "document_access.main",  /* policy_id  */
        "document_access",       /* domain     */
        sha256,                  /* sha256     */
        0);                      /* test_only  */
    if (rc != MAELYS_OK) return rc;
 
    /* --- Install domain vocabulary --- */
    maelys_datalog_predicate_registry_add_domain(
        &ruleset.registry, "owns",        2, MAELYS_DATALOG_PRED_KIND_EDB);
    maelys_datalog_predicate_registry_add_domain(
        &ruleset.registry, "shared_with", 2, MAELYS_DATALOG_PRED_KIND_EDB);
    maelys_datalog_predicate_registry_add_domain(
        &ruleset.registry, "sensitivity_level", 2, MAELYS_DATALOG_PRED_KIND_EDB);
    maelys_datalog_predicate_registry_add_domain(
        &ruleset.registry, "blocked",     1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT);
    maelys_datalog_predicate_registry_add_domain(
        &ruleset.registry, "allow",       2,
        MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY);
 
    /* --- Freeze: no predicates may be added after this point --- */
    maelys_datalog_predicate_registry_freeze(&ruleset.registry);
 
    /* --- Parse --- */
    maelys_datalog_diagnostic_t diag = {0};
    rc = maelys_datalog_parse_ruleset_ex(
        &ruleset,
        policy_src,
        src_len,
        "policies/document_access.dl",  /* file_path: used in diagnostics */
        &diag);
 
    if (rc != MAELYS_OK) {
        /* diag.message describes the exact parse failure */
        maelys_datalog_ruleset_clear(&ruleset);
        return rc;
    }
 
    /* ruleset is ready for solve_once() */
    return MAELYS_OK;
}

maelys_datalog_ruleset_init

Initializes the ruleset identity and internal structures. Does not parse policy text.

maelys_result_t
maelys_datalog_ruleset_init(
    maelys_datalog_ruleset_t *ruleset,
    const char *policy_id,
    const char *domain,
    const char *sha256,
    int test_only);
ParameterMeaning
rulesetRuleset to initialize. Must not already be loaded.
policy_idStable policy identifier, e.g. "document_access.main".
domainDomain name, e.g. "document_access".
sha256Lowercase hex SHA-256 of the policy source, e.g. "a3f7c291...".
test_onlyNon-zero marks this ruleset as test-only.

After a successful call, the predicate registry is open — domain predicates must be installed and the registry frozen before parsing.

Return codeMeaning
MAELYS_OKInitialized.
MAELYS_ERR_INVALID_ARGUMENTNULL or empty required argument.
MAELYS_ERR_INVALID_STATERuleset already loaded.
MAELYS_ERR_PAYLOAD_TOO_LARGEpolicy_id, domain, or sha256 exceeds its bounded field.

Between init and parse: install domain then freeze

After ruleset_init, the predicate registry is open — no domain vocabulary is installed yet, and the parser will refuse to run on an open registry. Two steps are required before parsing:

1. Install domain predicates via maelys_datalog_predicate_registry_add_domain()
2. Freeze via maelys_datalog_predicate_registry_freeze()

See the complete example above for the code. After freezing, parse_ruleset_ex validates every predicate name and arity in the source against the frozen registry — any predicate not installed in step 1 causes a parse failure.


maelys_datalog_parse_ruleset_ex

Parses .dl source text into the ruleset. The registry must be frozen.

maelys_result_t
maelys_datalog_parse_ruleset_ex(
    maelys_datalog_ruleset_t *ruleset,
    const char *src,
    size_t len,
    const char *file_path,
    maelys_datalog_diagnostic_t *out_diag);
ParameterMeaning
rulesetInitialized ruleset with a frozen predicate registry.
srcPolicy source bytes. Does not need to be NUL-terminated.
lenByte length of src. Authoritative — use sizeof(arr) - 1 for string literals.
file_pathOptional path used in diagnostics, e.g. "policies/document_access.dl". May be NULL.
out_diagOptional structured diagnostic on parse failure. May be NULL.

The simple variant omits file_path and out_diag:

maelys_result_t
maelys_datalog_parse_ruleset(
    maelys_datalog_ruleset_t *ruleset,
    const char *src,
    size_t len);
Return codeMeaning
MAELYS_OKParsed successfully.
MAELYS_ERR_INVALID_STATERuleset not initialized or registry not frozen.
MAELYS_ERR_INVALID_FIELDPolicy text violates grammar, predicate, typing, or safety rules.
MAELYS_ERR_PAYLOAD_TOO_LARGEA static parser or ruleset bound was exceeded.

maelys_datalog_ruleset_clear

Releases all structures held by the ruleset. Safe to call with NULL and safe to call on a ruleset that was never parsed.

void
maelys_datalog_ruleset_clear(
    maelys_datalog_ruleset_t *ruleset);

Policy source language

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.

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 */
!

EDB predicates and IDB predicates cannot be used as direct policy facts. Only predicates declared POLICY_FACT by the selected domain may appear as direct .dl facts.

Rules

Rules derive IDB or QUERY predicates from EDB and POLICY_FACT inputs.

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

KindExampleNotes
Named variableUser, DocumentUppercase. Must be bound in rule head by a positive body atom.
Anonymous variable_Allowed in positive body atoms only. Each occurrence is a fresh variable.
String atom"mallory"Interned as a symbol.
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

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.

!

Disjunction adds no semantic artifact and no downstream execution cost compared to the same rules written by hand: the canonical rules, their ids, the canonical SHA-256, the solve results, and the proof nodes are identical to the manual expansion. The only costs are parse-time expansion and the rule capacity consumed by the cartesian product actually produced.

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 at solve time to a type that is incompatible with the comparison, solve_once_ex() returns MAELYS_ERR_INVALID_FIELD, result is set to NULL, and the diagnostic carries the exact lhs_kind, rhs_kind, and comparison_op that caused the error. This is the DENY path for comparisons that cannot be caught statically.

!

Cross-type comparisons are never silently false. They are always a hard DENY. A missing fact and a type mismatch produce different outcomes: missing facts yield out_present == false from a successful query, while a type mismatch terminates solving with MAELYS_ERR_INVALID_FIELD before any query is reachable.

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 terminates solving with MAELYS_ERR_INVALID_FIELD.

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.


Variable safety

The parser enforces Datalog safety before accepting any rule.

Safety ruleMeaning
Head variables must be boundEvery variable in the rule head must appear in at least one positive body atom.
Negated variables must be boundEvery variable in not(...) must appear in a positive body atom.

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 be bound by a positive atom 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

Stratified negation is supported. When the parser encounters a negated atom, it computes a stratum assignment:

positive dependency:  stratum(head) >= stratum(body)
negative dependency:  stratum(head) >  stratum(body)

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)
ConditionBehavior
No negationNo stratum assignment needed.
Stratified negationAccepted.
Recursion through negationRejected — not stratifiable.
Stratum limit exceededRejected.
!

Recursion through negation is not supported. A policy that negates a predicate that recursively depends on itself is rejected at parse time.


Static bounds

The parser is bounded at compile time.

BoundMeaning
MAELYS_DATALOG_MAX_RULE_VARIABLESMaximum named plus anonymous variables per rule.
MAELYS_DATALOG_MAX_TERMSMaximum terms per atom.
MAELYS_DATALOG_MAX_RULE_FACTSMaximum direct policy facts.
MAELYS_DATALOG_MAX_RULESMaximum rules.
MAELYS_DATALOG_MAX_BODY_LITERALSMaximum literals in a rule body.
MAELYS_DATALOG_MAX_STRATAMaximum stratification depth.

Exceeding any bound returns MAELYS_ERR_PAYLOAD_TOO_LARGE.


Parser diagnostics

When out_diag is provided, parse failures include structured context.

DiagnosticMeaning
PARSER_UNKNOWN_PREDICATEPredicate name not found in the frozen registry.
PARSER_ARITY_MISMATCHPredicate found but arity does not match the declaration.
PARSER_RULE_HEAD_EDB_FORBIDDENEDB or POLICY_FACT predicate used as a rule head.
PARSER_FACT_USES_NON_BASE_PREDICATEDirect fact used a non-POLICY_FACT predicate.
PARSER_UNSAFE_VARIABLEHead or negated variable not bound by a positive body atom.
PARSER_INVALID_COMPARISONComparison is malformed or type-invalid.
POLICY_NOT_STRATIFIABLENegation cannot be stratified — recursion through negation.

Each diagnostic also includes file path, line, column, offending token, and a human-readable hint when available.