API Reference

Runtime EDB

The EDB (Extensional Database) is the caller-owned fact store for one evaluation. It holds ground input facts supplied at runtime. The solver reads these facts but never modifies them. After solving, derived results live in the solve result — not in the EDB.

The EDB connects to a loaded ruleset through two shared fields: policy_set.policies[0].symbols (the string intern table) and policy_set.policies[0].registry (the predicate vocabulary). See Manifest Loading for how policy_set is produced, and Rulesets for what the ruleset contains.

Caller
  → maelys_datalog_edb_add_fact()   (repeat for each input fact)
  → maelys_datalog_edb_finalize()
  → maelys_datalog_solve_once()
  → maelys_datalog_query_solved_ground_fact()  (read results)
  → maelys_datalog_solve_result_free()
i

EDB facts are input. IDB facts are output. The solver derives IDB facts from EDB facts and policy rules. The EDB is read by solve_once and remains caller-owned; solving does not modify the EDB.

Why EDB exists

EDB exists so caller-specific data can change from request to request without changing the policy program itself.

Runtime input

The caller supplies per-request facts such as document ownership, sharing, and sensitivity level. Those facts belong in the EDB, not in the ruleset.

Reusable logic

One ruleset can be reused across many evaluations. The solver combines the same parsed policy with a different EDB on each request.

Fail-closed boundary

If a fact is missing from the EDB, the solver cannot invent it. Missing input means the rule body may not match, which keeps evaluation fail-closed.

For the document_access example, the EDB might contain:

owns("alice", "roadmap.pdf").
shared_with("bob", "roadmap.pdf").
sensitivity_level("alice", 4).
sensitivity_level("bob", 3).

Those facts change per request. The ruleset does not.

Where EDB fits

The EDB sits between caller input and solver output.

Callerruntime inputEDBadd → finalizeSolver resultderived IDB

EDB vs IDB

TypeSourceExample
EDBCaller supplies at runtime, before solvingowns("alice", "roadmap.pdf"), sensitivity_level("alice", 4)
IDBSolver derives from EDB facts and policy rulesallow("alice", "roadmap.pdf")

EDB facts are what the caller knows. IDB facts are what the policy concludes.

Lifecycle

An EDB goes through four phases before results can be queried.

Initedb_initAddedb_add_factFinalizeedb_finalizeSolvesolve_onceQueryquery_*edb_clear()

After finalization, no more facts can be added to the EDB. To start a new evaluation, call maelys_datalog_edb_clear() and repeat the add and finalize phases.

Initialization

The policy set connection

Before you can initialize an EDB, you need a loaded policy. Any loading function — manifest_load_ex, manifest_load_from_text, load_policy_inline — returns a maelys_datalog_policy_set_t:

maelys_datalog_policy_set_t policy_set;
maelys_datalog_manifest_load_ex("manifest.json", 0, &policy_set, &diag);
/* or: maelys_datalog_load_policy_inline_with_static_domain(..., &policy_set, ...) */

A policy_set contains an array of loaded policies. In the common single-policy case you access the first one:

policy_set.policies[0]   /* maelys_datalog_ruleset_t */
  .symbols               /* symbol table — string ↔ integer ID mapping  */
  .registry              /* predicate registry — which predicates exist  */
  /* ... plus parsed rules, facts, strata, identity ... */

The EDB must share these two fields with the ruleset it will be solved against. The reason is concrete:

Ruleset  :  "alice" was interned at load time  →  symbol ID 1
EDB call :  edb_add_fact("alice", ...) interns "alice" in the SAME table  →  also ID 1
Solve    :  rule says owns(User, Doc) — User binds to ID 1 — matches!
 
If separate tables are used:
  Ruleset table : "alice" → ID 1
  EDB table     : "bob"   → ID 1
 
The solver compares symbol IDs, not raw strings. If the ruleset and EDB use
different symbol tables, the same numeric ID may refer to different strings.
Evaluation becomes invalid: facts may fail to match, or worse, match the
wrong value.

The registry serves a different role: it validates that each predicate you insert into the EDB is actually declared as EDB-kind in the domain. Passing the ruleset's registry prevents you from accidentally inserting a fact for a predicate that the policy does not expect from runtime callers.

