<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arif Ismailov</title>
    <description>The latest articles on DEV Community by Arif Ismailov (@arif_ismailov_6535c00c21c).</description>
    <link>https://dev.to/arif_ismailov_6535c00c21c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4095856%2F5f34ceb2-bba8-482e-9b7b-eb88935a2b6f.jpeg</url>
      <title>DEV Community: Arif Ismailov</title>
      <link>https://dev.to/arif_ismailov_6535c00c21c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arif_ismailov_6535c00c21c"/>
    <language>en</language>
    <item>
      <title>I gave an LLM the keys to a multi-tenant database</title>
      <dc:creator>Arif Ismailov</dc:creator>
      <pubDate>Thu, 27 Aug 2026 07:27:32 +0000</pubDate>
      <link>https://dev.to/arif_ismailov_6535c00c21c/i-gave-an-llm-the-keys-to-a-multi-tenant-database-5hjc</link>
      <guid>https://dev.to/arif_ismailov_6535c00c21c/i-gave-an-llm-the-keys-to-a-multi-tenant-database-5hjc</guid>
      <description>&lt;p&gt;&lt;em&gt;Five things that had to be true before I would point it at data belonging to&lt;br&gt;
more than one customer.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;An agent that writes good SQL is a solved problem, near enough. The&lt;br&gt;
&lt;a href="https://dev.to/arif_ismailov_6535c00c21c/the-hard-part-of-text-to-sql-is-not-the-sql-4880"&gt;first half of this project&lt;/a&gt; was about that: pulling&lt;br&gt;
descriptions, join keys, real filter values and signed-off metric definitions out&lt;br&gt;
of dbt and handing them to the model, so the query it writes is the query a&lt;br&gt;
competent analyst would have written.&lt;/p&gt;

&lt;p&gt;None of it makes the query safe. A perfectly grounded query against exactly the&lt;br&gt;
right table will return every tenant's rows if nothing stops it, and it will do&lt;br&gt;
so without an error, because from the warehouse's point of view nothing went&lt;br&gt;
wrong.&lt;/p&gt;

&lt;p&gt;So this half is the opposite discipline. The model gets no say in any of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model writes the query. The server enforces the rules.&lt;/strong&gt; Different jobs,&lt;br&gt;
and only one of them is safe to delegate. Whatever SQL comes back goes through a&lt;br&gt;
pipeline the model cannot influence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validate → guard → qualify → limit → offset → mask → govern → assert → estimate → execute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pure functions over &lt;code&gt;(sql, catalog, tenant_scopes)&lt;/code&gt;. No network, no model, no I/O&lt;br&gt;
of any kind, which is why the security-critical core can be tested exhaustively.&lt;br&gt;
About a thousand lines, small enough to read on a train.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Prove the filter, don't trust the injector
&lt;/h2&gt;

&lt;p&gt;When a query reads a &lt;em&gt;governed&lt;/em&gt; table, one with a &lt;code&gt;tenant_id&lt;/code&gt; column, the&lt;br&gt;
server parses the SQL into a syntax tree with&lt;br&gt;
&lt;a href="https://github.com/tobymao/sqlglot" rel="noopener noreferrer"&gt;sqlglot&lt;/a&gt;, walks &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;SELECT&lt;/code&gt; scope,&lt;br&gt;
and AND-injects a predicate.&lt;/p&gt;

&lt;p&gt;The model writes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customer_orders&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The warehouse executes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"analytics"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"customer_orders"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qg_tenant_scopes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tenant IDs are bound parameters; they never appear in the SQL text. The walk&lt;br&gt;
covers joins, subqueries and CTEs, so a governed table three levels deep inside&lt;br&gt;
a &lt;code&gt;WITH&lt;/code&gt; still gets filtered, while a public table in the same query is left&lt;br&gt;
alone.&lt;/p&gt;

&lt;p&gt;Then comes the part I'd argue is the actual design decision. Having injected the&lt;br&gt;
filter, the server &lt;strong&gt;re-parses the finished SQL and independently proves the&lt;br&gt;
filter is there&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assert_tenant_filter_present&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# fresh parse, no shared state
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_governed_refs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_scope_has_tenant_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_column&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;GovernanceError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;      &lt;span class="c1"&gt;# before execution, always
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why bother, when the injector ran forty microseconds ago?&lt;/p&gt;

&lt;p&gt;Because the injector could have a bug. Because a refactor in eight months could&lt;br&gt;
introduce one. Because I am the sort of person who writes an injector and then&lt;br&gt;
lies awake wondering whether it handles &lt;code&gt;LATERAL&lt;/code&gt;. The assert shares no state&lt;br&gt;
with the injector: it re-derives the proof from the text itself and demands the&lt;br&gt;
&lt;em&gt;exact&lt;/em&gt; predicate shape: right column, right parameter, top-level &lt;code&gt;AND&lt;/code&gt;&lt;br&gt;
conjunct. A filter that's missing, &lt;code&gt;OR&lt;/code&gt;-ed with a tautology, or bound to the&lt;br&gt;
wrong parameter gets rejected.&lt;/p&gt;

&lt;p&gt;And it's fail-closed. A governed table with an empty tenant scope doesn't return&lt;br&gt;
zero rows; it refuses to run. Absence of permission is not permission.&lt;/p&gt;
&lt;h3&gt;
  
  
  Does it hold? Here are the numbers
&lt;/h3&gt;

&lt;p&gt;Three bearer tokens, three tenant scopes, one question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tok_acme    →  $55,952.59
tok_globex  →  $62,841.34
tok_multi   →  $118,793.93     ← exactly the sum
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No bleed, no double-counting. There is no prompt you can hand &lt;code&gt;tok_acme&lt;/code&gt; that&lt;br&gt;
returns Globex's revenue, because the filter is added after the model is done&lt;br&gt;
and re-checked before execution. The model is never in the loop for that call.&lt;/p&gt;

&lt;p&gt;For fun, I spent an afternoon being the attacker. Asking as &lt;code&gt;tok_acme&lt;/code&gt; for&lt;br&gt;
&lt;code&gt;WHERE tenant_id = 'globex'&lt;/code&gt; returns &lt;code&gt;NULL&lt;/code&gt;, because the injected predicate ANDs with&lt;br&gt;
yours and the intersection is empty. Hiding the table in a CTE returns acme's&lt;br&gt;
own total. &lt;code&gt;OR 1=1&lt;/code&gt; returns acme's own total. Selecting the masked customer name&lt;br&gt;
returns a column of MD5 hashes. Asking &lt;code&gt;WHERE customer_name = 'Alice Smith'&lt;/code&gt; is&lt;br&gt;
refused outright, and I'll explain why in a moment. &lt;code&gt;SELECT 1; DROP TABLE&lt;br&gt;
customer_orders&lt;/code&gt; gets told, politely, that two statements is one too many.&lt;/p&gt;

&lt;p&gt;This runs as an integration test against real Postgres in CI, not a mock.&lt;/p&gt;


&lt;h2&gt;
  
  
  2. The cache is part of the security perimeter
&lt;/h2&gt;

&lt;p&gt;This one nearly got me, and I think it's the most transferable lesson here.&lt;/p&gt;

&lt;p&gt;Caching query results looks like a pure optimisation. In a multi-tenant system&lt;br&gt;
it is not. Two callers can send byte-identical SQL and legitimately be owed&lt;br&gt;
different rows, and the only thing separating them is the tenant scope the server&lt;br&gt;
binds. A cache keyed on the query alone hands one tenant another's data&lt;br&gt;
&lt;strong&gt;without ever running their query&lt;/strong&gt;, elegantly bypassing every guarantee above.&lt;/p&gt;

