Skip to content

.dh Language Reference

.dh is a strict subset of stratified Datalog with Soufflé-like syntax and a small number of domain-specific keywords. It is deliberately not a novel language — AI models are already proficient at Datalog, and .dh stays within that training distribution.

You write .dh files to declare your ontology: what facts exist, how they are derived, what must always be true, and what effects the system should trigger. The jacqos binary interprets these files directly — no compilation step required.

The grammar uses EBNF notation. Terminal strings are in double quotes. ? means optional, * means zero or more, + means one or more.

program = declaration* ;
declaration = relation_decl
| rule_decl
| invariant_decl
| comment ;
comment = "--" (any character except newline)* newline ;
relation_decl = "relation" relation_name "(" column_list ")" ;
relation_name = identifier ;
column_list = column ( "," column )* ;
column = identifier ":" type ;
type = "text" | "int" | "float" | "bool" ;
rule_decl = "rule" mutation? head ":-" body "." ;
mutation = "assert" | "retract" ;
head = qualified_name "(" arg_list ")" ;
body = condition ( "," condition )* ;
condition = positive_condition
| negated_condition
| aggregate_bind
| comparison
| helper_bind ;
positive_condition = qualified_name "(" arg_list ")" ;
negated_condition = "not" qualified_name "(" arg_list ")" ;
aggregate_bind = variable "=" aggregate_fn qualified_name "(" arg_list ")" ( "," variable )? ;
helper_bind = variable "=" "helper." identifier "(" arg_list ")" ;
comparison = expression comp_op expression ;
aggregate_fn = "count" | "sum" | "min" | "max" ;
comp_op = "==" | "!=" | "<" | "<=" | ">" | ">=" ;
arg_list = arg ( "," arg )* ;
arg = variable | literal | "_" ;
variable = lowercase identifier ;
literal = string_literal | int_literal | float_literal | bool_literal ;
qualified_name = ("intent." | "candidate." | "proposal.")? relation_name ;
invariant_decl = "invariant" identifier "(" arg_list ")" ":-" invariant_body "." ;
invariant_body = condition ( "," condition )* "," constraint ;
constraint = aggregate_fn qualified_name "(" arg_list ")" comp_op expression ;
identifier = letter ( letter | digit | "_" )* ;
string_literal = '"' (any character except '"' | '\"')* '"' ;
int_literal = digit+ ;
float_literal = digit+ "." digit+ ;
bool_literal = "true" | "false" ;

.dh distinguishes two operator families. They are not interchangeable, and the parser rejects either one used in the other position.

  • Binding (=) appears only in aggregate_bind and helper_bind. It assigns the result of an aggregate or helper call to a fresh variable on the left-hand side.
  • Comparison (==, !=, <, <=, >, >=) appears only in rule body comparisons, aggregate constraints, and invariant bodies.
-- Binding: assign the maximum sequence to `latest_seq`
rule latest_seq(pid, seq) :-
price_snapshot(_, pid, _, _, _),
seq = max price_snapshot(s, pid, _, _, _), s.
-- Comparison: keep only price changes
rule price_changed(pid, old, new) :-
price_previous(pid, old, _, _),
price_current(pid, new, _, _),
old != new.

Using = in a comparison position (price = 99) or == in a binding position (seq == max ...) is rejected at parse time.

JacqOS classifies the positive join core of every rule into one of five shapes:

ShapeWhat it means in practice
star queryEvery positive clause shares one pivot variable
guardedOne clause contains every variable used by the join
frontier-guardedOne clause contains every shared join variable
acyclic conjunctiveThe join graph is a tree or forest
unconstrainedThe rule does not match any of the tractability-friendly shapes

These shapes do not change semantics. They are guidance about tractability, witness size, and debugging scope. See Model-Theoretic Foundations for the full explanation.

Star queries are the best default for observation-first apps because one variable grounds the entire join.

rule booking_ready(req, email, slot) :-
atom(req, "booking.email", email),
atom(req, "booking.slot_id", slot),
atom(req, "booking.intent", "request").

req is the guard variable. That makes the rule easier to optimize and gives Studio a compact provenance witness anchored to one observation.

Unconstrained rules are often a sign that the ontology is trying to recover a coordination surface too late.

-- Unconstrained: the join graph is a cycle
rule unstable_triangle(service, alert, dependency) :-
service_alert(service, alert),
alert_dependency(alert, dependency),
dependency_service(dependency, service).

The usual refactor is to introduce an explicit guard relation first, then join through it:

