▲ Gen AI layer

how-to-agentic-on-your-warehouse

Every text-to-SQL demo shows an agent writing SQL that runs. This one shows the harder truth: on a correctly modeled warehouse the agent's SQL runs perfectly and the number is still wrong, because the traps are semantic - grain, status, label, entity. So the agent stays thin and the gate gets thick. Five deterministic checks, a signed-off metric registry, and a real captured run where the first answer to "what was our revenue in November" is 809% too big and gets rejected.

raw dump→lake→warehouse→marts→scorecard→agent
Case: Everrest · B2B2C retail platform seed = 42 · temperature 0 DuckDB read-only · Anthropic SDK or Ollama 5-check gate · 3 attempts max real captured transcript
01
Step 1

Input - a warehouse with nothing wrong with it

The agent sits at the top of the lineage, on the stage-2 star schema built by how-to-schema-and-warehouse. The orphans are resolved, the categories are normalized, the timezone bug is fixed. Nothing here is dirty. That is the point: this page is not about bad data.

fact_orders 107,841 rows
  • grain: one ORDER LINE · 50,000 orders
  • order_id / txn_id · line vs order key
  • status · delivered, shipped, paid, cancelled
  • net_amount · merchandise value
  • order_ts / date_key · timestamp + FK
dim_merchant 400 rows
  • merchant_id · conformed key
  • category · normalized to 8
  • tier · standard / premium / enterprise
dim_customer 18,226 rows
  • customer_id · deduplicated identity
  • channel / region
  • accounts, not buyers
dim_product 5,000 rows
  • product_id · SKU key
  • catalog_price · point-in-time aware
dim_date 365 rows
  • date_key · calendar spine
  • month, dow, is_weekend
What the schema cannot tell the model. A DDL lists columns. It does not say that finance recognizes one status, that net_amount is merchandise value and not revenue, that the fact is one row per line, or that a registered account is not an active buyer. Those four facts live in people's heads - and every one of them turns a query that runs into a number that lies.
Semantic trapWhat the naive SQL doesSize of the error, measured on this warehouse
GrainCOUNT(*) or COUNT(order_id) on fact_orders2.16x too many orders (84,375 delivered lines vs 39,045 delivered orders)
Statusno WHERE status = 'delivered'$6,779,354 instead of $5,250,255 · 29% inflation
LabelSUM(net_amount) answered as "revenue"$5,250,255 GMV vs $577,528 platform revenue · 9.1x
EntityCOUNT(*) FROM dim_customer18,226 accounts vs 7,767 buyers who actually bought in 2026Q2
02
Step 2

Sample data - the registry is the data that was missing

No new generator this time. The agent reads the same seeded Everrest warehouse (seed 42) that every layer below it produced. What gets built here is the artifact that closes the semantic gap: a metric registry, where each metric carries an owner, a grain, a prose definition, the trap it exists to prevent, and a control_sql that is the trusted answer. The control SQL is never shown to the model - it is the answer key the gate marks against.

metric_registry.yaml · one of five metrics
- name: platform_net_revenue
  window: >-
    order_ts from 2025-11-01 inclusive to 2025-12-01 exclusive (November 2025).
  owner: Finance
  grain: order_line
  unit: USD
  definition: >-
    SUM(net_amount) over DELIVERED order lines, multiplied by the 11 percent
    take rate. This is what Everrest earns. It is NOT the same as GMV.
  requires_status_filter: true
  trap: >-
    net_amount is merchandise value. Reporting it as revenue overstates the
    P&L line by about 9x, which is the single most expensive mislabel on this
    platform.
  control_sql: |
    SELECT ROUND(SUM(net_amount) * 0.11, 2) AS platform_net_revenue
    FROM fact_orders
    WHERE status = 'delivered'
      AND order_ts >= DATE '2025-11-01'
      AND order_ts < DATE '2025-12-01'

The 11% take rate is an assumption, not a data field - carried over from how-to-metric-to-scorecard, where the board metrics were defined and validated. The scorecard proved the definitions; the agent is held to them. That is the whole reason the registry can be trusted as an answer key.

03
Step 3

Objective - a trust question, not a technique question