maelys_result_t
maelys_datalog_edb_init(
    maelys_datalog_edb_t *edb,
    maelys_datalog_fact_t *fact_pool,
    size_t fact_capacity,
    maelys_datalog_symbol_table_t *symbols,
    const maelys_datalog_predicate_registry_t *registry);

The EDB does not allocate memory. The caller provides a static or stack-allocated fact pool:

static maelys_datalog_edb_t edb;
static maelys_datalog_fact_t fact_pool[MAELYS_DATALOG_MAX_EDB_FACTS];
 
maelys_datalog_edb_init(
    &edb,
    fact_pool,
    MAELYS_DATALOG_MAX_EDB_FACTS,
    &policy_set.policies[0].symbols,    /* shared string table  */
    &policy_set.policies[0].registry);  /* predicate validation */

edb vs fact_pool — two roles, one init call

The two static declarations serve different purposes.

maelys_datalog_edb_t edb is the logical manager: a small control structure that holds the fact count, the capacity, a pointer to the backing memory, and the state flags that track whether the EDB is mutable or finalized.

maelys_datalog_fact_t fact_pool[...] is the backing memory: the actual array where fact data lives. The EDB does not allocate this array — you provide it, and edb_init attaches the manager to it.

edb       →  index / manager  (knows where facts are, how many, what state)
fact_pool →  the pages        (holds the actual fact data)

In database terms: edb is the table descriptor with its metadata; fact_pool is the row storage.

Separating them avoids any dynamic allocation: the engine never calls malloc or free. Every byte the EDB will ever use is reserved in fact_pool at compile time, which makes the system bounded, deterministic, and safe in constrained runtimes (WASM, embedded, ASAN).

static on both declarations means the objects have program lifetime and file-local visibility — they persist across calls without living on the stack, and they are not exported to other translation units. This is the standard pattern for a single EDB object reused across many requests inside one C module.

ParameterMeaning
edbThe EDB to initialize. Must not be NULL.
fact_poolCaller-owned backing array for facts. Must not be NULL.
fact_capacityNumber of fact slots in fact_pool. Must be greater than zero.
symbolsSymbol table from the loaded policy set. Shared with the ruleset.
registryPredicate registry from the loaded policy set.
!

symbols is shared between the EDB and the loaded policy. EDB facts that use new symbol strings intern those strings into the shared table. The symbol table must remain valid for the lifetime of the EDB.

Adding facts

Two vocabulary modes

Maelys DL maintains a strict separation between two kinds of string values.

Policy source vocabulary  — string constants written in the .dl file
                            "push", "GIT_FORCE_PUSH", "--force", "main"
                            → must be registered as atoms before parsing
                            → part of the policy program, versioned and audited
 
Runtime EDB symbols       — string values injected at request time
                            user("alice"), target_ref("feature/foo"), commit_msg("fix: typo")
                            → injected via edb_add_runtime_symbol_fact for unary symbol facts
                            → or via symbol_intern + edb_add_fact for multi-arity / mixed-type facts
                            → no pre-registration required
                            → unknown at policy-load time

The short rule:

Constants in .dl are policy vocabulary. Facts in the EDB are runtime evidence.

Strings that belong to the policy program (operation names, deny codes, flags, protected branch names) are written in the .dl source and loaded with the manifest. Strings that come from the environment at request time (user names, branch names, file paths, messages) are injected into the EDB after loading. They never need to appear in the .dl source.

What is a fact?

A fact is a predicate name plus a fixed-arity list of typed terms — the ground data you want the policy to reason about.

owns( "alice",  "roadmap.pdf" )   →  predicate "owns", 2 terms, both symbols
age(  "bob",    34            )   →  predicate "age",  2 terms: symbol + integer
active( true                  )   →  predicate "active", 1 term: boolean

The predicate name must be declared EDB in the domain. Predicates declared POLICY_FACT cannot be added at runtime — their values are part of the policy source itself.

