C Low-level API

Registries

Low-level C operations for registries; concepts and integration code are documented separately.

This page documents the transparent C functions behind the registries concepts. New application integrations should start with the stable C API; this reference is for consumers of the advanced, structure-level API.

Domain registry

The low-level maelys_datalog_domain_def_t and the stable maelys_datalog_public_domain_t describe the same logical vocabulary, but they are different C structs and registration functions. The low-level struct additionally permits an installer callback and a description; the stable struct accepts only a predicate array. Do not pass one type to the other API.

The low-level declaration is:

CODE
typedef struct {
    const char *domain_name;
    const maelys_datalog_predicate_def_t *predicates;
    size_t predicate_count;
    const char *const *atoms;
    size_t atom_count;
    const char *description;
    maelys_result_t (*install_predicates)(
        maelys_datalog_predicate_registry_t *registry);
} maelys_datalog_domain_def_t;
FieldMeaning
domain_nameName selected by manifest or inline loading; documents in the common example.
predicates, predicate_countStatic predicate table and its length. Use NULL, 0 for a callback-based domain.
atoms, atom_countOptional source-string vocabulary and its length. Use NULL, 0 when the policy has no quoted literal.
descriptionOptional human-readable description.
install_predicatesAlternative callback that installs predicates; NULL for a static-table domain.

A domain provides its predicate vocabulary in exactly one of two ways:

MethodUse when
Static predicate tableVocabulary is fixed at compile time.
Installer callbackInstallation needs custom logic or multiple modules.

atoms is independent of that choice. It lists string literals appearing in policy source, not runtime EDB values. In the common quickstart policy, blocked(User) contains a variable, while blocked("mallory") arrives later as an EDB fact, so atoms = NULL and atom_count = 0 are correct. A policy-source fact such as blocked("mallory"). would instead require "mallory" in this array. The registry copies names, predicate declarations, and atom strings into bounded internal storage at registration; a callback is retained as a function pointer.

Method 1: static predicate table

Example — document access

Use the same seven predicates as the quickstart. Here blocked/1 is EDB, not a policy fact. The low-level declaration uses maelys_datalog_predicate_def_t and MAELYS_DATALOG_PRED_KIND_*; the stable declaration uses maelys_datalog_public_predicate_t and public builder macros, but both define the same predicate roles.

CODE
#include <maelys_datalog.h>

static const maelys_datalog_predicate_def_t document_access_predicates[] = {
    {"user", 1u, MAELYS_DATALOG_PRED_KIND_EDB},
    {"owns", 2u, MAELYS_DATALOG_PRED_KIND_EDB},
    {"delegated", 2u, MAELYS_DATALOG_PRED_KIND_EDB},
    {"blocked", 1u, MAELYS_DATALOG_PRED_KIND_EDB},
    {"can_read", 2u, MAELYS_DATALOG_PRED_KIND_IDB},
    {"has_any_document", 1u,
     MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY},
    {"allow", 2u,
     MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY},
};

static const maelys_datalog_domain_def_t document_access_domain_def = {
    .domain_name = "documents",
    .predicates = document_access_predicates,
    .predicate_count = sizeof(document_access_predicates) /
                       sizeof(document_access_predicates[0]),
    .atoms = NULL,
    .atom_count = 0u,
    .description = "Document access-control policy domain",
    .install_predicates = NULL,
};

The zero-atom choice does not omit a user or document: those symbols are supplied later as request facts. The domain name documents matches the stable example.

Method 2: installer callback

The same vocabulary can be installed through a callback instead of the static-table fields. This is an alternative declaration, not a second registration to perform under documents in the same process. The callback may reuse the already defined seven-entry table:

CODE
static maelys_result_t install_document_access_predicates(
    maelys_datalog_predicate_registry_t *registry)
{
    for (size_t i = 0u; i < sizeof(document_access_predicates) /
                              sizeof(document_access_predicates[0]); ++i) {
        const maelys_datalog_predicate_def_t *predicate =
            &document_access_predicates[i];
        maelys_result_t rc = maelys_datalog_predicate_registry_add_domain(
            registry, predicate->name, predicate->arity,
            predicate->kind_flags);
        if (rc != MAELYS_OK) return rc;
    }
    return MAELYS_OK;
}