&lt;p&gt;The fix isn't a code-review checklist item, because code-review checklist items&lt;br&gt;
are things humans do on Friday afternoons. It's a type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CacheKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;tenant_scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# required, no default
&lt;/span&gt;    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot construct a key without answering "on whose behalf?". Scopes are&lt;br&gt;
sorted, so a multi-tenant caller maps to one key, and hashed with a separator,&lt;br&gt;
so a crafted column name can't collide with somebody else's scope.&lt;/p&gt;

&lt;p&gt;The end-to-end test makes the subtlety visible. Both tenants happen to have the&lt;br&gt;
same &lt;code&gt;region&lt;/code&gt; values, &lt;code&gt;['APAC', 'EU', 'NA']&lt;/code&gt;. The second tenant still gets a&lt;br&gt;
&lt;strong&gt;miss&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;acme  1st: ['APAC','EU','NA']  cached=False
acme  2nd: ['APAC','EU','NA']  cached=True    ← served from cache
globex 1st:['APAC','EU','NA']  cached=False   ← miss, not acme's entry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cache never assumes what another tenant is entitled to see, even when the&lt;br&gt;
answer would coincidentally be identical.&lt;/p&gt;

&lt;p&gt;I also cache deliberately little: &lt;code&gt;get_filter_values&lt;/code&gt;, which is repetitive and&lt;br&gt;
stable, but &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;run_query&lt;/code&gt;. Analytical SQL is phrased differently on every&lt;br&gt;
attempt, so the hit rate is poor while the blast radius of a wrong key is a&lt;br&gt;
cross-tenant leak. Bad trade.&lt;/p&gt;

&lt;p&gt;Worth knowing before you build any of this: &lt;strong&gt;Postgres does not cache query&lt;br&gt;
results.&lt;/strong&gt; It caches pages. A repeated identical query still re-plans and&lt;br&gt;
re-executes, which I measured at 6-7 ms warm on demo-sized data: noise next to&lt;br&gt;
a multi-second LLM round trip. BigQuery and Snowflake &lt;em&gt;do&lt;/em&gt; cache identical&lt;br&gt;
results for 24 hours, which makes an app-level query cache largely redundant&lt;br&gt;
there. Caching earns its keep on large scans and repetitive lookups, not on&lt;br&gt;
principle.&lt;/p&gt;


&lt;h2&gt;
  
  
  3. Fail open, fail closed, and know which is which
&lt;/h2&gt;

&lt;p&gt;QueryGate has two guards that both protect the system and have &lt;strong&gt;opposite&lt;/strong&gt;&lt;br&gt;
failure modes. The asymmetry is deliberate and worth saying out loud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Governance fails closed.&lt;/strong&gt; Can't prove the tenant filter? Refuse. Empty tenant&lt;br&gt;
scope on governed data? Refuse. The cost of being wrong is a leak, so the&lt;br&gt;
default has to be no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting fails open.&lt;/strong&gt; If Redis is unreachable, requests go through and&lt;br&gt;
the server complains loudly in the logs. Rate limiting protects &lt;em&gt;availability&lt;/em&gt;,&lt;br&gt;
not confidentiality; refusing every query because the limiter is down trades a&lt;br&gt;
small risk for a certain outage.&lt;/p&gt;

&lt;p&gt;The limiter itself is a token bucket living entirely inside a Lua script, so the&lt;br&gt;
read-refill-write cycle is atomic across replicas. Fifty concurrent calls&lt;br&gt;
against a three-token bucket allow exactly three.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token bucket, not fixed window.&lt;/strong&gt; A fixed window lets a caller spend a full
quota at the end of one window and again at the start of the next, briefly
doubling the intended rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-caller buckets.&lt;/strong&gt; A global counter lets one enthusiastic tenant starve
everyone else, which is the same failure governance exists to prevent, just on
a different axis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tool costs.&lt;/strong&gt; &lt;code&gt;run_query&lt;/code&gt; reaches the warehouse and costs 5 tokens;
&lt;code&gt;search_catalog&lt;/code&gt; answers from memory and costs 1. Charging them equally either
throttles cheap discovery or waves through expensive scans.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I can report that the limiter works, because it fired at me during the attack&lt;br&gt;
run above and refused four of my six attempts. Genuinely annoying. Correct.&lt;/p&gt;


&lt;h2&gt;
  
  
  4. A second adapter is a test for the first
&lt;/h2&gt;

&lt;p&gt;The project claimed to be engine-agnostic. It shipped one adapter. That claim&lt;br&gt;
was therefore untested, and I knew better than to believe my own README.&lt;/p&gt;

&lt;p&gt;So I added DuckDB, chosen because it's in-process: the portability tests need no&lt;br&gt;
container and no credentials, and run in plain CI.&lt;/p&gt;

&lt;p&gt;It found two defects on the first run.&lt;/p&gt;

&lt;p&gt;The first was structural. The SQL dialect was a module-level constant, imported&lt;br&gt;
&lt;em&gt;by value&lt;/em&gt;, so no deployment could actually change it. The "swappable warehouse"&lt;br&gt;
was swappable in the documentation only. It moved to config.&lt;/p&gt;

&lt;p&gt;The second was a real latent bug. &lt;code&gt;bind_params()&lt;/code&gt; always bound the tenant list,&lt;br&gt;
including for public-table queries containing no tenant predicate at all.&lt;br&gt;
Postgres silently ignores an unused named parameter. DuckDB does not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Invalid Input Error: Parameter argument/count mismatch,
identifiers of the excess parameters: qg_tenant_scopes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That bug had been sitting there the entire time, invisible because exactly one&lt;br&gt;
engine was polite about it.&lt;/p&gt;

&lt;p&gt;BigQuery and Snowflake came later and made the same point louder. The canonical&lt;br&gt;
predicate the governance layer injects and re-proves is &lt;code&gt;col = ANY(:param)&lt;/code&gt;,&lt;br&gt;
which is Postgres syntax and nothing else's. BigQuery wants &lt;code&gt;IN UNNEST(@param)&lt;/code&gt;.&lt;br&gt;
Snowflake wants &lt;code&gt;ARRAY_CONTAINS(col::VARIANT, PARSE_JSON(:param))&lt;/code&gt;. So there is&lt;br&gt;
now a rewrite at the last hop before execution, and because a rewrite that&lt;br&gt;
quietly drops a predicate is the single worst bug this codebase could have, it&lt;br&gt;
re-proves its own output: the count of engine-form predicates must equal the&lt;br&gt;
canonical count it replaced, and no canonical form may survive. Same trick as&lt;br&gt;
section 1, one layer down. Four adapters in, the pattern has stopped feeling&lt;br&gt;
like paranoia and started feeling like the only way to sleep.&lt;/p&gt;

&lt;p&gt;The general lesson: &lt;strong&gt;an abstraction with one implementation is a guess.&lt;/strong&gt; If&lt;br&gt;
your architecture diagram has a box labelled "pluggable" and only one thing has&lt;br&gt;
ever been plugged in, that box is decoration.&lt;/p&gt;


&lt;h2&gt;
  
  
  5. Sometimes the right move is not to be the one enforcing it
&lt;/h2&gt;

&lt;p&gt;Everything above assumes a tenant column exists to filter on. Then I described&lt;br&gt;
the project to someone running a mid-sized company's warehouse and got the&lt;br&gt;
obvious question back: what if it doesn't?&lt;/p&gt;

&lt;p&gt;Their marts were modelled the way most marts are actually modelled, which is to&lt;br&gt;
say by whoever needed them, at the time, for one dashboard. Team-by-team schemas,&lt;br&gt;
no tenant column anywhere, and exactly one technical Snowflake user because&lt;br&gt;
Snowflake seats cost money and nobody wanted to explain twelve of them to&lt;br&gt;
finance.&lt;/p&gt;