Every predicate has exactly one arity. owns is always binary; blocked is always unary. A predicate name that changes arity is a modelling error — the registry rejects it. If two structurally different relations share the same conceptual name, they must be given distinct names in the domain. See Predicate identity for details.

The three term types

Maelys DL has three term types for EDB facts:

TypeUse whenC fieldExample value
SYMBOLThe value is a string (name, path, ID)as.symbol"alice", "roadmap.pdf"
INTThe value is a numberas.integer42, -7, 1024
BOOLThe value is true/falseas.boolean1 (true), 0 (false)

A fourth type, VAR, exists for rule variables — it does not appear in EDB facts, only in rule bodies and heads.

i

INT values stored in EDB facts can be used in arithmetic expression filters in rules — for example, allow(U) :- score(U, S), S + 1 >= 10. The arithmetic is non-generative: it tests already-bound integer values, it does not create new ones. See Rulesets for the full filter syntax.

Why symbols are different from ints and bools

Integers and booleans are stored directly in the term — you set the field and you are done. Symbols are different: the engine does not store the raw string "alice" in the term. Instead, it stores a small integer ID that uniquely identifies that string within the session.

"alice"  →  intern  →  ID 1
"bob"    →  intern  →  ID 2
"alice"  →  intern  →  ID 1  (same string → same ID, within the same symbol table)

This process is called interning. Once a string is interned, comparing two symbols is a single integer equality check — ID 1 == ID 1 — rather than a byte-by-byte string comparison. The loaded policy and the EDB must use the same symbol table, so "alice" in a policy rule and "alice" in an EDB fact always resolve to the same ID and match correctly during solving.

!

Symbol IDs are stable within one loaded policy set. They may differ across different process runs, different manifests, or different policy sets. Never serialize a symbol ID and reuse it across sessions.

/* Call symbol_intern once per string to get its ID */
maelys_datalog_symbol_id_t alice_id;
maelys_result_t rc = maelys_datalog_symbol_intern(
    &policy_set.policies[0].symbols,
    "alice",
    5,        /* byte length, not including NUL */
    &alice_id);
if (rc != MAELYS_OK) return rc;  /* e.g. MAELYS_ERR_PAYLOAD_TOO_LARGE if table full */
 
/* Symbol IDs are 1-based. 0 is not a valid ID. */

Building and adding a fact

The edb_add_fact function is the core fact insertion API. You fill in a maelys_datalog_term_t array and pass it alongside the predicate name:

maelys_result_t
maelys_datalog_edb_add_fact(
    maelys_datalog_edb_t *edb,
    const char *predicate,
    const maelys_datalog_term_t *terms,
    size_t arity);

A complete example adding owns("alice", "roadmap.pdf") and clearance("alice", 4):

/* Intern the symbol strings we need */
maelys_datalog_symbol_id_t alice_id, doc_id;
rc = maelys_datalog_symbol_intern(&ps.policies[0].symbols, "alice",       5, &alice_id);
if (rc != MAELYS_OK) return rc;
rc = maelys_datalog_symbol_intern(&ps.policies[0].symbols, "roadmap.pdf", 11, &doc_id);
if (rc != MAELYS_OK) return rc;
 
/* owns("alice", "roadmap.pdf")  — two symbol terms
   owns/2 is multi-arity, so we build terms manually.
   For unary runtime symbol facts, prefer edb_add_runtime_symbol_fact(). */
maelys_datalog_term_t owns_terms[2];
owns_terms[0].kind      = MAELYS_DATALOG_TERM_SYMBOL;
owns_terms[0].as.symbol = alice_id;
owns_terms[1].kind      = MAELYS_DATALOG_TERM_SYMBOL;
owns_terms[1].as.symbol = doc_id;
maelys_datalog_edb_add_fact(&edb, "owns", owns_terms, 2);
 
/* clearance("alice", 4)  — symbol + integer */
maelys_datalog_term_t cl_terms[2];
cl_terms[0].kind        = MAELYS_DATALOG_TERM_SYMBOL;
cl_terms[0].as.symbol   = alice_id;
cl_terms[1].kind        = MAELYS_DATALOG_TERM_INT;
cl_terms[1].as.integer  = 4;
maelys_datalog_edb_add_fact(&edb, "clearance", cl_terms, 2);
 
