In Part 1, we covered how the Data Access Service gives AI agents governed access to databases through a unified client and semantic dictionary. But access alone is not enough. An agent that can read your tables but doesn't understand what "revenue" means in your business, doesn't know that cancelled orders must be excluded from financial reports, or constructs queries by concatenating SQL strings -- that agent is a liability, not an asset. Part 2 is about giving agents genuine business intelligence: an ontology that maps business concepts to data, process models that encode tribal knowledge as rules, and a structured query engine that eliminates SQL injection by construction.
Code examples are simplified for clarity. See the DAS documentation for complete API reference.
The Data Access Service Series
The Ontology Layer: Business Domains as Data
The ontology is a formal model of your business domain -- not just what tables exist, but what they mean in business terms. It bridges the gap between how humans talk about the business ("show me revenue by segment for premium buyers") and how data is actually stored in the database (SELECT SUM(total_amount) FROM orders JOIN customers USING (customer_id) WHERE customer_segment = 'Premium').
The DAS ontology uses five node types:
- Domains -- top-level business areas (sales, customer, product, marketing, finance). Each domain groups related entity types and concepts into a coherent unit that can be versioned and exported independently.
- Entity Types -- business concepts mapped to database tables. Each entity type has context clues that help resolve ambiguous natural language. For example, a "Customer" entity type has clues like
["buyer", "purchaser", "account", "client"]. When a user asks about "premium buyers," the ontology resolves this to the Customer entity with the appropriate segment filter. - Column Mappings -- links entity types to actual database columns with semantic roles (
id,name,amount,date,category,status,quantity,flag). The agent knows thatcust_seg_cdon the customers table plays the "category" role for the Customer entity -- no guessing required. - Concepts -- derived or composite business ideas with calculation rules. "Revenue" is not just a column -- it is
SUM(orders.total_amount) WHERE order_status IN ('shipped', 'delivered'). The agent does not guess how to calculate revenue; it looks up the concept definition. - Relationships -- connections between entity types with cardinality and join hints.
Customer places Order (1:N)with join conditioncustomers.customer_id = orders.customer_id. The agent does not discover foreign keys by trial and error.
Here is how entity resolution works in practice:
// When a user says "premium buyers in California," the agent
// uses the ontology to resolve each term to data constructs.
const context = await dasClient.getOntologyContext({
connection: 'firekicks',
domain: 'customer',
});
// context.entityTypes includes Customer with:
// contextClues: ["buyer", "purchaser", "account", "client"]
// columns: [
// { column: "cust_seg_cd", role: "category", businessName: "Customer Segment" },
// { column: "cust_st", role: "category", businessName: "Customer State" },
// ]
// The agent resolves "premium buyers" -> Customer entity, cust_seg_cd = 'Premium'
// And "California" -> cust_st = 'CA'
Why this matters: Without an ontology, every agent must be taught individually what your business terms mean. With an ontology, the knowledge lives in the platform. Deploy a new agent and it immediately understands your domain vocabulary.
Business Process Models: Encoding Tribal Knowledge
Raw schemas tell you what data exists. Ontologies tell you what it means. But neither tells you how the business actually works. That tribal knowledge -- the rules everyone follows but nobody documented -- is what process models capture.
Process models have five node types:
- Processes -- named business workflows with timing, actors, and lifecycle. For example, "Order Fulfillment" involves the order system, warehouse, shipping carrier, and customer, and takes approximately 14 days end to end.
- Steps -- ordered stages within a process, each with data touchpoints that document which tables and columns it reads and writes. Step 2 "Payment Processed" reads
total_amountandpayment_method, writesorder_statusandpayment_date. This creates a data lineage map the agent can trace. - Business Rules -- constraints that govern query generation, with three enforcement levels:
- Hard-enforced -- always applied, cannot be overridden. "Never include cancelled orders in revenue calculations."
- Soft-enforced -- applied by default, but the agent can override with justification. "Exclude pending orders from delivery reports."
- Advisory -- suggestions only. "Consider filtering by fiscal quarter rather than calendar quarter."
- Annotations -- context-triggered tribal knowledge. When a query touches campaign data, the annotation fires: "Campaign ROI is unreliable during and shortly after campaign end dates."
- Calendar Context -- fiscal year definitions. Q4 is not always October through December; the calendar context tells the agent your company's fiscal boundaries.
Here is a before-and-after example showing how process models improve query correctness:
// WITHOUT process models -- the agent's naive query:
// SELECT SUM(total_amount) FROM orders
// WHERE order_date BETWEEN '2025-10-01' AND '2025-12-31'
//
// Problems:
// - Includes cancelled orders (hard rule violation)
// - Includes pending orders (soft rule default)
// - Assumes calendar Q4, but company fiscal Q4 might differ
// WITH process models -- the agent consults rules first:
const rules = await dasClient.getBusinessRules({
connection: 'firekicks',
table: 'orders',
});
const calendar = await dasClient.getCalendarContext({
connection: 'firekicks',
domain: 'finance',
});
// rules includes:
// { name: "exclude_cancelled_from_revenue",
// enforcement: "hard_enforced",
// conditions: [{ column: "order_status", operator: "not_in",
// values: ["cancelled"] }] }
// calendar includes:
// { fiscalYearStartMonth: 1,
// quarterMapping: { Q4: { startMonth: 10, endMonth: 12 } } }
// The agent now builds the correct query:
// SELECT SUM(total_amount) FROM orders
// WHERE order_date BETWEEN '2025-10-01' AND '2025-12-31'
// AND order_status NOT IN ('cancelled')
// AND order_status IN ('shipped', 'delivered')
The business rules prevented the agent from including cancelled and pending orders. The calendar context confirmed the correct date range. Without process models, the agent would have returned a number that includes refunded orders and shipments still in transit -- a number that no analyst at your company would trust.
AST Queries: A Client-Side Programming Model for Query Construction
DAS will happily accept plain SQL. It parses your SQL into an internal AST (Abstract Syntax Tree), applies transformations -- view expansion, business rule injection, security predicates -- and serializes the result to the target database dialect. You can send SQL and never think about the AST at all.
But the AST API exists because you can do all of this application-side too. And that changes everything for agentic workflows.
When an AI agent generates a raw SQL string, you get a string. You can execute it or not. That is the extent of your control. But when the agent generates an AST -- a structured JSON object representing the query's semantics -- you can programmatically inspect it, validate it, transform it, compose it with other AST fragments, and serialize it back to SQL for human review. The AST is not just a transport format for DAS. It is a client-side programming model for compositional query construction.
The AST Format
An AST query is a JSON object that mirrors SQL structure without being SQL:
{
"columns": [
{ "expr": { "column": { "table": "p", "column": "category" } } },
{ "expr": { "function": { "name": "sum", "args": [
{ "column": { "table": "oi", "column": "line_total" } }
] } }, "alias": "revenue" }
],
"from": { "table": { "table": "products", "alias": "p" } },
"joins": [{
"type": "JOIN_INNER",
"table": { "table": "order_items", "alias": "oi" },
"on": { "binary": {
"op": "BINARY_OP_EQ",
"left": { "column": { "table": "p", "column": "product_id" } },
"right": { "column": { "table": "oi", "column": "product_id" } }
}}
}],
"where": { "binary": {
"op": "BINARY_OP_NOT_IN",
"left": { "column": { "table": "oi", "column": "order_status" } },
"right": { "list": {
"items": [{ "literal": { "stringValue": "cancelled" } }]
}}
}},
"groupBy": [{ "column": { "table": "p", "column": "category" } }],
"orderBy": [{ "expr": { "column": { "alias": "revenue" } }, "desc": true }]
}
When DAS receives this, it validates the structure, checks access control on every referenced table and column, expands any stored view definitions, applies hard-enforced business rules, and serializes to the target dialect. SQL injection is impossible by construction -- every value is a typed literal node, serialized as a bind parameter.
The Full Round-Trip: SQL → AST → Transform → SQL
The real power emerges when you work with the AST client-side. FireFoundry's AST utilities library provides a complete round-trip:
- Parse: An LLM generates plain SQL (which LLMs are already good at). Parse it to a typed AST using the client-side parser.
- Transform: Apply programmatic transformations -- add WHERE clauses, inject CTEs, enforce business rules -- using composition utilities.
- Validate: Run visitor-validators against the AST to verify structural and semantic correctness.
- Serialize: Convert back to SQL so the AI (or a human) can review the final query in a readable format.
- Accept or iterate: If the review passes, send the AST to DAS for execution. If not, feed the issues back to the LLM and repeat.
// 1. LLM generates SQL
const rawSQL = await llm.generate('Revenue by category for Q4');
// 2. Parse to AST (client-side, no DAS round-trip needed)
const ast = parseSQL(rawSQL);
// 3. Apply programmatic transforms
const withRules = addWhere(ast, and(
notIn(col('order_status'), [lit('cancelled')]),
between(col('order_date'), lit('2025-10-01'), lit('2025-12-31')),
));
// 4. Validate
const result = validate(withRules,
requireDateFilter({ column: 'order_date' }),
denyColumns(['ssn', 'credit_card']),
);
// 5. Serialize back to SQL for review
const finalSQL = serializeSQL(withRules, { dialect: 'postgresql' });
// 6. Send validated AST to DAS
const data = await dasClient.queryAST('firekicks', { select: withRules });
Multi-LLM Query Assembly
Because the AST is composable data, different LLM calls can produce different parts of a query. This is the canonical pattern for complex analytical queries where no single prompt reliably produces the correct result:
- LLM call 1: Given the user's question and the ontology, generate the core SELECT columns and FROM/JOIN structure.
- LLM call 2: Given the user's question and the calendar context, generate the date range filter as an AST expression.
- LLM call 3: Given the user's question and the NER resolution results, generate the entity filter (e.g.,
brand_id = 42). - Orchestrator: Compose the sub-trees using
addWhere,addCTE, and other composition utilities. Each piece is validated independently, then the assembled query is validated as a whole.
Each LLM call receives a focused, constrained prompt with only the relevant schema. A smaller, more focused prompt produces more accurate output than a single prompt trying to handle date logic, entity resolution, and query structure simultaneously.
The Visitor-Validator Pattern
Here is where it gets interesting. Instead of just validating an AST against static rules, you can have one LLM generate a validator that another LLM's query output must pass.
A visitor-validator walks the AST tree and checks whether specific constraints are satisfied. The pattern works like this:
- LLM A analyzes the user's input and identifies the time periods referenced. It generates a visitor-validator that walks the AST and ensures the correct date range is enforced in the WHERE clause.
- LLM B resolves named entities from the user's input (via NER). It generates a visitor-validator that ensures the query filters by the resolved entity values.
- LLM C generates the actual query AST -- the SELECT, FROM, JOINs, grouping, and aggregation.
- Orchestrator runs LLM A's and LLM B's validators against LLM C's query. If validation fails, the specific errors ("missing date filter on order_date", "no filter for brand_id = 42") are fed back to LLM C for correction.
// LLM A: Analyzes user input, produces a time-period validator
const timeValidator = requireDateFilter({
column: 'order_date',
range: { after: '2025-10-01', before: '2025-12-31' },
});
// LLM B: Resolves entities via NER, produces an entity validator
const entityValidator = requireFilter({
column: 'brand_id',
values: [42], // NER resolved "Nike" -> brand_id 42
});
// LLM C: Generates the query AST
const queryAST = await llmC.generateAST(userPrompt, schema);
// Orchestrator: Validate C's output against A's and B's constraints
const result = validate(queryAST, timeValidator, entityValidator);
if (!result.valid) {
// Feed specific errors back to LLM C for correction
const corrected = await llmC.fixQuery(queryAST, result.errors);
}
This separation of concerns -- one LLM for temporal reasoning, one for entity resolution, one for query structure -- produces more reliable results than asking a single LLM to handle everything. And because every piece is an inspectable data structure, you can log, audit, and debug every step of the pipeline.
What DAS Does Server-Side
Whether you build the AST client-side or send SQL and let DAS parse it, the server-side processing is the same:
- Validates structure -- depth limits, node counts, required fields.
- Checks ACL -- walks the AST to find every table and column reference, verifies the caller has access.
- Expands views -- stored definitions are transparently injected as subqueries.
- Applies business rules -- hard-enforced WHERE clauses from the process model.
- Serializes to the target dialect -- PostgreSQL, MySQL, Snowflake, SQL Server, Oracle, SQLite, or Databricks.
The translate-ast endpoint lets you preview the generated SQL without executing -- useful for the review step in the round-trip pattern:
const preview = await dasClient.translateAST({
connection: 'firekicks',
select: validatedAST,
});
// preview.sql: "SELECT p.\"category\", SUM(oi.\"line_total\") AS \"revenue\" ..."
// preview.dialect: "postgresql"
Putting It All Together
Let us walk through a complete example using all three capabilities. A user asks: "Compare Q4 revenue by product category for premium customers vs. all customers."
Step 1: Resolve business concepts. The agent calls getOntologyContext(domain: 'sales') and learns the Revenue concept definition (SUM(orders.total_amount) WHERE order_status IN ('shipped', 'delivered')), the Customer entity with its segment column, and the Product entity with its category column.
Step 2: Get business rules and calendar. The agent calls getBusinessRules(table: 'orders') and getCalendarContext(domain: 'finance'). It now knows: exclude cancelled orders (hard-enforced), and Q4 = October 1 through December 31.
Step 3: Generate and compose. The agent makes focused LLM calls: one to produce the core query structure (SELECT with aggregation, JOIN to customers), one to produce the date filter from the calendar context, one to produce the segment filter from the ontology resolution. Each call receives only the context it needs. The orchestrator assembles the sub-trees using composition utilities and applies the hard-enforced business rules.
Step 4: Validate. Visitor-validators check that the assembled AST includes a date filter on order_date, excludes cancelled orders, and does not reference unauthorized columns. Any failures produce specific, actionable error messages.
Step 5: Review and execute. The AST is serialized back to SQL. The agent inspects the final query, confirms it matches the user's intent, and sends the validated AST to DAS for execution. DAS applies server-side checks (ACL, view expansion) and runs the query.
Step 6: Return results. The agent returns the comparison table with an explanation of its methodology: which concept definition it used for revenue, which business rules it applied, and what fiscal calendar determined the date range. Every step is logged and auditable.
The ontology tells the agent what to query. The process model tells it what rules to follow. The AST workbench gives it -- and you -- full programmatic control over how the query is built, validated, and reviewed. No black-box SQL generation. No hoping the LLM got it right. Every piece is inspectable, composable, and verifiable.