&lt;p&gt;Point QueryGate at that in &lt;code&gt;inject&lt;/code&gt; mode and here is what happens. The catalog&lt;br&gt;
loader marks a model governed if it has the tenant column. None of them do. So&lt;br&gt;
nothing is governed, no predicate is injected anywhere, every query runs under&lt;br&gt;
the one service identity, and each caller receives the union of everything that&lt;br&gt;
identity can read. The query succeeds. No error is raised. Nothing anywhere&lt;br&gt;
reports a problem.&lt;/p&gt;

&lt;p&gt;Wrong rows, no error. Again. The exact failure this whole project exists to&lt;br&gt;
prevent, arriving through the front door because the precondition quietly did not&lt;br&gt;
hold.&lt;/p&gt;

&lt;p&gt;The fix is not to inject harder. It is a second mode where QueryGate stops being&lt;br&gt;
the thing that enforces row security and the warehouse does it, because the&lt;br&gt;
warehouse is frankly better at it and their CDO already owns the grants. Set&lt;br&gt;
&lt;code&gt;QG_GOVERNANCE_MODE=warehouse&lt;/code&gt; and the query runs &lt;strong&gt;as the caller's own warehouse&lt;br&gt;
role&lt;/strong&gt;: their IdP role maps to exactly one Snowflake role, the session opens&lt;br&gt;
under it, Snowflake's grants decide what is readable.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    subgraph inject["QG_GOVERNANCE_MODE=inject"]
        direction TB
        A1[Agent writes SQL] --&amp;gt; B1[QueryGate injects&amp;lt;br/&amp;gt;tenant predicate]
        B1 --&amp;gt; C1[Independent re-parse&amp;lt;br/&amp;gt;proves it is there]
        C1 --&amp;gt; D1[(Warehouse)]
        D1 -.-&amp;gt;|one service identity,&amp;lt;br/&amp;gt;rows already filtered| E1[Caller's rows]
    end

    subgraph wh["QG_GOVERNANCE_MODE=warehouse"]
        direction TB
        A2[Agent writes SQL] --&amp;gt; B2[QueryGate resolves&amp;lt;br/&amp;gt;caller to ONE role]
        B2 --&amp;gt; C2[Session opens&amp;lt;br/&amp;gt;under that role]
        C2 --&amp;gt; D2[(Warehouse)]
        D2 -.-&amp;gt;|engine's own grants&amp;lt;br/&amp;gt;decide the rows| E2[Caller's rows]
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Alternatives, not layers. The left column needs a tenant column on every&lt;br&gt;
governed model; the right needs a warehouse role per audience.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three things make it hold up rather than just sound good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mapping is an allowlist, not a claim passed through.&lt;/strong&gt; It would be one line&lt;br&gt;
of code to read the role out of the caller's token and hand it to Snowflake. It&lt;br&gt;
would also mean a loose IdP configuration lets somebody name any role in the&lt;br&gt;
warehouse. So the map lives in operator config, and a role that is not in it does&lt;br&gt;
not exist as far as this server is concerned.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    KC["Keycloak / OIDC"] --&amp;gt;|"JWT"| T

    subgraph T["Token claims"]
        direction TB
        R["realm_access.roles&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;analyst&amp;lt;/b&amp;gt;"]
        TN["tenant_ids&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;acme&amp;lt;/b&amp;gt;"]
    end

    R --&amp;gt; MAP{"QG_WAREHOUSE_ROLE_MAP&amp;lt;br/&amp;gt;operator config"}
    MAP --&amp;gt;|"analyst maps to QG_ANALYST"| ROLE["Warehouse role&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;QG_ANALYST&amp;lt;/b&amp;gt;"]
    MAP --&amp;gt;|"unmapped, none,&amp;lt;br/&amp;gt;or several"| REF["REFUSED"]

    TN -.-&amp;gt;|"used in inject mode only"| PRED["Tenant predicate"]
    ROLE --&amp;gt; SF[("Snowflake&amp;lt;br/&amp;gt;session")]

    style MAP fill:#fff4e6,stroke:#c98a3a,stroke-width:2px
    style REF fill:#ffe0e0,stroke:#c0392b&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;The token's roles are lookup keys, never values. A forged claim of&lt;br&gt;
&lt;code&gt;QG_FINANCE&lt;/code&gt; matches no key and is refused.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refusal, never a fallback.&lt;/strong&gt; No mapped role, no roles at all, anonymous caller,&lt;br&gt;
or &lt;em&gt;several&lt;/em&gt; mapped roles all refuse the query. That last one took me a minute to&lt;br&gt;
land on: picking one silently would grant or withhold access by accident, and I&lt;br&gt;
would rather an analyst with two hats be told to get a third role created than&lt;br&gt;
guess which of their hats I meant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fails at startup, not at request time.&lt;/strong&gt; &lt;code&gt;warehouse&lt;/code&gt; mode with no role map&lt;br&gt;
refuses to boot. A deployment that would serve every caller as one identity&lt;br&gt;
should not accept traffic while somebody notices.&lt;/p&gt;

&lt;p&gt;And then the part that actually cost me an evening.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzlhn3vehdgjorzq4uy9v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzlhn3vehdgjorzq4uy9v.png" alt="A heavy dark door with a solid brass lock, firmly shut. The wall it sits in simply ends a metre to the right, leaving an open gap, and a trail of footprints walks around through it."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every surface you would think to check looks correct.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    START["Service user connects&amp;lt;br/&amp;gt;role=QG_ANALYST"]
    START --&amp;gt; CHECK{"DEFAULT_SECONDARY_ROLES&amp;lt;br/&amp;gt;on the user?"}

    CHECK --&amp;gt;|"('ALL'), the default&amp;lt;br/&amp;gt;for several creation paths"| BAD1["Session activates&amp;lt;br/&amp;gt;EVERY granted role"]
    BAD1 --&amp;gt; BAD2["USE ROLE narrows nothing"]
    BAD2 --&amp;gt; BAD3["Reads every audience's views"]
    BAD3 --&amp;gt; BAD4["Connection looks correct&amp;lt;br/&amp;gt;Logs look correct&amp;lt;br/&amp;gt;No error anywhere"]

    CHECK --&amp;gt;|"( ), set explicitly"| OK1["Only the primary role&amp;lt;br/&amp;gt;is active"]
    OK1 --&amp;gt; OK2["USE ROLE QG_ANALYST"]
    OK2 --&amp;gt; OK3["SELECT CURRENT_ROLE()&amp;lt;br/&amp;gt;read back and compared"]
    OK3 --&amp;gt; OK4["Wrong role, refuse&amp;lt;br/&amp;gt;Right role, execute"]

    style BAD4 fill:#ffe0e0,stroke:#c0392b,stroke-width:2px
    style OK4 fill:#e0f5e0,stroke:#27795b,stroke-width:2px&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Wrong rows, no error. The red path is what you get by default, and every&lt;br&gt;
surface you would think to check looks healthy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For any of this to work, the service user has to be granted &lt;em&gt;every&lt;/em&gt; mapped role.&lt;br&gt;
That is unavoidable: it is the account doing the switching. Now, Snowflake users&lt;br&gt;
carry a property called &lt;code&gt;DEFAULT_SECONDARY_ROLES&lt;/code&gt;, and for users created through&lt;br&gt;
several perfectly normal paths it defaults to &lt;code&gt;('ALL')&lt;/code&gt;. What that means is the&lt;br&gt;
session activates every role the user has been granted, regardless of which one&lt;br&gt;
you connected with.&lt;/p&gt;

