Getting started

Quick Start

Build the same document-access decision in C and Python, in five explained steps through the opaque API.

In this guide, you will decide whether a user can read a document: the user must own it or have delegated access, and must not be blocked.

The Python examples use the v2 binding (maelys_datalog), a higher-level wrapper that uses the opaque C API and hides the engine's internal structures.

Choose C or Python below. Your choice applies to every step.

This guide is verified against Maelys Datalog 0.11.1. The commands select that release, and the output examples below come from executing its C and Python programs—not from rewriting an older transcript.

What you need

Choose your language below for its prerequisites and setup commands. Keep the same working directory for setup, the complete example, and execution.

CODEPrepare your environment
git clone --branch v0.11.1 --depth 1 https://github.com/maelys-dev/maelys-datalog.git maelys-datalog

cmake -S maelys-datalog -B build/maelys-datalog -DCMAKE_BUILD_TYPE=Release
cmake --build build/maelys-datalog
cmake --install build/maelys-datalog --prefix "$PWD/.maelys"

Both integrations use the public opaque facade, never an internal engine header. In C, include <maelys/datalog.h> for the API and <maelys/datalog_builders.h> for the optional macros used in these examples. The macros build values and call that same API; they do not expose engine internals.

The vocabulary

Start with one statement: owns("alice", "roadmap.pdf") says that Alice owns the document. Datalog calls this a fact. It describes a relationship, not the contents of the document itself.

TermMeaning in this example
PredicateA named relationship, such as owns. Its arity is its number of arguments: owns/2 takes a user and a document.
FactA relationship with concrete values, such as owns("alice", "roadmap.pdf"). It contains no variables.
DomainThe vocabulary the application permits: predicate names, arities, and whether each is an input or a derived relationship. Declaring owns/2 does not yet say that anyone owns a document.
RuleA way to derive a new fact when its conditions hold. Our access rule requires a known user who owns the document or has delegated access, and who is not blocked.
PolicyThe set of rules expressing the access decision. This example keeps the rules fixed while changing the request facts. A policy can also contain fixed facts, although ours does not.
EDB — Extensional DatabaseThe input facts supplied by your application for one request: known users, ownership, delegation and blocked users. Think “what the application tells the engine.”
IDB — Intensional DatabaseThe facts the engine derives by applying the rules, such as can_read("alice", "roadmap.pdf") and allow("alice", "roadmap.pdf"). Think “what the engine concludes.”
QueryA question asked of the computed result: is a particular fact present? For example, is allow("alice", "roadmap.pdf") present?

EDB and IDB are logic-programming terms. They do not require a database server: here, the input and derived facts live in the embedded engine's memory.

Lifecycle

The application has two phases: prepare a policy once, then evaluate each request with its own complete input facts. A session holds the prepared policy and reusable working memory. It stays alive across requests; each result belongs to just one evaluation.

Prepare once

  1. Domain
    register vocabulary
  2. Policy
    load and validate
  3. Session
    prepare once; keep

Register the domain for a fixed vocabulary, load and validate the policy, then create a session for the selected policy. Keep that session for later requests. This phase supplies no request facts and computes no access decision.

For each request

  1. Input EDB
    all request facts
  2. Solve
    use the same session
  3. Query
    read the result
  4. Release result
    finish this request

Cycle: release — next request; same session — input

  1. Supply the entire request input. Fill an empty EDB with the relevant users, ownership, delegation and blocked-user facts.
  2. Solve using the prepared session. The engine applies the policy to these facts and returns a result containing the derived facts (IDB).
  3. Query that result. Check whether allow(...) is present. You can ask several queries or request explanations without running the solver again.
  4. Release the result when finished. Only one result may be live per session. Releasing it makes that session available for the next solve.

The loop returns to the input facts, not to session creation. Keep the same session. Either reuse the EDB storage—input_edb_clear() in C, edb.reset() in Python—or create a new EDB. Supply all facts for the next request, not only the changes. A solve does not inherit facts from the previous result; facts left in an uncleared EDB would still be input.

When no more requests remain, release the last result and then clean up the EDB and session. The C and Python notes below show their ownership rules and cleanup operations.

The five steps below build one program. Each code block is a fragment; the complete example later on includes imports, declarations, error handling and cleanup. Notes below each code block apply only to the selected language. In C, rc is the returned status, and goto cleanup jumps to the resource-release block in that complete example.

Step 1 — Define your vocabulary

The domain fixes the vocabulary before any policy is loaded:

PredicateKindWhat it means
user/1EDBThe user exists in this request.
owns/2EDBThe user owns a document.
delegated/2EDBThe user was delegated access to a document.
blocked/1EDBThe user is blocked.
can_read/2IDBAn internal, derived access relation.
has_any_document/1IDB + QUERYAn observation about ownership, not an authorization.
allow/2IDB + QUERYThe final answer that the application queries.

The Kind column determines which relationships are inputs (EDB), which are derived (IDB), and which your application may query (QUERY). These names and arities must be declared explicitly; the engine does not infer them from the policy.

A ground query supplies a concrete value for every argument. Asking allow("alice", "roadmap.pdf") means “may Alice read this document?” There are no variables to fill in: the answer is present or absent. By contrast, allow(User, "roadmap.pdf") asks for matching users. That is not the yes/no query operation used in this quickstart.