/* active(true)  — single boolean term */
maelys_datalog_term_t active_term;
active_term.kind        = MAELYS_DATALOG_TERM_BOOL;
active_term.as.boolean  = 1;
maelys_datalog_edb_add_fact(&edb, "active", &active_term, 1);

Helpers for common cases

For the common case of a 1-arity symbol fact, two helpers are available depending on whether the value is a runtime observation or a pre-declared atom:

/* Runtime open path — for user names, paths, IDs, any value unknown at load time */
maelys_result_t maelys_datalog_edb_add_runtime_symbol_fact(
    maelys_datalog_edb_t *edb,
    const char *predicate,
    const char *value);
 
/* Closed atom path — for values pre-declared in the atom vocabulary */
maelys_result_t maelys_datalog_edb_add_atom_fact(
    maelys_datalog_edb_t *edb,
    const char *predicate,
    const char *atom);
/* Runtime observations: no atom vocabulary precondition */
maelys_datalog_edb_add_runtime_symbol_fact(&edb, "user",       "alice");
maelys_datalog_edb_add_runtime_symbol_fact(&edb, "target_ref", "feature/foo");
maelys_datalog_edb_add_runtime_symbol_fact(&edb, "commit_msg", "fix: typo");
 
/* Pre-declared atoms: checked against vocabulary */
maelys_datalog_edb_add_atom_fact(&edb, "mode", "enforce");  /* "enforce" must be in atom vocabulary */
!

Use the right helper for the right kind of value.

edb_add_runtime_symbol_fact is the canonical path for runtime EDB values: user names, branch names, file paths, request IDs, commit messages. It interns the value and inserts the fact without any atom vocabulary precondition. It still validates predicate kind, arity, EDB capacity, and string length.

edb_add_atom_fact is for pre-declared policy vocabulary values. It checks that the atom is registered in the vocabulary and returns MAELYS_ERR_FORBIDDEN if it is not. If atom_count == 0 (inline/WASM loading), all values are rejected.

edb_add_fact remains available for multi-arity or mixed-type facts where the term array must be constructed manually.

If your domain uses an explicit atom vocabulary, enforce it consistently: the closed path (edb_add_atom_fact) enforces it; the runtime path (edb_add_runtime_symbol_fact) and edb_add_fact do not.

Batch insertion

The helpers above insert one fact per call. When you load many facts for the same predicate in one evaluation, the batch APIs insert a whole group in a single validated, atomic call — cheaper than N per-fact calls. Two families exist: insert by already-interned symbol id, and insert from runtime strings.

By symbol id

Intern once, reuse the integer handles, insert in bulk. Best when the same symbols recur (dense graphs, repeated nodes).

/* Unary batch: one fact per id */
maelys_result_t
maelys_datalog_edb_add_symbol_id_facts(
    maelys_datalog_edb_t *edb,
    const char *predicate,
    const maelys_datalog_symbol_id_t *values,
    size_t value_count);
 
/* Binary batch: flat pairs (left0,right0,left1,right1,...); pair_count = #facts */
maelys_result_t
maelys_datalog_edb_add_symbol_ids_facts(
    maelys_datalog_edb_t *edb,
    const char *predicate,
    const maelys_datalog_symbol_id_t *pairs,
    size_t pair_count);

The whole batch is validated up front — global capacity, per-predicate capacity, and every symbol id — before any fact is written. If validation fails, no fact is inserted (MAELYS_ERR_PAYLOAD_TOO_LARGE or an INVALID_* code).

From runtime strings

Insert many runtime values in one call. Each string is interned (through the symbol-table index) and the resulting ids are inserted through the symbol-id batch above.

/* Unary string batch */
maelys_result_t
maelys_datalog_edb_add_runtime_symbol_facts(
    maelys_datalog_edb_t *edb,
    const char *predicate,
    const char *const *values,
    size_t value_count);
 
/* Binary string batch: flat pairs of strings; pair_count = #facts */
maelys_result_t
maelys_datalog_edb_add_runtime_symbol_pair_facts(
    maelys_datalog_edb_t *edb,
    const char *predicate,
    const char *const *flat_pairs,
    size_t pair_count);