rule incident_scope(alert, service, dependency) :-
service_alert(service, alert),
alert_dependency(alert, dependency).
rule stable_assignment(alert, service, dependency, owner) :-
incident_scope(alert, service, dependency),
service_owner(service, owner).

Now both rules are star-shaped. The shared variable alert grounds the scope relation, and the derived incident_scope(...) relation becomes the coordination surface for downstream rules.

jacqos verify always includes a rule-shape summary:

Terminal window
Rule Shape Report
Star queries: 18 Guarded: 9 Unconstrained: 1
ontology/rules.dh:42:1 unstable_triangle(...) unconstrained - consider a guard variable

The CLI headline collapses guarded, frontier-guarded, and acyclic conjunctive into one Guarded bucket for quick scanning. Studio and exported artifacts keep the exact five-way breakdown.

If you see an unconstrained warning, the rule is still legal. It means the platform cannot attach an extra local tractability guarantee to that join shape. The first fix to try is usually to add or materialize a guard variable.

Every relation used in rules must be declared with typed columns before use:

relation booking_request(request_id: text, email: text, slot_id: text)
relation slot_reserved(slot_id: text)
relation booking_confirmed(request_id: text, slot_id: text)
relation normalized_email(request_id: text, email: text)
relation slot_booking_count(slot_id: text, n: int)
TypeDescriptionExample values
textUTF-8 string"hello", "slot-42"
int64-bit signed integer0, 42, -1
float64-bit floating point3.14, 0.8
boolBooleantrue, false

Relation names must be unique across all .dh files in the ontology. The evaluator rejects duplicate declarations at load time.

Rules derive new facts from existing atoms and facts. The syntax follows the standard Datalog convention: head :- body.

rule booking_request(req, email, slot) :-
atom(req, "booking.email", email),
atom(req, "booking.slot_id", slot).
rule slot_reserved(slot) :-
booking_confirmed(_, slot).

The head names the relation being derived. The body is a comma-separated list of conditions that must all be satisfied. Variables in the head must appear in at least one positive condition in the body.

Use _ when you need to match a column but don’t care about its value:

rule has_booking(slot) :-
booking_confirmed(_, slot).

You can write multiple rules that derive the same relation. A fact is derived if any rule succeeds (logical OR):

rule contact_email(person, email) :-
atom(obs, "profile.email", email),
atom(obs, "profile.person_id", person).
rule contact_email(person, email) :-
atom(obs, "signup.email", email),
atom(obs, "signup.person_id", person).

atom(observation_ref, predicate, value) is the built-in base relation that bridges observations into the logic layer. All external evidence enters the ontology through atom(). You never declare atom() — it is always available.

-- Extract a booking email from an observation
rule booking_request(req, email, slot) :-
atom(req, "booking.email", email),
atom(req, "booking.slot_id", slot).

The first argument is the observation reference. Joining on the same observation ref ensures atoms come from the same observation:

-- These atoms must come from the same observation
rule patient_intake(obs, name, dob) :-
atom(obs, "intake.patient_name", name),
atom(obs, "intake.date_of_birth", dob).
-- These atoms can come from different observations
rule patient_with_symptom(patient, symptom) :-
atom(obs1, "intake.patient_id", patient),
atom(obs2, "symptom.patient_id", patient),
atom(obs2, "symptom.name", symptom).

Atoms are produced by Rhai observation mappers. When an observation arrives, the mapper flattens it into (predicate, value) pairs. These become the atoms available to atom(). See the Rhai Mapper API for details.

Positive recursion is supported. The evaluator reaches a fixed point when no new facts can be derived:

relation edge(src: text, dst: text)
relation reachable(src: text, dst: text)
rule reachable(a, b) :- edge(a, b).
rule reachable(a, c) :- reachable(a, b), edge(b, c).

Recursive rules must converge — the evaluator terminates when no new tuples are produced in a round. Because the domain is finite (bounded by the observations in the lineage), positive recursion always terminates.

Negation checks that a fact does not exist in the current derived state. It is supported only against relations in a lower stable stratum.

rule intent.reserve_slot(req, slot) :-
booking_request(req, _, slot),
not slot_reserved(slot).

The evaluator automatically partitions rules into strata based on negation dependencies. A relation can only be negated if it is fully computed in a lower stratum. This guarantees a unique, well-defined semantics for negation.

-- Stratum 0: base facts from atoms
rule booking_request(req, email, slot) :-
atom(req, "booking.email", email),
atom(req, "booking.slot_id", slot).
-- Stratum 0: derived from atoms
rule slot_reserved(slot) :-
booking_confirmed(_, slot).
-- Stratum 1: negates slot_reserved (stratum 0) — valid
rule intent.reserve_slot(req, slot) :-
booking_request(req, _, slot),
not slot_reserved(slot).