Can an analyst ask the warehouse a question in plain English and put the answer straight into the board pack - and if not, does the system say so out loud?
  • Q1 "What was our revenue in November 2025?" - the label trap. GMV is not revenue.
  • Q2 "How many orders did we take in November 2025?" - the grain trap. Lines are not orders.
  • Q3 "What was the average order value between 1 April and 30 June 2026?" - per order, not per line, and an inclusive end date silently drops a day.
  • Q4 "How many buyers actually bought in that window?" - accounts are not buyers.
  • Q5 "What was the delivered GMV in November 2025?" - the easy one, to prove the gate is not just rejecting everything.
  • Q6 "Which category grew the most from 2026Q1 to 2026Q2?" - deliberately unregistered, to show the ceiling of the system rather than hide it.

Five of the six have a signed-off control. The sixth does not, and that is not an oversight - most questions people ask an agent will never have one. If the system presents a controlled answer and an uncontrolled answer the same way, it is lying by omission.

04
Step 4

Find-skills - what the agent is actually made of

Almost none of the effort here goes into the model. It goes into the surface the model is allowed to touch and the checks its output has to survive.

Execution surface

DuckDB (read-only)

The warehouse opened read_only=True, plus a static guard, so the blast radius of a generated statement is zero.

Semantic layer

metric_registry.yaml

Owner, grain, definition, mandatory filters and the control query. Plain YAML a finance lead can read and sign.

Model

Anthropic SDK · Ollama fallback

Same code path either way. The published transcript was captured on the local model so anyone can reproduce it with no API key.

Upstream skill

how-to-schema-and-warehouse

Builds the star schema the agent queries. Open the walkthrough.

Upstream skill

how-to-metric-to-scorecard

Where these metric definitions were argued out and validated. Open the walkthrough.

Guardrails

Deterministic checks

Regex and SQL, no LLM. A validator that can be talked out of a rejection is not a validator.

05
Step 5

Build - thin agent, thick gate

Two phases per question. The model proposes SQL; the harness executes it and runs five checks; if anything fails, the specific failure goes back as the next user turn. Only when a query passes is the model shown the real result rows and asked for one sentence. The model never does arithmetic and never states a number it did not read from a result row - that removes a whole class of hallucination by construction rather than by prompt.

Question plain English Model schema in context DuckDB read-only execution Validation gate G1 read-only · G2 executes G3 grain · G4 status policy G5 reconciliation no LLM in here Answer + badge VERIFIED / CHECKED metric_registry.yaml owner · grain · definition · window · control_sql (never shown to the model) rejection + the specific reason, up to 3 attempts

Where the trust comes from: the registry feeds the gate, not the prompt. The model is grounded on the DDL alone - real column names and row counts, no hints about the traps - so the failures below are real failures, not staged ones.

The five checks

CheckWhat it doesWhy it is not the model's job
G1 read-onlyRejects any statement containing a write, attach, install or load keyword.The connection is read-only too. A refusal is an auditable event; a crash is a stack trace.
G2 executesRuns the SQL, captures the real rows, hands DuckDB's own error back on failure.The database is a better SQL critic than any prompt.
G3 grainA per-order metric answered with COUNT(*) or COUNT(order_id) is rejected.Grain is a modelling fact, so it is enforced, not requested.
G4 status policyA recognized-revenue metric with no status filter is rejected.Accounting policy is not a preference the model gets to weigh.
G5 reconciliationCompares the number to control_sql; outside 0.5% it rejects and hands back both numbers, the definition and the period.This is the only check that can catch a query that is correct SQL and the wrong question.
agent.py · G5, the check that does the real work
res.control = float(con.execute(metric["control_sql"]).fetchone()[0])
res.value = _first_number(res.rows)
...
denom = abs(res.control) if res.control else 1.0
res.delta_pct = (res.value - res.control) / denom * 100
if abs(res.delta_pct) > tolerance_pct:
    res.checks.append(Check("G5 reconciliation", False,
        f"{res.value:,.2f} vs control {res.control:,.2f} ({res.delta_pct:+.1f}%)"))
    res.feedback = (
        f"Rejected on reconciliation. Your query returned {res.value:,.2f}. "
        f"The signed-off definition of `{metric['name']}` returns "
        f"{res.control:,.2f}, a gap of {res.delta_pct:+.1f}%. "
        f"Definition: {metric['definition'].strip()} "
        f"Period: {metric['window'].strip()} "
        "Rewrite the SQL to match that definition and that period exactly.")
    return res

