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 factsThe 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.
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 shared context — symbol table and predicate registry — that the EDB also depends on.
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).
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.
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.
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.
Validates predicates against the frozen registry.
Parses policy facts, rules, safety checks, and comparison guards.
Computes stratification order for negation.
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
ruleset_initCreate the ruleset shell and attach policy identity: policy_id, domain, and SHA-256. The predicate registry is initialized with core predicates.
domain installRegister the selected domain from the Domain Registry. This installs the predicate vocabulary — the set of predicates the parser is allowed to reference.
registry freezeLock 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.
parse_ruleset_exConvert .dl source text into policy facts, rules, safety checks, and stratification metadata. Fails closed on any violation.
solve_onceEvaluate 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.
ruleset_clearRelease 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_ex → ruleset_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);| Parameter | Meaning |
|---|---|
ruleset | Ruleset to initialize. Must not already be loaded. |
policy_id | Stable policy identifier, e.g. "document_access.main". |
domain | Domain name, e.g. "document_access". |
sha256 | Lowercase hex SHA-256 of the policy source, e.g. "a3f7c291...". |
test_only | Non-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 code | Meaning |
|---|---|
MAELYS_OK | Initialized. |
MAELYS_ERR_INVALID_ARGUMENT | NULL or empty required argument. |
MAELYS_ERR_INVALID_STATE | Ruleset already loaded. |
MAELYS_ERR_PAYLOAD_TOO_LARGE | policy_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);| Parameter | Meaning |
|---|---|
ruleset | Initialized ruleset with a frozen predicate registry. |
src | Policy source bytes. Does not need to be NUL-terminated. |
len | Byte length of src. Authoritative — use sizeof(arr) - 1 for string literals. |
file_path | Optional path used in diagnostics, e.g. "policies/document_access.dl". May be NULL. |
out_diag | Optional 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 code | Meaning |
|---|---|
MAELYS_OK | Parsed successfully. |
MAELYS_ERR_INVALID_STATE | Ruleset not initialized or registry not frozen. |
MAELYS_ERR_INVALID_FIELD | Policy text violates grammar, predicate, typing, or safety rules. |
MAELYS_ERR_PAYLOAD_TOO_LARGE | A 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.
| 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 */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
| Kind | Example | Notes |
|---|---|---|
| Named variable | User, Document | Uppercase. 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. |
| 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 |
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.
| 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 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 rule | Meaning |
|---|---|
| Head variables must be bound | Every variable in the rule head must appear in at least one positive body atom. |
| Negated variables must be bound | Every 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)| Condition | Behavior |
|---|---|
| No negation | No stratum assignment needed. |
| Stratified negation | Accepted. |
| Recursion through negation | Rejected — not stratifiable. |
| Stratum limit exceeded | Rejected. |
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.
| Bound | Meaning |
|---|---|
MAELYS_DATALOG_MAX_RULE_VARIABLES | Maximum named plus anonymous variables per rule. |
MAELYS_DATALOG_MAX_TERMS | Maximum terms per atom. |
MAELYS_DATALOG_MAX_RULE_FACTS | Maximum direct policy facts. |
MAELYS_DATALOG_MAX_RULES | Maximum rules. |
MAELYS_DATALOG_MAX_BODY_LITERALS | Maximum literals in a rule body. |
MAELYS_DATALOG_MAX_STRATA | Maximum stratification depth. |
Exceeding any bound returns MAELYS_ERR_PAYLOAD_TOO_LARGE.
Parser diagnostics
When out_diag is provided, parse failures include structured context.
| Diagnostic | Meaning |
|---|---|
PARSER_UNKNOWN_PREDICATE | Predicate name not found in the frozen registry. |
PARSER_ARITY_MISMATCH | Predicate found but arity does not match the declaration. |
PARSER_RULE_HEAD_EDB_FORBIDDEN | EDB or POLICY_FACT predicate used as a rule head. |
PARSER_FACT_USES_NON_BASE_PREDICATE | Direct fact used a non-POLICY_FACT predicate. |
PARSER_UNSAFE_VARIABLE | Head or negated variable not bound by a positive body atom. |
PARSER_INVALID_COMPARISON | Comparison is malformed or type-invalid. |
POLICY_NOT_STRATIFIABLE | Negation cannot be stratified — recursion through negation. |
Each diagnostic also includes file path, line, column, offending token, and a human-readable hint when available.