If the evaluator cannot find a valid stratification, the ontology is rejected at load time:

-- REJECTED: a(x) depends on not a(x) — no valid stratum assignment
rule a(x) :- b(x), not a(x).

Error:

error[E2501]: unstratified negation cycle between a and a
--> ontology/rules.dh:4:18
|
4 | rule a(x) :- b(x), not a(x).
| ^^^^^^^^ `a` cannot negate itself
|
= help: negation is only allowed against relations fully computed
in a lower stratum

Finite, non-recursive aggregates compute summary values over matching tuples.

FunctionDescriptionResult type
countNumber of matching tuplesint
sumSum of a numeric columnsame as input
minMinimum value of a columnsame as input
maxMaximum value of a columnsame as input
rule slot_booking_count(slot, n) :-
n = count booking_confirmed(_, slot).
rule total_revenue(total) :-
total = sum booking_price(_, amount), amount.
rule earliest_booking(slot, t) :-
t = min booking_time(slot, time), time.
rule latest_booking(slot, t) :-
t = max booking_time(slot, time), time.

For sum, min, and max, the second argument after the relation specifies which column to aggregate over.

An aggregate cannot appear in a rule body that transitively depends on its own head:

-- REJECTED: recursive aggregate
rule running_total(n) :-
n = sum running_total(prev), prev.

Error:

error[E2201]: aggregate cycle: running_total depends on aggregate over running_total
--> ontology/rules.dh:2:7
|
2 | n = sum running_total(prev), prev.
| ^^^^^^^^^^^^^^^^^^^^^^^^ `running_total` cannot aggregate
| over itself
|
= help: aggregates must be non-recursive — the aggregated relation
must be fully computed before the aggregate runs

Rules can explicitly assert or retract facts. This is how the system models state changes over time as new observations arrive.

assert adds a fact to the derived state when the rule body is satisfied:

rule assert booking_confirmed(req, slot) :-
atom(obs, "reserve.succeeded", "true"),
atom(obs, "reserve.request_id", req),
atom(obs, "reserve.slot_id", slot).

retract removes a previously asserted fact when the rule body is satisfied:

rule retract slot_available(slot) :-
booking_confirmed(_, slot).
rule retract booking_confirmed(req, slot) :-
atom(obs, "cancel.request_id", req),
atom(obs, "cancel.slot_id", slot).

Both assertions and retractions carry provenance — each records which observations and rules caused the state change. This is visible in Studio’s drill inspector and timeline, and in jacqos verify output.

relation slot_available(slot_id: text)
relation booking_confirmed(request_id: text, slot_id: text)
-- Slot becomes available when inventory is loaded
rule assert slot_available(slot) :-
atom(obs, "inventory.slot_id", slot),
atom(obs, "inventory.status", "open").
-- Slot becomes unavailable when booked
rule retract slot_available(slot) :-
booking_confirmed(_, slot).
-- Booking is confirmed when reservation succeeds
rule assert booking_confirmed(req, slot) :-
atom(obs, "reserve.succeeded", "true"),
atom(obs, "reserve.request_id", req),
atom(obs, "reserve.slot_id", slot).
-- Booking is removed when cancelled
rule retract booking_confirmed(req, slot) :-
atom(obs, "cancel.request_id", req),
atom(obs, "cancel.slot_id", slot).
-- Slot becomes available again after cancellation
rule assert slot_available(slot) :-
atom(obs, "cancel.slot_id", slot).

Invariants are integrity constraints checked after every evaluation fixed point. If an invariant is violated, the evaluation fails with a diagnostic pointing to the violating tuples.

invariant no_double_booking(slot) :-
count booking_confirmed(_, slot) <= 1.
invariant confirmed_has_email(req) :-
booking_confirmed(req, _),
booking_request(req, email, _),
email != "".

An invariant body must always hold for every binding in its parameter domain. After every evaluation fixed point, the evaluator computes the parameter domain — every binding of the invariant’s free variables that appears in the current state — and evaluates the body for each binding. If the body fails for any binding, the invariant is violated and the transition that produced the offending state is rejected.

This is the inverse of the “violation pattern” framing some Datalog dialects use. The body describes what must succeed, not what must be absent.

-- Correct: "must always hold" — count of confirmed bookings per slot is at most 1
invariant no_double_booking(slot) :-
count booking_confirmed(_, slot) <= 1.
-- Wrong: "violation pattern" framing — the evaluator does not negate the body
-- This invariant is satisfied only when there are zero pairs of distinct
-- bookings for the same slot, which is what we want — but it reads as
-- "this body must hold," not "this body must never hold."