CODERegister the domain
static const maelys_datalog_predicate_t predicates[] = {
    MAELYS_DATALOG_EDB("user", 1),
    MAELYS_DATALOG_EDB("owns", 2),
    MAELYS_DATALOG_EDB("delegated", 2),
    MAELYS_DATALOG_EDB("blocked", 1),
    MAELYS_DATALOG_IDB("can_read", 2),
    MAELYS_DATALOG_IDB_QUERY("has_any_document", 1),
    MAELYS_DATALOG_IDB_QUERY("allow", 2),
};
const maelys_datalog_domain_t domain = {
    "documents", predicates,
    sizeof(predicates) / sizeof(predicates[0]), NULL, 0u,
};
maelys_datalog_status_t rc = maelys_datalog_domain_register(&domain);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

The first four predicates accept request facts. The last three are derived; can_read stays internal, while allow and has_any_document can be queried. Domain registration belongs to application setup, not to every request.

Step 2 — Write the policy

In a rule, read :- as “if”. The expression on the left is the conclusion; the expressions on the right are its conditions. A comma means “and”, and the final period ends the rule. Names such as User and Doc are variables: the engine matches them to concrete values from facts.

Our policy contains three rules:

DATALOG
can_read(User, Doc) :-
    owns(User, Doc) or delegated(User, Doc),
    not(blocked(User)).

has_any_document(User) :- owns(User, _).

allow(User, Doc) :-
    user(User),
    can_read(User, Doc).

Read the final rule first: allow(User, Doc) needs a known user and a derived can_read(User, Doc). The first rule derives that relation from ownership or delegation, provided blocked(User) is absent. The second rule merely asks whether the user owns some document. Therefore a blocked owner can satisfy has_any_document while allow remains false.

The policy contains no literal users or filenames. Source strings in this language have no escape syntax: a quote or backslash cannot be encoded inside a source string literal. Here, concrete names instead arrive as runtime fact values, in C or Python. Keep this policy as a source string in your program; loading it comes in the next step.

CODEStore the policy source
const char source[] =
    "can_read(User, Doc) :-\n"
    "    owns(User, Doc) or delegated(User, Doc),\n"
    "    not(blocked(User)).\n"
    "has_any_document(User) :- owns(User, _).\n"
    "allow(User, Doc) :- user(User), can_read(User, Doc).\n";

Step 3 — Load the policy and prepare a session

First, load the policy from Step 2 under the domain registered in Step 1. The engine checks that its predicates, arities and syntax are valid.

A session is a reusable evaluation context in the engine for that policy. It keeps the prepared rules and the working memory needed to run them. It can also manage an optional workspace for computing explanations.

Keep these five objects and memory areas distinct:

  • The session keeps the same policy ready to evaluate and manages its reusable working memory.
  • The EDB contains the input facts you supply for a particular request.
  • The result contains the facts derived by one evaluation, which you can then query.
  • The explanation workspace holds the working data and prepared explanation when you ask why a fact is present or absent. It does not contain the formatted text to display.
  • The output text buffer receives that formatted explanation. In Python, the binding returns its contents as a string.

In this quickstart, the session manages the explanation workspace automatically. With the manual prepared API, your application supplies and manages that memory instead. In both cases, an explanation is built from a live result only when requested; reserving its memory does not compute it.

For example, your application can use the same session to evaluate one set of ownership and delegation facts, then another set for a later request. It does not need to load the policy again. Read and release the current result before using the session for the next evaluation.

At this step, you are only preparing the workspace. No access decision has been computed yet. You will build the input facts in Step 4 and pass them to the session's solve operation in Step 5.

CODELoad and prepare
/* Declare these with the other handles at the start of main,
 * before any operation that may jump to cleanup. */
maelys_datalog_policy_t *policy = NULL;
maelys_datalog_session_t *session = NULL;
maelys_datalog_session_config_t *config = NULL;
maelys_datalog_diagnostic_t diagnostic = MAELYS_DATALOG_DIAGNOSTIC_INIT;

maelys_datalog_diagnostic_clear(&diagnostic);
rc = maelys_datalog_policy_load_inline(
    domain.name, "documents.main", source, strlen(source),
    &policy, &diagnostic);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

rc = maelys_datalog_session_config_create(&config);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
rc = maelys_datalog_session_config_set_explanation_workspace(
    config, MAELYS_DATALOG_EXPLAIN_TRUE | MAELYS_DATALOG_EXPLAIN_FALSE);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

/* Inline loading returns one policy: select its index 0. */
rc = maelys_datalog_session_create_configured(policy, 0u, config, &session);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
rc = maelys_datalog_session_config_free(config);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
config = NULL;

rc = maelys_datalog_policy_free(policy);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
policy = NULL;

A failed load prevents the solve; do not continue with an invalid policy.

Why reserve explanation memory when creating the session?

An explanation workspace holds the engine's temporary data while it builds a Why-true or Why-false explanation. It is separate from the input facts and from the output text you will print. Reserving it does not compute an explanation.

We configure it here because session creation is where this reusable memory is allocated:

  • In C, session_config_set_explanation_workspace() only records the choice in config. The later session_create_configured() call performs the allocation.
  • In Python, ruleset.prepare(explanations=...) performs both operations for you.
  • The session keeps this memory until it is closed or freed and reuses it across requests. Changing or freeing config afterward does not change an existing session.

This moves the allocation out of individual explanation requests and into setup. You cannot enable this session-owned workspace later on the same session: create a new configured session, or use the on-demand alternatives below. This quickstart enables both kinds because it later explains both a granted and a denied access decision.

Does manual memory allow a larger explanation?

Automatic and manual memory management have the same explanation capabilities and limits, for the same backend and build profile, provided the buffers are large enough. The choice changes who supplies the workspace and manages its lifetime, not what the engine can explain. The manual prepared API is not a fallback for explanations that are too large for automatic mode.

