The failure mode
A recruitment operation runs dozens of concurrent hiring projects. The questions people actually have are trivially expressible in speech and annoying to express in a UI: how many candidates from this project are still in pre-screening? which pipelines have had no movement in ten days? who did we mark unreachable last month?
Building a filter for each question does not converge. Every answer produces a new question, and the recruiter who wanted it has already gone back to the spreadsheet.
So you give them a text box that talks to a model. The temptation — and this is a real product decision, not a strawman — is to have the model answer in prose from retrieved rows. That version is wrong for a different reason: pipeline questions are counting questions, and a language model is a bad calculator over a hundred candidate records. The model should emit SQL, not prose. Then a database does the arithmetic and the model never sees the answer at all.
Which leaves the actual problem: a language model is now writing SQL against a multi-tenant production schema.
Why prompt instructions are not the answer
You can tell a model "only generate SELECT statements" and "always filter by the current company" and it will comply most of the time. Most of the time is not a security boundary.
Prompt injection — first on the OWASP Top 10 for LLM applications — is not an exotic attack here. A candidate's CV is untrusted text that the system has already parsed and stored. A note field is untrusted text. Anything that can end up inside the model's context is a place someone can write ignore the previous instruction.
And even with no adversary at all, a model that occasionally emits DELETE or forgets a tenant filter is a model that will eventually do it on the day nobody is watching.
The rule I settled on: treat every generated statement as hostile input, and let the guardrail layer, not the prompt, decide what runs.
The mechanism
The layer does five things, in this order:
1. Reject before you parse further. Mutation keywords, statement chaining, SQL comments, catalog tables and dangerous functions are all grounds for immediate rejection. Comments matter more than people expect: -- is how you smuggle the rest of a statement past a naive check.
2. Allow-list the surface. The model is given a schema description covering only the tables it may query, and the guardrail independently verifies that every table and column referenced in the output is on that list. The prompt describes the surface; the guardrail enforces it. If those two ever disagree, the guardrail wins.
3. Force a limit. Every query carries a LIMIT. If the model did not write one, the layer adds it. This is not about performance — it is about a wrong-but-valid query being a small mistake instead of a full table export.
4. Bind the tenant, do not interpolate it.
// The model never sees this value and cannot write it into the statement.
const rows = await db.$queryRawUnsafe(
wrapWithTenantScope(validatedSql), // ... AND "companyId" = $1
session.companyId
);This is the load-bearing line of the whole feature. If the tenant identifier is a string the model writes, one successful injection reads another company's candidates. If it is a bound parameter applied by the wrapper, an injected instruction has nothing to reach.
5. Exclude personal columns by default. Names, phone numbers and email addresses are off the allow-list unless the user explicitly asked for people. "How many candidates are in interview" should not return a list of humans.
The edge cases that shaped it
Bilingual questions, one schema. Users ask in Turkish and English, sometimes in the same sentence. The schema description in the prompt stays English — the model maps the question onto it. Translating the schema per locale sounds helpful and quietly doubles the surface you have to keep in sync with the guardrail's allow-list.
Authorisation is not the same as tenancy. Tenant scoping stops company A reading company B. It does nothing about a recruiter reading a project they are not on. In this system permissions run on two orthogonal axes — what a user may do (18 discrete permission keys, from a system role or a fully custom one) and where they may do it (all company projects, or only their own) — resolved from the database per request rather than baked into the session token. A revoked permission takes effect on the next request instead of at token expiry. The query layer inherits that scope; it does not invent its own.
The model is one of four AI touchpoints, and the cheapest to get wrong. The same product parses CVs with Gemini, scores candidates against a job description on a weighted rubric, and writes client-facing summary PDFs. Parsing errors are visible and correctable. A silently over-broad query is not visible at all, which is why this is the piece that got a dedicated layer.
Rejected queries are a product signal. When the guardrail rejects, the user sees a plain message rather than a stack trace — but the rejection reason is recorded. A cluster of rejections for one table usually means the allow-list is too narrow for a question people genuinely have, not that people are attacking the system.
Limits, and what I would do differently
Rejection is not detection. The layer blocks unsafe statements; it does not tell you that someone tried. Distinguishing a model that miscompiled a hard question from a deliberate probe needs behaviour analysis I have not built.
An allow-list is maintenance. Every new table is a decision, and there is real pressure to widen the list to make a demo work. The mitigation is that widening it is a code change in review, not a config toggle in an admin panel.
Read-only is not free. Long analytical queries on the primary database are a noisy-neighbour problem waiting to happen. At current volume it is fine; the correct shape at scale is a replica.
The general rule transfers to any "chat with your data" feature: the model's job is to draft a statement, and the boundary is what you do with it before it touches the database.