Invariants are the primary human review surface in JacqOS. Instead of reading AI-generated rule code line by line, you declare what must always hold. The evaluator proves whether the rules satisfy your invariants across all fixture timelines.

See Shift from Code Review to Invariant Review for the full concept.

When an invariant is violated during jacqos verify or jacqos replay, you get a diagnostic like:

error: invariant violated — no_double_booking
--> ontology/rules.dh:12:1
|
12 | invariant no_double_booking(slot) :-
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= violating binding: slot = "slot-42"
= booking_confirmed("req-7", "slot-42") from observation obs-0019
= booking_confirmed("req-12", "slot-42") from observation obs-0023
|
= help: two bookings exist for the same slot — check your
reservation logic or add a guard rule

Invariants can combine multiple conditions to express complex constraints:

-- Every intent to send an email must have a valid recipient
invariant email_intent_has_recipient(req) :-
intent.send_confirmation(req, email),
email != "",
booking_confirmed(req, _).
-- No patient can have contradictory diagnoses
invariant no_contradictory_diagnosis(patient) :-
diagnosis(patient, d1),
diagnosis(patient, d2),
d1 != d2,
contradicts(d1, d2),
count diagnosis(patient, _) <= 10.

Relations prefixed with intent. derive effect requests. The shell intercepts these and maps them to declared capabilities in jacqos.toml.

rule intent.reserve_slot(req, slot) :-
booking_request(req, _, slot),
not slot_reserved(slot).
rule intent.send_confirmation(req, email) :-
booking_confirmed(req, _),
booking_request(req, email, _),
not confirmation_sent(req).
rule intent.schedule_reminder(req, slot, reminder_time) :-
booking_confirmed(req, slot),
booking_time(slot, time),
reminder_time = helper.subtract_hours(time, 24).
  1. The evaluator derives intent tuples from the current fact state.
  2. Intents are durably admitted before any external execution begins.
  3. The shell maps each intent. relation to a declared effect capability (e.g., http.fetch, llm.complete).
  4. Effects execute and produce new observations, which feed back into the pipeline.
  5. Idempotent effects auto-retry on failure. Non-idempotent effects require explicit reconciliation.

Intent relations must be mapped to capabilities in jacqos.toml. The mapping lives under [capabilities.intents] as a table-of-tables keyed by the fully qualified intent relation name. Each entry names the capability the intent binds to, plus a resource for the capabilities that need one (http.fetch and llm.complete). Capabilities that do not need a resource (timer.schedule, blob.put, blob.get, log.dev) omit it.

[capabilities]
http_clients = ["clinic_api", "notify_api"]
[capabilities.intents]
"intent.reserve_slot" = { capability = "http.fetch", resource = "clinic_api" }
"intent.send_confirmation" = { capability = "http.fetch", resource = "notify_api" }

Undeclared intent relations are a hard load error. See the jacqos.toml reference for the full shape of every binding (including result_kind for llm.complete).

Helper functions are capability-free, deterministic, pure functions callable from rules. They are prefixed with helper..

rule normalized_email(req, norm) :-
booking_request(req, raw_email, _),
norm = helper.normalize_email(raw_email).
rule normalized_slot(req, norm) :-
booking_request(req, _, raw_slot),
norm = helper.normalize_slot_id(raw_slot).
rule display_time(slot, display) :-
booking_time(slot, raw_time),
display = helper.format_time(raw_time, "America/New_York").
  • Pure: helpers cannot observe or mutate state, access the network, or perform I/O.
  • Deterministic: the same inputs always produce the same output.
  • Sandboxed: helpers run in the Rhai sandbox (or pre-compiled Wasm for complex cases).
  • Identity-bearing: helper digests are part of the evaluator identity. Changing a helper changes the evaluator digest.

Helpers are implemented as Rhai functions in the helpers/ directory:

helpers/normalize.rhai
fn normalize_email(email) {
let normalized = email;
normalized.trim();
normalized.to_lower()
}
fn normalize_slot_id(slot) {
let normalized = slot;
normalized.trim();
normalized.to_upper()
}

The helper name in .dh rules maps to the function name: helper.normalize_email calls normalize_email in the Rhai helper.

Any observation whose semantic content originates from an LLM, scraped content, heuristic parser, or other non-authoritative source must first enter the ontology as candidate. evidence. Only authoritative receipts and directly observed system facts may bypass candidate..