static const maelys_datalog_domain_def_t callback_domain_def = {
    .domain_name = "documents",
    .predicates = NULL,
    .predicate_count = 0u,
    .atoms = NULL,
    .atom_count = 0u,
    .description = "Document access-control policy domain",
    .install_predicates = install_document_access_predicates,
};

The callback installs predicates only; source atoms remain independent fields of the domain definition. The common policy needs none.

Registering a domain

Example — document access

Register one of the two declarations above before loading the policy. The static-table path uses:

CODE
maelys_result_t rc =
    maelys_datalog_domain_registry_register(&document_access_domain_def);
if (rc != MAELYS_OK) return rc;

Alternatively, pass &callback_domain_def in the same call. Registration is global and first-registration-wins: a second declaration under documents is ignored and does not replace the first one. Unlike the stable maelys_datalog_domain_register(), this low-level call accepts maelys_datalog_domain_def_t and returns maelys_result_t.

The registered name is used for a later inline load or manifest:

CODE
{"domain": "documents"}

For a complete, executable one-call alternative, maelys_datalog_load_policy_inline_with_static_domain() registers the same seven-entry table and loads the same policy in one operation. See the Full program and inline loading.

Predicate registry

The predicate registry contains the predicates available to one loaded policy. It is populated from the selected domain definition, then frozen before the policy source is parsed. It does not authorize a different vocabulary: the domain remains the source of the names, arities and flags. The stable C loader performs these steps internally; this low-level API exposes them. A predicate declaration defines:

FieldMeaning
namePredicate name, for example owns, shared_with, or allow.
arityNumber of terms accepted by the predicate.
kind_flagsPredicate role: runtime fact, derived fact, query-capable fact, or policy fact.

Example:

CODE
maelys_datalog_predicate_registry_add_domain(
    registry,
    "allow",
    2,
    MAELYS_DATALOG_PRED_KIND_IDB |
    MAELYS_DATALOG_PRED_KIND_QUERY);

This declaration means:

CODE
allow/2 exists
allow/2 is derived by Datalog rules
allow/2 is capable of being queried

It does not mean allow/2 is publicly observable for every manifest-loaded deployment. Manifest-loaded policies still require allow/2 to be listed in the Public Query Whitelist.

Predicate identity: one name, one arity

A predicate name expresses a unique relation with a fixed arity. The registry enforces this as an identity contract: once owns is declared with arity 2, no other declaration of owns with a different arity is accepted.

CODE
owns/2  EDB   ✓  — the relation "user owns document"
edge/1  EDB   ✓  — the relation "node exists"
edge/2  EDB   ✗  — rejected: "edge" already has arity 1

If the semantic relationship changes arity, the predicate must change name:

CODE
edge/1          node-exists relation
link/2          two-node relation  — different name, different concept

The registry returns MAELYS_ERR_INVALID_FIELD if the same name is registered with two different arities. This is by design: the name of a predicate is the contract of the relation it represents. Allowing the same name to mean two different structural relations — one with one argument, one with two — would make policy rules ambiguous and solver results unpredictable.

Registry lifecycle: open, then frozen

The predicate registry has exactly two states. When a ruleset is initialized the registry is open: predicates may be added through maelys_datalog_predicate_registry_add_domain (or by installing a domain). Once the vocabulary is complete it is frozen, and the policy source is parsed and validated against that frozen vocabulary. Freezing is the point of no return: no predicate may be added after it.

1. Open registryruleset_init

Right after ruleset_init the registry is open and empty. It is ready to accept predicate declarations.

2. Add predicateadd_domain

Declare a predicate — its name, arity, and kind flags — into the open registry. Called once per predicate (or via a domain installer that batches these calls).

one call per predicate
3. Freezeregistry_freeze

Lock the vocabulary. After this point the set of predicates, their arities, and their kind flags are fixed. This is what makes “manifests never create predicates” true.

✕
add after freeze → rejectedINVALID_STATE
4. Parse against frozen vocabularyparse_ruleset_ex

The parser validates every predicate name and arity in the policy source against the frozen registry. Any predicate not declared before freeze causes a parse failure.

