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 risk | Warning | Aggregation across two or more fan-out joins. |
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 after joining across several one-to-many paths. The SQL runs fine. The totals are simply too high, because rows were multiplied before the aggregation ran.
-- WARNING: Potential fan-trap: 2 one-to-many joins with aggregation
SELECT c.name, SUM(o.amount), COUNT(r.id)
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN reviews r ON c.id = r.customer_id
GROUP BY c.name;
OBQC catches this in two ways. First it looks for ontology axioms such as owl:disjointWith on sibling fact tables that share a dimension. Where those axioms are missing, it falls back to relationship metadata and join direction, and counts the fan-out joins in the query itself.
Direction is what makes the check precise. Joining from a fact table to a dimension is a lookup and multiplies nothing. Joining from a dimension out to several fact tables can multiply rows. So OBQC judges fan-out per join, against the table that join's ON condition attaches to, rather than asking whether a table happens to sit 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.
When a fan-trap is flagged, OBQC suggests aggregating each fact table separately with UNION ALL, or pre-aggregating in CTEs before joining. 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.
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;fan_trap_risk, a boolean flag;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 query joins across two one-to-many relationships, which may inflate the totals").
Requirements
OBQC relies on oba: namespace annotations in the ontology. generate_ontology writes them automatically, and load_my_ontology brings them along for any OrionBelt®-generated ontology:
- 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.
Related OrionBelt® Concepts
- Governed Text-to-SQL: where OBQC sits in the generate-validate-execute loop.
- 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-trap risk when a query aggregates across two or more one-to-many joins.
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?
First through ontology axioms such as owl:disjointWith on sibling fact tables that share a dimension. Where those are absent, through relationship metadata and join direction: fan-out is judged per join, against the table that join's ON condition attaches to, so a chain of many-to-one lookups is not flagged.
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.