Theory
Algorithms & formal foundations
Maelys DL is an embedded Datalog micro-kernel for security policy decisions. Its design follows the classical database-theory view of Datalog: a policy is a finite logical program, inputs are finite relations, and positive rules reach a least fixpoint. Negation and aggregates read completed lower layers.
Maelys is designed and developed by David Bromberg. The foundations below are established work; the Maelys contribution is their concrete integration into a bounded, auditable C runtime and the combined contracts described here, not a claim of priority for those algorithms. This page follows engine 0.11.1; its source and test links are pinned to v0.11.1. See Related work for the comparison with Soufflé, PUG, Binder and Biscuit.
Datalog as a least-fixpoint model
For positive Datalog, evaluation starts from base facts and applies rules until no new fact is produced: the least fixpoint. With stratified negation and aggregates, the engine completes each lower layer before evaluating the next. Maelys implements fail-closed loading and solving: an error is not a successful negative answer, and an incomplete solve is not published as a result. The embedding application must authorize only after successful solving and a successful positive query.
Evidence (v0.11.1): implementation · regression tests
- ▸EDB facts are runtime input facts supplied by the caller
- ▸POLICY_FACT facts are trusted facts embedded in the policy
- ▸IDB facts are derived by rule evaluation
- ▸Positive rules are monotone and can only add facts
- ▸Negation is handled through stratification, not as negative facts
- ▸Each layer reaches its positive fixpoint before a higher layer reads it
- ▸No new derived fact after an iteration means the fixpoint is reached
# Positive rules within one evaluation layer.
facts = EDB ∪ POLICY_FACTS
repeat:
new_facts = {}
for each rule H :- B1, ..., Bn:
for each substitution θ satisfying B1θ, ..., Bnθ:
new_facts += Hθ
delta = new_facts - facts
facts = facts ∪ delta
until delta = ∅
return factsSemi-naive evaluation
Naive bottom-up evaluation repeats work on old facts. Semi-naive evaluation requires each iterative rule variant to read at least one newly derived IDB fact. The pseudocode below separates old facts and delta conceptually. In Maelys, delta is a range inside the accepted current prefix of one bounded pool; merge is the appended candidate range. These are not three independently allocated copies.
Evidence (v0.11.1): implementation · regression tests
- ▸The accepted current prefix includes the previous-round delta range
- ▸idb_delta contains facts derived in the previous round
- ▸idb_merge collects facts newly derived in the current round
- ▸Each variant chooses one delta-eligible IDB body literal
- ▸EDB and POLICY_FACT literals never scan delta
- ▸Fixpoint is reached when idb_delta becomes empty
idb_current = {}
idb_delta = base_derivation(rules, EDB ∪ POLICY_FACTS)
while idb_delta ≠ ∅:
idb_merge = {}
for each rule r:
for each delta-eligible IDB literal i:
derive r with:
body[i] read from idb_delta
other IDBs read from idb_current ∪ idb_delta
base atoms read from EDB ∪ POLICY_FACTS
append only genuinely new facts to idb_merge
idb_current = idb_current ∪ idb_delta
idb_delta = idb_mergeStatic join-order planning
Rule evaluation is a sequence of joins. A bad order can multiply intermediate combinations. Maelys uses a deterministic greedy heuristic with bounded masks: it favors bound terms and constants, imposes a delta pivot for semi-naive variants, and selects only safe literals. This is a Maelys implementation choice, not a new general join-optimization algorithm or an optimality guarantee. The complexity below describes the built-in planner, not arbitrary extension callbacks or the joins themselves.
Evidence (v0.11.1): implementation · regression tests
- ▸The delta literal is forced to join_order[0] for semi-naive variants
- ▸Positive relational atoms bind variables
- ▸A ready aggregate also binds its result variable
- ▸Comparisons and negated atoms are constraints, not binders
- ▸A literal is safe only when all required variables are already bound
- ▸Orphan variables fail closed at plan time
- ▸Tie-breaking uses original body index for deterministic output
planned = {}
bound_vars = {}
order = []
if semi_naive_variant:
delta = selected_delta_literal
require delta is a positive IDB atom
order.push(delta)
planned += delta
bound_vars += vars(delta)
while order.size < body.size:
candidates = safe_literals(body - planned, bound_vars)
if candidates = ∅:
FAIL CLOSED
best = max_score(candidates)
order.push(best)
planned += best
bound_vars += variables_bound_by(best)
return orderStratified negation
Negation is not monotone: adding a fact can invalidate an earlier absence test. Stratification requires a negated relation to be complete before it is read. Maelys also gives aggregate dependencies a strict downward edge: a counted or summed relation must not depend on the result of that aggregate. Positive recursion is allowed within the lower layer; cycles through negation or aggregation are rejected.
Evidence (v0.11.1): implementation · regression tests
- ▸Positive dependency: stratum(head) ≥ stratum(body)
- ▸Negative dependency: stratum(head) > stratum(body)
- ▸Aggregate dependency: stratum(head) > stratum(source)
- ▸EDB and POLICY_FACT predicates are treated as stratum 0 inputs
- ▸Recursion through negation is rejected
- ▸Negative cycles are rejected before evaluation
- ▸A non-stratifiable policy fails closed
for each predicate p:
stratum[p] = 0
repeat up to MAX_PREDICATES:
changed = false
for each rule r:
required = stratum[r.head]
for each body literal L:
s = stratum[L.predicate]
if L is negative or an aggregate:
s = s + 1
required = max(required, s)
if required > stratum[r.head]:
stratum[r.head] = required
changed = true
if !changed:
return strata
FAIL CLOSEDFrozen stratum windows
A negative literal reads a complete lower relation, as required by stratification. The Maelys representation keeps derived facts in one bounded flat pool and records the boundaries of each completed stratum. Sorted slices support membership checks without duplicating the pool. These internal stratum windows are not the last-N event windows described below.
Evidence (v0.11.1): implementation · regression tests
- ▸A stratum is evaluated only after all lower strata are frozen
- ▸Negative lookup never reads an actively mutating window
- ▸Each frozen window is sorted and deduplicated
- ▸The IDB pool is partitioned by boundaries, not duplicated
- ▸For IDB, not(p(t)) checks absence in its frozen lower window; base predicates read their completed input relation
start[0] = 0
for s in 0..max_stratum:
evaluate all rules whose head is in stratum s
sort facts derived in stratum s
deduplicate facts derived in stratum s
end[s] = current_idb_end
frozen[s] = true
start[s + 1] = end[s]
negative_lookup(p, tuple):
if p is EDB or POLICY_FACT:
return !base_contains(p, tuple)
s = stratum[p]
require frozen[s]
return !binary_search(idb_facts[start[s]..end[s]], p(tuple))Sorted-array fact store
Maelys stores bounded fact sets in arrays, with a canonical comparator and adjacent deduplication after sorting. Its typed sort is an introsort: median-of-three partitioning, insertion sort for small segments, and a heapsort fallback when the depth budget is exhausted. Only the smaller partition recurses. The adaptation avoids allocator calls and variable-length arrays; introsort itself is an established algorithm. Why-false additionally uses a separate context-aware heapsort.
Evidence (v0.11.1): implementation · regression tests
- ▸Facts have a canonical lexicographic order
- ▸The comparison key is predicate id, arity, then terms
- ▸Lookup uses binary search once a window is sorted
- ▸Deduplication is a linear pass after sorting
- ▸Windows are tracked by index ranges rather than heap containers
- ▸Prepared reference-session append/solve/query/release avoids engine allocator calls; construction and other paths have separate contracts
fact_key(f) = ( f.predicate_id, f.arity, f.terms[0], f.terms[1], ... ) append_unsorted(window, fact) freeze(window): sort(window, by=fact_key) deduplicate_adjacent_equal_facts(window) contains(window, query): require window.sorted return binary_search(window, query)
Bounded Why-true witness graphs
Why-true explains one retained derivation of a present IDB fact. Each step records its rule and lexical premises, including positive facts, negative absence, comparisons, filters and aggregates. IDB premises link to earlier steps, and shared parents are emitted once: the result is a bounded directed acyclic witness graph, not simply a single-parent chain. This is not an enumeration of every derivation or a guarantee of the smallest explanation.
Evidence (v0.11.1): implementation · regression tests
- ▸Proof nodes are recorded in lockstep with derived IDB facts
- ▸Each derived fact can carry a proof index
- ▸Parent links describe the derivation dependencies
- ▸Sorting and deduplication preserve fact/proof pairing
- ▸Extraction emits ancestors before descendants and deduplicates shared parents
- ▸Truncation is non-fatal and does not change decision semantics
- ▸The canonical document exposes one bounded witness graph, not all possible proofs
derive fact F by rule R using parents P:
proof_index = proof_add(
derived_fact = F,
rule_id = R.id,
parents = P
)
idb_facts.append(F)
idb_proof_index.append(proof_index)
extract_proof_for_fact(F):
root = proof_index_for(F)
graph = ancestors_first_dag(root, deduplicate_shared_parents=true)
remap parent indexes locally
return graphBounded Why-false exploration
Why-false starts from an absent query and explores candidate rules against the completed result. It reports obstacles such as a missing positive fact, a present negated fact, a failed comparison, filter or aggregate condition. PUG is related work on why/why-not provenance, not a claim that Maelys implements the PUG algorithm. Maelys keeps this diagnostic exploration separate from solving and bounds its candidates, branches, depth and output.
Evidence (v0.11.1): implementation · regression tests
- ▸Membership is checked against an already completed result
- ▸Explaining absence never inserts facts or changes the decision
- ▸Canonical ordering makes the retained diagnostic reproducible
- ▸Hitting an explanation bound produces an explicitly truncated document
- ▸Complete describes this exploration under its contract, not a proof of impossibility under every possible input
- ▸Why-true and Why-false share the MAELYS-DATALOG-v2 envelope but explain different things
require successful membership query says absent frontier = candidate_rules(query) while frontier is not empty and explanation budgets remain: candidate = next_in_canonical_order(frontier) inspect candidate against the completed result retain bounded obstacles and nested causes return document(obstacles, complete_or_truncated) # No solve and no mutation of the result.
Aggregates over completed relations
The language supplies count, min, max and sum over a completed lower relation. Count deduplicates the projected typed values. Sum instead adds one integer contribution per distinct source fact: two different facts with the same projected amount both contribute. Signed overflow and invalid aggregate input fail the solve; they are not silently clamped or published as partial answers. These are language and engineering contracts, not newly invented aggregate operators.
Evidence (v0.11.1): implementation · regression tests · min/max/sum and failure tests
- ▸Grouping variables are bound before the aggregate is evaluated
- ▸The source relation is complete before its aggregate is read
- ▸A dependency cycle through the aggregate is rejected
- ▸Typed identity distinguishes an integer, a boolean and a symbol
- ▸No partial result is published after an aggregate domain or overflow error
source = completed_relation_for_bound_group() count = number_of_distinct_typed_projected_values(source) sum = 0 for each distinct source fact: require projected value is an integer sum = checked_int64_add(sum, projected value) on domain error or overflow: reject the solve
Reusable preparation and explicit memory
A reference session keeps its preparation and bounded working storage for successive requests. Only one result may be alive per session. Explanation workspace reuse is optional: prepare one explanation, measure it, then format it without repeating the exploration while the cached query is unchanged. Allocation-free native hot paths are tested, but this does not make construction, output text allocation or Python objects allocation-free. In 0.11.1, sessions also share engine-owned immutable policies; caller-owned policy storage still receives an independent session snapshot.
Evidence (v0.11.1): implementation · regression tests · allocation guard
- ▸A live result retains its session; a prepared explanation retains its result
- ▸Release explanations before their result and release that result before the next ordinary solve
- ▸Resetting input facts does not release a result
- ▸The explanation cache holds one query and is replaced when its key changes
- ▸Supplying memory changes ownership, not the explanation limits
- ▸Sharing engine-owned policy storage does not introduce incremental solving
session = prepare(policy, optional_workspace) for each request: result = solve(session, complete_input_snapshot(request)) query(result) optionally explain(result) release(result) release(session)
Policy identity
Maelys separates the manifest file digest from compiled-policy and execution identities. Canonical policy material includes the vocabulary, rules and applicable extension identities; the execution identity additionally records the selected backend and execution configuration. Applications can record these with a decision. SHA-256 identifies material: it does not authenticate its author, replace a signature, or guarantee confidentiality.
Evidence (v0.11.1): implementation · regression tests
- ▸Manifest-loaded policies verify file bytes against the manifest hash
- ▸Embedded policies are hashed from source or structural material
- ▸Applications read the ruleset hash rather than inventing a value
- ▸Hash mismatch fails closed at load time
- ▸The hash identifies policy material, not merely a policy name
- ▸Policy and execution fingerprints distinguish policy authority from backend/configuration identity
load_policy(path, manifest_hash):
actual = sha256(file_bytes(path))
if actual != manifest_hash:
FAIL CLOSED
ruleset.sha256 = actual
decide(request):
result = solve_once(ruleset, request.edb)
decision = query(result, allow(request.id))
return { decision, source_digest, policy_fingerprint, execution_fingerprint }Guarantees combined in one runtime
A successful solve has already established membership. Why-true and Why-false may reach their diagnostic limits and report truncation without changing that answer. In contrast, a solve capacity error means no successful result. These are different failure boundaries, not two kinds of approximate decision.
The native event-window adapters stage a candidate snapshot before publishing it. If a push is rejected, committed inputs, cursor and the previous result remain available. With backend ABI 5, commit occurs only after host acceptance. This guarantee belongs to those adapters: an ordinary session does not keep two live results or permit another solve before release.
These are demonstrable engineering properties of this implementation, not a claim that Maelys invented provenance, transactions or bounded evaluation. The evidence is executable: proof truncation, read-only Why-false, single-fact windows, group windows and backend acceptance.
The 0.11.1 release reports 515,416 bytes instead of 1,178,352 for a reference SMALL session sharing an engine-owned policy (56.3% less, still three engine allocations). Policy creation and optional workspaces are excluded. The release also reports unresolved prepared-session latency regressions; this memory reduction is not a promise of faster solving. See the release measurements and their limits.