The captured run

Model ollama/qwen2.5-coder:7b, temperature 0, seed 42, run on 2026-08-15 against the seeded warehouse. Six questions, at most three attempts each. Every line below is copied from transcript.json, including the SQL the gate threw away.

Q1 · "What was our revenue in November 2025?"metric: platform_net_revenue · 3 attempts · final VERIFIED
Attempt 1BLOCKED
SELECT SUM(net_amount) AS total_revenue
FROM fact_orders JOIN dim_date ON fact_orders.date_key = dim_date.date_key
WHERE dim_date.year = 2025 AND dim_date.month = 11
  • G1 read-only · SELECT only
  • G2 executes · 1 row
  • G3 grain · metric grain = order_line
  • G4 status policy · no status filter
Gate: Rejected on policy. Finance recognizes delivered orders only. Add WHERE status = 'delivered'.
Attempt 2BLOCKED
SELECT SUM(net_amount) AS total_revenue
FROM fact_orders JOIN dim_date ON fact_orders.date_key = dim_date.date_key
WHERE dim_date.year = 2025 AND dim_date.month = 11 AND status = 'delivered'
  • G1-G4 · all pass
  • G5 reconciliation · 747,224.77 vs control 82,194.72 (+809.1%)
Gate: Rejected on reconciliation. Your query returned 747,224.77. The signed-off definition of platform_net_revenue returns 82,194.72, a gap of +809.1%. Definition: SUM(net_amount) over DELIVERED order lines, multiplied by the 11 percent take rate. This is what Everrest earns. It is NOT the same as GMV.
Attempt 3VERIFIED
SELECT SUM(net_amount * 0.11) AS platform_net_revenue
FROM fact_orders JOIN dim_date ON fact_orders.date_key = dim_date.date_key
WHERE dim_date.year = 2025 AND dim_date.month = 11 AND status = 'delivered'
  AND order_ts >= '2025-11-01' AND order_ts < '2025-12-01'
  • G5 reconciliation · 82,194.72 matches control
Answer: Our revenue in November 2025 was $82,194.72.

This is the failure the whole page exists for. Attempt 2 is flawless SQL. It runs, it filters status, it hits the right month, and it is off by three quarters of a million dollars because "revenue" and net_amount are different things. No syntax check, no schema check and no amount of prompt engineering catches that. Only a reconciliation against a definition somebody signed does.