-- LLM extraction enters as a candidate
rule candidate.symptom(obs, symptom, confidence) :-
atom(obs, "llm_extraction.symptom", symptom),
atom(obs, "llm_extraction.confidence", confidence).
-- Acceptance rule: only high-confidence extractions become facts
rule symptom(patient, symptom) :-
candidate.symptom(obs, symptom, conf),
conf >= 0.8,
atom(obs, "intake.patient_id", patient).

LLMs hallucinate. Scrapers misparse. Heuristics guess wrong. The candidate. prefix forces you to write an explicit acceptance rule that decides when non-authoritative evidence becomes a trusted fact. This is a load-time enforcement — not a convention.

Any rule that derives an accepted fact directly from an llm_response-class observation without passing through a candidate. relation is rejected at load time:

-- REJECTED: direct LLM fact acceptance without candidate
rule symptom(patient, symptom) :-
atom(obs, "llm_extraction.symptom", symptom),
atom(obs, "llm_extraction.patient_id", patient).

Error:

error[E2401]: symptom derives from requires_relay observations without a candidate. relay
--> ontology/rules.dh:2:6
|
2 | rule symptom(patient, symptom) :-
| ^^^^^^^ `symptom` is derived directly from a relay-marked
| atom without a `candidate.` relay
|
= help: fallible-sensor evidence must pass through a `candidate.`
relation with an explicit acceptance rule
= example:
| rule candidate.symptom(obs, symptom, conf) :-
| atom(obs, "llm_extraction.symptom", symptom),
| atom(obs, "llm_extraction.confidence", conf).
|
| rule symptom(patient, symptom) :-
| candidate.symptom(obs, symptom, conf),
| conf >= 0.8,
| atom(obs, "intake.patient_id", patient).

You can write different acceptance rules for different confidence levels or contexts:

-- High-confidence: accept automatically
rule symptom(patient, symptom) :-
candidate.symptom(obs, symptom, conf),
conf >= 0.9,
atom(obs, "intake.patient_id", patient).
-- Medium-confidence: accept only if corroborated
rule symptom(patient, symptom) :-
candidate.symptom(obs1, symptom, conf),
conf >= 0.5,
conf < 0.9,
candidate.symptom(obs2, symptom, _),
obs1 != obs2,
atom(obs1, "intake.patient_id", patient).

proposal.* is the relay namespace for fallible-decider output — model-suggested actions. Where candidate.* covers descriptive evidence (“the model believes this is a symptom”), proposal.* covers prescriptive output (“the model wants to take this action”). A proposal.* fact is never authority to act. Before any intent.* rule may fire, an explicit domain decision relation must ratify the proposal.

The pipeline is always:

proposal.* -> domain decision relation -> intent.*
-- The model's raw decision lands as a proposal — never as an intent.
rule assert proposal.offer_action(request_id, vehicle_id, action, decision_seq) :-
atom(obs, "offer_decision.request_id", request_id),
atom(obs, "offer_decision.vehicle_id", vehicle_id),
atom(obs, "offer_decision.action", action),
atom(obs, "offer_decision.seq", decision_seq).
rule assert proposal.offer_price(request_id, vehicle_id, price_usd, decision_seq) :-
atom(obs, "offer_decision.request_id", request_id),
atom(obs, "offer_decision.vehicle_id", vehicle_id),
atom(obs, "offer_decision.price_usd", price_usd),
atom(obs, "offer_decision.seq", decision_seq).
-- The ontology gates the proposal against policy. Only authorized
-- decisions become a domain decision fact.
rule sales.decision.authorized_offer(request_id, vehicle_id, price_usd) :-
proposal.offer_action(request_id, vehicle_id, "send_offer", seq),
proposal.offer_price(request_id, vehicle_id, price_usd, seq),
policy.auto_authorize_min_price(vehicle_id, floor_usd),
price_usd >= floor_usd.
-- The intent fires only off the ratified domain decision, never off
-- the proposal directly.
rule intent.send_offer(request_id, vehicle_id, price_usd) :-
sales.decision.authorized_offer(request_id, vehicle_id, price_usd),
not sales.offer_sent(request_id, vehicle_id, price_usd).

This pattern comes from examples/jacqos-chevy-offer-containment/ontology/rules.dh. The same example also defines sales.decision.requires_manager_review and sales.decision.blocked_offer to handle proposals that fall outside the auto-authorize floor — every model-proposed action ends in exactly one decision class before any intent is allowed to fire.

Any rule that derives an executable intent.* directly from a requires_relay-marked atom — without first relaying through proposal.* and being ratified by a domain decision relation — is rejected at load time. The validator’s relay-boundary check (validate_relay_boundaries) is keyed on the predicate prefixes declared by the mapper’s mapper_contract(), not on observation class strings.

