.dh Language Reference
Overview
Section titled “Overview”.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.
Grammar
Section titled “Grammar”Notation
Section titled “Notation”The grammar uses EBNF notation. Terminal strings are in double quotes. ? means optional, * means zero or more, + means one or more.
Top-Level Declarations
Section titled “Top-Level Declarations”program = declaration* ;
declaration = relation_decl | rule_decl | invariant_decl | comment ;
comment = "--" (any character except newline)* newline ;Relation Declarations
Section titled “Relation Declarations”relation_decl = "relation" relation_name "(" column_list ")" ;relation_name = identifier ;column_list = column ( "," column )* ;column = identifier ":" type ;type = "text" | "int" | "float" | "bool" ;Rule Declarations
Section titled “Rule Declarations”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 Declarations
Section titled “Invariant Declarations”invariant_decl = "invariant" identifier "(" arg_list ")" ":-" invariant_body "." ;invariant_body = condition ( "," condition )* "," constraint ;constraint = aggregate_fn qualified_name "(" arg_list ")" comp_op expression ;Identifiers and Literals
Section titled “Identifiers and Literals”identifier = letter ( letter | digit | "_" )* ;string_literal = '"' (any character except '"' | '\"')* '"' ;int_literal = digit+ ;float_literal = digit+ "." digit+ ;bool_literal = "true" | "false" ;Binding vs Comparison
Section titled “Binding vs Comparison”.dh distinguishes two operator families. They are not interchangeable, and the parser rejects either one used in the other position.
- Binding (
=) appears only inaggregate_bindandhelper_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 changesrule 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.
Rule Shape Guidance
Section titled “Rule Shape Guidance”JacqOS classifies the positive join core of every rule into one of five shapes:
| Shape | What it means in practice |
|---|---|
star query | Every positive clause shares one pivot variable |
guarded | One clause contains every variable used by the join |
frontier-guarded | One clause contains every shared join variable |
acyclic conjunctive | The join graph is a tree or forest |
unconstrained | The 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.
Why star queries are preferred
Section titled “Why star queries are preferred”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.
How to refactor an unconstrained rule
Section titled “How to refactor an unconstrained rule”Unconstrained rules are often a sign that the ontology is trying to recover a coordination surface too late.
-- Unconstrained: the join graph is a cyclerule 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.
What jacqos verify reports
Section titled “What jacqos verify reports”jacqos verify always includes a rule-shape summary:
Rule Shape Report Star queries: 18 Guarded: 9 Unconstrained: 1 ⚠ ontology/rules.dh:42:1 unstable_triangle(...) unconstrained - consider a guard variableThe 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.
Relation Declarations
Section titled “Relation Declarations”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)Supported Column Types
Section titled “Supported Column Types”| Type | Description | Example values |
|---|---|---|
text | UTF-8 string | "hello", "slot-42" |
int | 64-bit signed integer | 0, 42, -1 |
float | 64-bit floating point | 3.14, 0.8 |
bool | Boolean | true, false |
Relation names must be unique across all .dh files in the ontology. The evaluator rejects duplicate declarations at load time.
Derivation Rules
Section titled “Derivation Rules”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.
Wildcards
Section titled “Wildcards”Use _ when you need to match a column but don’t care about its value:
rule has_booking(slot) :- booking_confirmed(_, slot).Multiple Rules for the Same Relation
Section titled “Multiple Rules for the Same Relation”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).The atom() Built-in
Section titled “The atom() Built-in”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 observationrule 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 observationrule patient_intake(obs, name, dob) :- atom(obs, "intake.patient_name", name), atom(obs, "intake.date_of_birth", dob).
-- These atoms can come from different observationsrule patient_with_symptom(patient, symptom) :- atom(obs1, "intake.patient_id", patient), atom(obs2, "symptom.patient_id", patient), atom(obs2, "symptom.name", symptom).How Atoms Get Created
Section titled “How Atoms Get Created”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.
Bounded Recursive Derivation
Section titled “Bounded Recursive Derivation”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.
Stratified Negation
Section titled “Stratified Negation”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).How Stratification Works
Section titled “How Stratification Works”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 atomsrule booking_request(req, email, slot) :- atom(req, "booking.email", email), atom(req, "booking.slot_id", slot).
-- Stratum 0: derived from atomsrule slot_reserved(slot) :- booking_confirmed(_, slot).
-- Stratum 1: negates slot_reserved (stratum 0) — validrule intent.reserve_slot(req, slot) :- booking_request(req, _, slot), not slot_reserved(slot).Unstratified Negation Is Rejected
Section titled “Unstratified Negation Is Rejected”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 assignmentrule 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 stratumAggregates
Section titled “Aggregates”Finite, non-recursive aggregates compute summary values over matching tuples.
Supported Aggregate Functions
Section titled “Supported Aggregate Functions”| Function | Description | Result type |
|---|---|---|
count | Number of matching tuples | int |
sum | Sum of a numeric column | same as input |
min | Minimum value of a column | same as input |
max | Maximum value of a column | same as input |
Syntax
Section titled “Syntax”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.
Recursive Aggregates Are Rejected
Section titled “Recursive Aggregates Are Rejected”An aggregate cannot appear in a rule body that transitively depends on its own head:
-- REJECTED: recursive aggregaterule 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 runsAssertions and Retractions
Section titled “Assertions and Retractions”Rules can explicitly assert or retract facts. This is how the system models state changes over time as new observations arrive.
Assert
Section titled “Assert”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
Section titled “Retract”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).Provenance
Section titled “Provenance”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.
Worked Example: State Over Time
Section titled “Worked Example: State Over Time”relation slot_available(slot_id: text)relation booking_confirmed(request_id: text, slot_id: text)
-- Slot becomes available when inventory is loadedrule assert slot_available(slot) :- atom(obs, "inventory.slot_id", slot), atom(obs, "inventory.status", "open").
-- Slot becomes unavailable when bookedrule retract slot_available(slot) :- booking_confirmed(_, slot).
-- Booking is confirmed when reservation succeedsrule 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 cancelledrule retract booking_confirmed(req, slot) :- atom(obs, "cancel.request_id", req), atom(obs, "cancel.slot_id", slot).
-- Slot becomes available again after cancellationrule assert slot_available(slot) :- atom(obs, "cancel.slot_id", slot).Invariant Declarations
Section titled “Invariant Declarations”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 != "".Semantics
Section titled “Semantics”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 1invariant 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."Why Invariants Matter
Section titled “Why Invariants Matter”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.
Invariant Violations
Section titled “Invariant Violations”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 ruleInvariants with Multiple Conditions
Section titled “Invariants with Multiple Conditions”Invariants can combine multiple conditions to express complex constraints:
-- Every intent to send an email must have a valid recipientinvariant email_intent_has_recipient(req) :- intent.send_confirmation(req, email), email != "", booking_confirmed(req, _).
-- No patient can have contradictory diagnosesinvariant no_contradictory_diagnosis(patient) :- diagnosis(patient, d1), diagnosis(patient, d2), d1 != d2, contradicts(d1, d2), count diagnosis(patient, _) <= 10.Intent Derivation
Section titled “Intent Derivation”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).Intent Lifecycle
Section titled “Intent Lifecycle”- The evaluator derives intent tuples from the current fact state.
- Intents are durably admitted before any external execution begins.
- The shell maps each
intent.relation to a declared effect capability (e.g.,http.fetch,llm.complete). - Effects execute and produce new observations, which feed back into the pipeline.
- Idempotent effects auto-retry on failure. Non-idempotent effects require explicit reconciliation.
Declaring Intent Capabilities
Section titled “Declaring Intent Capabilities”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 Calls
Section titled “Helper Calls”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").Helper Guarantees
Section titled “Helper Guarantees”- 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.
Implementing Helpers
Section titled “Implementing Helpers”Helpers are implemented as Rhai functions in the helpers/ directory:
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.
Candidate Relations
Section titled “Candidate Relations”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 candidaterule candidate.symptom(obs, symptom, confidence) :- atom(obs, "llm_extraction.symptom", symptom), atom(obs, "llm_extraction.confidence", confidence).
-- Acceptance rule: only high-confidence extractions become factsrule symptom(patient, symptom) :- candidate.symptom(obs, symptom, conf), conf >= 0.8, atom(obs, "intake.patient_id", patient).Why Candidates Exist
Section titled “Why Candidates Exist”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.
Mandatory Rejection
Section titled “Mandatory Rejection”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 candidaterule 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).Multiple Acceptance Strategies
Section titled “Multiple Acceptance Strategies”You can write different acceptance rules for different confidence levels or contexts:
-- High-confidence: accept automaticallyrule symptom(patient, symptom) :- candidate.symptom(obs, symptom, conf), conf >= 0.9, atom(obs, "intake.patient_id", patient).
-- Medium-confidence: accept only if corroboratedrule 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 Relations
Section titled “Proposal Relations”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.
Mandatory Rejection
Section titled “Mandatory Rejection”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 atomrule 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 authorityrule 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 relationThe 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.
Rejected at Load Time
Section titled “Rejected at Load Time”The following are hard errors — the evaluator will not load rules that use them.
Recursive Aggregates
Section titled “Recursive Aggregates”An aggregate in a rule body that depends on its own head:
-- REJECTEDrule running_total(n) :- n = sum running_total(prev), prev.error[E2201]: aggregate cycle: running_total depends on aggregate over running_totalUnstratified Negation
Section titled “Unstratified Negation”Negating a relation that transitively depends on the current rule’s head:
-- REJECTEDrule a(x) :- b(x), not a(x).error[E2501]: unstratified negation cycle between a and aAmbient I/O
Section titled “Ambient I/O”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 rulesrule data(x) :- read_file("input.txt", x).error[E2004]: relation 'read_file' is not declaredDynamic Rule Loading
Section titled “Dynamic Rule Loading”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 relayrule 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 relayrule 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. relayThe 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.
Comments
Section titled “Comments”.dh uses -- for line comments, following SQL and Datalog convention:
-- This is a commentrelation 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).File Organization
Section titled “File Organization”By convention, .dh files are organized in the ontology/ directory:
ontology/ schema.dh # Relation declarations rules.dh # Derivation rules intents.dh # Intent derivation rulesAll .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"]Recommended File Split
Section titled “Recommended File Split”| File | Contains |
|---|---|
schema.dh | All relation declarations |
rules.dh | Core derivation rules and assertions/retractions |
intents.dh | All intent. derivation rules |
For larger ontologies, you can split further (e.g., candidates.dh, invariants.dh). The evaluator does not assign meaning to filenames.
Design Rationale
Section titled “Design Rationale”Why Soufflé Syntax?
Section titled “Why Soufflé Syntax?”.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.
Why No General Recursion?
Section titled “Why No General Recursion?”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.
Why Explicit Assertions and Retractions?
Section titled “Why Explicit Assertions and Retractions?”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.
Why Mandatory Candidates?
Section titled “Why Mandatory Candidates?”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.
Diagnostic codes
Section titled “Diagnostic codes”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 asW/I).X— phase:0lexer,1parser,2validator.YY— subsystem:00syntax,01relations,02aggregates,03helpers,04relay,05stratification.ZZ— sequence within the subsystem.
Examples below show a minimal .dh fragment that triggers each code.
The authoritative source is the
diagnostic inventory.
Lexer errors (E0001–E0007)
Section titled “Lexer errors (E0001–E0007)”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,\tare 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).
Parser errors (E1001–E1039)
Section titled “Parser errors (E1001–E1039)”These fire once tokens are formed but the structure violates the grammar.
- E1001 — expected a top-level statement (
relation,rule, orinvariant). Example: a stray expression before the first declaration. - E1002 — unexpected statement keyword. Example:
query foo(x).(onlyrelation,rule,invariantare allowed). - E1003 — expected a scalar type at field declaration position.
Example:
relation r(x: 42). - E1004 — unsupported scalar type. Example:
relation r(x: bigint)(onlytext,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 ben = max ...orcount 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 afteratom. Example:rule a(x) :- atom o, "p", x. - E1028 —
,missing after the observation term inatom(...). 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 inatom(...). Example:atom(obs, "p" x). - E1031 —
)missing afteratom(...). 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.
-
E2001 —
atomis 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
relationdeclaration: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). -
E2102 —
helper.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)
Validator: aggregates (E2201–E2204)
Section titled “Validator: aggregates (E2201–E2204)”-
E2201 — aggregate cycle: a rule head depends on an aggregate over itself. Example:
rule running_total(n) :-n = sum running_total(prev), prev. -
E2202 —
countdoes not take a trailing value variable. Example:n = count r(x), x. -
E2203 —
sum,min,maxrequire 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.(ymust appear inr(x)).
Validator: helpers (E2301–E2303)
Section titled “Validator: helpers (E2301–E2303)”- 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.fetchcannot be called from a rule. - E2303 — helper is not deterministic. Example: a helper that reads the wall clock cannot be called from a rule.
Validator: relay (E2401)
Section titled “Validator: relay (E2401)”-
E2401 — a relation derives from
requires_relayobservations without going through the appropriatecandidate.orproposal.relay, or an executableintent.*derives directly fromproposal.*without a domain decision relation. Example:rule symptom(patient, symptom) :-atom(obs, "llm_extraction.symptom", symptom),atom(obs, "llm_extraction.patient_id", patient).
Validator: stratification (E2501)
Section titled “Validator: stratification (E2501)”-
E2501 — unstratified negation cycle. Example:
rule a(x) :- b(x), not a(x).
Complete Worked Example
Section titled “Complete Worked Example”A medical intake system that extracts symptoms from LLM analysis, validates them, and triggers follow-up actions:
-- Schemarelation 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 observationsrule 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 candidatesrule candidate.symptom(obs, symptom, confidence) :- atom(obs, "llm_extraction.symptom", symptom), atom(obs, "llm_extraction.confidence", confidence).
-- Accept high-confidence symptomsrule symptom(pid, symptom) :- candidate.symptom(obs, symptom, conf), conf >= 0.8, atom(obs, "intake.patient_id", pid).
-- Aggregate: count symptoms per patientrule symptom_count(pid, n) :- n = count symptom(pid, _).
-- Flag patients needing followuprule needs_followup(pid) :- symptom_count(pid, n), n >= 3.
-- Normalize patient names via helperrule normalized_name(pid, norm) :- patient(pid, raw_name), norm = helper.normalize_name(raw_name).
-- Intent: schedule followup for flagged patientsrule intent.schedule_followup(pid) :- needs_followup(pid), not followup_scheduled(pid).
-- Invariantsinvariant patient_has_name(pid) :- patient(pid, name), name != "".
invariant symptom_has_patient(pid) :- symptom(pid, _), patient(pid, _).