&lt;p&gt;A property, not a session parameter, which matters when you go looking for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;QUERYGATE_SVC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- the DEFAULT_SECONDARY_ROLES row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SHOW PARAMETERS&lt;/code&gt; will not find it and will not complain either, it just returns&lt;br&gt;
nothing, which is a memorable ten minutes. Reading another user's properties also&lt;br&gt;
needs &lt;code&gt;MONITOR&lt;/code&gt; on that user or a role like &lt;code&gt;USERADMIN&lt;/code&gt;, so an analyst checking a&lt;br&gt;
service account gets a privilege error. Correct behaviour, and a reminder that&lt;br&gt;
the person who can answer this is the person who created the account.&lt;/p&gt;

&lt;p&gt;Read that again with the previous paragraph in mind. &lt;code&gt;USE ROLE QG_ANALYST&lt;/code&gt;&lt;br&gt;
narrows nothing. The connection is configured correctly. The logs look right. The&lt;br&gt;
analyst reads every audience's views. Wrong rows, no error, one layer further&lt;br&gt;
down, and this time wearing the costume of the fix.&lt;/p&gt;

&lt;p&gt;So the adapter does three things in order, before any query runs: &lt;code&gt;USE SECONDARY&lt;br&gt;
ROLES NONE&lt;/code&gt;, which is what actually narrows the session; then &lt;code&gt;USE ROLE&lt;/code&gt;; then&lt;br&gt;
&lt;code&gt;SELECT CURRENT_ROLE()&lt;/code&gt; and compare, so a silent fallback to a default role fails&lt;br&gt;
closed instead of answering as somebody else.&lt;/p&gt;

&lt;p&gt;What I would say to anyone building this: the general shape of the bug is a&lt;br&gt;
control whose precondition is invisible. Predicate injection needs a tenant&lt;br&gt;
column. Role pinning needs secondary roles off. Both degrade to &lt;em&gt;success&lt;/em&gt; when&lt;br&gt;
the precondition is absent, which is why both now assert their own precondition&lt;br&gt;
rather than assuming it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyehsuzqv3nhbt1utgt6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyehsuzqv3nhbt1utgt6.png" alt="A hand holding out a single key ring loaded with a dozen different keys, above a long row of identical closed doors."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;You hand over one key. The ring comes with it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One thing this mode does not fix, and I would rather say so than let someone find&lt;br&gt;
out during an audit: the service user still holds the union of every mapped role.&lt;br&gt;
Role pinning is a control QueryGate applies at runtime, not a property of the&lt;br&gt;
account. Whoever holds those connection details holds the warehouse. The honest&lt;br&gt;
end state is External OAuth, where the analyst's own identity reaches Snowflake&lt;br&gt;
and no union principal exists at all, but that needs every analyst to have a&lt;br&gt;
Snowflake identity, which is precisely what a company with one technical user&lt;br&gt;
does not have. That is an org problem wearing an engineering costume, and it is&lt;br&gt;
usually what decides the timeline.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug I'd rather show than hide
&lt;/h2&gt;

&lt;p&gt;Column masking worked. PII columns marked in the dbt catalog got wrapped in a&lt;br&gt;
hash on the way out, equal values hashed equal so &lt;code&gt;count(distinct)&lt;/code&gt; still&lt;br&gt;
returned the right answer, and I was pleased with myself for roughly a day.&lt;/p&gt;

&lt;p&gt;Then I tested &lt;code&gt;SELECT *&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A wildcard is a &lt;code&gt;Star&lt;/code&gt; node, not a &lt;code&gt;Column&lt;/code&gt; node. My rewrite walked columns.&lt;br&gt;
It never saw the star. The single laziest query available to any user on earth&lt;br&gt;
walked straight through the entire control.&lt;/p&gt;

&lt;p&gt;Wildcards are now expanded from the catalog before masking runs. A test caught&lt;br&gt;
it, which is the argument for writing the paranoid ones. The tests you write&lt;br&gt;
while assuming you're an idiot are the tests that find out you were right.&lt;/p&gt;

&lt;p&gt;The second half of that control is less obvious and, I think, more interesting.&lt;br&gt;
Masking only the SELECT list is theatre. If a masked column can still appear in&lt;br&gt;
a predicate, the caller has an oracle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Alice Smith'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that a few hundred times and you have read the column one guess at a time,&lt;br&gt;
while every returned value stayed dutifully masked. So masked columns are&lt;br&gt;
confined to the projection: &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt; and &lt;code&gt;HAVING&lt;/code&gt;&lt;br&gt;
are all refused, with an error explaining why.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A governance matrix.&lt;/strong&gt; Every case that could leak a tenant is a test: single&lt;br&gt;
governed table, aliased table, join of two governed tables, join of governed and&lt;br&gt;
public where only one side gets filtered, subquery, CTE body, a schema-qualified&lt;br&gt;
table shadowing a same-named CTE, empty scope, and the adversarial asserts:&lt;br&gt;
filter missing, filter &lt;code&gt;OR&lt;/code&gt;-ed with &lt;code&gt;1=1&lt;/code&gt;, right column but wrong parameter.&lt;br&gt;
This is the suite that lets me sleep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ground-truth evals.&lt;/strong&gt; Tests prove the code does what I said. They cannot tell&lt;br&gt;
me whether the &lt;em&gt;agent&lt;/em&gt; found the right table or computed the right number. So&lt;br&gt;
there's a golden set of plain analyst questions, each pinned to a ground-truth&lt;br&gt;
answer computed from a deterministic seed, with retrieval quality measured&lt;br&gt;
separately as &lt;a href="mailto:hit@k"&gt;hit@k&lt;/a&gt;. Two independent signals: did it find the right table, and&lt;br&gt;
did it compute the right number. When something's off, you know which layer to&lt;br&gt;
fix, which saves you from tuning retrieval to solve what was actually a prompt&lt;br&gt;
problem.&lt;/p&gt;

&lt;p&gt;290 tests. 213 of them pass on a bare &lt;code&gt;uv sync&lt;/code&gt; with no optional dependency and&lt;br&gt;
no infrastructure at all; the rest need DuckDB, Redis or a real Postgres, and CI&lt;br&gt;
runs every one except the Qdrant case.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making it yours
&lt;/h2&gt;

&lt;p&gt;Because the point was a template, the boundaries got drawn where a team would&lt;br&gt;
actually need to cut.&lt;/p&gt;

&lt;p&gt;The warehouse is two coroutines, &lt;code&gt;estimate&lt;/code&gt; and &lt;code&gt;execute&lt;/code&gt;. Postgres, DuckDB,&lt;br&gt;
BigQuery and Snowflake ship; Trino is one module more plus a line in a dict. The catalog comes from whatever your dbt&lt;br&gt;
CI already publishes, or a JSON bundle in a documented shape if you don't run&lt;br&gt;
dbt. Auth is a &lt;code&gt;TokenVerifier&lt;/code&gt; returning a principal, so an in-house SSO gateway&lt;br&gt;
drops in beside the OIDC one. Tenancy is a column name in config, and if you'd&lt;br&gt;
rather let Snowflake row access policies do that job, &lt;code&gt;QG_GOVERNANCE_MODE=warehouse&lt;/code&gt;&lt;br&gt;
turns injection off and keeps the pipeline for cost control, masking, grounding&lt;br&gt;
and retrieval. A &lt;code&gt;snow&lt;/code&gt; CLI script provisions the role model from the same map&lt;br&gt;
the server reads, so the grants and the mapping cannot drift.&lt;/p&gt;

&lt;p&gt;The part I'd ask a team to read before changing anything is &lt;code&gt;query/&lt;/code&gt;: pure, no&lt;br&gt;
I/O, the whole safety argument in about 1,200 lines. Its companion is&lt;br&gt;
&lt;code&gt;test_governance.py&lt;/code&gt;, where that argument is written down as assertions. Change&lt;br&gt;
the test first and the code second.&lt;/p&gt;