Any rule that derives an executable intent.* directly from a proposal.* relation is also rejected. The relay namespace is only the staging room; a separate domain decision relation is the ratification boundary.

-- REJECTED: an executable intent derived directly from a fallible-decider atom
rule intent.issue_refund(request_id, amount_usd) :-
atom(obs, "llm_action.request_id", request_id),
atom(obs, "llm_action.amount_usd", amount_usd).
error[E2401]: intent.issue_refund derives from requires_relay observations without a proposal. relay
-- REJECTED: a proposal tuple is not execution authority
rule intent.issue_refund(request_id, amount_usd) :-
proposal.refund_action(request_id, amount_usd).
error[E2401]: intent.issue_refund derives executable intent directly from proposal. without a domain decision relation

The same rejection applies to the descriptive case for candidate.*: an accepted fact derived directly from a requires_relay-marked sensor atom without going through candidate.* is rejected with the matching candidate. relay message.

The following are hard errors — the evaluator will not load rules that use them.

An aggregate in a rule body that depends on its own head:

-- REJECTED
rule running_total(n) :-
n = sum running_total(prev), prev.
error[E2201]: aggregate cycle: running_total depends on aggregate over running_total

Negating a relation that transitively depends on the current rule’s head:

-- REJECTED
rule a(x) :- b(x), not a(x).
error[E2501]: unstratified negation cycle between a and a

Rules cannot access files, network, or external state. All external evidence enters through atom() and all external actions exit through intent.. The parser rejects unknown clause shapes (here, read_file(...) is an undeclared relation):

-- REJECTED: no ambient I/O in rules
rule data(x) :- read_file("input.txt", x).
error[E2004]: relation 'read_file' is not declared

All rules are declared statically in .dh files. There is no mechanism to add rules at runtime; an attempt to drive rules from observation atoms is rejected for the same reason any other unauthorized control-flow construct would be — there is no syntax to express it. Use a load-time scaffold instead.

Direct Acceptance From Relay-Marked Observations

Section titled “Direct Acceptance From Relay-Marked Observations”

Observations whose semantic content originates from a fallible sensor or fallible decider must go through the appropriate relay namespace. Sensor evidence relays through candidate.*; decider output relays through proposal.*:

-- REJECTED: descriptive LLM output bypassing the candidate relay
rule diagnosis(patient, d) :-
atom(obs, "llm_response.diagnosis", d),
atom(obs, "llm_response.patient_id", patient).
error[E2401]: diagnosis derives from requires_relay observations without a candidate. relay
-- REJECTED: action output bypassing the proposal relay
rule intent.issue_refund(request_id, amount_usd) :-
atom(obs, "llm_action.request_id", request_id),
atom(obs, "llm_action.amount_usd", amount_usd).
error[E2401]: intent.issue_refund derives from requires_relay observations without a proposal. relay

The relay-boundary check is keyed on the predicate prefixes declared in your mapper’s mapper_contract(), not on string-matched observation classes. See Rhai Mapper API for the contract shape.

.dh uses -- for line comments, following SQL and Datalog convention:

-- This is a comment
relation booking_request(request_id: text, email: text, slot_id: text)
rule booking_request(req, email, slot) :- -- inline comment
atom(req, "booking.email", email),
atom(req, "booking.slot_id", slot).

By convention, .dh files are organized in the ontology/ directory:

ontology/
schema.dh # Relation declarations
rules.dh # Derivation rules
intents.dh # Intent derivation rules

All .dh files matching the glob in jacqos.toml are loaded together. The evaluator resolves dependencies across files automatically — you can reference a relation declared in schema.dh from a rule in rules.dh.

[paths]
ontology = ["ontology/*.dh"]
FileContains
schema.dhAll relation declarations
rules.dhCore derivation rules and assertions/retractions
intents.dhAll intent. derivation rules

For larger ontologies, you can split further (e.g., candidates.dh, invariants.dh). The evaluator does not assign meaning to filenames.

.dh stays as close to Soufflé/Datalog conventions as possible. Every syntactic deviation from standard Datalog is a deviation from the AI training distribution. Since AI agents are the primary authors of .dh rules, maximizing compatibility with existing Datalog knowledge in language models is a design priority.

V1 limits recursion to positive derivation only. Recursive aggregates and unstratified negation introduce semantic ambiguity that makes it harder to reason about correctness and harder for invariant checking to be sound. These restrictions may relax in future versions once the conformance corpus is stable.

