I build text-to-SQL agents on Oracle and Postgres for a living. Every one of them had the same bug, and it wasn’t in my code. It was in the order of operations.
The bug
The schema goes into the prompt before the query runs. Row-level security runs when the query runs. So the model sees a table the user can’t read, writes perfectly valid SQL against it, the database returns zero rows, and the agent says “no records found”. A wrong answer, delivered with confidence. Vanna (23k stars, archived March 2026) applied identity exactly there: at execution, after the model had seen everything.
The fix
Apply identity at selection. Decide which tables the model is shown, per caller, before any SQL exists. A restricted table isn’t ranked low — it’s absent.
from schemagate import Catalog, Principal
cat = Catalog().bootstrap("postgresql://localhost/app")
cat.restrict("hr_compensation", roles=["payroll"])
analyst = Principal("okta:jdoe", roles={"analyst"})
cat.select("salary by employee", principal=analyst).table_names # no hr_compensation
pip install schemagate — one dependency, no API key, any SQLAlchemy database.
The side effect that pays for it
You’re now sending ~6 tables instead of the schema dump. Measured on the test schemas: 65–79% fewer prompt tokens on small ones, 97% on a 260-object one (16,095 → 444 per question). The selector never calls a model — BM25 plus a hashed embedder, offline, milliseconds.
What broke while building it
Six invented schemas found ten bugs before release. My favourite: a three-column orders_bkp outranked the real orders table, because short documents win cosine similarity. Backup and staging copies now rank below the object they shadow. The full list is in TESTING.md.
Where it plugs in
MCP server for Claude Desktop and Cursor, a LangChain retriever, a native Oracle 23ai VECTOR store, and a browser demo that needs no install: https://ashishsinha1602.github.io/schemagate/
Repo: https://github.com/ashishsinha1602/schemagate — tell me where it breaks on your schema.
Top comments (1)
Apply identity at selection rather than at execution is the same lesson RAG teams learn the hard way: filter before retrieval, not after, or the permission boundary holds while the answer still leaks. Here it leaks as a confident no records found, which is arguably worse than an error because the user believes it. Two adjacent cases worth handling explicitly. Column-level restrictions inside a table the user can otherwise read - showing the schema with the salary column in it means the model will happily reference it, so the redaction has to happen in the schema the model sees, not just in the result set. And schema metadata itself is disclosure: a table named hr_compensation_2027_layoffs tells the reader something even when zero rows come back. Absence rather than low ranking is the right call for both.