Three situations must stay distinct:

  • Workspace too small: a caller-provided buffer can be rejected with STORAGE_TOO_SMALL. Supply enough workspace; this error is not a truncated explanation. Automatic reservation uses the reference backend's maximum requirement for the selected kinds and profile, so it covers every supported bounded explanation. Failure to allocate that reservation is a session-creation error, not a truncated document.
  • Text buffer too small: PAYLOAD_TOO_LARGE means there is not enough room to write the formatted text. Supply a larger output buffer; an existing cached or explicitly retained preparation can be written again without rebuilding it.
  • Engine explanation limit reached: a successfully returned document can say status=truncated because of bounded proof retention or exploration, such as proof depth or the number of diagnostics. Switching to manual storage, or increasing either buffer, does not raise those internal limits.

Some internal bounds limit the amount of proof or diagnostic data the engine retains, so they do have a memory cost. They are not determined by the size of the workspace you supply. The precise distinction is memory ownership versus explanation limits, not “memory can never be a problem.”

What do TRUE and FALSE select?

Why-true explains how a present fact was derived; Why-false explores why an absent fact could not be derived, within the engine's exploration limits.

The C flags are MAELYS_DATALOG_EXPLAIN_TRUE and MAELYS_DATALOG_EXPLAIN_FALSE; Python uses ExplanationKind.TRUE and ExplanationKind.FALSE. The | operator combines these choices. It does not combine query answers or ask the engine to compute two explanations at once.

For the C direct-text functions and Python explain_true() / explain_false() methods used in this guide:

ConfigurationBehavior when requesting an explanation
Neither option, or 0No reusable explanation workspace. Either kind remains available, using temporary storage when requested.
TRUE onlyReuse the workspace for Why-true. Requesting Why-false returns UNSUPPORTED.
FALSE onlyReuse the workspace for Why-false. Requesting Why-true returns UNSUPPORTED.
TRUE | FALSEReuse one shared workspace for either kind, one explanation at a time.

With only one kind enabled, there is no automatic allocating fallback for the other kind. C returns MAELYS_DATALOG_STATUS_UNSUPPORTED; Python raises MaelysDatalogError with that status. This is an API error, not a negative authorization answer. None of these settings changes the policy or the facts it derives.

How much memory does this reserve?

Yes: the option shown here makes one additional dynamic allocation, using malloc, when the session is created. The engine frees it with the session. Its size is the selected kind's storage bound for the reference backend and build profile. With both kinds, it uses the larger bound, not the sum. It does not grow with each request.

These are measured workspace sizes for the published 0.5.0 reference engine on macOS ARM64, including the explanation handle and alignment padding:

Enabled kindsSMALL profile, bytesLARGE profile, bytes
Why-true only46,64046,640
Why-false only169,880202,648
Both kinds169,880202,648

These figures are additional workspace, not the session's total memory or the output-text capacity. They are measurements, not portable buffer constants. In C, maelys_datalog_session_explanation_storage_bound() returns the bound and alignment for one kind on your linked library, before any solve. Python's prepare() lets the native engine choose this size.

What if I do not reserve it in advance?

Omit the C workspace setter, or omit Python's explanations argument. You can still solve, query and request explanations:

  • C: each direct-text call allocates temporary engine storage, prepares the explanation and frees that storage before returning. The measure-then-write sequence therefore prepares it twice when there is no reusable workspace.
  • Python: each explain_true() or explain_false() call allocates temporary CFFI storage and prepares the explanation once, then returns its text. Without the option, that workspace is not retained for the next call.
  • No explanation requested: neither path reserves explanation workspace just for solving or querying.

If you want to avoid the workspace's malloc altogether, C also supports caller-provided storage through session_config_set_explanation_storage(), or storage supplied only when requesting an explanation through result_explain_text_in(). These memory-control APIs require you to provide a sufficiently large, aligned buffer; the session-storage buffer must remain available until the session is freed. They do not eliminate other session-creation allocations. In every mode, the output text needs separate memory; Python also allocates its objects, conversions and strings.

What does policy index 0 select?

Inline loading creates one policy containing all the rules in the source string. Here, the rules for can_read, has_any_document and allow belong to that same policy. Use 0u in C or policy_index=0 in Python to prepare its session.

The zero selects that policy; it is not a rule number or a session number. Adding more rules to the string does not create more policies. There is no inline syntax for separating the string into several independent policies.

A manifest can load several policy files into one set, making other indices meaningful. The manifest tutorial explains when to use this and how to select a policy in C and Python.

Step 4 — Supply runtime facts

The policy rules stay fixed. The application now supplies seven facts for this request: three users, two ownership relations, one delegation, and one blocked user.

CODEBuild the request facts
/* Declare with the other handles at the start of main. */
maelys_datalog_input_edb_t *edb = NULL;

rc = maelys_datalog_input_edb_create(&edb);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

rc = MAELYS_DATALOG_ADD_FACTS(
    edb, &diagnostic,
    MAELYS_DATALOG_FACT("user", "alice"),
    MAELYS_DATALOG_FACT("user", "bob"),
    MAELYS_DATALOG_FACT("user", "mallory"),
    MAELYS_DATALOG_FACT("owns", "alice", "roadmap.pdf"),
    MAELYS_DATALOG_FACT("delegated", "bob", "roadmap.pdf"),
    MAELYS_DATALOG_FACT("owns", "mallory", "roadmap.pdf"),
    MAELYS_DATALOG_FACT("blocked", "mallory")
);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

