Definition
OBQC stands for Ontology-Based Query Check. It is the deterministic safety layer in OrionBelt® Analytics (OBA), and it validates SQL against the loaded RDF/OWL ontology before the query is allowed to reach the database.
There is no LLM inside OBQC and no probabilistic reasoning. It parses the SQL with sqlglot, compares what the query actually does against the ontology's oba: annotations (tables, columns, SQL types, primary and foreign keys, join conditions, relationship direction), and hands back structured errors or warnings the calling assistant can act on.
In one sentence: OBQC gives LLM-generated SQL a hard structural check against the ontology. Errors block execution before the query reaches the database, warnings travel back with the result.
The Problem: Plausible SQL That Is Still Wrong
An LLM will happily write SQL that reads perfectly and computes the wrong thing. The same handful of failures keep coming back, and all of them are structural:
- Misspelled or hallucinated identifiers. A column that does not exist in that table, or a table that does not exist at all.
- Joins that ignore the foreign keys. Plausible-looking
ONconditions that pair up the wrong columns. - Missing
GROUP BYcolumns. An aggregation the database rejects, or worse, silently accepts with an arbitrary value. - Analytical fan-traps. The query runs, returns a number, and the number is inflated because rows multiplied before aggregation.
The first three fail loudly sooner or later. The last one never does. It gives you a confident, wrong answer that looks exactly like a correct one, and that is where a deterministic check earns its keep, because a language model cannot help you here.
How OBQC Works
OBQC runs inside the session, against the ontology OBA generated or loaded for the connected database:
- OBA connects to a database and discovers the schema.
generate_ontology()orload_my_ontology()creates or loads an ontology carryingoba:annotations for tables, columns, SQL types, primary keys, foreign keys, join conditions, and relationship direction.- OBA creates a session-local validator from that ontology.
- When
execute_sql_query()receives SQL, OBQC parses it with sqlglot. - OBQC extracts the referenced tables, columns, joins, aggregations, aliases, and join anchors.
- Those parts are validated against the ontology cache.
- If there are no blocking errors, OBA executes the query and attaches any warnings to the response.
connect_database()
-> discover_schema()
-> generate_ontology()
-> execute_sql_query()
-> OBQC parses SQL
-> OBQC checks ontology rules
-> errors block, warnings attach
-> database execution only if valid
execute_sql_query() is the only entry point that runs OBQC. If OBQC returns an error, OBA answers with an obqc_error response instead of executing anything. The database never sees the query.
Severity: What Blocks and What Warns
| Severity | Effect on execution |
|---|---|
| Error | Query is blocked and never sent to the database. The response carries "success": false and the issue details. |
| Warning | Query executes normally. Warnings are attached to the response so the assistant can self-correct or tell the user. |
| Info | Informational note, no effect on execution. |
The overall is_valid flag follows one rule: any ERROR-level issue makes the result invalid.
What OBQC Checks
| Check | Severity | Purpose |
|---|---|---|
| Table existence | Error | Every table in FROM and JOIN must exist in the ontology. |
| Column existence | Error | Qualified and unqualified columns must resolve to real columns. |
| Ambiguous column | Warning | An unqualified name that exists in more than one referenced table. |
| Missing join condition | Error | Multiple tables in one SELECT with no JOIN ... ON, which is a Cartesian product. |
| Non-matching join | Warning | A join condition that matches no declared foreign-key relationship. |
| Type compatibility | Warning | WHERE and ON comparisons should use compatible types. |
| Aggregation correctness | Error | Non-aggregated selected columns must appear in GROUP BY. |
| Fan-trap | Error | A measure aggregated across a one-to-many join, disjoint sibling facts, or two or more fan-out joins in one aggregating SELECT. |
| Conditional row count | Warning | A conditional count over a table the join repeats; the SQL does not say which count was meant. |
Table and column existence
When a table is not found, OBQC lists the table names the ontology does know about, so the assistant can correct the name instead of guessing again.
-- ERROR: Table 'cusotmers' not found in ontology
SELECT * FROM cusotmers;
-- ERROR: Column 'emial' not found in table 'customers'
SELECT emial FROM customers;
Qualified references (table.column) are checked against that specific table. Unqualified references resolve within their own scope: the SELECT they appear in, plus any enclosing ones, so a correlated subquery can still reach the outer query's tables. It does not work the other way round. A subquery's tables never resolve names in the outer query.
Join validity
-- ERROR: Multiple tables without explicit JOIN (Cartesian product)
SELECT * FROM customers, orders;
-- WARNING: JOIN condition may not match declared FK relationship
-- Suggested: orders.customer_id = customers.id
SELECT * FROM customers JOIN orders ON customers.name = orders.note;
The Cartesian-product rule is judged per SELECT. That makes SELECT id FROM customers WHERE id IN (SELECT customer_id FROM orders) perfectly fine, since each scope only ever sees one table.
Aggregation correctness
-- ERROR: Column 'name' in SELECT with aggregation but no GROUP BY
SELECT name, COUNT(*) FROM customers;
-- ERROR: Column 'status' not in GROUP BY clause
SELECT status, region, SUM(amount) FROM orders GROUP BY region;
Grouping by an alias satisfies the check on the column behind it. The rule is again evaluated per SELECT, so an aggregate inside a subquery belongs to that subquery and nowhere else.
Fan-Trap Protection
A fan-trap happens when a query aggregates a measure after a join has repeated its rows. The SQL runs fine. The total is simply too high, because each row was counted once per matching row on the other side of the join. A wrong answer is worse than no answer, so a provable fan-trap blocks execution.
One join is enough. If the ontology puts shipments on the many side of sales, summing a sales amount across that join counts each sale once per shipment:
-- ERROR: Fan-trap: aggregating 'sales' across the join to 'shipments',
-- which the ontology puts on the many side of 'sales'.
SELECT SUM(s.amount)
FROM sales s
JOIN shipments sh ON sh.sale_id = s.id;
Which table sits in FROM makes no difference, because FROM shipments JOIN sales produces the same rows. A measure taken from the many side is fine, since the repeated rows are its own: SUM(sh.cost) per sale counts each shipment once.
OBQC reports four kinds of finding, strongest first:
| Finding | Severity | What it means |
|---|---|---|
measure_across_fan_out | Error | A measure read across a join that repeats its rows. |
disjoint_facts | Error | Two sibling fact tables the ontology declares owl:disjointWith, at different grains, sharing a dimension. |
multiple_fan_out_joins | Error | Two or more fan-out joins in one aggregating SELECT, the fallback for ontologies without disjointness axioms. |
conditional_row_count | Warning | A conditional count such as SUM(CASE WHEN o.total > 100 THEN 1 ELSE 0 END) over a table the join repeats. Reported, never blocked: the same shape is a correct idiom in a star join, and the SQL does not say which count was meant. |
Aggregates that repeated rows cannot change are left alone. MIN, MAX and COUNT(DISTINCT ...) give the same answer however many times a row appears. COUNT(*) is a middle case: it names no table, so a single join never triggers the measure rule, but across two fan-out joins it returns the product of both children and the other rules still apply. Within an aggregate, only the columns that produce its value count as measures; a column tested in a CASE condition only filters.
Direction is what keeps the check precise. OBQC judges fan-out per join, against the table that join's ON condition attaches to, rather than asking whether a table sits on the many side of some relationship elsewhere in the schema. Walking a chain of many-to-one lookups (sales to clients to countries) is never flagged, however many foreign keys those dimensions carry.
The fix OBQC points to is to pre-aggregate the fanning table in a CTE and join the one row per key that produces, or to aggregate each fact separately and combine with UNION ALL. That is the same correction the Composite Fact Layer (CFL) applies automatically in the OrionBelt® Semantic Layer, where such queries come out correct by construction rather than being checked after the fact.
The structured verdict
Every execute_sql_query response carries obqc_fan_trap, whatever the outcome, so an agent or an automated flow can act on it without reading prose:
{
"evaluated": true,
"detected": true,
"blocking": true,
"findings": [
{
"kind": "measure_across_fan_out",
"measure_table": "sales",
"fan_out_table": "shipments",
"tables": ["sales", "shipments"]
}
]
}
A clean query reports detected: false, so "no fan-trap" is an answer you can read. evaluated: false means the rules never ran: no ontology loaded, an ontology without oba: annotations, or a request that failed before validation. Treat it as unknown, not as clean. A query can run and return inflated numbers with detected: false when there was nothing to check it against.
Running one anyway, with the user's consent
Sometimes the multiplied rows are what you want, or a relationship is one-to-one in practice despite its declared cardinality. The model can then pass allow_fan_out: true, which downgrades the finding to a warning and runs the query. The finding is still reported, and blocking becomes false.
The override is the model's to request, but the decision is the user's. When it actually downgrades a blocking finding, a client that supports elicitation asks the user first, in plain words and naming the tables, whether to accept inflated totals. A no, or a dismissed question, withdraws the override: the query fails with the normal blocking verdict, and the model is told to restructure the query rather than try again. A yes is recorded in the warning as accepted by the user. What OBQC detects is the same either way.
What OBQC Deliberately Does Not Flag
Three things sit outside the rules, because treating them as violations blocked SQL that was perfectly correct:
- Database catalog schemas are exempt from the table rule.
information_schema,pg_catalog,system,performance_schema,snowflakeandsysdescribe the database itself, so they are never part of an ontology of user data. MySQL'smysqlschema is deliberately not exempt: it holds accounts and grants rather than metadata, and OBA's security layer blocks those tables outright. SELECTaliases are not columns.ORDER BY revenueoverSUM(total) AS revenueresolves to the select list. Where an alias may be referenced varies by database, so OBQC follows each dialect: PostgreSQL accepts one inGROUP BYandORDER BYbut not inHAVING, while DuckDB resolves aliases in every clause,WHEREincluded.- Rules apply per
SELECT. Tables, columns, and aggregation in a subquery belong to that subquery. A name in one scope is not resolved against another. The one exception is a correlated subquery, which may still see its enclosing query's tables.
A validator that cries wolf gets switched off within a week. These exemptions exist so that every issue OBQC does report is worth reading.
How the AI Agent Uses OBQC
OBQC results come back as structured data in the tool response. They are not rendered for the user. The calling LLM receives:
obqc_valid, the overall pass/fail;obqc_issues, each with type, severity, message, location, and suggestion;obqc_fan_trap, the fan-trap verdict as data:evaluated,detected,blockingandfindings;obqc_error_countandobqc_warning_count, the summary counts.
When a query is blocked, the agent gets the error details and the suggestions, revises the SQL and tries again, usually without the user ever seeing the failed attempt. When warnings come back alongside a successful result, it is up to the agent whether to surface them ("this count may be counting order lines rather than orders").
Requirements
OBQC relies on oba: namespace annotations in the ontology. generate_ontology writes them automatically, and an ontology you edit or author yourself keeps working as long as it carries them:
- Per OWL Class (table):
oba:tableName,oba:schemaName. - Per DatatypeProperty (column):
oba:columnName,oba:tableName,oba:sqlDataType,oba:isPrimaryKey,oba:isForeignKey, and anrdfs:rangeXSD type for the type-compatibility checks. - Per ObjectProperty (relationship):
oba:foreignKeyColumn,oba:referencedTable,oba:referencedColumn,oba:relationshipType(many_to_oneorone_to_many), andoba:sqlJoinCondition.
If a loaded ontology lacks these annotations, a generic OWL ontology for instance, OBQC reports ontology_compatible: false and falls back to basic syntax checking only. The full annotation vocabulary is published at the oba: namespace.
An ontology uploaded with load_my_ontology has to pass an activation check before OBQC uses it: it must map tables, columns and joins with these annotations. One that does not is still loaded for SPARQL, the previously active ontology stays in force, and the response lists what is missing. An activated upload applies to the session that loaded it only. Join discovery without declared keys shows where an uploaded ontology fits in.
Related OrionBelt® Concepts
- OrionBelt® Analytics docs: the server OBQC runs in, its tools and how to deploy it.
- Governed Text-to-SQL: where OBQC sits in the generate-validate-execute loop.
- GraphRAG schema discovery: how the model is handed the right tables and join paths before it writes the SQL that OBQC checks.
- Join discovery without declared keys: inferred joins, your own ontology, and informational constraints, all of which OBQC can check against.
- Composite Fact Layer (CFL): the Semantic Layer's answer to the same fan-trap problem, with multi-fact queries decomposed and recomposed so they come out correct by construction.
- Agentic AI Data Access: why agents need deterministic guardrails around a probabilistic generator.
- Glossary: all coined terms in one place.
Frequently Asked Questions
What does OBQC stand for?
Ontology-Based Query Check. It is the deterministic safety layer in OrionBelt® Analytics (OBA), and it checks SQL against the loaded RDF/OWL ontology before the query reaches the database.
Does OBQC use an LLM?
No. OBQC makes no LLM calls and does no probabilistic reasoning. It parses the SQL with sqlglot, compares the structure against the ontology, and returns structured errors and warnings. The guarantees it gives are structural, which is exactly what LLM-generated SQL lacks.
What does OBQC check?
Table existence, column existence, ambiguous unqualified columns, join validity (missing join conditions and joins that do not match a declared foreign key), type compatibility in WHERE and ON comparisons, aggregation correctness, and fan-traps: a measure aggregated across a one-to-many join, sibling facts declared disjoint, or two or more fan-out joins in one aggregating query. Fan-traps block execution.
What is the difference between an error and a warning?
An error blocks execution: the query is never sent to the database and the tool returns the issue details instead. A warning lets the query run and is attached to the response, so the agent can revise it or explain the caveat. Info issues are informational only.
How does OBQC detect a fan-trap?
The ontology says which side of each join is the many side. One join is enough: summing sales while joined to shipments is blocked. OBQC also blocks sibling fact tables declared owl:disjointWith, and two or more fan-out joins in one aggregating SELECT. Fan-out is judged per join, so a chain of many-to-one lookups is never flagged, and MIN, MAX and COUNT(DISTINCT ...) are left alone.
Can a fan-trap block be overridden?
Yes, with allow_fan_out: true, when the multiplied rows are intended. Where the client supports it, the user is asked first, naming the tables, whether to accept inflated totals. Declining withdraws the override and the query stays blocked.
What does OBQC need in order to run?
An ontology carrying oba: namespace annotations, produced by generate_ontology or loaded with load_my_ontology. Without one, or with a generic OWL ontology lacking those annotations, OBQC reports ontology_compatible: false and falls back to basic syntax checking only. An uploaded ontology must pass an activation check before OBQC uses it.