Most Datalog systems derive facts from the current database state. JacqOS adds explicit assert and retract because the observation-first model requires tracking state changes over time. Each assertion or retraction carries provenance, making it possible to trace exactly why the system believes (or stopped believing) a fact.

The candidate. requirement is the trust boundary between AI-generated evidence and system-trusted facts. Without it, an LLM hallucination could silently become a trusted fact that drives effects. The load-time check makes this a structural guarantee, not a code review finding.

Every error the .dh validator emits carries a stable EXYYZZ code so you can grep build output, link to specific failures, and write fixture assertions that survive message rewrites. The scheme:

  • E — error (warnings and infos reserved as W / I).
  • X — phase: 0 lexer, 1 parser, 2 validator.
  • YY — subsystem: 00 syntax, 01 relations, 02 aggregates, 03 helpers, 04 relay, 05 stratification.
  • ZZ — sequence within the subsystem.

Examples below show a minimal .dh fragment that triggers each code. The authoritative source is the diagnostic inventory.

These fire while the source is being tokenised, before any structure is recognised.

  • E0001 — bare ! outside !=. Example: rule a(x) :- b(x), !c(x).
  • E0002 — unexpected character (anything outside the allowed punctuation, identifier, or numeric set). Example: rule a(x) :- b(x@y).
  • E0003 — bare - where a negative literal was expected. Example: rule a(x) :- b(x), x > -.
  • E0004 — unterminated string literal (source ends before the closing "). Example: rule a(x) :- atom(o, "p, x).
  • E0005 — unterminated escape (\ at end of source inside a string). Example: rule a(x) :- atom(o, "p\ (no closing quote).
  • E0006 — unsupported escape sequence. Only \", \\, \n, \r, \t are recognised. Example: atom(o, "p\q", x).
  • E0007 — literal newline inside a string literal. Strings must be single-line. Example: atom(o, "line1<LF>line2", x).

These fire once tokens are formed but the structure violates the grammar.

  • E1001 — expected a top-level statement (relation, rule, or invariant). Example: a stray expression before the first declaration.
  • E1002 — unexpected statement keyword. Example: query foo(x). (only relation, rule, invariant are allowed).
  • E1003 — expected a scalar type at field declaration position. Example: relation r(x: 42).
  • E1004 — unsupported scalar type. Example: relation r(x: bigint) (only text, int, float, bool).
  • E1005( missing after relation name. Example: relation foo x: text).
  • E1006) missing after relation field list. Example: relation foo(x: text (no closing paren).
  • E1007 — expected field name. Example: relation foo(: text).
  • E1008: missing after field name. Example: relation foo(x text).
  • E1009:- missing after rule head. Example: rule a(x) b(x).
  • E1010. missing after rule body. Example: rule a(x) :- b(x) (no terminating dot).
  • E1011 — invariant keyword without a name. Example: invariant :- ...
  • E1012( missing after invariant name. Example: invariant inv :- count r(_) <= 1.
  • E1013 — invariant parameter is not an identifier. Example: invariant inv(42) :- ....
  • E1014) missing after invariant parameter list. Example: invariant inv(x :- ....
  • E1015:- missing after invariant head. Example: invariant inv(x) count r(x) <= 1.
  • E1016. missing after invariant body. Example: invariant inv(x) :- count r(x) <= 1.
  • E1017 — clause in a rule body that is neither a relation atom, an assignment, nor a comparison. Example: rule a(x) :- b(x), 42.
  • E1018 — bare helper call as a clause. Helpers must appear in an assignment or comparison. Example: rule a(x) :- b(x), helper.norm(x).
  • E1019 — aggregate in clause position without a comparator. Example: rule a(n) :- max r(x), x. (must be n = max ... or count r(_) <= 1).
  • E1020 — expected an aggregate operator after =. Example: n = totally r(x), x.
  • E1021( missing after aggregate source relation. Example: n = count r .
  • E1022) missing after aggregate term list. Example: n = count r(x .
  • E1023 — expected aggregate value variable identifier. Example: n = sum r(x), 42.
  • E1024 — wildcard _ used in a rule head. Example: rule a(_) :- b(x). (heads must bind named variables).
  • E1025( missing after a relation name in a body atom or head. Example: rule a(x) :- b x.
  • E1026) missing after a relation atom term list. Example: rule a(x) :- b(x .
  • E1027( missing after atom. Example: rule a(x) :- atom o, "p", x.
  • E1028, missing after the observation term in atom(...). Example: atom(obs "p", x).
  • E1029 — second atom(...) argument must be a string literal. Example: atom(obs, p, x).
  • E1030, missing after the predicate string in atom(...). Example: atom(obs, "p" x).
  • E1031) missing after atom(...). Example: atom(obs, "p", x (no close).
  • E1032( missing after a helper name. Example: n = helper.norm.
  • E1033) missing after a helper call argument list. Example: n = helper.norm(x .
  • E1034 — helper call name does not start with helper.. Example: n = norm(x).
  • E1035. missing in a multi-segment helper name. Example: n = helper norm(x).
  • E1036 — helper segment after . is not an identifier. Example: n = helper.42(x).
  • E1037 — qualified relation segment after . is not an identifier. Example: rule intent.42(x) :- b(x).
  • E1038 — expected a term but got an unparseable token. Example: rule a(x) :- b(,).
  • E1039 — integer literal does not fit in i64. Example: rule a(x) :- b(99999999999999999999).

Validator: relations (E2001, E2004–E2005, E2102–E2103)

Section titled “Validator: relations (E2001, E2004–E2005, E2102–E2103)”

These fire once the AST is structurally valid but semantic checks reject it.

  • E2001atom is built in and cannot be redeclared. Example: relation atom(x: text).

  • E2004 — relation is not declared. Example: a body atom or aggregate referencing a name with no relation declaration:

    rule a(x) :- read_file("input.txt", x).
  • E2005 — relation arity mismatch between declaration and use. Example:

    relation r(x: text, y: text)
    rule a(x) :- r(x).
  • E2102helper. prefix is reserved for pure helper calls and cannot name a relation. Example: relation helper.norm(x: text).

  • E2103 — duplicate relation declaration. Example:

    relation r(x: text)
    relation r(x: int)
  • E2201 — aggregate cycle: a rule head depends on an aggregate over itself. Example:

    rule running_total(n) :-
    n = sum running_total(prev), prev.
  • E2202count does not take a trailing value variable. Example: n = count r(x), x.

  • E2203sum, min, max require a trailing value variable. Example: n = sum r(x).

  • E2204 — aggregate value variable is not bound by the aggregate source atom. Example: rule a(t) :- t = sum r(x), y. (y must appear in r(x)).

  • E2301 — helper is not declared. Example: rule a(n) :- b(x), n = helper.unknown(x).
  • E2302 — helper is not capability-free (declared with capabilities; helpers must be pure). Example: a helper marked with http.fetch cannot be called from a rule.
  • E2303 — helper is not deterministic. Example: a helper that reads the wall clock cannot be called from a rule.
  • E2401 — a relation derives from requires_relay observations without going through the appropriate candidate. or proposal. relay, or an executable intent.* derives directly from proposal.* without a domain decision relation. Example:

    rule symptom(patient, symptom) :-
    atom(obs, "llm_extraction.symptom", symptom),
    atom(obs, "llm_extraction.patient_id", patient).
  • E2501 — unstratified negation cycle. Example:

    rule a(x) :- b(x), not a(x).

A medical intake system that extracts symptoms from LLM analysis, validates them, and triggers follow-up actions:

-- Schema
relation patient(patient_id: text, name: text)
relation intake_form(obs_id: text, patient_id: text)
relation symptom(patient_id: text, symptom: text)
relation symptom_count(patient_id: text, n: int)
relation needs_followup(patient_id: text)
-- Base facts from observations
rule patient(pid, name) :-
atom(obs, "registration.patient_id", pid),
atom(obs, "registration.name", name).
rule intake_form(obs, pid) :-
atom(obs, "intake.patient_id", pid),
atom(obs, "intake.type", "initial").
-- LLM extractions enter as candidates
rule candidate.symptom(obs, symptom, confidence) :-
atom(obs, "llm_extraction.symptom", symptom),
atom(obs, "llm_extraction.confidence", confidence).
-- Accept high-confidence symptoms
rule symptom(pid, symptom) :-
candidate.symptom(obs, symptom, conf),
conf >= 0.8,
atom(obs, "intake.patient_id", pid).
-- Aggregate: count symptoms per patient
rule symptom_count(pid, n) :-
n = count symptom(pid, _).
-- Flag patients needing followup
rule needs_followup(pid) :-
symptom_count(pid, n),
n >= 3.
-- Normalize patient names via helper
rule normalized_name(pid, norm) :-
patient(pid, raw_name),
norm = helper.normalize_name(raw_name).
-- Intent: schedule followup for flagged patients
rule intent.schedule_followup(pid) :-
needs_followup(pid),
not followup_scheduled(pid).
-- Invariants
invariant patient_has_name(pid) :-
patient(pid, name),
name != "".
invariant symptom_has_patient(pid) :-
symptom(pid, _),
patient(pid, _).