Bob has no owns fact: his access comes solely from delegated. Building the request input does not run the solver; solving comes in Step 5.

The seven facts form one atomic batch in both languages. If any addition fails, none of this batch is appended; any facts already present stay unchanged. C reports a status and diagnostic; Python raises an exception.

Adding just one fact

When one fact arrives on its own, use the unit operation below. This is an alternative syntax, not an extra step to run after the seven-fact batch:

CODEAdd one fact
rc = MAELYS_DATALOG_ADD_FACT(
    edb, &diagnostic, "owns", "alice", "roadmap.pdf");
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

Multiple unit calls are independent: if the third fails, the first two remain. Use the batch form when those facts belong to one all-or-nothing update. The C input reference also shows the ordinary typed array API for large or dynamically constructed batches.

Both languages now use the same owned native input buffer. Append copies names and symbol text; a rejected batch appends nothing. The entry limit is checked before deduplication, and text lengths are checked in bytes. Domain membership, the declared arity and policy-specific capacities remain solve-time checks. No finalize() or public symbol interning is required.

Step 5 — Solve and query

Solve the request's facts once, then ask whether allow("alice", "roadmap.pdf") is present in the result. This is the ground query introduced in Step 1: both arguments are concrete values. It returns a yes/no answer, not a list of matching users or documents.

CODESolve and check the decisions
/* Declare with the other handles at the start of main. */
maelys_datalog_result_t *result = NULL;

maelys_datalog_diagnostic_clear(&diagnostic);
rc = maelys_datalog_session_solve_edb(
    session, edb, &result, &diagnostic);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

int present = 0;
rc = MAELYS_DATALOG_QUERY(
    result, &present, "allow", "alice", "roadmap.pdf");
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

/* Only now is present a valid answer: 1 for Alice in this request. */

Only allow is an authorization decision. has_any_document illustrates what _ derived independently: ownership and permission are different questions.

After the first solve — reuse the session

Suppose the next request reports that Alice is now blocked. The rules have not changed, so there is no reason to reload the policy or prepare another session. Reuse the session from Step 3, replace the input facts, and compute a new result.

ObjectFirst requestSecond request
Prepared sessionCreated once for this policy.The same session, with its reusable workspace.
Input EDBSeven facts from Step 4.The same buffer, emptied and refilled with three facts.
ResultAlice is allowed. Read it, then release it.A new evaluation: Alice is blocked, so access is denied.

Keep the session; release the result and reset the input. Before evaluating the second request, follow this sequence:

  1. Finish reading and release the first result. In C, call maelys_datalog_result_free(result) and set result = NULL. In Python, leave the first with session.solve(edb) as result: block; leaving it closes that result.
  2. Empty the input EDB. Use maelys_datalog_input_edb_clear(edb) in C or edb.reset() in Python. This removes the first request’s facts while keeping the allocated input buffer.
  3. Add the second request’s facts, then solve again with the same session. Do not reload the policy or recreate the session.

A session allows only one live result at a time. Clearing or resetting the EDB does not release that result: another solve on the same session is rejected until the result is released. Once you have released the first result and replaced the EDB facts, call solve again with the same session. You do not need to reset or recreate the session: it continues to use the same policy.

The fragment below includes this transition. In C, place the whole fragment after the successful Step 5 query and before final cleanup: it releases the first result before solving again. In Python, place it outside and after the first with session.solve(edb) block, at the same indentation level as that with, while keeping the outer Engine context open. This optional second request is separate from the complete single-request example below.

To count stored entries, use maelys_datalog_input_edb_count() in C or len(edb) in Python. For this second request, use edb.reset(), not edb.clear(): Python’s clear() only clears an unsolved EDB, whereas reset() also allows reuse after a successful solve. Both operate on the input EDB, not on the session.

Closing or freeing an EDB does not invalidate a returned result.

CODEEvaluate a second request
/* Continue after the successful solve and query in Step 5. */
printf("request 1: %s\n", present ? "ALLOW" : "DENY");

/* Release the first result before solving again on this session. */
rc = maelys_datalog_result_free(result);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
result = NULL;

size_t count = 0;
rc = maelys_datalog_input_edb_count(edb, &count);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
printf("input entries before reset: %zu\n", count);

/* Keep the buffer, but discard every fact from request 1. */
rc = maelys_datalog_input_edb_clear(edb);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
rc = maelys_datalog_input_edb_count(edb, &count);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
printf("input entries after reset: %zu\n", count);

rc = MAELYS_DATALOG_ADD_FACTS(
    edb, &diagnostic,
    MAELYS_DATALOG_FACT("user", "alice"),
    MAELYS_DATALOG_FACT("owns", "alice", "roadmap.pdf"),
    MAELYS_DATALOG_FACT("blocked", "alice")
);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;

rc = maelys_datalog_input_edb_count(edb, &count);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
printf("input entries for request 2: %zu\n", count);

/* Reuse session: no load_policy_inline() or session_create() here. */
maelys_datalog_diagnostic_clear(&diagnostic);
rc = maelys_datalog_session_solve_edb(session, edb, &result, &diagnostic);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
rc = MAELYS_DATALOG_QUERY(result, &present, "allow", "alice", "roadmap.pdf");
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
printf("request 2: %s\n", present ? "ALLOW" : "DENY");

rc = maelys_datalog_result_free(result);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
result = NULL;

Both versions print:

CODE
request 1: ALLOW
input entries before reset: 7
input entries after reset: 0
input entries for request 2: 3
request 2: DENY

