<?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: Ashish sinha</title>
    <description>The latest articles on DEV Community by Ashish sinha (@ashish_sinha_5241c7673d93).</description>
    <link>https://dev.to/ashish_sinha_5241c7673d93</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%2F4114397%2F3524a3c8-07e7-4ac0-a016-fd931fa4e63d.jpg</url>
      <title>DEV Community: Ashish sinha</title>
      <link>https://dev.to/ashish_sinha_5241c7673d93</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashish_sinha_5241c7673d93"/>
    <language>en</language>
    <item>
      <title>What one month of public benchmarks says about my text-to-SQL access layer</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 24 Oct 2026 03:20:24 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/what-one-month-of-public-benchmarks-says-about-my-text-to-sql-access-layer-4n91</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/what-one-month-of-public-benchmarks-says-about-my-text-to-sql-access-layer-4n91</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; is an open-source layer for text-to-SQL that starts from one question: &lt;strong&gt;what is this caller allowed to see?&lt;/strong&gt; It shows the model only the tables the current user may read, before any SQL is written, reads the permissions from your database's own grants, and refuses a query that touches anything else before it reaches the database.&lt;/p&gt;

&lt;p&gt;This month I ran it on public benchmarks. Every number below comes from that benchmark's own evaluator, and the predictions are in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access control
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RBAC Text-to-SQL&lt;/strong&gt; (SIGMOD '27): &lt;strong&gt;90.1 AC-F1&lt;/strong&gt;, against 88.1 for the paper's best setup with the same model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spider-ACL&lt;/strong&gt;: &lt;strong&gt;0.984 refusal F1&lt;/strong&gt; over all 19,624 question-and-role pairs; best published 0.933.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 queries read data the role was not granted&lt;/strong&gt;, out of 8,128 answered.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  BIRD
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dev set, all 1,534 questions: 77.1% execution accuracy with one model call per question.&lt;/strong&gt; No candidate voting, no agent loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mini-Dev: 81.0 / 72.6 / 72.4&lt;/strong&gt; on SQLite / PostgreSQL / MySQL, above the current listed leaders. The leaderboard pull request is waiting on the maintainers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden test set:&lt;/strong&gt; submitted; the BIRD team runs it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Spider 2.0
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQLite questions (135): 71.9%&lt;/strong&gt; with one call per question. The best published agent on the same subset reports 57.8% &lt;a href="mailto:Pass@1"&gt;Pass@1&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The BigQuery and Snowflake runs for a full leaderboard entry are under way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Oracle
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UniQL, Oracle dialect, 1,534 questions: 64.5% on the first try&lt;/strong&gt;, against a best published baseline of 49.6. Two prompt rules about Oracle syntax did most of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Adoption
&lt;/h2&gt;

&lt;p&gt;About &lt;strong&gt;10,000 downloads&lt;/strong&gt; on PyPI so far (pypistats, mirrors excluded). Apache-2.0. Works with Oracle, PostgreSQL, SQL Server, MySQL and SQLite, as an MCP server, a LangChain retriever or a CLI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;schemagate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What these numbers are not: the Mini-Dev rows are self-reported until BIRD merges them, single runs vary by about a point, and the Spider 2.0 figure is one third of that benchmark. The details and every caveat are on the &lt;a href="https://ashishsinha1602.github.io/schemagate/benchmarks/" rel="noopener noreferrer"&gt;benchmarks page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate&lt;/a&gt; · a star helps other people find it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>security</category>
      <category>sql</category>
    </item>
    <item>
      <title>77.1% on BIRD dev with one model call per question, and a test-set submission in</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:55:10 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/771-on-bird-dev-with-one-model-call-per-question-and-a-test-set-submission-in-5g6p</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/771-on-bird-dev-with-one-model-call-per-question-and-a-test-set-submission-in-5g6p</guid>
      <description>&lt;p&gt;&lt;a href="https://bird-bench.github.io/" rel="noopener noreferrer"&gt;BIRD&lt;/a&gt; is the text-to-SQL benchmark the serious systems compete on: 1,534 dev questions over 11 real databases, scored by execution accuracy, with a hidden test set that only the BIRD team can run. I ran &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; on the full dev set this week and sent the test-set submission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dev set, all 1,534 questions, BIRD's own evaluator: 77.12% execution accuracy&lt;/strong&gt; (simple 80.97, moderate 71.34, challenging 71.03), with Claude Opus 5.5.&lt;/p&gt;

&lt;p&gt;What makes that number interesting is the cost. Per question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;schemagate reflects the database and picks the tables the question needs;&lt;/li&gt;
&lt;li&gt;the model sees only those tables' DDL and BIRD's column notes;&lt;/li&gt;
&lt;li&gt;the model writes &lt;strong&gt;one&lt;/strong&gt; query; if SQLite errors, it sees the error and fixes it (that happened once in 1,534).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's one model call per question, about 2,100 prompt tokens, no candidate generation, no voting, no agent loop. The systems at the top of the main leaderboard (82.95 on the test set) typically spend 5–20 calls per question. We're submitting to find out where one call lands on the hidden test set; BIRD's turnaround for API-based methods is 1–5 days, and I'll post the verified number either way.&lt;/p&gt;

&lt;p&gt;Three things worth knowing before you compare:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A re-run of the same code gave 80.20 on Mini-Dev where the first run gave 81.00.&lt;/strong&gt; Opus is not deterministic run to run; treat single-run numbers as ±1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BIRD's dev set has known annotation errors&lt;/strong&gt; (Jin et al., VLDB 2026 found problems in about half of Mini-Dev's reference answers). Every entry is scored against the same answers, so rankings hold, but absolute numbers are softer than they look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The leaderboard is self-reported for dev and Mini-Dev.&lt;/strong&gt; Our Mini-Dev rows (81.0 / 72.6 / 72.4 on SQLite / PostgreSQL / MySQL, above the current #1 in each) are in &lt;a href="https://github.com/bird-bench/bird-bench.github.io/pull/251" rel="noopener noreferrer"&gt;PR #251&lt;/a&gt; awaiting the maintainers. The test set is the only part BIRD runs itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why bother with a one-call system at all? Because schemagate's job isn't the leaderboard; it's making sure a caller never sees a table they can't read, and never gets a query run that touches one. On the access-control benchmarks (RBAC Text-to-SQL, Spider-ACL) it has the best published results, with zero ungranted reads in 8,128 queries. The BIRD number shows that layer costs nothing in accuracy.&lt;/p&gt;

&lt;p&gt;Predictions, evaluator output and the exact submission package: &lt;a href="https://github.com/ashishsinha1602/schemagate/tree/main/benchmarks/results/bird_dev" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate/tree/main/benchmarks/results/bird_dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>llm</category>
      <category>sql</category>
    </item>
    <item>
      <title>Text-to-SQL on Oracle: two prompt rules took a light model from 35% to 64%</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Tue, 20 Oct 2026 02:46:49 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/text-to-sql-on-oracle-two-prompt-rules-took-a-light-model-from-35-to-64-3mpc</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/text-to-sql-on-oracle-two-prompt-rules-took-a-light-model-from-35-to-64-3mpc</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/JerryGao818/UniQL" rel="noopener noreferrer"&gt;UniQL&lt;/a&gt; is a new benchmark that translates BIRD's 1,534 text-to-SQL questions into 16 SQL dialects. Its Oracle column is hard. The best published result is &lt;strong&gt;49.61%&lt;/strong&gt; (Claude Sonnet 4.5), and Gemini 2.5 Pro scores 26.14%.&lt;/p&gt;

&lt;p&gt;I ran &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; on it with a light model, &lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt;. The scores below come from UniQL's own evaluator, on Oracle Free 23ai.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;UniQL Oracle, 1,534 questions&lt;/th&gt;
&lt;th&gt;First try&lt;/th&gt;
&lt;th&gt;With error repair&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;schemagate + Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;35.0&lt;/td&gt;
&lt;td&gt;69.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;+ two Oracle rules in the prompt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;67.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best published (Claude Sonnet 4.5, single try)&lt;/td&gt;
&lt;td&gt;49.6&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The first run looked good for the wrong reason.&lt;/strong&gt; 69.9% was mostly schemagate's repair loop: run the query, show Oracle's error to the model, let it fix it. On a first try alone, Flash got 35%. That's below the bare models, and not a fair comparison with their single-try numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two mistakes caused almost all of it.&lt;/strong&gt; Of the 694 failed first queries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;551 wrote &lt;code&gt;FROM account AS a&lt;/code&gt;. Oracle doesn't allow &lt;code&gt;AS&lt;/code&gt; before a table alias.&lt;/li&gt;
&lt;li&gt;100 used a column named &lt;code&gt;DATE&lt;/code&gt; or &lt;code&gt;NUMBER&lt;/code&gt; without quotes. Both are reserved words.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So schemagate now tells the model Oracle's rules whenever the target is Oracle. First-try accuracy went from &lt;strong&gt;35.0% to 64.5%&lt;/strong&gt;, about 15 points above the best published model, with a lighter one. Failed first queries dropped from 694 to 85. Other databases' prompts are unchanged.&lt;/p&gt;

&lt;p&gt;The lesson I'd take: measure before you tune, and report the number that matches what others report. A repair loop is a fine design, but it doesn't belong in a column of single-try results.&lt;/p&gt;

&lt;p&gt;Submitted to the UniQL leaderboard: &lt;a href="https://github.com/JerryGao818/UniQL/issues/2" rel="noopener noreferrer"&gt;JerryGao818/UniQL#2&lt;/a&gt;. Predictions, evaluator output and the workflow that reproduces it: &lt;a href="https://github.com/ashishsinha1602/schemagate/tree/main/benchmarks/results/uniql_oracle" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate/tree/main/benchmarks/results/uniql_oracle&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>llm</category>
      <category>sql</category>
    </item>
    <item>
      <title>schemagate tops BIRD Mini-Dev in SQLite, PostgreSQL and MySQL</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Mon, 19 Oct 2026 13:28:50 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/schemagate-tops-bird-mini-dev-in-sqlite-postgresql-and-mysql-372d</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/schemagate-tops-bird-mini-dev-in-sqlite-postgresql-and-mysql-372d</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/bird-bench/mini_dev" rel="noopener noreferrer"&gt;BIRD Mini-Dev&lt;/a&gt; is 500 text-to-SQL questions over 11 real databases, in three dialects. It is scored by execution accuracy: run the query, and compare its rows with the gold query's rows. I ran &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; on all of it. Every number below comes from BIRD's own evaluator.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;schemagate + Claude Opus 5.5&lt;/th&gt;
&lt;th&gt;#1 until now&lt;/th&gt;
&lt;th&gt;that entry's model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;75.6 (Jitto Build)&lt;/td&gt;
&lt;td&gt;Claude Opus 5.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;65.8 (Ontology2SQL)&lt;/td&gt;
&lt;td&gt;deepseek-v4-flash, as a 12-step agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;49.2 (TA + GPT-4)&lt;/td&gt;
&lt;td&gt;GPT-4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;SQLite is the like-for-like result.&lt;/strong&gt; The previous #1 uses the same model (per JamLabs' own LiveSQLBench listing), and schemagate is 5.4 points ahead.&lt;/p&gt;

&lt;p&gt;I also ran it with light models, because a benchmark that only a frontier model wins says little about the tool:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SQLite&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;schemagate + Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;63.6&lt;/td&gt;
&lt;td&gt;55.6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54.8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;schemagate + Claude Haiku 5.5&lt;/td&gt;
&lt;td&gt;61.8&lt;/td&gt;
&lt;td&gt;55.0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54.4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On MySQL, schemagate with a light model still beats the previous best (GPT-4, 49.2). On PostgreSQL it doesn't. A light model running as an agent (65.8) beats a light model writing one query.&lt;/p&gt;

&lt;p&gt;What schemagate does per question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;picks the tables the question needs;&lt;/li&gt;
&lt;li&gt;gives the model only their DDL and BIRD's column notes;&lt;/li&gt;
&lt;li&gt;runs the query, and shows the model any error to fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no voting across runs, and nothing reads the gold SQL. The leaderboard PR is &lt;a href="https://github.com/bird-bench/bird-bench.github.io/pull/251" rel="noopener noreferrer"&gt;bird-bench.github.io#251&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;These are self-reported, as all Mini-Dev entries are.&lt;/li&gt;
&lt;li&gt;BIRD has been public since 2023, so any LLM may have seen it.&lt;/li&gt;
&lt;li&gt;The MySQL evaluator was changed only to connect over TCP on Windows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Predictions, evaluator output and code: &lt;a href="https://github.com/ashishsinha1602/schemagate/tree/main/benchmarks/results/bird_minidev" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate/tree/main/benchmarks/results/bird_minidev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>database</category>
      <category>mysql</category>
      <category>sql</category>
    </item>
    <item>
      <title>A benchmark found a bug in my access-control check. After the fix it scored 0.984.</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:29:53 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/a-benchmark-found-a-bug-in-my-access-control-check-after-the-fix-it-scored-0984-1fnj</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/a-benchmark-found-a-bug-in-my-access-control-check-after-the-fix-it-scored-0984-1fnj</guid>
      <description>&lt;p&gt;schemagate sits between an LLM and a database. It shows the model only the tables and columns the user is allowed to read, and before any generated SQL runs, it checks that the query doesn't touch anything else.&lt;/p&gt;

&lt;p&gt;I ran it on Spider-ACL, a dataset from an EACL 2026 paper (&lt;a href="https://aclanthology.org/2026.findings-eacl.316/" rel="noopener noreferrer"&gt;Klisura et al.&lt;/a&gt;). It takes Spider's questions, gives each one four user roles as real PostgreSQL GRANTs, and labels every question with what Postgres itself said when the reference query ran as that role: allowed or "permission denied". 19,624 question-and-role pairs. The job: answer the allowed ones, refuse the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first run was bad
&lt;/h2&gt;

&lt;p&gt;A 200-question pilot scored an F1 of 0.83. It was refusing questions it should have answered.&lt;/p&gt;

&lt;p&gt;32 of the 33 wrong refusals had the same cause, and it was mine. The check that reads table names out of a query handled &lt;code&gt;FROM students as t1&lt;/code&gt; fine, but not &lt;code&gt;FROM students AS T1&lt;/code&gt;. One regex was case-sensitive. With upper-case &lt;code&gt;AS&lt;/code&gt;, it read &lt;code&gt;T1&lt;/code&gt; as a second table, couldn't find a table called T1, and refused the query as "not available to this caller".&lt;/p&gt;

&lt;p&gt;Most generated SQL writes &lt;code&gt;AS&lt;/code&gt; in capitals. So real users would have hit this too. One flag, a regression test, and a check that the tests fail without the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the fix, all 19,624
&lt;/h2&gt;

&lt;p&gt;Same pipeline, Claude Sonnet 5.5, every record:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;refusal F1&lt;/th&gt;
&lt;th&gt;answered a forbidden question&lt;/th&gt;
&lt;th&gt;read data the role wasn't granted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;schemagate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.984&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 of 8,128 answered&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paper's best (fine-tuned Mistral-7B)&lt;/td&gt;
&lt;td&gt;0.933&lt;/td&gt;
&lt;td&gt;not reported&lt;/td&gt;
&lt;td&gt;not reported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 600 records the pilot never touched: 0.986.&lt;/p&gt;

&lt;p&gt;The zero in the last column I didn't take on trust. I parsed every answered query with a separate SQL parser and compared each table and column against the role's GRANTs. None outside them.&lt;/p&gt;

&lt;p&gt;The 1.8% are questions the model answered from columns it was allowed to see. Some are fine (the reference query fetched a column the question never asked for). Some are near-miss answers. That's what's left to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The paper's systems used 2024-25 models. This is a 2026 model, so part of the gap is the model.&lt;/li&gt;
&lt;li&gt;This scores the answer-or-refuse decision, not whether each answer is right.&lt;/li&gt;
&lt;li&gt;The paper's BIRD half isn't run yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;My own tests passed. A benchmark someone else wrote, with labels from Postgres itself, found a bug in two days. If you build access control for LLMs, run it against labels you didn't make.&lt;/p&gt;

&lt;p&gt;Fixed in schemagate 1.6.1. Every decision and query: &lt;a href="https://github.com/ashishsinha1602/schemagate/tree/main/benchmarks/results/acl" rel="noopener noreferrer"&gt;results&lt;/a&gt;. Write-up: &lt;a href="https://ashishsinha1602.github.io/schemagate/rbac-benchmark/" rel="noopener noreferrer"&gt;results page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>I ran my text-to-SQL access control on a benchmark I didn't write</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 17 Oct 2026 02:46:35 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/i-ran-my-text-to-sql-access-control-on-a-benchmark-i-didnt-write-23n4</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/i-ran-my-text-to-sql-access-control-on-a-benchmark-i-didnt-write-23n4</guid>
      <description>&lt;p&gt;Every number I had published for schemagate came from my own tests. This month a SIGMOD'27 paper released a public benchmark for exactly the problem schemagate exists for: &lt;a href="https://arxiv.org/abs/2607.22115" rel="noopener noreferrer"&gt;Benchmarking Text-to-SQL under Role-Based Access Control&lt;/a&gt;. So I ran it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;Each question comes with a role and a list of the columns that role may read. If the answer needs a column the role can't see, the right reply is "Sorry, I cannot answer." The authors score it with their own evaluator. I didn't change a line of it.&lt;/p&gt;

&lt;p&gt;Their main finding is uncomfortable for tools like mine. If you just hide the restricted columns, models still answer. They patch something together from the columns that are left. On BIRD, their best "hide the schema" setup still answered 27% of the questions it should have refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  What schemagate does differently
&lt;/h2&gt;

&lt;p&gt;Three things happen for every question:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The role's policy becomes visibility. The model never sees a hidden table or column, not even its name.&lt;/li&gt;
&lt;li&gt;One line tells the model that its view is partial, without saying what is missing. If the question needs data it can't see, it should refuse instead of improvising.&lt;/li&gt;
&lt;li&gt;Every query passes a scope check before it runs. SQL that touches anything outside the policy is refused.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Same model as the paper's best BIRD run (Claude Sonnet 4.5), five trials, the authors' scoring:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AC-F1&lt;/th&gt;
&lt;th&gt;answered when it should refuse&lt;/th&gt;
&lt;th&gt;SQL that read forbidden data&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BIRD, schemagate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BIRD, paper's best&lt;/td&gt;
&lt;td&gt;88.1&lt;/td&gt;
&lt;td&gt;7.4%&lt;/td&gt;
&lt;td&gt;not reported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spider, schemagate&lt;/td&gt;
&lt;td&gt;93.0&lt;/td&gt;
&lt;td&gt;5.1%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spider, paper with the same model&lt;/td&gt;
&lt;td&gt;93.0&lt;/td&gt;
&lt;td&gt;4.6%&lt;/td&gt;
&lt;td&gt;not reported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spider, paper's best (DeepSeek V3.2-Reasoning)&lt;/td&gt;
&lt;td&gt;95.9&lt;/td&gt;
&lt;td&gt;2.0%&lt;/td&gt;
&lt;td&gt;not reported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On BIRD it beats the best of the paper's thirteen models. On Spider it ties the same model and is behind the paper's best Spider result (95.9, DeepSeek V3.2-Reasoning). In neither did any query read data the role wasn't allowed to see. The scope check makes that a guarantee rather than a measured rate.&lt;/p&gt;

&lt;p&gt;And the paper's best setup shows the model the &lt;em&gt;whole&lt;/em&gt; schema plus the policy. schemagate gets these numbers without the model ever seeing what is restricted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each piece is worth
&lt;/h2&gt;

&lt;p&gt;Taking the scope check out doubled the bad answers on BIRD (5.4% to 11.3%). The one-line notice was tested on 100 questions: 16% bad answers without it, 5% with it.&lt;/p&gt;

&lt;p&gt;One surprise: giving the model BIRD's column descriptions made things &lt;strong&gt;worse&lt;/strong&gt;. Better notes on the visible columns helped it find workarounds for questions it should have refused.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;I chose the notice's wording on 100 BIRD questions that are part of the test set. Spider played no part in any choice, so it's the cleaner of the two results.&lt;/li&gt;
&lt;li&gt;One model, where the paper tested thirteen.&lt;/li&gt;
&lt;li&gt;My BIRD databases are the 2024 release; the paper used the 2025 one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The notice ships on by default in schemagate 1.6.0. To reproduce the runs: &lt;code&gt;benchmarks/rbac_text2sql.py&lt;/code&gt;, plus the evaluator's reports in &lt;code&gt;benchmarks/results/rbac/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pip install schemagate&lt;/code&gt; · &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>llm</category>
      <category>sql</category>
    </item>
    <item>
      <title>My LLM reranker did nothing, and every test passed</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Fri, 16 Oct 2026 22:00:29 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/my-llm-reranker-did-nothing-and-every-test-passed-20am</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/my-llm-reranker-did-nothing-and-every-test-passed-20am</guid>
      <description>&lt;p&gt;schemagate picks the few tables a text-to-SQL model should see out of hundreds. It ranks them with plain maths first (BM25, embeddings, name matching, fused with RRF). Then, if you give it an API key, a model can reorder the top 20. That second step is the reranker.&lt;/p&gt;

&lt;p&gt;I was asked why it uses RRF and no LLM reranking. I said it has one, &lt;code&gt;--rerank&lt;/code&gt;, and went to show it working. It didn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I saw
&lt;/h2&gt;

&lt;p&gt;I ran the same three questions with and without &lt;code&gt;--rerank&lt;/code&gt; on claude-haiku-5-5. The table order came out identical all three times.&lt;/p&gt;

&lt;p&gt;That can happen if the maths already got it right. So I wrapped the provider to log every call and what came back. The model was being called. It just never gave an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SYSTEM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;120 tokens is plenty for a reply like &lt;code&gt;3, 1, 7, 2, 5&lt;/code&gt;. It was plenty when I wrote it.&lt;/p&gt;

&lt;p&gt;Claude 5 models think before they answer, and that thinking counts against &lt;code&gt;max_tokens&lt;/code&gt;. They used up all 120 tokens thinking and stopped before writing a single number. The provider raised an error. The reranker caught it, as designed:&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;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# never worse than the maths alone
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and handed back the maths order. No crash, no warning, just the same result you'd get without &lt;code&gt;--rerank&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the tests didn't catch it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tests/test_rerank.py&lt;/code&gt; had good tests. A model that errors leaves the order alone. A model that replies with garbage leaves the order alone. A model can't promote a table the caller isn't allowed to see. All true, all passing.&lt;/p&gt;

&lt;p&gt;Every one of them tested that failing is safe. None tested that the model actually got to answer. When the fallback is designed to be invisible, total failure looks exactly like success.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;max_tokens&lt;/code&gt; is now 1024. The test suite now has a fake model that refuses to answer below 512 tokens, so if anyone lowers it again, a test fails. And the benchmark now logs whether the model answered on each question, not just the final score.&lt;/p&gt;

&lt;p&gt;I checked it on the released package, not my working copy: 1.4.4 from PyPI got 1 of 3 model answers and changed 0 of 3 orders; 1.5.0 got 3 of 3 answers and changed 2 orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth fixing? The numbers
&lt;/h2&gt;

&lt;p&gt;Once it actually ran, I measured it for the first time on public benchmarks. Every database from Spider dev (876 tables) and BIRD dev in one pool, no hint which database a question belongs to. A question counts as a hit if every table the correct SQL uses is in the top 5.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set     questions   maths order   reranked   only rerank right   only maths right
spider  1034        80.3%         96.5%      169                 1
bird    1534        88.6%         96.7%      125                 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paired McNemar test, p around 1e-49 on Spider and 1e-36 on BIRD. It's the largest single gain in the benchmarks file.&lt;/p&gt;

&lt;p&gt;What this measures: the right tables reaching the prompt, not whether the final SQL was correct. One model. Spider and BIRD are public, so the model may have seen them in training. It only reorders tables the maths already shortlisted, and it sees table names and short summaries, not the answer SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I'm keeping
&lt;/h2&gt;

&lt;p&gt;If you build a "safe fallback", also log when it fires. Otherwise the feature can be 100% broken and your dashboard still looks fine.&lt;/p&gt;

&lt;p&gt;Try it: &lt;code&gt;pip install "schemagate[ai]"&lt;/code&gt;, then &lt;code&gt;schemagate demo "which customers owe us money" --rerank --provider anthropic --model claude-haiku-5-5&lt;/code&gt;. In the MCP server, set &lt;code&gt;SCHEMAGATE_MCP_RERANK=1&lt;/code&gt;. Code and full benchmark: &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate&lt;/a&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>Four supply-chain badges for a one-person open-source project, in a day</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 10 Oct 2026 23:43:25 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/four-supply-chain-badges-for-a-one-person-open-source-project-in-a-day-mn4</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/four-supply-chain-badges-for-a-one-person-open-source-project-in-a-day-mn4</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt;, an open-source library and MCP server that stops an AI agent from seeing database tables the user isn't allowed to read. It's a security tool, so "trust me" isn't good enough. A security team looking at it reasonably asks: is the project run properly, is the licensing clean, and is the package on PyPI really built from this repository?&lt;/p&gt;

&lt;p&gt;There are free, public answers to all three questions. This week I went through them in one sitting. Here's what each one took, including the parts that didn't go as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. OpenSSF Best Practices: Passing
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.bestpractices.dev" rel="noopener noreferrer"&gt;bestpractices.dev&lt;/a&gt; is the Linux Foundation's self-certification: about 60 criteria covering how you take bug reports, how you handle vulnerabilities, whether you test, whether you use static analysis. You sign in with GitHub and it pre-fills what it can detect (licence, HTTPS, public repo).&lt;/p&gt;

&lt;p&gt;The honest part is that every answer needs a justification, and most of mine were links to files that already existed: &lt;code&gt;SECURITY.md&lt;/code&gt; for private vulnerability reporting, &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; for the test policy, CodeQL for static analysis. Before answering "no open static-analysis findings", I checked the code-scanning API instead of assuming. CodeQL had zero alerts. The 19 open alerts were all Scorecard repository-settings findings, which is a different claim.&lt;/p&gt;

&lt;p&gt;One criterion I answered "Unmet": dynamic analysis. There's no fuzzer yet. It's only &lt;em&gt;suggested&lt;/em&gt; at this level, and a badge with an honest "no" in it is worth more than one without.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; passing, 100%.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. OpenSSF Baseline Level 1
&lt;/h2&gt;

&lt;p&gt;Baseline is the newer, shorter OpenSSF checklist, aimed at security controls. Almost everything was already true. The one gap was branch protection: &lt;code&gt;main&lt;/code&gt; required CI to pass but didn't stop a direct push. Requiring a pull request (zero approvals, so a solo maintainer can still merge) and turning off the admin bypass closed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; baseline-1, 100%.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. REUSE compliance (FSFE)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://reuse.software" rel="noopener noreferrer"&gt;REUSE&lt;/a&gt; checks that every file in the repository has machine-readable copyright and licence information. That matters to anyone whose legal team scans dependencies. You don't have to add a header to every file: one &lt;code&gt;REUSE.toml&lt;/code&gt; can annotate whole globs.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;reuse lint&lt;/code&gt; was the useful part, because it made me find the third-party code I'd forgotten about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a bundled BLAKE2b implementation (blakejs, CC0-1.0), inlined into &lt;strong&gt;three&lt;/strong&gt; files, not the one I remembered;&lt;/li&gt;
&lt;li&gt;a test fixture generated from the MCP registry's schema, a project that's partway through relicensing from MIT to Apache-2.0, so I declared both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then you register at api.reuse.software, confirm by email, and their checker runs against the public repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; compliant, 238 of 238 files.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SLSA Build Level 3 provenance
&lt;/h2&gt;

&lt;p&gt;This is the one that answers "is the file on PyPI really built from this repo". I used the official &lt;a href="https://github.com/slsa-framework/slsa-github-generator" rel="noopener noreferrer"&gt;slsa-github-generator&lt;/a&gt;. Its generic generator runs as an isolated reusable workflow and signs a provenance statement for whatever hashes you give it.&lt;/p&gt;

&lt;p&gt;The design choice that matters is &lt;em&gt;which&lt;/em&gt; hashes. I didn't rebuild the package in the provenance job. After the publish workflow finishes, a separate workflow downloads the exact wheel and sdist PyPI serves, checks them against PyPI's own sha256 digests, and attests those. The provenance then names the files people actually install.&lt;/p&gt;

&lt;p&gt;Then I verified it the way a user would, with the official &lt;code&gt;slsa-verifier&lt;/code&gt;, after checking the verifier binary's own hash against its published checksums. Both files from PyPI passed. A deliberately tampered file failed, and so did a wrong source repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gotcha:&lt;/strong&gt; my docs told users to verify with &lt;code&gt;--source-tag v1.3.1&lt;/code&gt;, and that &lt;strong&gt;fails&lt;/strong&gt;. A workflow triggered by &lt;code&gt;workflow_run&lt;/code&gt; runs on the default branch, so the provenance records &lt;code&gt;refs/heads/main&lt;/code&gt; and the commit, not the tag. &lt;code&gt;--source-branch main&lt;/code&gt; passes, and the commit it names is the one the tag points to. If you trigger provenance after publish, test the verification command you document, not just the generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell another solo maintainer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do them in this order.&lt;/strong&gt; Passing makes you write the policies, Baseline makes you lock down the repository, REUSE makes you find code you didn't write, and SLSA proves the build. Each step makes the next one easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check every claim against the system, not your memory.&lt;/strong&gt; Two of my assumptions were wrong: the third-party file count, and the verification flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer "Unmet" when it's unmet.&lt;/strong&gt; I'm still at 98% on OpenSSF Silver because it requires a second maintainer, and I'd rather show that than fake it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A security process gets used.&lt;/strong&gt; While writing the reply to a review comment I found a real bug, handled it through a private GitHub security advisory, and shipped the fix in a release with an advisory. One practical note: an advisory's temporary private fork doesn't run your CI, so run the full suite locally before merging from it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The badges are on the README: &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate&lt;/a&gt;. Everything above is reproducible with free tools, and a weekend is plenty.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>security</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>I gave my text-to-SQL agent a business glossary. One version helped a lot, one did nothing.</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Mon, 05 Oct 2026 01:23:14 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/i-gave-my-text-to-sql-agent-a-business-glossary-one-version-helped-a-lot-one-did-nothing-2c9o</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/i-gave-my-text-to-sql-agent-a-business-glossary-one-version-helped-a-lot-one-did-nothing-2c9o</guid>
      <description>&lt;p&gt;A text-to-SQL agent sees table and column names. It does not know that "revenue" in your company means &lt;code&gt;billing_invoice.total_net&lt;/code&gt;, only for invoices with &lt;code&gt;status = 'issued'&lt;/code&gt;. So it guesses, and the guess runs without errors and returns the wrong number.&lt;/p&gt;

&lt;p&gt;I added business terms to &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; 1.2.0 (open source, Apache-2.0) and measured whether they help. Short answer: a complete glossary helps a lot. A glossary learned from other people's questions did not help at all. Here are both results.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a term looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;synonyms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turnover&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sales&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;maps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing_invoice.total_net&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing_invoice.status = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;issued&lt;/span&gt;&lt;span class="sh"&gt;'"&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;When a question uses "turnover", two things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;billing_invoice&lt;/code&gt; is pulled into the few tables the model sees, even if no table name looks like "turnover".&lt;/li&gt;
&lt;li&gt;A line goes into the prompt, above the DDL:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What the business terms in this question mean:&lt;/span&gt;
&lt;span class="c1"&gt;-- revenue (turnover): billing_invoice.total_net; rule: billing_invoice.status = 'issued'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terms can also have broader and narrower terms ("net revenue" is a kind of "revenue"), which pulls in related tables at half weight.&lt;/p&gt;

&lt;p&gt;You don't have to type them by hand. You can import them from a dbt semantic manifest, a Snowflake semantic model, or a CSV export from Collibra or Purview:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;schemagate ontology import semantic_manifest.json &lt;span class="nt"&gt;--from&lt;/span&gt; dbt &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="nv"&gt;$DB&lt;/span&gt; &lt;span class="nt"&gt;--config&lt;/span&gt; catalog.json &lt;span class="nt"&gt;--save&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or learn them from past (question, SQL) pairs: if "refunds" keeps showing up in questions whose SQL reads &lt;code&gt;billing_credit_note&lt;/code&gt;, that becomes a suggested term.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I care about: access control
&lt;/h2&gt;

&lt;p&gt;schemagate's main job is hiding tables and columns a caller may not read, before the model sees anything. A glossary could leak them right back: "pay means &lt;code&gt;hr_compensation.annual_amount&lt;/code&gt;" tells a payroll clerk that the column exists.&lt;/p&gt;

&lt;p&gt;So a meaning line is dropped for any caller who can't see every table and column it names. The term still exists. That caller just never gets its line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;BIRD dev, 201 questions, Claude Sonnet 5, execution accuracy, the hint text withheld:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No terms&lt;/td&gt;
&lt;td&gt;44.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No terms, run again (noise check)&lt;/td&gt;
&lt;td&gt;46.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terms learned from &lt;em&gt;other&lt;/em&gt; questions' hints&lt;/td&gt;
&lt;td&gt;46.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complete glossary for this database&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The question's own hint pasted in (ceiling)&lt;/td&gt;
&lt;td&gt;61.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The complete glossary won 28 questions and lost 8 against no terms (McNemar p = 0.001). That's real.&lt;/p&gt;

&lt;p&gt;Terms learned from other questions did nothing: 46.8%, the same as running the baseline twice. BIRD's hints are mostly one-off formulas ("percentage = count where x / count"), and a formula from one question rarely fits the next. I'm reporting this because it's the result you would hit if you expected a glossary to build itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval, Spider, 876 tables pooled:&lt;/strong&gt; terms learned from half the questions raised recall@5 on the other half from 73.3% to 82.8% (49 questions better, 0 worse). Finding the right tables is where learned terms do help.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write down the 20-50 terms people really argue about (revenue, active customer, churn). That's where the 10 points came from.&lt;/li&gt;
&lt;li&gt;Learned terms are good for finding tables, not for teaching formulas.&lt;/li&gt;
&lt;li&gt;Whatever you add to the prompt, check it against the caller's permissions too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"schemagate[mcp]"&lt;/span&gt;
&lt;span class="nv"&gt;SCHEMAGATE_DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo python &lt;span class="nt"&gt;-m&lt;/span&gt; schemagate.mcp_server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works with Postgres, Oracle, MySQL, SQL Server and SQLite, as a Python library, a LangChain retriever, or an MCP server for Claude and Cursor.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate&lt;/a&gt; · In-browser demo: &lt;a href="https://ashishsinha1602.github.io/schemagate/" rel="noopener noreferrer"&gt;https://ashishsinha1602.github.io/schemagate/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you run an agent over a real warehouse: how many business terms would you need to write down before it stopped guessing?&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>sql</category>
    </item>
    <item>
      <title>Hiding the table is not enough. Your LLM can still see the salary column.</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 03 Oct 2026 20:36:51 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/hiding-the-table-is-not-enough-your-llm-can-still-see-the-salary-column-12ne</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/hiding-the-table-is-not-enough-your-llm-can-still-see-the-salary-column-12ne</guid>
      <description>&lt;p&gt;Most text-to-SQL setups protect data at the table level. The analyst cannot read &lt;code&gt;hr_compensation&lt;/code&gt;, so that table never goes into the prompt. Good.&lt;/p&gt;

&lt;p&gt;But a lot of sensitive data does not live in its own table. It lives in one column of a table everyone uses. &lt;code&gt;employees.salary&lt;/code&gt;. &lt;code&gt;customers.national_id&lt;/code&gt;. &lt;code&gt;patients.diagnosis&lt;/code&gt;. If the model sees the column name in the schema, it will happily write &lt;code&gt;SELECT salary FROM employees&lt;/code&gt;. Your database might block the query, or it might not. Either way, the model has already learned the column exists.&lt;/p&gt;

&lt;p&gt;schemagate 1.1.0 adds column rules you can write in a JSON file:&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;"restrict_column"&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;span class="nl"&gt;"employees"&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;span class="nl"&gt;"salary"&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;"hr"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"national_id"&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;"hr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"compliance"&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;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;A caller without the &lt;code&gt;hr&lt;/code&gt; role now gets the &lt;code&gt;employees&lt;/code&gt; table with &lt;code&gt;salary&lt;/code&gt; simply not there. Not marked secret. Not ranked low. Absent. The model cannot write a query against a column it was never shown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it in one minute
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;schemagate

&lt;span class="c"&gt;# the bundled demo database; this prints its sqlite:/// URL&lt;/span&gt;
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from schemagate.demo_schema import create_demo_db; print(create_demo_db())"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"restrict_column": {"hr_compensation": {"annual_amount": ["hr"]}}}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; catalog.json

schemagate &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; &amp;lt;url printed above&amp;gt; &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"salary by employee"&lt;/span&gt;
schemagate &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; &amp;lt;url printed above&amp;gt; &lt;span class="nt"&gt;--config&lt;/span&gt; catalog.json &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"salary by employee"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first prompt shows &lt;code&gt;annual_amount&lt;/code&gt;. The second does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same file works everywhere
&lt;/h2&gt;

&lt;p&gt;The command line, the local Studio page and the MCP server all read the same &lt;code&gt;catalog.json&lt;/code&gt;. So if you connect Claude, Cursor or any MCP client to your database through schemagate, the column rule applies there too. You state the policy once.&lt;/p&gt;

&lt;h2&gt;
  
  
  A typo is an error, not a silent no-op
&lt;/h2&gt;

&lt;p&gt;This one matters more than it sounds. Before 1.1.0, a block the loader did not know was quietly ignored. Write &lt;code&gt;"restrict_columns"&lt;/code&gt; with an extra &lt;code&gt;s&lt;/code&gt;, and you had a file that looked like a policy and did nothing.&lt;/p&gt;

&lt;p&gt;Now it stops with one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;schemagate: --config catalog.json: unknown block(s) 'restrict_columns'; expected any of restrict, restrict_column, hint, describe, groups
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for a table or column name that does not exist. An access rule that "succeeds" on a typo is a rule that is not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;schemagate is the step before the model writes SQL: it picks the few tables a question needs, and it drops the ones the caller may not read. That keeps prompts about 75% smaller on a big schema, and it means the model never sees what it should not.&lt;/p&gt;

&lt;p&gt;It works with Postgres, Oracle, MySQL, SQL Server and SQLite, as a Python library, a CLI, an MCP server, or a LangChain retriever (now listed in the LangChain docs).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Browser demo, no install: &lt;a href="https://ashishsinha1602.github.io/schemagate/" rel="noopener noreferrer"&gt;https://ashishsinha1602.github.io/schemagate/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pip install schemagate&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you protect columns some other way in your text-to-SQL stack, I would like to hear how. Views per role? Column-level grants? Masking in the database?&lt;/p&gt;

</description>
      <category>llm</category>
      <category>privacy</category>
      <category>security</category>
      <category>sql</category>
    </item>
    <item>
      <title>Your RLS policy returns zero rows. The view next to it returns two.</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Fri, 02 Oct 2026 06:00:24 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/your-rls-policy-returns-zero-rows-the-view-next-to-it-returns-two-e9n</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/your-rls-policy-returns-zero-rows-the-view-next-to-it-returns-two-e9n</guid>
      <description>&lt;p&gt;I have a row-level security policy that denies a user every row in a table. The&lt;br&gt;
user queries the table and gets nothing, which is right. The user queries a view&lt;br&gt;
over that same table and gets real salary data back.&lt;/p&gt;

&lt;p&gt;This is documented PostgreSQL behaviour, not a bug, and it is six years older&lt;br&gt;
than the option that fixes it. I went looking for it because I write a library&lt;br&gt;
that decides which tables to show a language model, and I wanted to know whether&lt;br&gt;
"you don't have the grant" was the same as "you can't read it". It isn't.&lt;/p&gt;

&lt;p&gt;Here is the whole thing, on PostgreSQL 16.13. Paste it into a scratch database.&lt;br&gt;
Note that roles are cluster-wide, not per-database, so if you run this twice you&lt;br&gt;
will need the teardown at the bottom first.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Ann'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;91000&lt;/span&gt;&lt;span class="p"&gt;),(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Bob'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;87000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                                   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Cy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ENG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;120000&lt;/span&gt;&lt;span class="p"&gt;),(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Di'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'HR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;70000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;sgrls_r1&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;sgrls_r2&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;p_r1&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;sgrls_r1&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;p_r2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;sgrls_r2&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;v_sales_salary&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;v_sales_salary_si&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;sgrls_r1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sgrls_r2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_sales_salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_sales_salary_si&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;sgrls_r1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sgrls_r2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sgrls_r2&lt;/code&gt; has the policy &lt;code&gt;USING (false)&lt;/code&gt;. Not a narrow policy. False. No row&lt;br&gt;
can satisfy it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;postgres=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\c&lt;/span&gt; - sgrls_r2
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;postgres=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SELECT &lt;span class="k"&gt;*&lt;/span&gt; FROM employee_salary&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt; id | name | dept | salary
----+------+------+--------
(0 rows)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct. Now the view:&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;postgres&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;v_sales_salary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt;
&lt;span class="c1"&gt;----+------+-------+--------&lt;/span&gt;
  &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Ann&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SALES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="mi"&gt;91000&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Bob&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SALES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="mi"&gt;87000&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rows, with the salaries in them, to a role whose policy admits nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;A view executes with the privileges of its &lt;strong&gt;owner&lt;/strong&gt;, not its caller. The owner&lt;br&gt;
here is &lt;code&gt;postgres&lt;/code&gt;, who is exempt from the policy. The RLS check runs as the&lt;br&gt;
owner, passes, and the rows come back. The caller's own policy is never&lt;br&gt;
consulted, because the caller never touched the table — the view did.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;security_invoker = true&lt;/code&gt; flips that: the view reads the base table as the&lt;br&gt;
caller, so the caller's policy applies. The second view in the setup has it, and&lt;br&gt;
the same role gets zero rows from it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;as &lt;code&gt;sgrls_r2&lt;/code&gt;, policy &lt;code&gt;USING (false)&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT * FROM employee_salary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT * FROM v_sales_salary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT * FROM v_sales_salary_si&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of this is undocumented. &lt;code&gt;CREATE POLICY&lt;/code&gt; says it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;permission checks and policies for the tables which are referenced by a view&lt;br&gt;
will use the view owner's rights and any policies which apply to the view&lt;br&gt;
owner, except if the view is defined using the &lt;code&gt;security_invoker&lt;/code&gt; option&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the PostgreSQL 15 release note that introduced the option is blunter still:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Previously, view accesses were always treated as being done by the view's&lt;br&gt;
owner. &lt;strong&gt;That's still the default.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The problem is the timeline. Row-level security arrived in PostgreSQL 9.5, released&lt;br&gt;
7 January 2016. &lt;code&gt;security_invoker&lt;/code&gt; arrived in PostgreSQL 15, in 2022. For six years&lt;br&gt;
there was no way to make a view respect the caller's policy, so every view written&lt;br&gt;
in that window runs as its owner — and, per the release note above, that is still&lt;br&gt;
what you get unless you opt in.&lt;/p&gt;

&lt;p&gt;If your schema has RLS on the tables and views on top of them, and nobody went&lt;br&gt;
back and added &lt;code&gt;security_invoker&lt;/code&gt;, the views are a way around the policy for&lt;br&gt;
anyone holding &lt;code&gt;SELECT&lt;/code&gt; on them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Find out whether you have this
&lt;/h2&gt;

&lt;p&gt;This lists views that read an RLS-enabled table and do &lt;strong&gt;not&lt;/strong&gt; have&lt;br&gt;
&lt;code&gt;security_invoker&lt;/code&gt;. Run it as a superuser or table owner.&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;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt;                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_get_userbyid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relowner&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;runs_as&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rls_table&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_rewrite&lt;/span&gt;  &lt;span class="n"&gt;r&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ev_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_depend&lt;/span&gt;   &lt;span class="n"&gt;d&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;
                   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;classid&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pg_rewrite'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;
                   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refclassid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pg_class'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;    &lt;span class="n"&gt;t&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refobjid&lt;/span&gt;
                   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;
                   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'v'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pg_catalog'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'information_schema'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reloptions&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'security_invoker=true'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="k"&gt;false&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the schema above it returns exactly &lt;code&gt;v_sales_salary&lt;/code&gt; and nothing else. I&lt;br&gt;
checked it against decoys: a view over a table with no RLS is not flagged, and&lt;br&gt;
neither is a &lt;code&gt;security_invoker&lt;/code&gt; view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One limitation, so you don't trust it further than it goes.&lt;/strong&gt; It follows a&lt;br&gt;
single dependency hop. A view over a &lt;em&gt;view&lt;/em&gt; over an RLS table also leaks, and&lt;br&gt;
this query will not report it. If you nest views, walk the chain. I would rather&lt;br&gt;
tell you that than have you run it, get an empty result, and conclude you are&lt;br&gt;
fine.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that caught me out
&lt;/h2&gt;

&lt;p&gt;I expected the catalogue to tell me something. It does not. Under both roles —&lt;br&gt;
the one that reads two rows and the one that reads none — &lt;code&gt;employee_salary&lt;/code&gt; is&lt;br&gt;
present in &lt;code&gt;information_schema.tables&lt;/code&gt;, and all four columns are in&lt;br&gt;
&lt;code&gt;information_schema.columns&lt;/code&gt;. Both roles hold &lt;code&gt;SELECT&lt;/code&gt; in the catalogue. The&lt;br&gt;
grant says yes, the policy says no, and the catalogue believes the grant.&lt;/p&gt;

&lt;p&gt;I ran the same shape of test on Oracle AI Database 26ai (23.26.3.3.0) with a&lt;br&gt;
&lt;code&gt;DBMS_RLS&lt;/code&gt; policy of &lt;code&gt;1 = 0&lt;/code&gt;. Same result: zero readable rows, still in&lt;br&gt;
&lt;code&gt;ALL_TABLES&lt;/code&gt;, &lt;code&gt;SELECT&lt;/code&gt; still in &lt;code&gt;ALL_TAB_PRIVS&lt;/code&gt;, four columns in&lt;br&gt;
&lt;code&gt;ALL_TAB_COLUMNS&lt;/code&gt;. Oracle has no &lt;code&gt;security_invoker&lt;/code&gt; equivalent for this; there I&lt;br&gt;
had to use proxy authentication (&lt;code&gt;ADMIN[user]&lt;/code&gt;) to see the per-caller row counts&lt;br&gt;
at all.&lt;/p&gt;

&lt;p&gt;So if you are building anything that decides what a caller may read by querying&lt;br&gt;
the data dictionary — an access audit, a data catalogue, a prompt builder for a&lt;br&gt;
SQL agent — the dictionary will overstate it. RLS is a row filter, not a&lt;br&gt;
privilege, and privileges are the only thing the dictionary records. That is&lt;br&gt;
defensible behaviour on the database's part and completely wrong as an input to&lt;br&gt;
"what is this user allowed to see".&lt;/p&gt;
&lt;h2&gt;
  
  
  MySQL has the same default and cannot have the same bug
&lt;/h2&gt;

&lt;p&gt;I tested MariaDB 10.11.14 too, because MySQL-family views have the identical&lt;br&gt;
owner-vs-caller split: &lt;code&gt;SQL SECURITY DEFINER&lt;/code&gt; against &lt;code&gt;SQL SECURITY INVOKER&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A view created with no &lt;code&gt;SQL SECURITY&lt;/code&gt; clause comes out &lt;code&gt;DEFINER&lt;/code&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;v_default&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;security_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;views&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;-- v_default  DEFINER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the default is owner-rights there as well, and a user with &lt;strong&gt;no privilege at all&lt;/strong&gt;&lt;br&gt;
on &lt;code&gt;employee_salary&lt;/code&gt; reads real salaries straight through 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="n"&gt;MariaDB&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sgtest&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;v_default&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- as a user with no grant on the table&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----+------+-------+----------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----+------+-------+----------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Ann&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SALES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;91000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Bob&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SALES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;87000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----+------+-------+----------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;INVOKER&lt;/code&gt; version of the same view refuses, as it should:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR 1142 (42000): SELECT command denied to user 'sgrls_r2'@'localhost'
for table `sgtest`.`employee_salary`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the &lt;em&gt;dictionary&lt;/em&gt; behaves differently from PostgreSQL, and this is the part worth&lt;br&gt;
knowing. For that same user, &lt;code&gt;employee_salary&lt;/code&gt; is simply &lt;strong&gt;not there&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;as a user with no grant on the table&lt;/th&gt;
&lt;th&gt;MariaDB&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rows from the DEFINER / owner-rights view&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;information_schema.tables&lt;/code&gt; for the base table&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;information_schema.columns&lt;/code&gt; for the base table&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MariaDB's &lt;code&gt;information_schema&lt;/code&gt; is privilege-filtered, so it never claims you can read&lt;br&gt;
something you cannot. PostgreSQL showed the table because the caller genuinely &lt;em&gt;held&lt;/em&gt;&lt;br&gt;
&lt;code&gt;SELECT&lt;/code&gt; — the row policy denied it separately, and the dictionary has no column for&lt;br&gt;
that.&lt;/p&gt;

&lt;p&gt;Which gives the honest answer to my own question: &lt;strong&gt;MySQL's dictionary cannot be wrong&lt;br&gt;
about row-level policies, because MySQL has none.&lt;/strong&gt; Hold &lt;code&gt;SELECT&lt;/code&gt; and you read every&lt;br&gt;
row — I confirmed it, 4 of 4, for a user who was "supposed" to see only SALES. The only&lt;br&gt;
way to filter rows per caller is a &lt;code&gt;DEFINER&lt;/code&gt; view, and then the dictionary is correct&lt;br&gt;
about the view and silent about the filter. Accurate, but not informative.&lt;/p&gt;
&lt;h2&gt;
  
  
  Teardown
&lt;/h2&gt;

&lt;p&gt;Roles outlive the database, so dropping the database is not enough:&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;DROP&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;v_sales_salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_sales_salary_si&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;employee_salary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;OWNED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sgrls_r1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;sgrls_r1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;OWNED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sgrls_r2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;sgrls_r2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two things I would like to know
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If you run the audit query on a real schema, what does it return? I am most
interested in people who get a non-empty result and did not expect one.&lt;/li&gt;
&lt;li&gt;Is there an engine where the data dictionary &lt;em&gt;does&lt;/em&gt; reflect row-level
policies, rather than just grants? I have now tested PostgreSQL, Oracle and
MariaDB; none of them do, for the different reasons above. &lt;strong&gt;SQL Server I have
not tested&lt;/strong&gt; — it has real RLS via security policies, so it is the interesting
case, and I would rather be told than guess.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;The reason I went digging: I maintain &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt;,&lt;br&gt;
which picks the tables a SQL agent is allowed to see. It had a mode that derived&lt;br&gt;
that list from grants. The test above is why that mode is no longer the secure&lt;br&gt;
one — the fail-closed path is explicit per-caller rules, not the dictionary.&lt;br&gt;
Writeup of the full test, both engines:&lt;br&gt;
&lt;a href="https://ashishsinha1602.github.io/schemagate/row-level-security/" rel="noopener noreferrer"&gt;row-level security&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>security</category>
      <category>database</category>
    </item>
    <item>
      <title>Your LangChain SQL agent sends the model every table, including the ones that caller can't read</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Wed, 30 Sep 2026 03:40:50 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/your-langchain-sql-agent-sends-the-model-every-table-including-the-ones-that-caller-cant-read-56oo</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/your-langchain-sql-agent-sends-the-model-every-table-including-the-ones-that-caller-cant-read-56oo</guid>
      <description>&lt;p&gt;A LangChain SQL agent hands the model your schema and asks it to write SQL. On a demo database with eight tables that is fine. On a real one it breaks in two ways at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema outgrows the context window.&lt;/strong&gt; I wrote about this before, on a warehouse with &lt;a href="https://dev.to/ashish_sinha_5241c7673d93/i-described-1245-tables-with-an-llm-and-retrieval-got-worse-58a"&gt;1,245 tables&lt;/a&gt; — the table listing alone did not fit, and describing every table with an LLM to help retrieval made it &lt;em&gt;worse&lt;/em&gt;, not better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the model sees tables the caller is not allowed to read.&lt;/strong&gt; This one is quieter and worse. &lt;code&gt;SQLDatabase.get_table_info()&lt;/code&gt; returns what the &lt;em&gt;connection&lt;/em&gt; can see, not what the &lt;em&gt;person asking&lt;/em&gt; can see. If your app connects as one service account and serves twenty users, every user's agent gets the full schema: salaries, PII, all of it. The model may never write SQL against those tables. It was still told they exist, and their column names went into the prompt.&lt;/p&gt;

&lt;p&gt;So I wrote a retriever.&lt;/p&gt;

&lt;h2&gt;
  
  
  SchemagateRetriever
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;schemagate&lt;/span&gt; &lt;span class="kn"&gt;import&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;Principal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;schemagate.integrations.langchain&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SchemagateRetriever&lt;/span&gt;

&lt;span class="n"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Catalog&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://localhost/app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;retriever&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SchemagateRetriever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;okta:jdoe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retriever&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue by month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It subclasses &lt;code&gt;BaseRetriever&lt;/code&gt;, so it drops into any chain that already takes a retriever. Each selected object comes back as one &lt;code&gt;Document&lt;/code&gt;: the DDL in &lt;code&gt;page_content&lt;/code&gt;, and the name, kind, score and selection reason in &lt;code&gt;metadata&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things happen before the model sees anything. The caller's grants decide which objects are candidates at all. Then the question decides which of &lt;em&gt;those&lt;/em&gt; are relevant. A table jdoe cannot read is not ranked and then filtered out — it never enters the ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The principal is bound at construction, not passed per call
&lt;/h2&gt;

&lt;p&gt;This is the part I would push back on in someone else's library, so here is the reasoning.&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="nc"&gt;SchemagateRetriever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# identity fixed here
&lt;/span&gt;&lt;span class="n"&gt;retriever&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# not here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A retriever is usually built once per request. Binding identity to the object means a chain &lt;strong&gt;cannot forget to pass it&lt;/strong&gt;. There is no call signature where the principal is optional and quietly defaults to everything. If you want a different caller, build another retriever — they are cheap.&lt;/p&gt;

&lt;p&gt;The alternative, &lt;code&gt;invoke(question, principal=...)&lt;/code&gt;, has one failure mode I did not want to ship: somebody omits the argument, the call still succeeds, and it returns the whole schema. Fail-closed beats convenient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s1"&gt;'schemagate[langchain]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Needs &lt;code&gt;langchain-core&amp;gt;=0.3&lt;/code&gt;, and it is an optional extra — if you do not use LangChain you do not pay for it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The page, with the longer version: &lt;a href="https://ashishsinha1602.github.io/schemagate/langchain/" rel="noopener noreferrer"&gt;https://ashishsinha1602.github.io/schemagate/langchain/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repo, Apache-2.0: &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/schemagate&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Postgres, Oracle, MySQL and SQL Server. On Oracle it reads VPD policies rather than inferring from grants.&lt;/p&gt;

&lt;p&gt;If you are running a SQL agent against a database where different callers should see different tables, I would like to know how you handle it today — especially if the answer is "one service account and we hope". That was the answer where I work, which is why this exists.&lt;/p&gt;

</description>
      <category>langchain</category>
      <category>python</category>
      <category>ai</category>
      <category>security</category>
    </item>
  </channel>
</rss>