The red branch is the security property in visual form: once the registry is frozen, any further attempt to add a predicate is refused. The vocabulary a policy may use is sealed before a single line of policy source is parsed.

CODE
void
maelys_datalog_predicate_registry_freeze(
    maelys_datalog_predicate_registry_t *registry);

In the manual ruleset pipeline, freeze sits between domain installation and parsing:

CODE
/* Open registry: install the 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, "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: validated against the frozen vocabulary */
maelys_datalog_parse_ruleset_ex(&ruleset, src, src_len, file_path, &diag);

Predicate kinds

KindMeaning
MAELYS_DATALOG_PRED_KIND_EDBRuntime fact supplied by the caller.
MAELYS_DATALOG_PRED_KIND_IDBDerived fact produced by Datalog rules.
MAELYS_DATALOG_PRED_KIND_QUERYObservation capability combined with EDB, POLICY_FACT, or IDB to make that predicate queryable.
MAELYS_DATALOG_PRED_KIND_POLICY_FACTFact declared by the policy source, not supplied by runtime callers.

A predicate may combine flags:

CODE
MAELYS_DATALOG_PRED_KIND_IDB |
MAELYS_DATALOG_PRED_KIND_QUERY

This means the predicate is derived by the solver and has query capability. For manifest-loaded policies, public visibility is still controlled by the manifest Public Query Whitelist.

Loading paths

Once a domain is registered, it is available to both loading paths. The path chosen determines whether the QUERY predicates declared by the domain are subject to a Public Query Whitelist.

Domain Registry
vocabulary authority
Manifest path
  1. Manifest
    queries whitelist
  2. Policy set
    restricted QUERY surface
Inline path
  1. load_policy_inline
    no whitelist
  2. Policy set
    all QUERY accessible

Manifest path — the manifest selects the registered domain, verifies policy source integrity, and enforces a Public Query Whitelist through its queries field. Only predicates explicitly listed there are publicly observable after solving. See Manifest loading for the full model.

APIPublic Query WhitelistUse when
maelys_datalog_manifest_load_exManifest queries fieldProduction, multi-policy, SHA, versioned deployments
maelys_datalog_manifest_load_from_textManifest queries fieldWASM, embedded, manifest model without filesystem

Inline path — the domain name is passed directly to maelys_datalog_load_policy_inline or maelys_datalog_load_policy_inline_with_static_domain. There is no manifest and no Public Query Whitelist: all predicates declared as QUERY by the selected domain are accessible. See Manifest loading — Inline loading for the full call signature.

APIRegister stepUse when
maelys_datalog_load_policy_inlineExplicit, before loadTests, examples, REPLs, embedded simple callers
maelys_datalog_load_policy_
inline_with_static_domain
Combined, idempotentStatic C tables, WASM, one-call convenience

Loading API comparison

Manifest APIs — require explicit domain registration before load, enforce a Public Query Whitelist.

APIPublic Query WhitelistUse when
maelys_datalog_manifest_load_exManifest queries fieldProduction, multi-policy, SHA, versioned deployments
maelys_datalog_manifest_load_from_textManifest queries fieldWASM, embedded, manifest model without filesystem

Inline APIs — no manifest, no whitelist. All domain QUERY predicates accessible.

APIRegister stepUse when
maelys_datalog_load_policy_inlineExplicit, before loadTests, examples, REPLs, embedded simple callers
maelys_datalog_load_policy_
inline_with_static_domain
Combined, idempotentStatic C tables, WASM, one-call convenience

Validation behavior

Error caseBehavior
Unknown domainPolicy loading fails.
Policy uses an undeclared predicatePolicy loading or parsing fails.
Policy uses a predicate with the wrong arityPolicy loading or parsing fails.
Both methods provided in domain definitionMAELYS_ERR_INVALID_ARGUMENT.
Neither method provided in domain definitionMAELYS_ERR_INVALID_ARGUMENT.
Manifest queries entry unknown to domainLoading fails closed.
Manifest queries entry arity mismatchLoading fails closed.
Manifest queries entry lacks QUERY flagLoading fails closed.
Manifest queries: [] or field absentNothing publicly queryable.
Runtime caller supplies an undeclared EDB predicateRuntime fact insertion fails.
Caller queries a non-whitelisted predicate from a manifest-loaded policyQuery rejected.
Caller queries a QUERY predicate from an inline-loaded policyAccepted — no whitelist.