&lt;p&gt;What deliberately doesn't generalise is the dbt project in &lt;code&gt;dbt/&lt;/code&gt;. Three tenants&lt;br&gt;
and a few hundred orders, small enough that the diffs stay readable. It is meant&lt;br&gt;
to be deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest backlog
&lt;/h2&gt;

&lt;p&gt;Things that are missing, stated plainly, because a template that oversells&lt;br&gt;
itself wastes somebody's sprint:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No connection pooling.&lt;/strong&gt; Every call opens a fresh &lt;code&gt;psycopg&lt;/code&gt; connection, and&lt;br&gt;
&lt;code&gt;run_query&lt;/code&gt; opens two: one for the &lt;code&gt;EXPLAIN&lt;/code&gt;, one to execute. Fine for a demo&lt;br&gt;
and for a handful of analysts. It is unambiguously the first thing that falls&lt;br&gt;
over under real concurrency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The masking hash is MD5.&lt;/strong&gt; It's a pseudonymiser, not a secret. Analytical&lt;br&gt;
value survives; a low-cardinality column with a guessable domain, like customer&lt;br&gt;
names, does not survive a determined dictionary attack by somebody who can see&lt;br&gt;
the hashes. For regulated data, swap in a keyed HMAC whose secret the query&lt;br&gt;
layer never returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost ceiling is off unless you set it.&lt;/strong&gt; &lt;code&gt;QG_MAX_PLAN_COST&lt;/code&gt; defaults to&lt;br&gt;
zero, which disables it. Compose sets a real number. A bare install does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The guarantee is only as strong as sqlglot's parse.&lt;/strong&gt; The assert is&lt;br&gt;
independent of the injector but not of the parser, since both read the SQL through&lt;br&gt;
the same library. If sqlglot resolves a scope differently from your engine, the&lt;br&gt;
predicate can land in the wrong place and re-parsing won't catch it. This is the&lt;br&gt;
honest boundary of the threat model, and it's why inject-and-assert belongs&lt;br&gt;
&lt;em&gt;over&lt;/em&gt; native row access policies rather than instead of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you're wiring an LLM to a database, the question worth asking on day one is:&lt;br&gt;
&lt;strong&gt;what is the model allowed to decide?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let it write the query. That's its strength and it's genuinely good at it now.&lt;br&gt;
Do not let it decide who may see what, whether the query is read-only, or how&lt;br&gt;
much it's allowed to scan. Put those behind a deterministic layer, inject them&lt;br&gt;
yourself, then re-verify them independently before you execute.&lt;/p&gt;

&lt;p&gt;Trust the model to be clever. Don't trust it to be safe.&lt;/p&gt;

&lt;p&gt;The exec, for the record, still doesn't know what SQL is. He gets his number&lt;br&gt;
before the 2pm, it's correct, and it's only ever his company's number. That was&lt;br&gt;
always the entire brief.&lt;/p&gt;

&lt;p&gt;The analyst is still on item 38 of 37. But she's no longer on it because&lt;br&gt;
somebody wanted a sum.&lt;/p&gt;

&lt;p&gt;The code is on GitHub. Clone it, &lt;code&gt;docker compose up&lt;/code&gt;, and try to make one tenant&lt;br&gt;
read another's data. I'd genuinely like to know if you can, and if you lift it&lt;br&gt;
into your own stack, I'd like to hear which seam turned out to be in the wrong&lt;br&gt;
place.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;QueryGate is MIT-licensed. Built with FastMCP, sqlglot and dbt, running on&lt;br&gt;
Postgres, DuckDB, BigQuery or Snowflake.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://www.linkedin.com/in/arif-ismailov-819949229/" rel="noopener noreferrer"&gt;Arif Ismailov&lt;/a&gt;. If you&lt;br&gt;
work on LLM data access, MCP servers, or multi-tenant analytics, I'd like to&lt;br&gt;
compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sql</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>The hard part of text-to-SQL is not the SQL</title>
      <dc:creator>Arif Ismailov</dc:creator>
      <pubDate>Thu, 27 Aug 2026 07:25:25 +0000</pubDate>
      <link>https://dev.to/arif_ismailov_6535c00c21c/the-hard-part-of-text-to-sql-is-not-the-sql-4880</link>
      <guid>https://dev.to/arif_ismailov_6535c00c21c/the-hard-part-of-text-to-sql-is-not-the-sql-4880</guid>
      <description>&lt;p&gt;&lt;em&gt;An LLM will write you a query in seconds. Getting it to write the correct one&lt;br&gt;
means shipping it everything your analyst knows and never wrote down.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a genre of Slack message that arrives at 1:40pm and ends with "before&lt;br&gt;
my 2pm."&lt;/p&gt;

&lt;p&gt;It is always from someone who does not write SQL. Not because they couldn't&lt;br&gt;
learn, they could in an afternoon, but the afternoon is committed to a boat.&lt;br&gt;
And the question itself is entirely reasonable: how much did we make in Q3, by&lt;br&gt;
region. One correct answer. Sitting in a table. Ninety seconds of work.&lt;/p&gt;

&lt;p&gt;Ninety seconds &lt;em&gt;if you are the analyst&lt;/em&gt;. The analyst is currently on item 38 of&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The analyst has been on item 38 of 37 since March.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the obvious 2026 move is to cut her out of the loop. Hand the warehouse to a&lt;br&gt;
language model, let the exec ask in English, everyone leaves early.&lt;/p&gt;

&lt;p&gt;This takes about ten minutes. Genuinely. An MCP server, a connection string, and&lt;br&gt;
a system prompt that says &lt;em&gt;you are a helpful analytics assistant, only return&lt;br&gt;
rows belonging to the user's own company&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That last clause is doing a great deal of work in that sentence. It is, in fact,&lt;br&gt;
doing all of the security.&lt;/p&gt;

&lt;p&gt;This is the first of two write-ups of&lt;br&gt;
&lt;a href="https://github.com/Arif-krepkiy/querygate" rel="noopener noreferrer"&gt;QueryGate&lt;/a&gt;,&lt;br&gt;
an open-source MCP server I built because that clause kept me up at night. Not&lt;br&gt;
"look, the AI wrote SQL", which has been solved for a while now. The&lt;br&gt;
interesting engineering is everything between the question and the rows.&lt;/p&gt;

&lt;p&gt;I built it as a template rather than a product. Every DWH team I've worked with&lt;br&gt;
has a different warehouse, a different identity provider, and a different&lt;br&gt;
theory of what a tenant even is, so shipping one opinionated deployment would&lt;br&gt;
have been useless to almost everybody. What generalises is the shape. The demo&lt;br&gt;
stack exists so a team can watch the whole path work once, then start deleting&lt;br&gt;
the parts that are mine.&lt;/p&gt;

&lt;p&gt;Everything below runs locally with &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  A word in defence of the analyst
&lt;/h2&gt;

&lt;p&gt;This article is otherwise going to read as though the goal is to replace her, so&lt;br&gt;
let me be precise about what she actually does.&lt;/p&gt;

&lt;p&gt;She is the only person in the building who knows that &lt;code&gt;orders&lt;/code&gt; has had three&lt;br&gt;
rows per order since the 2023 migration. That &lt;code&gt;status = 'complete'&lt;/code&gt; matches&lt;br&gt;
exactly zero records, because the data says &lt;code&gt;'completed'&lt;/code&gt;. That joining&lt;br&gt;
customers to plans on &lt;code&gt;plan_name&lt;/code&gt; works fine until you hit the twelve accounts&lt;br&gt;
where someone typed it by hand.&lt;/p&gt;