const char *users[] = { "alice", "bob", "carol" };
maelys_datalog_edb_add_runtime_symbol_facts(&edb, "user", users, 3);
/* → user("alice"). user("bob"). user("carol"). */
 
const char *owns[] = { "alice", "doc.pdf",  "bob", "img.png" };
maelys_datalog_edb_add_runtime_symbol_pair_facts(&edb, "owns", owns, 2);
/* → owns("alice","doc.pdf"). owns("bob","img.png"). */
!

EDB insertion is atomic. Symbol interning is monotonic and is not rolled back.

The EDB write is all-or-nothing: a capacity failure or invalid value inserts zero EDB facts. But interning a string mutates the shared symbol table and that mutation is never undone. If a string batch interns several strings and then fails (e.g. a later element is NULL, or the EDB is at capacity), no EDB fact is inserted, yet strings interned earlier in that call may remain in the symbol table. Do not treat a batch as a single transaction over both the symbol table and the EDB. See Performance — batch insertion for the full model.

Clearing the EDB

To reuse the EDB for a new evaluation without re-initializing:

void maelys_datalog_edb_clear(maelys_datalog_edb_t *edb);

clear resets the fact count and marks the EDB as mutable again. The backing fact_pool is not zeroed — it is simply considered empty. The symbol table and registry remain attached.

Finalization

maelys_result_t
maelys_datalog_edb_finalize(maelys_datalog_edb_t *edb);

Finalization sorts and deduplicates the fact pool. After finalization the EDB is immutable — no more facts can be added. The EDB is then ready to be passed to solve_once.

maelys_result_t rc = maelys_datalog_edb_finalize(&edb);
if (rc != MAELYS_OK) return rc;
 
maelys_datalog_solve_result_t *result;
rc = maelys_datalog_solve_once(
    &policy_set.policies[0],
    &edb,
    &result);
i

Finalization is required before solving. If edb_finalize fails, do not call solve_once. Reset the EDB with edb_clear and start again.

Checking for a fact

int
maelys_datalog_edb_contains(
    const maelys_datalog_edb_t *edb,
    const maelys_datalog_fact_t *fact);

Returns 1 if the EDB already contains the given fact, 0 otherwise. The EDB automatically deduplicates facts at finalization, but edb_contains may be called before finalization if the EDB has been sorted.

Full example

#include "include/maelys_datalog.h"
 
maelys_result_t
evaluate_access(
    const maelys_datalog_policy_set_t *policy_set,
    const char *user,
    const char *document)
{
    maelys_result_t rc;
 
    /* --- Static EDB storage --- */
    static maelys_datalog_edb_t edb;
    static maelys_datalog_fact_t fact_pool[MAELYS_DATALOG_MAX_EDB_FACTS];
 
    /* --- Initialize EDB --- */
    rc = maelys_datalog_edb_init(
        &edb,
        fact_pool,
        MAELYS_DATALOG_MAX_EDB_FACTS,
        &policy_set->policies[0].symbols,
        &policy_set->policies[0].registry);
    if (rc != MAELYS_OK) return rc;
 
    /* --- Intern symbol strings --- */
    maelys_datalog_symbol_id_t user_sid, doc_sid;
    rc = maelys_datalog_symbol_intern(
        &policy_set->policies[0].symbols,
        user, strlen(user), &user_sid);
    if (rc != MAELYS_OK) return rc;
 
    rc = maelys_datalog_symbol_intern(
        &policy_set->policies[0].symbols,
        document, strlen(document), &doc_sid);
    if (rc != MAELYS_OK) return rc;
 
    /* --- Build owns(user, document) fact --- */
    maelys_datalog_term_t terms[2];
    terms[0].kind = MAELYS_DATALOG_TERM_SYMBOL;
    terms[0].as.symbol = user_sid;
    terms[1].kind = MAELYS_DATALOG_TERM_SYMBOL;
    terms[1].as.symbol = doc_sid;
 
    rc = maelys_datalog_edb_add_fact(&edb, "owns", terms, 2);
    if (rc != MAELYS_OK) return rc;
 
    /* --- Finalize and solve --- */
    rc = maelys_datalog_edb_finalize(&edb);
    if (rc != MAELYS_OK) return rc;
 
    maelys_datalog_solve_result_t *result = NULL;
    rc = maelys_datalog_solve_once(
        &policy_set->policies[0], &edb, &result);
    if (rc != MAELYS_OK) return rc;
 
    /* --- Query allow(user, document) --- */
    bool present = false;
    rc = maelys_datalog_query_solved_ground_fact(
        result, "allow", terms, 2, &present);
 
    maelys_datalog_solve_result_free(result);
 
    if (rc != MAELYS_OK) return rc;
    return present ? MAELYS_OK : MAELYS_ERR_FORBIDDEN;
}

