Registries
██████╗ ███████╗ ██████╗ ██╗███████╗████████╗██████╗ ██╗███████╗███████╗ ██╔══██╗██╔════╝██╔════╝ ██║██╔════╝╚══██╔══╝██╔══██╗██║██╔════╝██╔════╝ ██████╔╝█████╗ ██║ ███╗██║███████╗ ██║ ██████╔╝██║█████╗ ███████╗ ██╔══██╗██╔══╝ ██║ ██║██║╚════██║ ██║ ██╔══██╗██║██╔══╝ ╚════██║ ██║ ██║███████╗╚██████╔╝██║███████║ ██║ ██║ ██║██║███████╗███████║ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝╚══════╝
Registries define the vocabulary a Datalog policy is allowed to use.
A Datalog rule can mention predicates such as owns, shared_with, blocked,
or allow, but the Datalog engine does not know what these names mean by
itself. The application must first declare which predicates exist, how many
arguments they accept, and how they may be used.
In Maelys DL, this is a core security boundary:
Domain Registry = vocabulary authority
Policy source = Datalog logic using that vocabulary
Manifest = deployment contract and public observation gateRegistries keep the Datalog engine domain-neutral. The engine evaluates rules, but the application defines the vocabulary those rules are allowed to use.
The Domain Registry is the sole source of predicate vocabulary. Manifests never create predicates. A predicate is valid only if it is declared by the selected domain.
Why registries exist
A Datalog engine can evaluate rules generically, but applications need their own vocabulary. For example, a document-access application might need:
owns(User, Document)
shared_with(User, Document)
blocked(User)
allow(User, Document)A graph application might need:
edge(From, To)
reachable(From, To)A decision engine might need:
safe(Request)
allow(Request)
deny(Request)The Datalog engine should not hard-code any of these names. Instead, each application registers a domain that declares the predicates policies may use. Registries answer three questions:
| Question | Answered by |
|---|---|
| Which domains are available? | Domain registry |
| Which predicates may this policy use? | Predicate registry |
| Which predicates may callers observe publicly? | Manifest Public Query Whitelist, constrained by the domain |
Where registries fit
Registries define and freeze the vocabulary before policy loading happens.
Architectural model
Three layers govern how a policy is loaded and evaluated.
| Layer | Role |
|---|---|
| Domain Registry | Vocabulary authority. Defines predicate names, arities, and kind flags. |
| Policy source | Datalog logic. Uses predicates declared by the selected domain. |
| Manifest | Deployment contract. Binds policies to domains, verifies integrity, and controls public observation through the Public Query Whitelist. |
Domain Registry
-> declares what predicates exist and what they can do
Policy source
-> uses that vocabulary to derive facts
Manifest
-> selects a domain, verifies source integrity, and exposes only
whitelisted QUERY predicatesThe manifest controls public observation. It does not control vocabulary. The selected domain remains the vocabulary authority.
Predicate kinds at a glance
Four kinds are used throughout this page. They are introduced here so the examples that follow are self-explanatory.
| Kind constant | Short | Meaning |
|---|---|---|
MAELYS_DATALOG_PRED_KIND_EDB | EDB | Runtime fact supplied by the caller before solving. |
MAELYS_DATALOG_PRED_KIND_IDB | IDB | Fact derived by Datalog rules during solving. |
MAELYS_DATALOG_PRED_KIND_QUERY | QUERY | Derived fact that may be inspected through the query API. |
MAELYS_DATALOG_PRED_KIND_POLICY_FACT | POLICY_FACT | Fact declared directly in the policy source. |
Flags may be combined: IDB|QUERY means a predicate is both derived and
queryable. The full C definitions and combination rules are in
Predicate kinds below.
Domain-closed vocabulary
Maelys DL uses a domain-closed vocabulary model. That means every predicate used by a policy must be accepted by the selected domain. A policy cannot invent predicates during loading, and a manifest cannot add predicates to the domain. For example, this policy:
helper(X) :- safe(X).
allow(X) :- helper(X).requires the selected domain to declare:
safe/1 EDB
helper/1 IDB
allow/1 IDB|QUERYIf helper/1 is not declared by the domain, loading fails.
A predicate is usable only if it is declared by the selected domain and used in a way that matches its registered kind and arity.
Example domain: document access
Suppose an application wants to evaluate document access rules. The application wants this vocabulary:
| Predicate | Arity | Kind | Meaning |
|---|---|---|---|
owns(User, Document) | 2 | EDB | Runtime fact saying a user owns a document. |
shared_with(User, Document) | 2 | EDB | Runtime fact saying a document was shared with a user. |
sensitivity_level(User, Level) | 2 | EDB | Runtime fact saying a user has a sensitivity level. |
blocked(User) | 1 | POLICY_FACT | Fact declared by the policy source. |
allow(User, Document) | 2 | IDB + QUERY | Derived access decision. |
A policy in this domain might contain:
blocked("mallory").
allow(User, Document) :-
owns(User, Document),
not blocked(User),
sensitivity_level(User, Level),
Level >= 3.
allow(User, Document) :-
shared_with(User, Document),
not blocked(User),
sensitivity_level(User, Level),
Level >= 3.The extra sensitivity_level/2 guard adds a numeric threshold: ownership or
sharing alone is not enough, the caller must also meet the minimum level.
In this example:
| Predicate | Who provides it? |
|---|---|
owns/2 | Runtime caller |
shared_with/2 | Runtime caller |
sensitivity_level/2 | Runtime caller |
blocked/1 | Policy source |
allow/2 | Datalog solver |
The application supplies runtime facts such as:
owns("alice", "roadmap.pdf").
shared_with("bob", "roadmap.pdf").
sensitivity_level("alice", 4).
sensitivity_level("bob", 3).The policy declares policy facts such as:
blocked("mallory").The solver derives facts such as:
allow("alice", "roadmap.pdf").
allow("bob", "roadmap.pdf").Both users satisfy the Level >= 3 guard, so the solver still derives allow/2
for each of them. A user with a lower sensitivity level would not derive
allow/2 even if the ownership or sharing fact were present.
Whether callers may publicly inspect allow/2 depends on the loading path:
| Loading path | Public query behavior |
|---|---|
| Manifest loading | allow/2 must be listed in the manifest Public Query Whitelist. |
| Inline loading | Any predicate declared QUERY by the domain is accessible. |
Registry model
Maelys DL uses two related registries.
| Registry | Purpose |
|---|---|
| Domain registry | Global catalog of application domains that policies may reference. |
| Predicate registry | Per-policy vocabulary used to parse and validate one loaded policy. |
The domain registry answers:
Which domains are available?The predicate registry answers:
Which predicates may this policy use?During loading:
selected domain
-> installs predicate vocabulary
-> policy source is validated against that vocabularyDomain registry
The domain registry stores domain definitions. A domain definition has:
| Field | Meaning |
|---|---|
domain_name | Stable name used by manifests or inline loading. |
description | Optional human-readable description. |
predicates | Static predicate table. NULL for callback-based domains. |
predicate_count | Number of entries in the static table. 0 for callback-based domains. |
install_predicates | Installer callback. NULL for static-table domains. |
A domain may provide its vocabulary in exactly one of two ways:
| Method | Use when |
|---|---|
| Static predicate table | Vocabulary is fixed at compile time. |
| Installer callback | Installation needs custom logic, shared setup, or multiple modules. |
Exactly one method must be provided. Providing both or neither returns
MAELYS_ERR_INVALID_ARGUMENT.
The constraint is an XOR:
valid:
install_predicates != NULL
predicates == NULL
predicate_count == 0
valid:
install_predicates == NULL
predicates != NULL
predicate_count > 0
invalid:
install_predicates != NULL
predicates != NULL
invalid:
install_predicates == NULL
predicates == NULLMethod 1: static predicate table
A fixed domain can declare its predicates as a static table.
static const maelys_datalog_predicate_def_t document_access_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 document_access_domain_def = {
.domain_name = "document_access",
.predicates = document_access_predicates,
.predicate_count = sizeof(document_access_predicates) /
sizeof(document_access_predicates[0]),
.description = "Document access-control policy domain",
};This form is convenient when the whole vocabulary is known at compile time.
The domain registry stores a shallow copy of the domain definition. The
domain_name string and predicates array must remain valid for as long as
the global domain registry may reference them. Static arrays and string
literals fulfill this requirement automatically.
Method 2: installer callback
A domain can also install its predicates through a callback.
static maelys_result_t
install_document_access_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 document_access_domain_def = {
.domain_name = "document_access",
.predicates = NULL,
.predicate_count = 0,
.description = "Document access-control policy domain",
.install_predicates = install_document_access_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.
Registering a domain
A domain is registered globally with:
maelys_datalog_domain_registry_register(&document_access_domain_def);The global domain registry uses first-registration-wins semantics. If a
domain name is already registered, subsequent calls with the same name are
silently ignored and return MAELYS_OK. The originally registered domain
remains authoritative for the lifetime of the process.
Once registered, the domain is available to all loading paths by name.
A manifest references it through the domain field:
{
"domain": "document_access"
}An inline load passes the domain name as the first argument to
maelys_datalog_load_policy_inline. See Inline loading
for the full call signature and semantics.
Use a distinct name for each distinct vocabulary. Because registration is first-wins and permanent for the lifetime of the process, re-registering the same name with a different predicate table has no effect — the original domain remains in place.
Predicate registry
The predicate registry contains the predicates available to one loaded policy. A predicate declaration defines:
| Field | Meaning |
|---|---|
name | Predicate name, for example owns, shared_with, or allow. |
arity | Number of terms accepted by the predicate. |
kind_flags | Predicate role: runtime fact, derived fact, query-capable fact, or policy fact. |
Example:
maelys_datalog_predicate_registry_add_domain(
registry,
"allow",
2,
MAELYS_DATALOG_PRED_KIND_IDB |
MAELYS_DATALOG_PRED_KIND_QUERY);This declaration means:
allow/2 exists
allow/2 is derived by Datalog rules
allow/2 is capable of being queriedIt 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.
owns/2 EDB ✓ — the relation "user owns document"
edge/1 EDB ✓ — the relation "node exists"
edge/2 EDB ✗ — rejected: "edge" already has arity 1If the semantic relationship changes arity, the predicate must change name:
edge/1 node-exists relation
link/2 two-node relation — different name, different conceptThe 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.
If you need two structurally different relations, give them distinct names.
edge/1 and edge/2 must become, for example, node/1 and edge/2.
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.
ruleset_initRight after ruleset_init the registry is open and empty. It is ready to accept predicate declarations.
add_domainDeclare 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).
registry_freezeLock 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.
INVALID_STATEparse_ruleset_exThe 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.
void
maelys_datalog_predicate_registry_freeze(
maelys_datalog_predicate_registry_t *registry);In the manual ruleset pipeline, freeze sits between domain installation and
parsing:
/* 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);Most callers never invoke freeze directly. The high-level loaders
(maelys_datalog_manifest_load_ex, maelys_datalog_manifest_load_from_text,
maelys_datalog_load_policy_inline, and the WASM domainCommit) install the
domain, freeze the registry, and parse in one call. freeze is exposed for
callers that drive the ruleset pipeline manually — see
Rulesets — Lifecycle for the full sequence.
Freezing is what guarantees the manifest cannot widen a policy’s vocabulary. The manifest selects a domain and a Public Query Whitelist, but the predicate set is already sealed by the time the manifest’s policy source is parsed.
Predicate kinds
| Kind | Meaning |
|---|---|
MAELYS_DATALOG_PRED_KIND_EDB | Runtime fact supplied by the caller. |
MAELYS_DATALOG_PRED_KIND_IDB | Derived fact produced by Datalog rules. |
MAELYS_DATALOG_PRED_KIND_QUERY | Derived fact capable of being inspected through the query API. |
MAELYS_DATALOG_PRED_KIND_POLICY_FACT | Fact declared by the policy source, not supplied by runtime callers. |
A predicate may combine flags:
MAELYS_DATALOG_PRED_KIND_IDB |
MAELYS_DATALOG_PRED_KIND_QUERYThis 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.
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.
| API | Public Query Whitelist | Use when |
|---|---|---|
maelys_datalog_manifest_load_ex | Manifest queries field | Production, multi-policy, SHA, versioned deployments |
maelys_datalog_manifest_load_from_text | Manifest queries field | WASM, 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.
| API | Register step | Use when |
|---|---|---|
maelys_datalog_load_policy_inline | Explicit, before load | Tests, examples, REPLs, embedded simple callers |
maelys_datalog_load_policy_inline_with_static_domain | Combined, idempotent | Static C tables, WASM, one-call convenience |
Domain registration in WASM
The domain registration model is the same in WASM and C. How you load the domain depends on which loading strategy you use.
Dynamic target — domain defined in JavaScript
With maelys_datalog_dynamic.js, the domain is defined at runtime from
JavaScript using the dynamic builder. No C code is needed.
pg.domainBegin('access')
pg.domainAddPredicate('safe', 1, PredKind.EDB)
pg.domainAddPredicate('allow', 1, PredKind.IDB | PredKind.QUERY)
pg.domainCommit()This corresponds to the static-table model but called from JavaScript.
The predicate vocabulary is registered inside the WASM module. One domain
per module instance — domainCommit() is permanent.
Under the hood, the dynamic builder accumulates the domain definition in a
WASM-side static buffer, then makes the same core registration call as the
static-table model above — it is not a separate registration mechanism. Only
domainCommit enters the core engine:
| JavaScript call | WASM wrappermaelys_datalog_ prefix omitted | Effectmaelys_datalog_ prefix omitted |
|---|---|---|
| domainBegin(name) | wasm_domain_begin | WASM-side only — copies the domain name into a static buffer (s_domain_name) and resets the builder. |
| domainAddPredicate(name, arity, kind) | wasm_domain_add_predicate | WASM-side only — appends a predicate_def_t to a static array (s_predicates[]), the same struct the static table uses. |
| domainCommit() | wasm_domain_commit | Core engine call — builds a domain_def_t from the buffered predicates and calls domain_registry_register. |
In other words, domainBegin and domainAddPredicate build, in WASM-module
memory, the exact predicate table shown in
Method 1: static predicate table — the
runtime equivalent of writing that static table, which in C is a compile-time
artifact, not a function call. The single core-engine entry point is
domainCommit, which performs the same
domain registration call
(maelys_datalog_domain_registry_register) that C code would make directly. The
vocabulary, kind flags, arity contract, and first-registration-wins semantics
are therefore identical; only the call site differs (JavaScript instead of C).
See WASM bindings — Dynamic builder.
Production target — domain table compiled in C
This target is a two-faced model. In it, JavaScript does not build the domain: the domain table is C code compiled into the WASM module. JavaScript only calls the exported loader for that fixed domain. The C face declares the vocabulary; the JS face just hands a policy source to a single export.
The C face — the same static table as Method 1, plus one exported loader:
static const maelys_datalog_predicate_def_t preds[] = {
{ "owns", 2, MAELYS_DATALOG_PRED_KIND_EDB },
{ "allow", 2, MAELYS_DATALOG_PRED_KIND_IDB |
MAELYS_DATALOG_PRED_KIND_QUERY },
};
EMSCRIPTEN_KEEPALIVE
maelys_result_t load_document_access(const char *src, size_t src_len) {
return maelys_datalog_load_policy_inline_with_static_domain(
preds, 2, "document_access", "doc.main",
src, src_len, 0, &g_policy, &g_diag);
}This is the static-table approach compiled into the WASM binary. The domain is
fixed; the policy source can be fetched at runtime. The JavaScript side should
normally call this through a small application wrapper rather than raw ccall;
see the WASM static-domain strategy
for the full loader.
Loading API comparison
Manifest APIs — require explicit domain registration before load, enforce a Public Query Whitelist.
| API | Public Query Whitelist | Use when |
|---|---|---|
maelys_datalog_manifest_load_ex | Manifest queries field | Production, multi-policy, SHA, versioned deployments |
maelys_datalog_manifest_load_from_text | Manifest queries field | WASM, embedded, manifest model without filesystem |
Inline APIs — no manifest, no whitelist. All domain QUERY predicates accessible.
| API | Register step | Use when |
|---|---|---|
maelys_datalog_load_policy_inline | Explicit, before load | Tests, examples, REPLs, embedded simple callers |
maelys_datalog_load_policy_inline_with_static_domain | Combined, idempotent | Static C tables, WASM, one-call convenience |
Validation behavior
| Error case | Behavior |
|---|---|
| Unknown domain | Policy loading fails. |
| Policy uses an undeclared predicate | Policy loading or parsing fails. |
| Policy uses a predicate with the wrong arity | Policy loading or parsing fails. |
| Both methods provided in domain definition | MAELYS_ERR_INVALID_ARGUMENT. |
| Neither method provided in domain definition | MAELYS_ERR_INVALID_ARGUMENT. |
Manifest queries entry unknown to domain | Loading fails closed. |
Manifest queries entry arity mismatch | Loading fails closed. |
Manifest queries entry lacks QUERY flag | Loading fails closed. |
Manifest queries: [] or field absent | Nothing publicly queryable. |
| Runtime caller supplies an undeclared EDB predicate | Runtime fact insertion fails. |
| Caller queries a non-whitelisted predicate from a manifest-loaded policy | Query rejected. |
Caller queries a QUERY predicate from an inline-loaded policy | Accepted — no whitelist. |
Summary
| Concept | Defines |
|---|---|
| Domain Registry | Sole vocabulary authority. Predicate names, arities, and kind flags. |
| Domain definition | How to install predicates for one domain: static table XOR callback. |
| Predicate registry | Which predicates one loaded policy may use. |
| Manifest loading | Loads policy sets with manifest JSON, metadata, SHA verification, and Public Query Whitelist. |
| Public Query Whitelist | Manifest queries field. Controls which QUERY predicates are publicly observable. Does not create predicates. |
| Inline loading | Loads one in-memory policy. No manifest whitelist. All domain QUERY predicates accessible. |
| Inline with static domain | Registers a static-table domain and loads a policy in one call. Inline semantics. |
| Predicate kind flags | Whether a predicate is runtime-supplied, policy-declared, derived, or query-capable. |
| First-registration-wins | If a domain name is already registered, re-registration is ignored. |
idb_predicates | Deprecated manifest field. Accepted for compatibility, no semantic effect. |
The registry layer makes policy evaluation explicit: a policy can only use the vocabulary installed by the selected domain. The manifest controls public observation — it never extends the vocabulary.