&lt;p&gt;That knowledge is the product. The model does not have it, cannot infer it from&lt;br&gt;
column names, and (this is the important bit) will produce a confident number&lt;br&gt;
without it. Not an error. A number. Formatted nicely, to two decimal places,&lt;br&gt;
wrong by a factor of three.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tn50h691xkuqvvqqzhh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tn50h691xkuqvvqqzhh.png" alt="Two hands presenting a certificate on a pedestal, lit like an award ceremony. The same certificate lies crumpled beside it, off its stand."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A number arrives formatted, centred and confident. That tells you nothing at all about whether it is true.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So half of this project turned out to be about getting her knowledge to the&lt;br&gt;
model without needing her to be awake. The other half is about the exec, and&lt;br&gt;
what happens when a system that will answer any question meets a person who&lt;br&gt;
cannot evaluate any answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one idea
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The model writes the query. The server does the other two jobs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are two, and conflating them is why most text-to-SQL demos are demos. The&lt;br&gt;
first is making the query &lt;em&gt;right&lt;/em&gt;: the model has to know that &lt;code&gt;orders&lt;/code&gt; has three&lt;br&gt;
rows per order, that the status column says &lt;code&gt;completed&lt;/code&gt; and not &lt;code&gt;complete&lt;/code&gt;, that&lt;br&gt;
"revenue" in this company means one specific aggregation someone signed off. The&lt;br&gt;
second is making the query &lt;em&gt;safe&lt;/em&gt;: only the caller's rows, read-only, bounded&lt;br&gt;
cost.&lt;/p&gt;

&lt;p&gt;Neither can be delegated to the model, and they fail differently. An unsafe&lt;br&gt;
query gets refused. A wrong query gets answered.&lt;/p&gt;

&lt;p&gt;Writing SQL from a fuzzy question is genuinely what LLMs are good at, &lt;em&gt;provided&lt;br&gt;
they are told the things above&lt;/em&gt;. Nobody's model knows them from column names. So&lt;br&gt;
half this project is a pipeline for getting human knowledge into the model, and&lt;br&gt;
the other half is a pipeline for not trusting it afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This article is the first half.&lt;/strong&gt; The second, on injecting a tenant filter&lt;br&gt;
and then re-proving it, on the oracle that makes naive column masking useless,&lt;br&gt;
and on a Snowflake parameter that silently undoes the entire thing, is&lt;br&gt;
&lt;a href="https://dev.to/arif_ismailov_6535c00c21c/i-gave-an-llm-the-keys-to-a-multi-tenant-database-5hjc"&gt;here&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    subgraph client[MCP Client · Claude / Cursor]
        A[Analyst asks in natural language]
    end

    subgraph qg[QueryGate MCP Server]
        direction TB
        AUTH[auth · verify token → tenant scopes]
        RET[retrieval · BM25 + vectors over the catalog]
        subgraph pipe[query pipeline · pure, unit-tested]
            V[validate · one read-only SELECT] --&amp;gt; Q[qualify names]
            Q --&amp;gt; L[clamp limit + offset] --&amp;gt; G[inject tenant filter]
            G --&amp;gt; ASSERT[independent assert]
        end
        WH[warehouse adapter · estimate + execute]
    end

    subgraph data[Data plane]
        DBT[dbt docs&amp;lt;br/&amp;gt;manifest + catalog + metrics] --&amp;gt;|CI publishes| S3[(S3 / GCS)]
        PG[(Warehouse)]
    end

    A --&amp;gt;|search / describe / stats| RET
    A --&amp;gt;|run_query sql| AUTH --&amp;gt; pipe
    S3 --&amp;gt;|TTL pull + compile| RET
    RET -.slice.-&amp;gt; A
    ASSERT --&amp;gt; WH --&amp;gt; PG
    PG --&amp;gt;|rows| WH -.compact result.-&amp;gt; A&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Left half of the server is the answer to "what should I ask?". Right half is&lt;br&gt;
the answer to "may you have it?". The article follows the same order.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The pipeline on the right is pure functions over &lt;code&gt;(sql, catalog, tenant_scopes)&lt;/code&gt;.&lt;br&gt;
No network, no model, no I/O of any kind, which is why the security-critical core&lt;br&gt;
can be tested exhaustively. About a thousand lines, small enough to read on a&lt;br&gt;
train.&lt;/p&gt;


&lt;h2&gt;
  
  
  1. The context already exists, and it is not in your prompt
&lt;/h2&gt;

&lt;p&gt;The instinct when wiring an LLM to a warehouse is to write a big system prompt&lt;br&gt;
describing the tables. This is a bad idea twice over: it goes stale the moment&lt;br&gt;
someone ships a migration, and it does not scale past about fifteen models&lt;br&gt;
before it eats the context window.&lt;/p&gt;

&lt;p&gt;The better answer is that your data team already wrote all of it, in dbt, as&lt;br&gt;
part of their normal job. Nobody has to author a new artefact:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the agent needs&lt;/th&gt;
&lt;th&gt;Where it already lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What a table means, and its grain&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;description&lt;/code&gt; in &lt;code&gt;_models.yml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which columns exist, and their real warehouse types&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;catalog.json&lt;/code&gt;, from &lt;code&gt;dbt docs generate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which columns are PII&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;meta: {mask: hash}&lt;/code&gt; on the column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How tables join&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;relationships&lt;/code&gt; tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What "completed revenue" means&lt;/td&gt;
&lt;td&gt;MetricFlow semantic models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;That "money" means "revenue" here&lt;/td&gt;
&lt;td&gt;a glossary file next to the project&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;QueryGate compiles those four artefacts into an in-memory catalog on boot and&lt;br&gt;
refreshes on a TTL. Precedence is S3, then a local dbt target, then a bundled&lt;br&gt;
sample so the server always starts. A failed refresh keeps serving the&lt;br&gt;
last-known-good copy, because a stale description is a cosmetic problem and a&lt;br&gt;
crashed server is not.&lt;/p&gt;

&lt;p&gt;The consequence worth stating plainly: &lt;strong&gt;improving the agent is a pull request to&lt;br&gt;
the dbt repo, not to this one.&lt;/strong&gt; An analyst who writes a better column&lt;br&gt;
description has improved the model's SQL, and never touched Python.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Finding the right table when the words don't match
&lt;/h2&gt;

&lt;p&gt;Retrieval sounds like the boring part. It produced the most useful measurement in&lt;br&gt;
the project, and it was not the one I expected.&lt;/p&gt;

&lt;p&gt;The agent's first move is &lt;code&gt;search_catalog&lt;/code&gt;, which returns a &lt;em&gt;slice&lt;/em&gt; of the&lt;br&gt;
catalog rather than a copy of the schema. Behind it is BM25 over a document per&lt;br&gt;
model, assembled from name, description, column names and column descriptions.&lt;/p&gt;

&lt;p&gt;Run the demo catalog's golden questions against that and one of them does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question: "how much money did we make"
BM25 returns: []
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a wrong table. An empty list. The word "money" appears nowhere in a catalog&lt;br&gt;
whose text says "revenue" throughout. Keyword search is lexical, the analyst is&lt;br&gt;
not, and this is the characteristic way retrieval fails in a data context: not&lt;br&gt;
ranking badly, but matching nothing at all.&lt;/p&gt;