Why keep a session?

  • Preparation stays outside the request loop. The loaded policy and prepared evaluation workspace are reused. A second session.solve(edb) does not create another session.
  • The input buffer is reusable too. C clears it with input_edb_clear(); Python starts a new batch with edb.reset(). Counts are stored entries before deduplication, not the number of derived facts.
  • Decisions are recomputed, not remembered. The second solve uses the complete replacement input. It does not add a delta to the first result or carry Alice’s old permission forward. Supply every fact relevant to the new request, including identity and ownership again—not just blocked("alice").
  • Only one result may be live per session. Releasing the first result makes the workspace available for the next solve. Resetting or closing the EDB does not release that result; these are separate lifetimes. Do not read a result after closing it.

In Python, ruleset.solve(edb) is a convenience operation that prepares a private session for that result. For a request loop, the explicit session = ruleset.prepare() followed by repeated session.solve(edb) is what demonstrates session reuse here. This reuses native preparation and storage; it does not promise that Python itself performs no allocations.

Complete example — put the five steps together

This one program declares the domain, loads the policy, supplies seven runtime facts, solves once, and checks five ground queries. Copy the complete file for your selected language; do not concatenate the step fragments yourself. Each complete file includes error handling and cleanup:

CODEComplete program

main.c

#include <maelys/datalog.h>
#include <maelys/datalog_builders.h>

#include <stdio.h>
#include <string.h>

static int ok(maelys_datalog_status_t status, const char *step) {
    if (status == MAELYS_DATALOG_STATUS_OK) return 1;
    fprintf(stderr, "%s: %s\n", step, maelys_datalog_status_name(status));
    return 0;
}

static int expect(
    const maelys_datalog_result_t *result,
    const char *predicate, const char *first, const char *second,
    int expected) {
    const maelys_datalog_value_t terms[] = {
        MAELYS_DATALOG_SYMBOL(first),
        MAELYS_DATALOG_SYMBOL(second),
    };

    int present = 0;
    size_t arity = second == NULL ? 1u : 2u;
    if (!ok(maelys_datalog_result_query(
            result, predicate, terms, arity, &present), "query")) {
        return 0;
    }
    printf("%s(%s", predicate, first);
    if (second != NULL) printf(", %s", second);
    printf(") = %s\n", present ? "true" : "false");
    return present == expected;
}

int main(void) {
    static const maelys_datalog_predicate_t predicates[] = {
        MAELYS_DATALOG_EDB("user", 1),
        MAELYS_DATALOG_EDB("owns", 2),
        MAELYS_DATALOG_EDB("delegated", 2),
        MAELYS_DATALOG_EDB("blocked", 1),
        MAELYS_DATALOG_IDB("can_read", 2),
        MAELYS_DATALOG_IDB_QUERY("has_any_document", 1),
        MAELYS_DATALOG_IDB_QUERY("allow", 2),
    };
    const maelys_datalog_domain_t domain = {
        "documents", predicates,
        sizeof(predicates) / sizeof(predicates[0]), NULL, 0u,
    };
    const char source[] =
        "can_read(User, Doc) :-\n"
        "    owns(User, Doc) or delegated(User, Doc),\n"
        "    not(blocked(User)).\n"
        "has_any_document(User) :- owns(User, _).\n"
        "allow(User, Doc) :- user(User), can_read(User, Doc).\n";

    maelys_datalog_policy_t *policy = NULL;
    maelys_datalog_session_t *session = NULL;
    maelys_datalog_session_config_t *config = NULL;
    maelys_datalog_result_t *result = NULL;
    maelys_datalog_input_edb_t *edb = NULL;
    maelys_datalog_diagnostic_t diagnostic = MAELYS_DATALOG_DIAGNOSTIC_INIT;
    int exit_code = 1;

    if (!ok(maelys_datalog_domain_register(&domain), "register domain"))
        goto cleanup;

    maelys_datalog_diagnostic_clear(&diagnostic);
    if (!ok(maelys_datalog_policy_load_inline(
            domain.name, "documents.main", source, strlen(source),
            &policy, &diagnostic), "load policy")) {
        if (diagnostic.message[0] != '\0')
            fprintf(stderr, "  %s\n", diagnostic.message);
        goto cleanup;
    }

    if (!ok(maelys_datalog_session_config_create(&config), "create config"))
        goto cleanup;
    if (!ok(maelys_datalog_session_config_set_explanation_workspace(
            config, MAELYS_DATALOG_EXPLAIN_TRUE | MAELYS_DATALOG_EXPLAIN_FALSE),
            "reserve explanation workspace"))
        goto cleanup;
    if (!ok(maelys_datalog_session_create_configured(policy, 0u, config, &session),
            "create session"))
        goto cleanup;
    if (!ok(maelys_datalog_session_config_free(config), "free config"))
        goto cleanup;
    config = NULL;

    /* The prepared session owns its state; the policy handle may be freed. */
    if (!ok(maelys_datalog_policy_free(policy), "free policy"))
        goto cleanup;
    policy = NULL;

    if (!ok(maelys_datalog_input_edb_create(&edb), "create input EDB")) goto cleanup;
    if (!ok(MAELYS_DATALOG_ADD_FACTS(
        edb, &diagnostic,
        MAELYS_DATALOG_FACT("user", "alice"),
        MAELYS_DATALOG_FACT("user", "bob"),
        MAELYS_DATALOG_FACT("user", "mallory"),
        MAELYS_DATALOG_FACT("owns", "alice", "roadmap.pdf"),
        MAELYS_DATALOG_FACT("delegated", "bob", "roadmap.pdf"),
        MAELYS_DATALOG_FACT("owns", "mallory", "roadmap.pdf"),
        MAELYS_DATALOG_FACT("blocked", "mallory")
    ), "add request facts")) {
        if (diagnostic.message[0] != '\0')
            fprintf(stderr, "  %s\n", diagnostic.message);
        goto cleanup;
    }

    maelys_datalog_diagnostic_clear(&diagnostic);
    if (!ok(maelys_datalog_session_solve_edb(
            session, edb,
            &result, &diagnostic), "solve")) {
        if (diagnostic.message[0] != '\0')
            fprintf(stderr, "  %s\n", diagnostic.message);
        goto cleanup;
    }

    int answers_match = 1;
    answers_match &= expect(result, "allow", "alice", "roadmap.pdf", 1);
    answers_match &= expect(result, "allow", "bob", "roadmap.pdf", 1);
    answers_match &= expect(result, "allow", "mallory", "roadmap.pdf", 0);
    answers_match &= expect(result, "has_any_document", "alice", NULL, 1);
    answers_match &= expect(result, "has_any_document", "bob", NULL, 0);
    exit_code = answers_match ? 0 : 2;

cleanup:
    if (config != NULL) (void)maelys_datalog_session_config_free(config);
    (void)maelys_datalog_input_edb_free(edb);
    if (result != NULL) (void)maelys_datalog_result_free(result);
    if (session != NULL) (void)maelys_datalog_session_free(session);
    if (policy != NULL) (void)maelys_datalog_policy_free(policy);
    return exit_code;
}