Inline loading and WASM

When using inline loading or the WASM dynamic playground, the policy source typically contains rules with variables only (no string constants in the .dl source). Runtime string values — user names, document paths, request IDs — are injected into the EDB after loading and are unknown at parse time.

For unary runtime symbol facts, use edb_add_runtime_symbol_fact. It interns the value and inserts the fact without requiring the value to be pre-declared in the atom vocabulary.

For multi-arity or mixed-type facts, build the term array manually and call edb_add_fact.

/* Unary runtime symbol fact — no atom precondition */
maelys_datalog_edb_add_runtime_symbol_fact(&edb, "user", "alice");
 
/* Multi-arity fact — build terms manually */
maelys_datalog_term_t terms[2];
terms[0] = ...;  /* symbol term */
terms[1] = ...;  /* int or bool term */
maelys_datalog_edb_add_fact(&edb, "clearance", terms, 2);

Manifest-based deployments may use string constants in the .dl source (operation names, deny codes, protected branch names). Those constants must belong to the atom vocabulary declared in the manifest, but runtime EDB values always go through edb_add_runtime_symbol_fact regardless.

The WASM edb_add_symbol helper follows this pattern internally: it interns the value and delegates to a symbol-id insertion path, which WASM callers can also reach directly (intern once, insert by id) for repeated symbols. See WASM bindings — inserting by pre-interned symbol ID for the JavaScript API.

EDB constraints

ConstraintLimit
Maximum EDB factsMAELYS_DATALOG_MAX_EDB_FACTS
Maximum facts per predicateMAELYS_DATALOG_MAX_FACTS_PER_PRED
Maximum term arityMAELYS_DATALOG_MAX_ARITY

Exceeding any of these limits returns MAELYS_ERR_PAYLOAD_TOO_LARGE.

Return values

Return codeMeaning
MAELYS_OKSuccess.
MAELYS_ERR_INVALID_ARGUMENTNULL pointer, zero capacity, or zero arity.
MAELYS_ERR_INVALID_STATEEDB is immutable (already finalized).
MAELYS_ERR_INVALID_FIELDUnknown predicate name or arity mismatch.
MAELYS_ERR_FORBIDDENPredicate is not accepted for runtime EDB insertion, or edb_add_atom_fact received an atom that is not present in the registered atom vocabulary.
MAELYS_ERR_PAYLOAD_TOO_LARGEEDB capacity exceeded.

Summary

edb_init()             →  attach fact pool, symbol table, registry
edb_add_fact()         →  add a typed ground fact
edb_add_runtime_symbol_fact()  →  1-arity runtime symbol helper, open values
edb_add_atom_fact()            →  1-arity closed atom helper, vocabulary checked
edb_add_symbol_id_facts()      →  batch insert N unary facts by symbol id (atomic)
edb_add_symbol_ids_facts()     →  batch insert N binary facts by flat id pairs (atomic)
edb_add_runtime_symbol_facts() →  batch insert N unary facts from strings (intern + insert)
edb_add_runtime_symbol_pair_facts()  →  batch insert N binary facts from flat string pairs
symbol_intern()        →  intern a string to get a stable symbol ID
edb_clear()            →  reset for reuse without reinitializing
edb_finalize()         →  sort and dedup; EDB becomes immutable
solve_once()           →  evaluate policy against finalized EDB