&lt;p&gt;Two mechanisms address it, and they are not interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A glossary, written by a person&lt;/strong&gt;, sitting next to the dbt project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"revenue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sales"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"income"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turnover"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"money"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"earnings"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"churn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"attrition"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inactive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lost"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At index time, any document containing &lt;code&gt;revenue&lt;/code&gt; is enriched with its synonyms.&lt;br&gt;
A query for "money" now matches the model whose description only ever said&lt;br&gt;
"revenue". On the demo catalog this takes retrieval from 75% to 100% hit@1, and&lt;br&gt;
the fix costs four lines of JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings&lt;/strong&gt;, second. Local ONNX vectors via fastembed, cosine similarity over&lt;br&gt;
a normalised matrix, fused with BM25 by Reciprocal Rank Fusion. RRF rather than a&lt;br&gt;
weighted blend because the two scales are not comparable: a BM25 score of 8.2 and&lt;br&gt;
a cosine of 0.71 mean nothing beside each other, but rank 1 and rank 1 do. No&lt;br&gt;
calibration constant to tune, which is the whole appeal.&lt;/p&gt;

&lt;p&gt;On the demo catalog, they also take it to 100%. Four models and four questions,&lt;br&gt;
so both hit the ceiling and neither can distinguish itself. That result says&lt;br&gt;
nothing, and I nearly published it as though it did.&lt;/p&gt;
&lt;h3&gt;
  
  
  The measurement, including the one I got wrong
&lt;/h3&gt;

&lt;p&gt;So I built a catalog at a realistic size: 40 models across ten domains, from&lt;br&gt;
sales and finance through support and ops to platform and legal, described in&lt;br&gt;
schema language the way a dbt project reads. It is a fixture, not a warehouse:&lt;br&gt;
retrieval only ever reads catalog metadata, so names, domains, descriptions and&lt;br&gt;
column names are the whole of it, and they live as literals in the benchmark&lt;br&gt;
file rather than as a second dbt project. Then 32 questions in the language an&lt;br&gt;
executive actually uses, written before running anything: &lt;em&gt;what is our top line&lt;/em&gt;,&lt;br&gt;
&lt;em&gt;who is about to leave us&lt;/em&gt;, &lt;em&gt;how busy is the help desk&lt;/em&gt;, &lt;em&gt;which bits of the app&lt;br&gt;
does nobody touch&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The first glossary scored zero. Not "less". Zero.&lt;/p&gt;

&lt;p&gt;There was no bug. I had written it backwards, and the direction is worth knowing.&lt;/p&gt;

&lt;p&gt;Expansion fires on the &lt;em&gt;key&lt;/em&gt;. A document containing the key gains its synonyms.&lt;br&gt;
So the key has to be a word that is &lt;strong&gt;already in your catalog&lt;/strong&gt;, and the values&lt;br&gt;
have to be what people say instead. I had done the reverse: keys like &lt;code&gt;revenue&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;churn&lt;/code&gt;, which is how humans talk. Nothing in a catalog full of&lt;br&gt;
&lt;code&gt;gross_amount&lt;/code&gt; and &lt;code&gt;fct_plan_changes&lt;/code&gt; ever contains the word "revenue", so no&lt;br&gt;
document was ever enriched and six sensible entries did nothing at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn4ngxot5kxywgq1z0cd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn4ngxot5kxywgq1z0cd.png" alt="A plank bridge built out from the left clifftop, anchored with posts and rope, reaching over a gap and stopping in mid-air. The far cliff has nothing to attach to and the loose rope hangs down. A thin arc crosses the gap above it without touching either edge."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A glossary anchored on the words people use, reaching for a word the catalog never contains. The thin arc is the embedding, which needs no anchor at either end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Turned around, keys drawn from the schema, it jumped to 97% hit@3 and I nearly&lt;br&gt;
published that.&lt;/p&gt;

&lt;p&gt;It is not a real number. I wrote that glossary &lt;em&gt;after&lt;/em&gt; looking at which questions&lt;br&gt;
were failing, so entries like &lt;code&gt;"tickets": ["help desk", "complaints", "busy"]&lt;/code&gt;&lt;br&gt;
are lifted almost verbatim from the test set. That is not a measurement of a&lt;br&gt;
glossary. It is a measurement of me holding the answer key.&lt;/p&gt;

&lt;p&gt;So I wrote a third one blind: walk the 40 models, take the salient noun from&lt;br&gt;
each, attach the business synonyms a data person would reach for knowing only&lt;br&gt;
their schema, never look at the questions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;hit@1&lt;/th&gt;
&lt;th&gt;hit@3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BM25 alone&lt;/td&gt;
&lt;td&gt;22%&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glossary written backwards&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glossary written blind, from the schema&lt;/td&gt;
&lt;td&gt;31%&lt;/td&gt;
&lt;td&gt;56%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embeddings (RRF), no glossary&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;62%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blind glossary + embeddings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;69%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned glossary (leaked the test set)&lt;/td&gt;
&lt;td&gt;81%&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reproduce it with &lt;code&gt;make bench&lt;/code&gt;. The catalog, the questions and all three&lt;br&gt;
glossaries are literals in&lt;br&gt;
&lt;a href="//evals/retrieval_benchmark.py"&gt;&lt;code&gt;evals/retrieval_benchmark.py&lt;/code&gt;&lt;/a&gt;, so disagreeing&lt;br&gt;
with any of this is a matter of editing one file and running it again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings beat a fairly written glossary, 62 against 56.&lt;/strong&gt; They also took no&lt;br&gt;
maintenance, no vocabulary decisions and no knowledge of what anyone would ask.&lt;br&gt;
Together the honest pair reaches 69, which is the best either of them manages&lt;br&gt;
without somebody peeking at the answers.&lt;/p&gt;

&lt;p&gt;That is the opposite of the conclusion I had drafted, and I am leaving the wrong&lt;br&gt;
one visible above because the gap between the two glossary rows is the actual&lt;br&gt;
finding here.&lt;/p&gt;

&lt;p&gt;The same mechanism scores 56% or 97% depending on nothing but whether it was&lt;br&gt;
written before or after somebody looked at what people were really asking. The&lt;br&gt;
difference is not the technique. It is the feedback loop.&lt;/p&gt;

&lt;p&gt;Which is a much more useful thing to know than "glossary versus vectors",&lt;br&gt;
because it tells you where to spend. Embeddings are a one-time install that buys&lt;br&gt;
you a solid floor. A glossary is a practice: you read the questions that returned&lt;br&gt;
nothing, you add three lines, retrieval gets better this week and better again&lt;br&gt;
next month. Neither replaces the other, and the one that compounds is the one&lt;br&gt;
with a person attached.&lt;/p&gt;

&lt;p&gt;Two caveats. The catalog is synthetic, so it reflects how I wrote it. And I could&lt;br&gt;
not un-see the questions while writing the blind glossary, so treat 56% as&lt;br&gt;
generous rather than conservative.&lt;/p&gt;

&lt;p&gt;Embeddings are strictly additive in the code, which matters more than the&lt;br&gt;
scoreboard: a missing extra, a failed model load or an unreachable vector store&lt;br&gt;
each degrade to keyword-only and log it. A question is never left unanswered&lt;br&gt;
because the semantic half is down.&lt;/p&gt;

&lt;p&gt;Vectors sit behind a &lt;code&gt;VectorBackend&lt;/code&gt; protocol with two implementations. The&lt;br&gt;
default is a normalised numpy matrix in memory, and for most catalogs that is the&lt;br&gt;
right answer: three thousand models at 384 dimensions is about 4.5 MB, so an&lt;br&gt;
external service would be pure operational overhead. &lt;strong&gt;Qdrant&lt;/strong&gt; earns its place&lt;br&gt;
once the catalog outgrows the process, or replicas would otherwise re-embed the&lt;br&gt;
same data on every sync, or you want metadata-filtered search pushed down. BM25&lt;br&gt;
and the fusion always stay in-process, which is what makes the swap invisible to&lt;br&gt;
everything above it.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Removing the rest of the guesses
&lt;/h2&gt;