Compile, run, and interpret the answers

Save the complete example using the filename shown in its tab, then run the corresponding commands from the setup working directory.

CODERun the complete example
cc -std=c11 -Wall -Wextra \
  -I "$PWD/.maelys/include" \
  main.c "$PWD/.maelys/lib/libmaelys_datalog.a" \
  -o quickstart

./quickstart

Both programs must print the same five answers and exit with status zero:

CODE
allow(alice, roadmap.pdf) = true
allow(bob, roadmap.pdf) = true
allow(mallory, roadmap.pdf) = false
has_any_document(alice) = true
has_any_document(bob) = false

Alice owns the document. Bob does not own one, but delegation grants access. Mallory owns the same document, yet the blocked fact prevents access. The wildcard query says only whether someone owns any document; it is not the access decision.

Because not(blocked(User)) tests absence in the supplied facts, the application must provide a complete blocked-user view for this decision. A missing blocked fact is not an "unknown" value: the rule treats it as unblocked. That makes the fact producer part of the security boundary.

Try deleting the delegated(bob, roadmap.pdf) fact from the C input-building calls or the Python batch. Bob's allow answer becomes false. Then restore it and delete blocked(mallory): Mallory's allow answer becomes true. Each change alters request facts, not the policy or its declared vocabulary. The sample exits with code 2 when its original expected-answer checks no longer match; that is intentional. Read the printed answers to see the change.

Membership, Why-true and Why-false

The five queries above answer whether a fact is present in this solved request. An explanation answers a different question: how was it derived, or what prevented its derivation? Query first, then choose the explanation. Neither explanation runs the solver again or changes its answer.

Solved request
same policy and input facts
present
  1. Why-true
    one retained derivation
absent
  1. Why-false
    bounded failed branches

An API error is neither branch of this diagram: stop and handle it. In this example, allow("alice", "roadmap.pdf") is present, whereas allow("mallory", "roadmap.pdf") is absent. Those are the two facts we will explain, using the same live result as in Step 5.

1. Read the same result in C and Python

QuestionC callPython call
How was this present fact derived?result_explain_true_text()result.explain_true()
What prevented this absent fact?result_explain_false_text()result.explain_false()

The session was created with a reusable explanation workspace. The first call builds the explanation and reports the required text length. Your program allocates the output buffer. The second call writes the already-prepared explanation into that buffer.

The output buffer holds the text to print; it is separate from the session's explanation workspace. Neither call solves the policy again or allocates inside the reference engine. The application still allocates the output text in this example.

  1. Measure
    first API call
  2. Allocate text
    length + final NUL
  3. Render
    second API call
  4. Free text
    including on API failure

Python uses the same session workspace through its explanation methods. It returns an independent string; Python/CFFI still allocate argument conversions, output buffers and strings. This is not a zero-allocation Python program. For caller-owned memory or an explicitly retained explanation handle, see Prepared explanations.

Add this helper before the complete program's main function:

CODEExplanation helpers
#include <stdlib.h>

/* Add above main(), after the includes in the complete example. */
static int print_explanation(
    maelys_datalog_result_t *result,
    const char *predicate,
    const maelys_datalog_value_t *terms,
    size_t arity) {
    int present = 0;
    maelys_datalog_status_t rc = maelys_datalog_result_query(
        result, predicate, terms, arity, &present);
    if (rc != MAELYS_DATALOG_STATUS_OK) return 0;

    /* First call: obtain the text length, excluding the final NUL. */
    size_t required = 0;
    if (present) {
        rc = maelys_datalog_result_explain_true_text(
            result, predicate, terms, arity, NULL, 0, &required);
    } else {
        rc = maelys_datalog_result_explain_false_text(
            result, predicate, terms, arity, NULL, 0, &required);
    }
    if (rc != MAELYS_DATALOG_STATUS_OK) return 0;

    char *text = malloc(required + 1u);
    if (text == NULL) return 0;

    /* Second call: render into the application's output buffer. */
    if (present) {
        rc = maelys_datalog_result_explain_true_text(
            result, predicate, terms, arity, text, required + 1u, &required);
    } else {
        rc = maelys_datalog_result_explain_false_text(
            result, predicate, terms, arity, text, required + 1u, &required);
    }
    int success = 0;
    if (rc == MAELYS_DATALOG_STATUS_OK) success = fputs(text, stdout) >= 0;
    free(text); /* Also runs when the second API call fails. */
    return success;
}