Q2 · "How many orders did we take in November 2025?"metric: order_count · 3 attempts · final VERIFIED
Attempt 1BLOCKED
SELECT COUNT(order_id) AS order_count
FROM fact_orders JOIN dim_date ON fact_orders.date_key = dim_date.date_key
WHERE dim_date.year = 2025 AND dim_date.month = 11
  • G3 grain · COUNT(order_id on a per-order metric
Gate: Rejected on grain. fact_orders holds one row per ORDER LINE, so COUNT(order_id) counts line items, not orders. This metric is defined per ORDER - use COUNT(DISTINCT order_id).
Attempt 2BLOCKED
SELECT COUNT(DISTINCT order_id) AS order_count ... WHERE dim_date.year = 2025 AND dim_date.month = 11
  • G3 grain · fixed
  • G4 status policy · no status filter
Gate: Rejected on policy. Finance recognizes delivered orders only. Add WHERE status = 'delivered'.
Attempt 3VERIFIED
SELECT COUNT(DISTINCT order_id) AS order_count
FROM fact_orders JOIN dim_date ON fact_orders.date_key = dim_date.date_key
WHERE dim_date.year = 2025 AND dim_date.month = 11 AND status = 'delivered'
Answer: We took 6318 orders in November 2025. (control: 6,318)

Two different traps, one after the other, each caught by the check built for it. Without G3 this answer ships as 13,547 orders - a 114% overstatement that would land in a demand plan.

Q4 · "How many buyers actually bought between 1 April and 30 June 2026?"metric: active_buyers · 3 attempts · final VERIFIED
Attempt 2BLOCKED(attempt 1 was rejected on G4)
SELECT COUNT(DISTINCT customer_id) AS buyer_count
FROM fact_orders JOIN dim_date ON fact_orders.date_key = dim_date.date_key
WHERE order_ts BETWEEN '2026-04-01' AND '2026-06-30' AND status = 'delivered'
  • G5 reconciliation · 7,707.00 vs control 7,767.00 (-0.8%)
Gate: Rejected on reconciliation ... a gap of -0.8%. Period: order_ts from 2026-04-01 inclusive to 2026-07-01 exclusive.
Attempt 3VERIFIED
SELECT COUNT(DISTINCT customer_id) AS buyer_count
FROM fact_orders JOIN dim_date ON fact_orders.date_key = dim_date.date_key
WHERE order_ts >= '2026-04-01' AND order_ts < '2026-07-01' AND status = 'delivered'
Answer: 7767 buyers bought something between 1 April 2026 and 30 June 2026.

The most dangerous number on this page is 7,707. BETWEEN '2026-04-01' AND '2026-06-30' against a timestamp truncates to midnight and silently drops the last day of the quarter. It is wrong by 0.8% - too small for anyone to notice in a review, big enough to make two reports disagree forever. Nothing but reconciliation finds it.

What the gate did, across the whole run

Dot chart of six questions by attempt. Five questions were rejected on their first attempt, on checks G2 to G5, and reached VERIFIED by attempt two or three. The sixth passed the guards on attempt one and ended at CHECKED.

Five of six first attempts were rejected. Every rejection was on meaning (grain, status, reconciliation) or on SQL that did not run - never on safety, because this model never tried to write anything.

Bar chart of the gap between the agent's first reportable number and the signed-off definition: Q1 plus 809 percent, Q3 minus 53.8 percent, Q4 minus 0.8 percent.

The first number each question produced that a system without reconciliation would have shipped. Q4's bar is almost invisible at this scale. That is the point.

Bar chart of how many times each of the five checks was evaluated and how many times it rejected the SQL. G1 never rejected anything, G2 rejected once, G3 once, G4 three times, G5 five times.

Syntax was almost never the problem. G5, the check that compares against a human definition, did most of the work.

Bar chart: SUM of net_amount across every status is 6.78 million dollars, delivered GMV is 5.25 million, platform net revenue at an 11 percent take rate is 578 thousand.

Why Q1 is worth 809%: the distance between the number the schema volunteers and the number the P&L wants, measured over the full 12 months.

Grouped bar chart by month comparing COUNT star against COUNT DISTINCT order id on delivered rows; the line count is 2.16 times the order count every month.

The grain trap is not an edge case - it is 2.16x, every month, in the same direction.

What it still gets wrong

Q6 was left out of the registry on purpose, because that is the state most real questions are in. Here is exactly what happened, unedited:

Q6 · "Which merchant category grew the most in delivered GMV from 2026Q1 to 2026Q2?"no registered metric · 1 attempt · final CHECKED
Attempt 1CHECKED
SELECT d.category, SUM(f.net_amount) AS gmv
FROM fact_orders f JOIN dim_merchant d ON f.merchant_id = d.merchant_id
JOIN dim_date dd ON f.date_key = dd.date_key
WHERE dd.year = 2026 AND (dd.month BETWEEN 1 AND 3 OR (dd.month = 4 AND dd.dow < 5))
GROUP BY d.category ORDER BY gmv DESC LIMIT 1
  • G1 read-only · SELECT only
  • G2 executes · 1 row
  • G3 grain · no registry grain to enforce
  • G4 status policy · no registry policy to enforce
  • G5 reconciliation · no signed-off control for this question
Answer: The merchant category that grew the most in delivered GMV from the quarter starting 1 January 2026 to the quarter starting 1 April 2026 was Grocery with a GMV of $471,310.63.
Read that query again. It never computes growth - it takes a single window and sorts by size. It never filters status, so cancelled and shipped rows are in the total. Its window is January through March plus weekdays in April, which is not a quarter, not two quarters, and not anything a human asked for. The number $471,310.63 corresponds to no defined metric at all.

And the headline is right: Grocery did grow the most, from $290,818 to $393,075, up 35.2%. It is right because Grocery is also the largest category by GMV, so "biggest" and "fastest growing" happened to be the same answer. A fluent sentence, a plausible number, the correct category, and a query that answers a different question. The only honest thing the system can do here is refuse to call it verified - which is what CHECKED means, and why it must never look like a pass.

Other limits, stated plainly: G3 and G4 pass vacuously on unregistered questions, so CHECKED is a weaker claim than it looks. The registry binds each question to its metric explicitly here rather than by a classifier, so routing errors are out of scope. A wrong answer that lands inside the 0.5% tolerance is not caught. And a different model fails differently: everything on this page is one captured run of one 7B local model, not a benchmark.

06
Step 6

Expert review - five reviewers, five defects, all re-run

An anonymous panel of senior reviewers (10+ years each) read the code and the first captured runs. Every finding below was a real defect in this build, and every fix was applied and the run repeated. The console logs of the two pre-fix runs are committed in materials/how-to-agentic-on-your-warehouse/review/ so the before state is checkable.

Agent Safety and Eval Reviewer
12 yrs · LLM systems in production

"Your agent is not grounded at all. You are passing the schema as a top-level system field to Ollama's chat endpoint, which ignores it. The model is answering from thin air - it invented an orders table that does not exist in your warehouse. You cannot claim a real failure-to-fix run when the model never saw the schema."

Fix applied: the system prompt is now sent as a system-role message. Before: 6 of 6 questions BLOCKED, including SQL against non-existent tables. After: the model queries the real star schema on every attempt.
Analytics Engineering Lead
14 yrs · semantic layers

"Your grain check only matches COUNT(*). The first thing any model writes is COUNT(order_id), which counts exactly the same lines and sails straight through G3. You are relying on reconciliation to catch a modelling error you claim to have a check for."

Fix applied: G3 now rejects any non-DISTINCT count over order_id on a per-order metric. Q2 attempt 1 moved from a 114% overstatement caught two checks later to an immediate, specific grain rejection.
Data Platform Reviewer
11 yrs · warehouse tooling

"The model wrote status = 'Delivered', matched zero rows, and your gate told it 'no numeric column - return one number'. That is the wrong diagnosis, so it repeated the same query twice more and burned all three attempts. A gate that misdiagnoses is worse than no gate."

Fix applied: an empty result is now detected as such and the feedback names the accepted values from the warehouse. Q5 went from 3 attempts BLOCKED to VERIFIED on attempt 2.
Finance Systems Lead
15 yrs · board reporting

"Your registry definitions describe the formula and never state the period, but every control query is period-bound. So you reject the model for a window mismatch and hand it feedback that never mentions the window. It cannot converge on information you are withholding."

Fix applied: each metric carries an explicit window that is surfaced in the rejection. Before: all 5 registered questions BLOCKED after 3 attempts. After: 5 of 5 VERIFIED.
Risk and Controls Reviewer
10 yrs · model risk in regulated reporting

"On the unregistered question your gate reports G3 and G4 as passes. They did not pass anything - there was nothing to check. If that renders next to a real pass, an auditor reads it as approval, and the badge becomes the exact false assurance you built this to prevent."

Fix applied: unregistered questions are badged CHECKED with distinct styling, the vacuous passes are labelled "no registry policy to enforce", and Q6's wrong query is published in full as the page's honest ceiling rather than dropped.
before → after · the one-line bug that made the whole first run meaningless
# v1 (before): /api/chat silently ignores a top-level "system" key, so the
# schema never reached the model. It answered like a chatbot with no warehouse.
payload = {"model": self.model, "system": system, "messages": messages, ...}

# v2 (after): the system prompt is a message with role "system".
payload = {"model": self.model,
           "messages": [{"role": "system", "content": system}] + messages,
           "options": {"temperature": 0, "seed": SEED, "num_ctx": 8192}, ...}

Re-run confirmation: after all five fixes, the full six-question run was executed again end to end - 5 VERIFIED, 1 CHECKED, every chart on this page regenerated from that final transcript.json. black and ruff clean.

Run this on your own warehouse

Install the skill, point it at your DuckDB file, and replace the registry with the metrics your finance team signs. The local model path needs no API key.

Claude Code
/plugin marketplace add phoebefu6/phoebe-data-skills
/plugin install how-to-agentic-on-your-warehouse@phoebe-data-skills
Reproduce this exact run
ollama pull qwen2.5-coder:7b
python materials/how-to-agentic-on-your-warehouse/agent.py --provider ollama
python materials/how-to-agentic-on-your-warehouse/charts.py

# or the hosted path
export ANTHROPIC_API_KEY=...
python materials/how-to-agentic-on-your-warehouse/agent.py --provider anthropic