Additional example — sensitivity-level policy

The following earlier variant of document access is kept for readers whose policy writes blocked("mallory"). in source and uses a sensitivity level. It is not the shared quickstart policy: blocked/1 is a policy fact here, not a runtime EDB predicate. When loading this variant with the default atom guard, the snippets below declare "mallory" in atoms with atom_count = 1u. Its distinct domain name prevents accidental reuse of the documents registration.

Static-table variant

A fixed domain can declare its predicates as a static table.

CODE
static const char *const sensitive_atoms[] = {"mallory"};

static const maelys_datalog_predicate_def_t sensitive_document_predicates[] = {
    {
        .name       = "owns",
        .arity      = 2,
        .kind_flags = MAELYS_DATALOG_PRED_KIND_EDB,
    },
    {
        .name       = "shared_with",
        .arity      = 2,
        .kind_flags = MAELYS_DATALOG_PRED_KIND_EDB,
    },
    {
        .name       = "sensitivity_level",
        .arity      = 2,
        .kind_flags = MAELYS_DATALOG_PRED_KIND_EDB,
    },
    {
        .name       = "blocked",
        .arity      = 1,
        .kind_flags = MAELYS_DATALOG_PRED_KIND_POLICY_FACT,
    },
    {
        .name       = "allow",
        .arity      = 2,
        .kind_flags = MAELYS_DATALOG_PRED_KIND_IDB |
                      MAELYS_DATALOG_PRED_KIND_QUERY,
    },
};
static const maelys_datalog_domain_def_t sensitive_document_domain_def = {
    .domain_name     = "sensitive_document_access",
    .predicates      = sensitive_document_predicates,
    .predicate_count = sizeof(sensitive_document_predicates) /
                       sizeof(sensitive_document_predicates[0]),
    .atoms           = sensitive_atoms,
    .atom_count      = 1u,
    .description     = "Document access-control policy domain",
};

This form is convenient when the whole vocabulary is known at compile time.

Callback variant

A domain can also install its predicates through a callback.

CODE
static maelys_result_t
install_sensitive_document_predicates(
    maelys_datalog_predicate_registry_t *registry)
{
    maelys_result_t rc;
    rc = maelys_datalog_predicate_registry_add_domain(
        registry,
        "owns",
        2,
        MAELYS_DATALOG_PRED_KIND_EDB);
    if (rc != MAELYS_OK) {
        return rc;
    }
    rc = maelys_datalog_predicate_registry_add_domain(
        registry,
        "shared_with",
        2,
        MAELYS_DATALOG_PRED_KIND_EDB);
    if (rc != MAELYS_OK) {
        return rc;
    }
    rc = maelys_datalog_predicate_registry_add_domain(
        registry,
        "sensitivity_level",
        2,
        MAELYS_DATALOG_PRED_KIND_EDB);
    if (rc != MAELYS_OK) {
        return rc;
    }
    rc = maelys_datalog_predicate_registry_add_domain(
        registry,
        "blocked",
        1,
        MAELYS_DATALOG_PRED_KIND_POLICY_FACT);
    if (rc != MAELYS_OK) {
        return rc;
    }
    return maelys_datalog_predicate_registry_add_domain(
        registry,
        "allow",
        2,
        MAELYS_DATALOG_PRED_KIND_IDB |
        MAELYS_DATALOG_PRED_KIND_QUERY);
}
static const maelys_datalog_domain_def_t sensitive_document_domain_def = {
    .domain_name        = "sensitive_document_access",
    .predicates         = NULL,
    .predicate_count    = 0,
    .atoms              = sensitive_atoms,
    .atom_count         = 1u,
    .description        = "Document access-control policy domain",
    .install_predicates = install_sensitive_document_predicates,
};

predicates = NULL and predicate_count = 0 indicate the callback path. This form is useful when predicates come from multiple C modules, require conditional installation, or need shared setup logic.