After the five membership checks, call the helper twice before releasing the result. This extends the complete program above; it is not a separate program and does not require a second solve.

CODEExplain this request
/* After the five checks, before freeing result. */
const maelys_datalog_value_t query_alice[] = {
    MAELYS_DATALOG_SYMBOL("alice"),
    MAELYS_DATALOG_SYMBOL("roadmap.pdf"),
};
const maelys_datalog_value_t query_mallory[] = {
    MAELYS_DATALOG_SYMBOL("mallory"),
    MAELYS_DATALOG_SYMBOL("roadmap.pdf"),
};
if (!print_explanation(result, "allow", query_alice, 2u)
    || !print_explanation(result, "allow", query_mallory, 2u)) {
    fprintf(stderr, "Could not render the explanation\n");
    goto cleanup;
}

The helper selects Why-true for Alice and Why-false for Mallory. A failed membership call never selects Why-false. If explanation retrieval fails, the sample reports an error; it does not turn that error into a negative fact or an authorization. Explanation availability and membership remain distinct.

Both returned documents start with MAELYS-DATALOG-v2. The next line identifies the document, not its truth value or API status:

First lineSecond linePayload
MAELYS-DATALOG-v2document=why-trueA retained derivation witness.
MAELYS-DATALOG-v2document=why-falseObstacles found while exploring failed derivations.

When consuming the text programmatically, check both lines before interpreting the payload. The old Why-false-only header has not been emitted since 0.4.0.

2. Why-true: follow one successful derivation

For Alice, the C and Python examples emit the same document:

EXPLANATION
MAELYS-DATALOG-v2
document=why-true
status=complete
steps=2 premises=4
step=0 rule=1 fact="can_read"("alice","roadmap.pdf")
premise=0 body=0 kind=positive origin=edb fact="owns"("alice","roadmap.pdf") parent=-
premise=1 body=1 kind=negative-absence origin=edb fact="blocked"("alice") parent=-
step=1 rule=4 fact="allow"("alice","roadmap.pdf")
premise=2 body=0 kind=positive origin=edb fact="user"("alice") parent=-
premise=3 body=1 kind=positive origin=idb fact="can_read"("alice","roadmap.pdf") parent=0
result-step=1

Read it from its premises toward its result:

  1. Step 0 derives can_read. The input contains Alice's ownership fact, and blocked("alice") is absent. negative-absence records that check against this request's facts; it does not invent a negative input fact.
  2. Step 1 derives allow. Alice is a known user, and the derived can_read from step 0 supplies the other premise. parent=0 points back to that derivation; parent=- means there is no parent derivation step.
  3. result-step=1 identifies the requested conclusion. steps=2 and premises=4 count the retained witness, not every fact in the solve.

This is one retained derivation, not an enumeration of all possible ways to authorize Alice. The source or was expanded into ordinary rules before solving, so the document refers to rules and premises, not an or node. Rule numbers belong to this compiled policy; do not hard-code them as permanent application identifiers.

3. Why-false: inspect the obstacles on failed branches

Mallory owns the document but is blocked. Why-false has more to inspect than the successful witness: ownership and delegation are two candidate paths, and both must fail to leave can_read absent. The examples emit:

EXPLANATION
MAELYS-DATALOG-v2
document=why-false
status=complete
query="allow"("mallory","roadmap.pdf") origin=none
summary=none
limit-hits=none candidate-rules=3 substitutions=2 diagnostics=3 filter-cost=0
diagnostic=0 rule=1 depth=1 target="can_read"("mallory","roadmap.pdf")
binding=3 value="roadmap.pdf"
binding=20 value="mallory"
support=0 body=0 origin=edb fact="owns"("mallory","roadmap.pdf")
obstacle=negative-contradicted body=1 origin=edb pattern="blocked"("mallory")
diagnostic=1 rule=2 depth=1 target="can_read"("mallory","roadmap.pdf")
binding=3 value="roadmap.pdf"
binding=20 value="mallory"
obstacle=positive-no-match body=0 origin=none pattern="delegated"("mallory","roadmap.pdf")
diagnostic=2 rule=4 depth=0 target="allow"("mallory","roadmap.pdf")
binding=3 value="roadmap.pdf"
binding=20 value="mallory"
support=0 body=0 origin=edb fact="user"("mallory")
obstacle=positive-no-match body=1 origin=none pattern="can_read"("mallory","roadmap.pdf")

The three diagnostics explain the chain of failure:

DiagnosticWhat already worksObstacle
Ownership path to can_readMallory owns the document.negative-contradicted: the existing blocked fact contradicts not(blocked(...)).
Delegation path to can_readNo matching delegation is available.positive-no-match: delegated("mallory", "roadmap.pdf") is absent.
Final rule for allowMallory is a known user.positive-no-match: neither path produced the required can_read fact.

depth=1 is the exploration of the missing intermediate can_read; depth=0 is the original allow query. binding=3 and binding=20 are rule-local variable IDs for Doc and User, not symbol IDs or line numbers. A diagnostic retains supports already found and one obstacle for that branch. It is not a list of every conceivable repair to the policy.

status=complete with limit-hits=none means the bounded diagnostic exploration finished without reporting a hit. It is not a formal absence certificate. Read the experimental API contract before using Why-false as audit evidence.