&lt;p&gt;A human analyst explores by looking: scrolling a table, eyeballing a column,&lt;br&gt;
noticing half of it is NULL. An agent cannot do any of that, so it guesses. And&lt;br&gt;
the guesses fail &lt;em&gt;quietly&lt;/em&gt;, which is the worst available way to fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Declared joins.&lt;/strong&gt; The most common way generated SQL goes wrong is a wrong join&lt;br&gt;
key, because a plausible-but-incorrect join doesn't error. It silently fans out&lt;br&gt;
row counts and returns a confidently wrong total. Your dbt project already&lt;br&gt;
declares its foreign keys, in &lt;code&gt;relationships&lt;/code&gt; tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan_name&lt;/span&gt;
  &lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;relationships&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ref('plan_catalog')&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan_name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those exist to catch broken data in CI. QueryGate reads them for a second&lt;br&gt;
purpose their author never intended: they are the only place in a normal project&lt;br&gt;
where join keys are written down as fact rather than convention. Parsed out of&lt;br&gt;
the manifest, handed to the agent through &lt;code&gt;describe_model&lt;/code&gt;. It stops guessing&lt;br&gt;
because it no longer has to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data profiling.&lt;/strong&gt; &lt;code&gt;get_table_stats&lt;/code&gt; returns row count, null fraction,&lt;br&gt;
cardinality and ranges per column, plus a few sample rows. The agent learns that&lt;br&gt;
a column is 33% NULL, or that a &lt;code&gt;GROUP BY&lt;/code&gt; would produce 50,000 groups, for a few&lt;br&gt;
hundred tokens instead of a few thousand rows. It runs through the governed&lt;br&gt;
pipeline, which gives a nice property: &lt;strong&gt;a profile only ever describes rows the&lt;br&gt;
caller may see.&lt;/strong&gt; One tenant's maximum order is $100 and another's is $300;&lt;br&gt;
neither profile leaks the other's range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real filter values.&lt;/strong&gt; &lt;code&gt;get_filter_values&lt;/code&gt; returns the actual distinct values of&lt;br&gt;
a column, so the agent filters on &lt;code&gt;'completed'&lt;/code&gt; instead of inventing&lt;br&gt;
&lt;code&gt;'complete'&lt;/code&gt; and reporting, with total composure, that Q3 revenue was zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagination.&lt;/strong&gt; A truncated result returns &lt;code&gt;next_cursor&lt;/code&gt;. The cursor is opaque&lt;br&gt;
and stateless, with no server-side registry to size, expire, or leak between&lt;br&gt;
tenants, and carries a fingerprint of the query it belongs to. Replay it against&lt;br&gt;
different SQL and it's refused, because silently returning the wrong page is&lt;br&gt;
worse than an error.&lt;/p&gt;

&lt;p&gt;None of this is glamorous. All of it is the difference between a demo and&lt;br&gt;
something you'd let a colleague use unsupervised.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Don't let the model decide what "revenue" means
&lt;/h2&gt;

&lt;p&gt;Everything so far helps the model write SQL that runs. This one is about SQL that&lt;br&gt;
agrees with the board deck.&lt;/p&gt;

&lt;p&gt;"Completed revenue" is not a fact about the schema. It is a decision somebody&lt;br&gt;
made: which statuses count, whether refunds net off, what grain it is measured&lt;br&gt;
at. An agent handed a table and the word "revenue" will produce &lt;em&gt;a&lt;/em&gt; number, and&lt;br&gt;
it will be defensible, and it will disagree with finance by four percent for a&lt;br&gt;
reason nobody can reconstruct three weeks later.&lt;/p&gt;

&lt;p&gt;So definitions live in dbt's semantic layer, owned by a person:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;completed_revenue&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Completed Revenue&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Total USD from completed orders only. The canonical revenue number.&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;simple&lt;/span&gt;
  &lt;span class="na"&gt;type_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;measure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;order_amount&lt;/span&gt;
  &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Dimension('order__status')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'completed'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dbt parse&lt;/code&gt; compiles that to &lt;code&gt;semantic_manifest.json&lt;/code&gt;, which QueryGate ingests&lt;br&gt;
alongside the rest of the catalog.&lt;/p&gt;

&lt;p&gt;Surfacing the definition to the agent is not enough on its own, and this took me&lt;br&gt;
a while to accept. Hand a model an &lt;code&gt;expr&lt;/code&gt; and a &lt;code&gt;filter&lt;/code&gt; and ask it to assemble&lt;br&gt;
the SQL, and most of the time it will, and occasionally it will drop the filter&lt;br&gt;
or use &lt;code&gt;sum&lt;/code&gt; where the definition says &lt;code&gt;count(distinct)&lt;/code&gt;. The definition was&lt;br&gt;
never the hard part. So &lt;code&gt;get_metric&lt;/code&gt; &lt;strong&gt;compiles it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;get_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"completed_revenue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;-- the server writes:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;completed_revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"analytics"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"customer_orders"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;                       &lt;span class="c1"&gt;-- from the definition&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;customer_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;       &lt;span class="c1"&gt;-- from governance&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"region"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk171ggud73l5nzpvpct3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk171ggud73l5nzpvpct3.png" alt="A carved printing plate on a table with three identical clean impressions pressed from it, and further along the same table four hand-drawn attempts at the same shape, each slightly different from the others."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The agent decides what to ask for. A person decided what it means.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The agent decides &lt;em&gt;what to ask for&lt;/em&gt;. A person decided &lt;em&gt;what it means&lt;/em&gt;. The server&lt;br&gt;
is the only thing that turns the second into SQL, and it still runs the full&lt;br&gt;
governed pipeline, so a metric cannot be used to sidestep row or column security.&lt;/p&gt;

&lt;p&gt;There is a stricter mode on top, for the audience that will quote the number as&lt;br&gt;
official. Principals holding a role in &lt;code&gt;QG_CERTIFIED_ONLY_ROLES&lt;/code&gt; may read metrics&lt;br&gt;
the data team certified in dbt, and nothing else. No ad-hoc SQL, no uncertified&lt;br&gt;
metric. The refusal is a redirect rather than a wall: it names the certified&lt;br&gt;
metrics that do exist, so the agent asks an answerable question instead of&lt;br&gt;
guessing an aggregation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this half buys you
&lt;/h2&gt;

&lt;p&gt;None of the above is a security control. A perfectly grounded query against the&lt;br&gt;
right table with the right join keys and the correct metric definition will&lt;br&gt;
still happily return another tenant's rows if nothing stops it.&lt;/p&gt;

&lt;p&gt;That is the other half, and it is a completely different kind of engineering:&lt;br&gt;
where this half is about giving the model more, that half is about trusting it&lt;br&gt;
with nothing. It is &lt;a href="https://dev.to/arif_ismailov_6535c00c21c/i-gave-an-llm-the-keys-to-a-multi-tenant-database-5hjc"&gt;the second article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;QueryGate is MIT-licensed, Python 3.12, built on FastMCP, sqlglot and dbt, with&lt;br&gt;
Postgres, DuckDB, BigQuery and Snowflake adapters. Clone it, &lt;code&gt;docker compose up&lt;/code&gt;,&lt;br&gt;
and ask it something in plain English.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://www.linkedin.com/in/arif-ismailov-819949229/" rel="noopener noreferrer"&gt;Arif Ismailov&lt;/a&gt;. If you&lt;br&gt;
work on LLM data access, MCP servers, or multi-tenant analytics, I'd like to&lt;br&gt;
compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sql</category>
      <category>dbt</category>
      <category>python</category>
    </item>
  </channel>
</rss>