4. Yes, both explanations can be truncated

There is a symmetry in boundedness, but not in what is being explained. Why-true reconstructs one successful witness. Why-false explores candidate rules and substitutions to diagnose their failure. Their limits and their truncated documents therefore differ.

SituationWhy-trueWhy-false
completeA complete retained witness for one derived IDB fact. Not every possible derivation.The diagnostic exploration finished without hitting a reported bound. Not a formal proof of non-derivability.
truncatedThe fact is still present, but a complete usable witness cannot be returned. The current text contains steps=0 premises=0, with no partial derivation.The exploration hit at least one bound. It can retain partial diagnostics; limit-hits names the limits reached.
Wrong kind of questionnot-derived: no matching derived IDB fact. This is not itself a membership answer.not-applicable: the queried fact is present, so an absence explanation does not apply.

A directly supplied EDB or policy fact can be present without a derived IDB witness. Therefore not-derived does not mean false. For the derived allow predicate in this example, checking membership first makes the choice of explanation straightforward.

The current reference implementation bounds retained Why-true provenance with a 64-node proof store and proof depth 10; explanation steps and premises are also bounded. Why-false separately bounds candidate rules (128), substitutions per rule (4,096), depth (10), retained diagnostics (16), and ground-filter cost. These are implementation/backend limits, not parameters to explain_true() or explain_false().

For example, an eight-edge recursive path fixture in the 0.11.1 SMALL build has a present derived path but emits this Why-true text:

EXPLANATION
MAELYS-DATALOG-v2
document=why-true
status=truncated
steps=0 premises=0

A separate fixture with 17 failing candidate rules reaches the 16-diagnostic bound. Its Why-false document begins as follows, then retains 16 diagnostic records (only the header is shown):

EXPLANATION
MAELYS-DATALOG-v2
document=why-false
status=truncated
query="allow"("alice") origin=none
summary=none
limit-hits=diagnostics candidate-rules=17 substitutions=17 diagnostics=16 filter-cost=0

Both cases were exercised through the 0.11.1 Python facade; native C calls also verify the text-buffer behavior below. Python returns these status lines unchanged: truncated is a returned document, not an exception. The C API likewise returns OK when that document is successfully rendered. Neither truncation changes the independently computed membership answer.

5. Do not confuse three different kinds of status

LayerWhat it tells youWhat to do
MembershipIs the ground fact present in this solved request?Use the successful query result for the application's decision.
Explanation documentIs this explanation complete, truncated, not-derived, or not-applicable?Preserve and display its status; never hide a diagnostic limit.
API callCould the operation run and return its output?Handle failures separately; an error is not a false fact.

In C, the first text call receives NULL, 0 and reports the length in required, excluding the terminating NUL. Allocate required + 1 bytes, then make the second call. Check both API statuses and the allocation result; free the text buffer even if rendering fails.

A too-small output buffer produces MAELYS_DATALOG_STATUS_PAYLOAD_TOO_LARGE. The API reports the required length and sets only the first output byte to NUL when capacity is nonzero; it does not return a clipped document. Calling it again with a larger buffer reuses the cached preparation on the configured session shown here. Without a session workspace, each direct-text call prepares separately. The prepared API also lets you retain an explicit handle.

The session has a one-entry cache: changing the explanation kind, predicate or typed argument values replaces it. Ordinary membership queries do not evict it; releasing the result clears it automatically. Requesting an explanation kind not reserved in Step 3 returns UNSUPPORTED, with no allocating fallback. C and Python return the same documents.

A larger text buffer fixes storage capacity, not proof or exploration limits. A correctly sized buffer can contain status=truncated.

Unknown query symbols produce NOT_FOUND from the explanation API without interning new symbols. A backend without the requested explanation capability returns UNSUPPORTED. Python exposes native failures as MaelysDatalogError; neither is equivalent to the legacy binding's optional None explanation.

What you have learned

Keep this model

One policy, a new decision for each request
  • Declare the vocabulary before loading rules

    A domain names the allowed predicates, their arities and their roles. Register it before loading the policy; declaring a predicate does not create any facts.

  • Keep rules and request facts separate

    The policy stays fixed. The EDB contains the current request inputs; solving applies the rules to derive IDB facts. Each solve receives the complete input for that request.

  • Solve first, then ask a concrete question

    A ground query supplies a value for every argument and checks whether that fact is present in the result. An absent fact is a valid negative answer; an API failure is a separate error.

  • Reuse the session, not the previous decision

    Keep the prepared session, release its current result, then clear or reset the input EDB and supply the next request facts. Only one result may be live per session; previous facts are not inherited implicitly.

  • Explanations do not decide access

    Why-true shows a derivation witness; Why-false describes obstacles found during a bounded search. Either document can be truncated without changing the independently computed membership answer.

  • Separate explanation workspace from output text

    The optional session workspace reuses memory for preparing explanations. The output text needs its own buffer. Automatic or manual memory ownership does not change the engine limits on explanation content.

Decision ruleRegister the domain, load the policy, prepare a session, supply facts, solve, then query. Authorize only when solving and querying succeed and the requested allow fact is present. Otherwise, deny access.

Continue

The stable C API reference covers every facade operation, ownership rule, and Why-true explanation. The Python reference develops the same lifecycle, batched facts, filters, enumeration and explanations in Python. The language guide explains negation, wildcards, and bounded alternatives in detail. The tutorial grows this access-control policy. If you maintain the transparent low-level API, compare this flow with the advanced quick start.