<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://dataplatformadvisory.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://dataplatformadvisory.com/" rel="alternate" type="text/html" /><updated>2026-08-20T12:26:22+00:00</updated><id>https://dataplatformadvisory.com/feed.xml</id><title type="html">Data Platform Advisory</title><subtitle>Database modernization for AI: indexing, performance, CI/CD, vector databases, AI semantics, and data engineering — with an eye on quantum&apos;s future impact on data systems. Written by Ivan Lima.</subtitle><author><name>Ivan Lima</name></author><entry><title type="html">The Refusal Took 11 Milliseconds. The Query Would Have Taken 1,584.</title><link href="https://dataplatformadvisory.com/blog/2026/08/20/ai-agent-query-cost-governor-sql-server/" rel="alternate" type="text/html" title="The Refusal Took 11 Milliseconds. The Query Would Have Taken 1,584." /><published>2026-08-20T11:45:00+00:00</published><updated>2026-08-20T11:45:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/20/ai-agent-query-cost-governor-sql-server</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/20/ai-agent-query-cost-governor-sql-server/"><![CDATA[<p><img src="/assets/images/ai-agent-query-cost-governor-01.png" alt="A refused query showing an 11.4ms estimated-cost-only check next to the 1,584ms and 400,000 rows it would have taken to actually run" /></p>

<p>This is the fifth piece in a series on database-side controls for AI agents with direct SQL execute access. The <a href="/blog/2026/08/17/ai-agent-database-firewall-sql-server/">first</a> refused badly-shaped statements. The <a href="/blog/2026/08/18/schema-change-approval-queue-for-ai-agents/">second</a> queued schema changes for human review. The <a href="/blog/2026/08/18/ai-agent-row-level-security-sql-server/">third</a> scoped access by identity. The <a href="/blog/2026/08/19/ai-agent-credential-connection-auditor-sql-server/">fourth</a> audited whether that identity still deserved to be trusted. None of them ask the question this one does: even if a query is well-formed, authorized, correctly scoped, and issued by a trustworthy identity — is it <em>expensive</em>?</p>

<h2 id="the-incident-that-motivates-it">The incident that motivates it</h2>

<p>A three-person agency ate a $14,000 AWS bill in a single day after attackers extracted static access keys and burned Claude invocations on Bedrock. Different layer than what this post covers — infrastructure billing, not database compute — but the same underlying failure shape as everything else in this series: nothing was checking cost before it was incurred, and at agent speed, that gap turns a small mistake into a large one before a human notices.</p>

<p>The same failure exists one layer down, at the query level, every time an agent runs something expensive before anyone checks what it will cost. A join missing its condition, a WHERE clause that defeats every available index — a human writing SQL by hand rarely produces these by accident more than once. An agent generating queries at machine speed produces them constantly, and by the time the query is running, the cost is already being paid.</p>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>A query can pass every check built earlier in this series — correct shape, authorized, correctly scoped, trustworthy identity — and still be the single most expensive thing that ran that day</li>
  <li>SQL Server can estimate a query’s cost <em>before</em> running it, via <code class="language-plaintext highlighter-rouge">SET SHOWPLAN_XML</code> — refuse anything over a threshold using that estimate alone, and the refusal itself costs milliseconds regardless of how expensive the query would have been</li>
  <li>Intuition about what’s expensive and what a cost-based optimizer actually finds expensive can point in different directions — my own assumption about which demo query would be the worst offender was wrong, and the real data is more useful than the guess would have been</li>
  <li>The right cost threshold isn’t a number to copy from someone else’s project — SQL Server’s cost units are relative to the optimizer’s internal accounting, not wall-clock time or dollars, and have to be calibrated against real data volume</li>
</ul>

<h2 id="what-was-actually-built">What was actually built</h2>

<p>The core of it: get the estimated plan without running the query, read its cost, decide.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_estimate_cost</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">sql</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="k">with</span> <span class="bp">self</span><span class="p">.</span><span class="n">engine</span><span class="p">.</span><span class="n">connect</span><span class="p">()</span> <span class="k">as</span> <span class="n">conn</span><span class="p">:</span>
        <span class="n">conn</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">text</span><span class="p">(</span><span class="s">"SET SHOWPLAN_XML ON"</span><span class="p">))</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">text</span><span class="p">(</span><span class="n">sql</span><span class="p">))</span>
            <span class="n">plan_xml</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="n">scalar</span><span class="p">()</span>
        <span class="k">finally</span><span class="p">:</span>
            <span class="n">conn</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">text</span><span class="p">(</span><span class="s">"SET SHOWPLAN_XML OFF"</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">_parse_estimated_cost</span><span class="p">(</span><span class="n">plan_xml</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">run</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">sql</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">agent_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">intent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">GovernedQueryResult</span><span class="p">:</span>
    <span class="n">estimated_cost</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_estimate_cost</span><span class="p">(</span><span class="n">sql</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">estimated_cost</span> <span class="o">&gt;</span> <span class="bp">self</span><span class="p">.</span><span class="n">max_estimated_cost</span><span class="p">:</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">_audit</span><span class="p">(</span><span class="n">agent_id</span><span class="p">,</span> <span class="n">intent</span><span class="p">,</span> <span class="n">sql</span><span class="p">,</span> <span class="n">estimated_cost</span><span class="p">,</span> <span class="bp">None</span><span class="p">,</span> <span class="bp">None</span><span class="p">,</span> <span class="s">"BLOCKED_COST"</span><span class="p">)</span>
        <span class="k">raise</span> <span class="n">CostGovernorBlocked</span><span class="p">(</span>
            <span class="sa">f</span><span class="s">"Refused: estimated cost </span><span class="si">{</span><span class="n">estimated_cost</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s"> exceeds "</span>
            <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">max_estimated_cost</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">. The query was never executed."</span>
        <span class="p">)</span>

    <span class="c1"># only reached if the estimate was within budget
</span>    <span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">perf_counter</span><span class="p">()</span>
    <span class="k">with</span> <span class="bp">self</span><span class="p">.</span><span class="n">engine</span><span class="p">.</span><span class="n">connect</span><span class="p">()</span> <span class="k">as</span> <span class="n">conn</span><span class="p">:</span>
        <span class="n">result</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">text</span><span class="p">(</span><span class="n">sql</span><span class="p">))</span>
        <span class="n">rows</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="n">fetchall</span><span class="p">()</span>
    <span class="n">elapsed_ms</span> <span class="o">=</span> <span class="p">(</span><span class="n">time</span><span class="p">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span>
    <span class="p">...</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SET SHOWPLAN_XML ON</code> is the mechanism worth calling out specifically: subsequent statements on that connection return their estimated execution plan instead of executing. A blocked query here never touches a single row — a meaningfully different guarantee than the guardrail project earlier in this series, which runs a write inside a transaction and rolls back after measuring the actual row count. That approach already pays the execution cost for anything it later decides to refuse. This one never spends it.</p>

<h2 id="seeing-the-actual-numbers-including-the-one-i-got-wrong">Seeing the actual numbers, including the one I got wrong</h2>

<p>Four scenarios against a 100,000-row orders table: a cheap indexed lookup, a query whose WHERE clause defeats the only relevant index, a cross join missing its join condition entirely, and a legitimate full-table aggregate.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>=== SCENARIO C: a join missing its condition -- a cartesian product ===
[analytics-agent] intent: Join accounts to their orders for a summary
          sql:    SELECT a.owner_name, o.amount FROM dbo.demo_accounts a CROSS JOIN dbo.demo_orders o
          -&gt; BLOCKED: Refused: agent 'analytics-agent' submitted a query with estimated
             cost 2.26, exceeding the 1.50 threshold. The query was never executed --
             only its estimated plan was.
          (checked and refused in 11.4ms -- the query itself never ran)
</code></pre></div></div>

<p>That query, run in an earlier uncalibrated pass before I fixed the threshold, actually took 1,584 milliseconds and returned 400,000 rows. The governor refused it in 11.4 milliseconds. That comparison is most of the pitch by itself.</p>

<p>Here’s the part I got wrong going in: I built this demo expecting the non-sargable <code class="language-plaintext highlighter-rouge">WHERE YEAR(order_date) = 2026</code> query — the one deliberately designed to defeat the only index on that column — to be the standout expensive scenario. It wasn’t. At 100,000 rows, a full table scan on narrow columns costs almost exactly the same, by SQL Server’s own accounting, as an index seek returning a similar row count (0.52 vs. 0.51 estimated cost). The actual outlier was the missing join condition, by a wide margin. My first threshold guess of 5.0 was wrong for a related reason — every single scenario passed under it, including the 400,000-row cartesian join, because SQL Server’s cost units are much smaller in absolute terms at this data volume than I’d assumed. I recalibrated to 1.5 based on the real observed numbers, not a second guess.</p>

<p>Worth stating plainly: don’t assume which query pattern is “the expensive one” without measuring it against real data. That’s the actual argument for building a cost governor around the optimizer’s own estimate instead of a simpler rule like “flag any WHERE clause without a matching index” — the cost-based optimizer’s model of what’s expensive is a better judge of that than intuition is, mine included.</p>

<h2 id="what-this-doesnt-solve">What this doesn’t solve</h2>

<p>SQL Server’s estimated cost is relative, not literal — it’s not wall-clock time or dollars, and the right threshold is specific to your schema, data volume, and hardware. Copying the <code class="language-plaintext highlighter-rouge">1.5</code> used in this demo directly into a production deployment without calibrating against real traffic would be a mistake of exactly the kind I made on the first attempt here. This also only measures magnitude, not intent — it can’t distinguish a legitimately expensive month-end report from a malicious one, and it doesn’t estimate cost for DDL statements at all (those are still the sibling guardrail and approval-queue projects’ job).</p>

<p>Across all five projects now: the firewall refuses what should never run. The approval queue routes schema changes to a human. Row-level security scopes what an agent can reach by identity. The credential auditor checks whether that identity still deserves trust. This refuses what would cost too much, regardless of whether every other check already passed. An AI agent with real database access needs checks at all five layers, because a query can clear four of them and still be the one that matters.</p>

<p>The full project — the <code class="language-plaintext highlighter-rouge">SHOWPLAN_XML</code>-based estimator, the calibration numbers, and all four scenarios runnable via Docker Compose against a real SQL Server 2025 instance — is open source: <a href="https://github.com/mrivanlima/ai-agent-query-cost-governor" target="_blank" rel="noopener noreferrer">github.com/mrivanlima/ai-agent-query-cost-governor</a>. If your agents can already run arbitrary queries and nothing is checking their cost before they run, that’s worth measuring before the bill does it for you. <a href="/about/#contact">Get in touch</a> if you want help calibrating this against your own environment.</p>

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="case-studies" /><category term="ai-agents" /><category term="agent-access" /><category term="database-performance" /><category term="open-source" /><category term="real-incidents" /><summary type="html"><![CDATA[A fifth layer for AI agent database access: refusing expensive queries before they run at all, using SQL Server's own estimated execution plan -- because a correctly authorized, properly scoped query can still be the one that costs the most.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/ai-agent-query-cost-governor-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/ai-agent-query-cost-governor-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Stop LLMs From Hallucinating SQL: Self-Correcting Agents</title><link href="https://dataplatformadvisory.com/blog/2026/08/20/stop-llms-hallucinate-sql-self-correcting-agents/" rel="alternate" type="text/html" title="Stop LLMs From Hallucinating SQL: Self-Correcting Agents" /><published>2026-08-20T09:40:00+00:00</published><updated>2026-08-20T09:40:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/20/stop-llms-hallucinate-sql-self-correcting-agents</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/20/stop-llms-hallucinate-sql-self-correcting-agents/"><![CDATA[<p><img src="/assets/images/stop-llms-hallucinate-sql-self-correcting-agents-01.png" alt="Diagram showing a self-correcting text-to-SQL agent loop: draft query, execute against a sandboxed connection, catch the error, read information_schema, rewrite, and retry, with state persisted to the Agent State Ledger" /></p>

<p>Hallucinated joins aren’t a sign the LLM is dumb. They’re what happens every time an agent gets asked to write SQL against a schema it has never actually seen — no catalog, no DDL, no foreign key map, just a table name typed into the prompt and a guess. Single-shot text-to-SQL fails on real enterprise schemas for the same reason a new hire fails when you hand them a business question and no data dictionary: not because they can’t reason, but because they’re reasoning over a schema that exists only in your head.</p>

<h2 id="why-one-shot-prompting-cant-survive-a-real-schema">Why one-shot prompting can’t survive a real schema</h2>

<p>The demo version of text-to-SQL looks convincing because demo schemas are small. Five tables, obvious column names, one sensible join path. Production schemas aren’t like that. They’re forty tables deep, with <code class="language-plaintext highlighter-rouge">customer_id</code> meaning three different things across three different systems, nullable foreign keys, and join paths that only make sense if you know which table got deprecated in 2019 but never dropped.</p>

<p>Ask a model to write SQL against that with nothing but a natural-language description of the schema, and it will do exactly what it’s built to do: produce the most statistically plausible query, whether or not that query matches the actual constraints in the catalog. It invents a <code class="language-plaintext highlighter-rouge">customers.region</code> column because regions feel like something a customer table would have. It joins on <code class="language-plaintext highlighter-rouge">order_id</code> instead of <code class="language-plaintext highlighter-rouge">order_uuid</code> because that’s the more common convention it’s seen in training data. None of that is a reasoning failure. It’s a grounding failure — the model was never given the one artifact that would have prevented it: the actual schema, queried at generation time, not summarized in a prompt.</p>

<h2 id="why-better-prompting-doesnt-fix-it-either">Why “better prompting” doesn’t fix it either</h2>

<p>The usual fix is to stuff more schema description into the prompt — table names, a few sample rows, a paragraph explaining the business logic. That helps, marginally, and it doesn’t scale. Every enterprise schema changes: columns get renamed, tables get partitioned, a nullable column becomes required after a migration. A static schema description in a prompt goes stale the first time someone runs <code class="language-plaintext highlighter-rouge">ALTER TABLE</code>, and nobody updates the prompt when that happens. You end up maintaining a second, shadow copy of your schema in English, by hand, forever — which is precisely the kind of manual synchronization problem database engineers have spent decades building tools to eliminate.</p>

<p>The actual fix isn’t better wording. It’s giving the agent the same thing you’d give a new engineer on day one: read access to the catalog itself, plus a way to find out when it’s wrong.</p>

<h2 id="the-database-first-fix-execute-catch-diagnose-rewrite">The database-first fix: execute, catch, diagnose, rewrite</h2>

<p>This is a multi-step loop, not a single inference call, and every step maps to something a database engineer already does instinctively when a query fails:</p>

<ol>
  <li>The agent drafts a candidate query based on the user’s question and whatever schema context it has.</li>
  <li>It executes that query against a sandboxed, read-only connection — never production, never with write privileges.</li>
  <li>If execution fails, it catches the actual database error: <code class="language-plaintext highlighter-rouge">column "region" does not exist</code>, a constraint violation, a type mismatch.</li>
  <li>It reads <code class="language-plaintext highlighter-rouge">information_schema</code> (or the engine’s equivalent catalog views) to resolve the real column names, types, and foreign keys for the tables in question.</li>
  <li>It rewrites the query against what the schema actually says, and retries — with a hard cap on retry count so a genuinely bad question doesn’t loop forever.</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">MAX_ATTEMPTS</span> <span class="o">=</span> <span class="mi">4</span>

<span class="k">def</span> <span class="nf">run_text_to_sql</span><span class="p">(</span><span class="n">question</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">session_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">agent_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">QueryResult</span><span class="p">:</span>
    <span class="n">attempt</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="n">context</span> <span class="o">=</span> <span class="p">{}</span>  <span class="c1"># accumulates real schema facts as errors surface
</span>
    <span class="k">while</span> <span class="n">attempt</span> <span class="o">&lt;</span> <span class="n">MAX_ATTEMPTS</span><span class="p">:</span>
        <span class="n">sql</span> <span class="o">=</span> <span class="n">draft_sql</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="n">context</span><span class="p">)</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">execute_readonly</span><span class="p">(</span><span class="n">sql</span><span class="p">)</span>
            <span class="n">persist_ledger_state</span><span class="p">(</span><span class="n">session_id</span><span class="p">,</span> <span class="n">agent_id</span><span class="p">,</span> <span class="p">{</span>
                <span class="s">"final_sql"</span><span class="p">:</span> <span class="n">sql</span><span class="p">,</span> <span class="s">"attempts"</span><span class="p">:</span> <span class="n">attempt</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"status"</span><span class="p">:</span> <span class="s">"success"</span>
            <span class="p">})</span>
            <span class="k">return</span> <span class="n">result</span>
        <span class="k">except</span> <span class="n">DatabaseError</span> <span class="k">as</span> <span class="n">err</span><span class="p">:</span>
            <span class="n">attempt</span> <span class="o">+=</span> <span class="mi">1</span>
            <span class="n">missing</span> <span class="o">=</span> <span class="n">resolve_from_information_schema</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="n">sql</span><span class="p">)</span>
            <span class="n">context</span><span class="p">.</span><span class="n">update</span><span class="p">(</span><span class="n">missing</span><span class="p">)</span>  <span class="c1"># real column names, types, FK targets
</span>            <span class="n">persist_ledger_state</span><span class="p">(</span><span class="n">session_id</span><span class="p">,</span> <span class="n">agent_id</span><span class="p">,</span> <span class="p">{</span>
                <span class="s">"last_error"</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">err</span><span class="p">),</span> <span class="s">"attempts"</span><span class="p">:</span> <span class="n">attempt</span><span class="p">,</span> <span class="s">"status"</span><span class="p">:</span> <span class="s">"retrying"</span>
            <span class="p">})</span>

    <span class="k">raise</span> <span class="n">UnresolvableQueryError</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="n">context</span><span class="p">)</span>
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">persist_ledger_state</code> call isn’t decorative. This retry loop has state that matters across attempts — how many tries have been spent, what schema facts have already been resolved, what the last error was — and that state belongs in the same place I described in <a href="/blog/2026/08/18/why-your-ai-agents-keep-crashing-database-architect/">the first post in this series</a>: the Agent State Ledger. A retry loop that keeps its progress in a local variable loses everything on a restart mid-loop and starts back at attempt one with no memory of what it already ruled out. A retry loop that writes its progress to durable state resumes exactly where it left off. Same failure mode as agent memory generally, just at a smaller scale.</p>

<p>Recent research backs the shape of this loop, not just the intuition behind it. MAC-SQL’s Refiner agent executes a candidate query, observes the actual error or an empty result set, and rewrites accordingly, rather than trying to reason its way to a correct query in a single pass (<a href="https://arxiv.org/abs/2312.11242" target="_blank" rel="noopener noreferrer">arXiv:2312.11242</a>). CHESS goes further for large, enterprise-scale schemas, pairing a Schema Selector that prunes an oversized catalog down to the relevant sub-schema with a Unit Tester that validates candidate queries before they’re trusted (<a href="https://arxiv.org/abs/2405.16755" target="_blank" rel="noopener noreferrer">arXiv:2405.16755</a>). Across this line of work, using the database’s own execution feedback — a real error message, not a model second-guessing itself — is consistently what makes the correction loop actually converge instead of drifting into a different wrong answer.</p>

<h2 id="the-analogy">The analogy</h2>

<p>A self-correcting SQL agent reading its own execution traceback and retrying is functionally what a query optimizer does when a plan fails: it doesn’t sit and philosophize about why the plan might be suboptimal, it captures what actually happened, diagnoses the specific cause, and recompiles a new plan against ground truth. Nobody would trust an optimizer that picked a plan once and refused to ever look at execution statistics again. We shouldn’t trust a text-to-SQL agent that works the same way.</p>

<h2 id="practical-guidance">Practical guidance</h2>

<ul>
  <li>Never let the agent execute against production or with write privileges during query drafting. A sandboxed, read-only connection is non-negotiable — this loop will generate plenty of invalid SQL on the way to valid SQL.</li>
  <li>Query <code class="language-plaintext highlighter-rouge">information_schema</code> (or your engine’s catalog views) live, at generation time. Don’t hand the model a static schema summary and hope it stays current.</li>
  <li>Cap retries explicitly. A question the agent can’t resolve in four attempts against the real catalog usually means the question itself is ambiguous, not that attempt five will succeed.</li>
  <li>Persist retry state — attempt count, resolved schema facts, last error — to durable storage, not a local variable, so a mid-loop restart doesn’t erase progress.</li>
  <li>Log every failed attempt and its real database error. That log is what tells you whether your schema is genuinely hard to query or whether your agent’s prompt context is stale.</li>
</ul>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>Hallucinated SQL isn’t an LLM defect — it’s what happens when an agent generates queries against a schema it’s never actually seen at the DDL/catalog level.</li>
  <li>Static schema descriptions in prompts go stale the moment the real schema changes; querying <code class="language-plaintext highlighter-rouge">information_schema</code> live doesn’t.</li>
  <li>The fix is a bounded loop: draft, execute against a sandboxed connection, catch the real error, resolve against the catalog, rewrite, retry.</li>
  <li>This loop needs durable state across attempts — the same Agent State Ledger pattern from post one, not a variable that disappears on restart.</li>
  <li>This is post two of three in <a href="/blog/2026/08/18/why-your-ai-agents-keep-crashing-database-architect/">Database-First Agent Architecture</a>. Post three covers the governance boundary this loop depends on: exactly what an autonomous agent is allowed to execute once it’s confident in a query, building on the access-control ground covered in <a href="/blog/2026/08/14/ai-agent-guardrails-for-databases/">AI Agent Guardrails for Databases</a>.</li>
</ul>

<p>If your agents are guessing at your schema instead of reading it, that’s a fixable architecture problem, not a prompting problem. <a href="/about/#contact">Get in touch</a> or see how we approach it on <a href="/services/">our services page</a>.</p>

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="ai-semantics" /><category term="text-to-sql" /><category term="schema-linking" /><category term="agent-access" /><category term="database-first-architect" /><category term="grounded-architect" /><summary type="html"><![CDATA[Hallucinated joins aren't an LLM defect. They're what happens when an agent queries a schema it's never actually seen at the DDL or catalog level, unguided.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/stop-llms-hallucinate-sql-self-correcting-agents-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/stop-llms-hallucinate-sql-self-correcting-agents-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Your Database’s Billing Minimums Weren’t Built for AI Agents</title><link href="https://dataplatformadvisory.com/blog/2026/08/20/agent-query-billing-minimums-database-cost-blowout/" rel="alternate" type="text/html" title="Your Database’s Billing Minimums Weren’t Built for AI Agents" /><published>2026-08-20T07:45:00+00:00</published><updated>2026-08-20T07:45:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/20/agent-query-billing-minimums-database-cost-blowout</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/20/agent-query-billing-minimums-database-cost-blowout/"><![CDATA[<p><img src="/assets/images/agent-query-billing-minimums-database-cost-blowout-01.png" alt="Diagram comparing a human traffic query pattern against a spiky agent query pattern hitting the same database billing minimum, showing the agent pattern paying the minimum charge many more times per hour" /></p>

<p>Most database and warehouse billing was built around a traffic shape that no longer describes how your systems get used: a moderate number of longer-running queries from a relatively small number of human sessions. AI agents query differently — many more requests, each shorter, arriving in unpredictable bursts — and when that pattern meets a billing model with per-query minimums or coarse-grained compute increments, the minimum itself becomes a cost multiplier nobody budgeted for. A recent review of 127 enterprise agentic AI implementations found 73% went over budget, some by more than 2.4x, and the database and data-platform layer is a disproportionate and under-tracked share of why.</p>

<h2 id="whats-actually-happening">What’s actually happening</h2>

<p>Two things are colliding at once. The first is agent adoption outpacing the billing models built to meter it. <a href="https://www.mckinsey.com/capabilities/quantumblack/our-insights/cost-versus-value-managing-agentic-ai-system-performance" target="_blank" rel="noopener noreferrer">McKinsey’s 2026 research on agentic AI system performance</a> found that a large share of agentic cost — 60% in their sample — comes from response refinement and retry loops, not raw model inference, and that cost driver is frequently invisible to teams tracking aggregate spend instead of per-agent, per-query attribution. The second is that the database layer specifically has its own version of this problem: billing minimums.</p>

<p>Cloud data warehouses and managed databases commonly bill in fixed increments — a minimum charge per query, a minimum compute-second block, a minimum container spin-up cost — because that pricing model was designed around infrequent, longer-running human or batch workloads where the minimum rarely binds. Agent workloads invert that assumption. An agent orchestrating a multi-step task might issue dozens of short lookups per user request, each one individually trivial but each one tripping the same billing floor a much larger human-driven query would trip once. <a href="https://selfhost.dev/blog/neon-pricing-cost-of-serverless-postgres/" target="_blank" rel="noopener noreferrer">Analysis of serverless and warehouse pricing models in 2026</a> makes the mechanism explicit: billing minimums act as a cost multiplier that’s easy to underestimate unless total cost of ownership is calculated against actual query-duration distributions rather than average load — and agent traffic is defined by exactly the kind of spiky, bursty distribution that average-load math misses.</p>

<p>Layer on top of this a second, related finding: <a href="https://www.gartner.com/en/articles/hype-cycle-for-agentic-ai" target="_blank" rel="noopener noreferrer">Gartner’s 2026 Hype Cycle for Agentic AI</a> names “FinOps for agentic AI” as an emerging category in its own right — a signal that the market itself has recognized existing cost-governance tooling doesn’t cleanly cover agent-driven infrastructure spend yet. The database layer is squarely inside that gap, because most FinOps tooling built for cloud cost management was designed to allocate spend to teams and services, not to the specific agent, task, or orchestration step that triggered a given burst of queries.</p>

<h2 id="who-this-affects">Who this affects</h2>

<p><strong>Data platform and DBA teams</strong> own the choice of billing model, instance sizing, and query-batching strategy — the levers that actually determine whether agent traffic hits a billing minimum repeatedly or gets consolidated into fewer, larger operations. They’re also usually the first to notice the symptom (a cost anomaly on the database line item) without necessarily knowing which agent or workflow caused it.</p>

<p><strong>FinOps and cloud cost teams</strong> own cost attribution and budget forecasting, and this is precisely the blind spot the <a href="https://www.ciodive.com/news/finops-teams-gain-clout-ai-costs-climb/812887/" target="_blank" rel="noopener noreferrer">FinOps Foundation’s State of FinOps 2026 report</a> describes: AI pricing models based on tokens, inference requests, and now agent-triggered database calls don’t map cleanly onto cost-allocation frameworks built for traditional infrastructure, and 98% of FinOps practitioners are now actively working this problem compared to under a third two years ago.</p>

<p><strong>Engineering leadership and CTOs</strong> own the budget line that absorbs the overrun, and they’re the ones who need to ask a question most teams currently can’t answer: what does it cost, per agent workflow, to hit the database — not what does the database cost in aggregate this month.</p>

<p><strong>Product and platform teams building agent-facing features</strong> are the ones whose design choices — how chatty an agent’s tool-calling pattern is, whether it retries liberally, whether it batches lookups — directly set the query-volume dial that determines whether billing minimums matter or don’t.</p>

<h2 id="when-this-becomes-real">When this becomes real</h2>

<p>This isn’t a future risk — it’s already showing up in the numbers. The 73%-over-budget figure and the 2.4x overrun cases reflect implementations running today, not a hypothetical. What’s still developing is the tooling and discipline to fix it.</p>

<p><strong>Already happening</strong>: any organization running agents against a metered database or warehouse today is already paying whatever premium its billing model’s minimums impose on spiky traffic — most just haven’t isolated that line item from the rest of their AI spend yet.</p>

<p><strong>Near-term, 2026-2027</strong>: expect two things to mature roughly in parallel — cloud and database vendors introducing pricing tiers or batching features explicitly aimed at agent traffic patterns (some, like consumption-based serverless databases with sub-second billing granularity, already exist and are a partial answer), and FinOps tooling catching up on agent-level cost attribution, per Gartner’s naming of it as an emerging Hype Cycle category this year. Neither is fully mature yet, which means the gap between what agents cost and what teams can currently measure will likely widen before it narrows.</p>

<p><strong>Longer horizon, 2028 and beyond</strong>: as agent-to-agent and multi-agent orchestration patterns become more standard (rather than single-agent-to-database), query fan-out will increase further, and billing models that haven’t adapted by then will impose a compounding, not linear, cost penalty on organizations that haven’t restructured how their databases meter agent traffic.</p>

<h2 id="how-this-actually-plays-out-in-a-database-environment">How this actually plays out in a database environment</h2>

<p>The mechanism is straightforward once you isolate it, which is exactly why it’s easy to miss when you’re looking at an aggregate bill.</p>

<p>Take a warehouse or managed database with, say, a 10-second minimum billable compute block per query and a cold-start cost for spinning up compute if the system has been idle. A human analyst running a handful of substantial queries an hour rarely triggers that minimum in a way that matters — their queries often run longer than the minimum anyway, and the minimum is a rounding error relative to the query’s actual cost. An agent orchestrating a customer-support workflow might issue a lookup to check order status, another to check inventory, another to check a return policy table, another to log the interaction — four or more short queries, each taking a fraction of a second of actual compute, each billed at the 10-second minimum. Multiply by however many workflow steps an agent takes per user interaction, by however many interactions per hour, and the minimum — not the actual compute consumed — becomes the dominant cost driver.</p>

<p>This compounds with retrieval-augmented generation specifically: <a href="https://www.finout.io/blog/ai-cost-visibility-in-2026-strategies-tools-and-best-practices" target="_blank" rel="noopener noreferrer">vector database costs scale with retrieval volume</a>, and over-retrieval — an agent pulling more context than a task actually needs, often because prompt engineering erred toward “retrieve broadly to be safe” — generates additional similarity-search queries on top of the workflow’s core database calls, each one subject to the same minimum-billing dynamic.</p>

<p>The failure mode compounds further because of orchestration depth. A single user-facing request today can trigger an orchestrator, multiple retrieval calls, several tool invocations, and possibly sub-agent delegation, each layer capable of hitting the database independently and each one invisible to a cost dashboard that only shows total database spend for the month. That’s the attribution problem McKinsey’s research names directly: the cost driver is buried in the agent graph, not visible at the aggregate billing layer, which means the team that could actually fix it — by batching the four lookups into one, or caching the return-policy table lookup that never changes — often doesn’t know it needs to.</p>

<h2 id="actions-to-take-now">Actions to take now</h2>

<p>Start with visibility, because you can’t fix a cost driver you can’t see, and work toward architectural changes as the pattern becomes clear.</p>

<ol>
  <li>
    <p><strong>Pull query-duration and query-count distributions for your database and warehouse workloads, not just monthly spend totals.</strong> Look specifically for a high volume of very short queries clustered near your billing model’s minimum threshold — that’s the signature of agent-driven billing-minimum multiplication, and most cost dashboards don’t surface it by default.</p>
  </li>
  <li>
    <p><strong>Tag or attribute database calls to the agent, workflow, or orchestration step that triggered them</strong>, even if it’s a rough first pass using request headers or a correlation ID threaded through your agent framework. You need per-workflow cost, not just per-service cost, to find where the multiplier is actually happening.</p>
  </li>
  <li>
    <p><strong>Audit for redundant or over-broad retrieval</strong> in any RAG-backed agent workflow — cases where an agent queries a vector store or lookup table more broadly, or more often, than the task strictly requires. Tightening retrieval scope is usually the cheapest fix available and doesn’t require any infrastructure change.</p>
  </li>
  <li>
    <p><strong>Batch what can be batched.</strong> Many multi-step agent workflows issue several small, independent lookups that could be combined into one query or one round-trip. This is standard query optimization, but it matters more now because the cost of not doing it scales with agent call volume in a way it never did with human traffic.</p>
  </li>
  <li>
    <p><strong>Evaluate your database and warehouse billing model against your actual (not average) traffic shape.</strong> If your provider bills in coarse minimums and your workload is agent-driven and spiky, compare it against consumption-based or sub-second-granularity alternatives — the cost delta at agent-scale query volume can be substantial, and several providers now offer pricing tiers built explicitly for this pattern.</p>
  </li>
  <li>
    <p><strong>Build agent-level cost attribution into your FinOps practice now, not after the first budget overrun.</strong> This is the specific gap the FinOps Foundation and Gartner are both flagging as unresolved in 2026 — getting ahead of it means your organization isn’t discovering the multiplier effect for the first time in a quarterly cost review.</p>
  </li>
</ol>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>A 2026 review of 127 enterprise agentic AI implementations found 73% went over budget, some by more than 2.4x, and database-layer costs are a disproportionately under-tracked contributor.</li>
  <li>Billing models with per-query minimums or coarse compute increments were built for infrequent, longer-running human traffic — agent workloads are the opposite shape: frequent, short, and bursty — and the minimum itself becomes a cost multiplier.</li>
  <li>McKinsey research attributes 60% of agentic cost to response refinement and retry behavior that’s typically invisible without agent-level, not aggregate, cost attribution.</li>
  <li>Gartner’s 2026 Hype Cycle for Agentic AI names “FinOps for agentic AI” as an emerging category, signaling that existing cost-governance tooling doesn’t yet cleanly cover this gap.</li>
  <li>The cheapest fixes — retrieval scope audits and query batching — require no infrastructure change and are available today; billing-model changes and full agent-level attribution take longer but close the gap for good.</li>
</ul>

<p>If your database costs are climbing faster than your agent workload growth explains, that mismatch is worth investigating before it shows up as next quarter’s budget overrun. <a href="/services/">Get in touch</a> if you want help finding where it’s hiding.</p>

<hr />

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="cost-efficiency" /><category term="cost-efficiency" /><category term="ai-agents" /><category term="finops" /><category term="cloud-infrastructure" /><category term="future-outlook" /><summary type="html"><![CDATA[Agent workloads generate short, spiky database queries that trip billing minimums built for human traffic, and it's driving real budget overruns.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/agent-query-billing-minimums-database-cost-blowout-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/agent-query-billing-minimums-database-cost-blowout-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">pgvector vs. Purpose-Built Vector Databases: A 2026 Decision Framework</title><link href="https://dataplatformadvisory.com/blog/2026/08/20/pgvector-vs-purpose-built-vector-databases/" rel="alternate" type="text/html" title="pgvector vs. Purpose-Built Vector Databases: A 2026 Decision Framework" /><published>2026-08-20T05:15:00+00:00</published><updated>2026-08-20T05:15:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/20/pgvector-vs-purpose-built-vector-databases</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/20/pgvector-vs-purpose-built-vector-databases/"><![CDATA[<p><img src="/assets/images/pgvector-vs-purpose-built-vector-databases-01.png" alt="Decision framework diagram comparing pgvector and purpose-built vector databases across vector count, query volume, and operational complexity" /></p>

<p>Use pgvector if you’re already running Postgres and your workload stays under roughly 10 to 50 million vectors with moderate query volume – it’s cheaper, it’s one less system to operate, and 2026 benchmarks show it’s no longer the performance compromise it used to be. Reach for a purpose-built vector database like Qdrant, Milvus, or Pinecone once you’re past that range, need sub-10ms p95 latency at high query-per-second rates, or require advanced filtering and multi-tenant isolation that a general-purpose relational engine wasn’t built for. The rest of this post is the reasoning behind that line, and where it actually sits for your workload.</p>

<h2 id="why-this-decision-keeps-coming-up">Why this decision keeps coming up</h2>

<p>Every team adding retrieval-augmented generation, semantic search, or a recommendation feature to an existing product hits the same fork: bolt vector search onto the Postgres database that’s already running production traffic, or stand up a dedicated vector database next to it. Two years ago the answer leaned heavily toward “dedicated” – pgvector’s HNSW implementation was new, slow to build, and fell over past a few million rows. That’s no longer the full picture, and treating it as settled either way costs teams real money and real latency.</p>

<h2 id="what-changed-pgvector-closed-the-performance-gap">What changed: pgvector closed the performance gap</h2>

<p>The biggest shift is pgvectorscale, Timescale’s extension that adds StreamingDiskANN indexing and statistical binary quantization on top of pgvector. In Timescale’s own benchmark, Postgres with pgvectorscale hit 471 queries per second at 99% recall against 50 million vectors, versus 41.47 QPS for Qdrant on the same hardware – an 11.4x throughput advantage, with 28x lower p95 latency than Pinecone’s storage-optimized index at matching recall (<a href="https://www.tigerdata.com/blog/pgvector-vs-qdrant" target="_blank" rel="noopener noreferrer">Tiger Data, “pgvector vs. Qdrant”</a>). That’s a vendor-run benchmark – Timescale built pgvectorscale and published the numbers – so treat the magnitude with appropriate skepticism, but the direction is consistent with what other teams have independently reported: the raw performance gap that used to make “just use a real vector database” the safe default answer has mostly closed for mid-scale workloads.</p>

<p>Binary quantization is doing a lot of that work. AWS’s own guidance for Aurora PostgreSQL shows the same pattern: quantizing vectors to binary representations cuts memory footprint dramatically and keeps HNSW’s RAM ceiling from becoming the limiting factor as dataset size grows (<a href="https://aws.amazon.com/blogs/database/scale-pgvector-with-binary-quantization-on-amazon-aurora-postgresql/" target="_blank" rel="noopener noreferrer">AWS Database Blog</a>). Without quantization, HNSW keeps the full graph in memory, and that’s the mechanism behind most “pgvector fell over” stories from 2024.</p>

<h2 id="where-pgvector-still-runs-out-of-road">Where pgvector still runs out of road</h2>

<p>None of that erases pgvector’s real ceiling. Each <code class="language-plaintext highlighter-rouge">vector(1536)</code> value takes roughly 6KB on disk before row overhead and index cost, so 100 million rows of raw vector storage alone exceeds 600GB before you’ve built an index against it (<a href="https://www.paradedb.com/learn/postgresql/pgvector-limitations" target="_blank" rel="noopener noreferrer">ParadeDB, “pgvector Limitations”</a>). pgvector also only supports HNSW and IVFFlat as index types – IVFFlat builds fast but degrades as the dataset grows, and HNSW gives good recall but has long build times and high memory demand during construction. Single-table PostgreSQL limits (32TB per table, TOAST object size ceilings) mean billion-vector deployments need partitioning strategies that most teams aren’t set up to maintain. And critically, none of pgvector’s scaling techniques change the fact that it’s still bound by Postgres’s general-purpose query planner and storage engine – it was never rebuilt from the ground up around approximate nearest-neighbor search the way Qdrant’s Rust engine or Milvus’s distributed architecture were.</p>

<p>If you’re already tuning <a href="/2026/08/18/indexing-for-ai-workloads/">HNSW and IVFFlat indexes</a> on a relational workload, you’ve likely felt this tension firsthand: the index type that gives you the best recall is also the one most likely to blow past your <code class="language-plaintext highlighter-rouge">maintenance_work_mem</code> budget during a rebuild.</p>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li><strong>Under ~10M vectors, on Postgres already:</strong> pgvector wins on cost and operational simplicity – no new system, no new failure mode to monitor.</li>
  <li><strong>10M-50M vectors, with pgvectorscale/quantization tuning:</strong> pgvector is now genuinely competitive on throughput, not just “good enough.”</li>
  <li><strong>Past 50M-100M vectors, or high sustained QPS:</strong> purpose-built databases pull ahead, and self-hosting one becomes cost-justified once a managed option like Pinecone would otherwise run well past $5,000/month at that scale.</li>
  <li><strong>Advanced filtering, hybrid search, or strict multi-tenant isolation:</strong> purpose-built engines like Qdrant were designed around these patterns; pgvector supports them but with more manual query engineering.</li>
  <li><strong>The index type matters as much as the database:</strong> IVFFlat vs. HNSW vs. DiskANN changes build time, memory ceiling, and recall independently of which database holds them.</li>
</ul>

<h2 id="what-actually-drives-the-cost-line">What actually drives the cost line</h2>

<p>The economics flip at a predictable point. A team already paying for Postgres gets pgvector close to free at low scale – no new infrastructure bill, no new operational surface. But HNSW’s memory requirements scale with vector count, and once a workload needs a large, RAM-heavy instance just to hold the index, the “database you already run” line item quietly turns into a dedicated, expensive machine anyway. At that point the comparison isn’t “free vs. paid,” it’s “one expensive Postgres instance vs. one purpose-built system” – and the purpose-built system usually wins on throughput per dollar once you’re actually paying real infrastructure cost either way.</p>

<p>This is also where embedding hygiene starts to matter more, not less. A system carrying tens of millions of vectors across a partitioned, quantized index is much harder to audit for <a href="/2026/08/17/silent-embedding-drift-vector-search/">drift when embedding models change underneath it</a> than a small, single-table pgvector setup – scale amplifies the blast radius of a silent re-embedding mistake.</p>

<h2 id="the-actual-decision-framework">The actual decision framework</h2>

<p>Ask four questions, in this order: How many vectors, today and in 18 months? What’s the sustained query volume, not the peak demo number? Does the workload need hybrid search, complex metadata filtering, or hard multi-tenant isolation? And is there already a team that owns Postgres operations, or would a vector database be the first new piece of infrastructure this team has run? Most teams answering honestly land on pgvector for anything under 10-20 million vectors with a Postgres-literate team already in place, and shift the calculus toward a purpose-built system only when the vector count, query volume, or filtering complexity genuinely outgrows what a well-tuned Postgres extension can carry.</p>

<p>If you’re mid-migration and unsure which side of that line your workload actually sits on, <a href="/services/">get in touch</a> – this is exactly the kind of tradeoff that’s cheap to model before committing infrastructure spend to it.</p>

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="vector-databases" /><category term="vector-databases" /><category term="pgvector" /><category term="cost-efficiency" /><category term="rag" /><category term="performance-engineering" /><summary type="html"><![CDATA[pgvector wins on cost and simplicity under roughly 10-50 million vectors if you're already on Postgres; past that, purpose-built databases pull ahead on throughput and ops.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/pgvector-vs-purpose-built-vector-databases-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/pgvector-vs-purpose-built-vector-databases-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Permissions Were Never Checked Again After They Were Granted</title><link href="https://dataplatformadvisory.com/blog/2026/08/19/ai-agent-credential-connection-auditor-sql-server/" rel="alternate" type="text/html" title="The Permissions Were Never Checked Again After They Were Granted" /><published>2026-08-19T11:50:00+00:00</published><updated>2026-08-19T11:50:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/19/ai-agent-credential-connection-auditor-sql-server</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/19/ai-agent-credential-connection-auditor-sql-server/"><![CDATA[<p><img src="/assets/images/ai-agent-credential-auditor-01.png" alt="Three flagged findings from an AI agent credential audit: a shared credential used by three agents, a credential 270 days overdue for rotation, and 67% of a granted access scope never actually used" /></p>

<p>This is the fourth piece in a series on database-side controls for AI agents with direct SQL execute access. The <a href="/blog/2026/08/17/ai-agent-database-firewall-sql-server/">first post</a> refused badly-shaped statements outright. The <a href="/blog/2026/08/18/schema-change-approval-queue-for-ai-agents/">second</a> queued schema changes for human review instead of refusing them outright. The <a href="/blog/2026/08/18/ai-agent-row-level-security-sql-server/">third</a> scoped what an agent could see and touch based on its declared identity — and closed with an honest caveat I want to pick up here: Row-Level Security enforces scope given an <em>honestly declared</em> identity. It doesn’t authenticate that identity, and it doesn’t check whether the identity’s grant is still appropriate. This post is what closes that gap.</p>

<h2 id="the-incident-underneath-the-incident">The incident underneath the incident</h2>

<p>The Mexican government breach that motivated the row-level-security post — nine agencies, 195 million taxpayer records, 220 million civil records, December 2025 through February 2026 — had a root cause underneath its root cause. The excessive, shared permissions that made the breach possible didn’t appear the day it happened. They existed for a long time before that, unaudited, because nobody was checking whether a grant made sense against how it was actually being used.</p>

<p>That’s not a hypothetical pattern. A <a href="https://www.kiteworks.com/cybersecurity-risk-management/ai-agent-security-incidents-2026/" target="_blank" rel="noopener noreferrer">2026 least-privilege research report</a> analyzing over 3 billion permissions found that on average only about 4% had been used in the trailing 90 days, while nearly one in three could modify or delete sensitive data. And per IBM’s Cost of a Data Breach Report 2025, 97% of AI-related breaches involved organizations lacking proper AI access controls — not lacking access controls generically, lacking ones scoped and audited for AI and agent identities specifically. Nobody had to break in cleverly the first time either. The permissions were just never checked again after they were granted.</p>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>Row-level security and statement-shape guardrails both assume the calling identity is trustworthy — none of the controls earlier in this series verify that assumption or check whether a grant is still appropriate</li>
  <li>Treat agent credentials the way DBAs already treat human access reviews: register what was granted, log what’s actually used, and periodically audit the gap between the two</li>
  <li>Three checks catch three distinct failure modes: agents sharing one underlying credential (a single compromise takes out every agent using it), credentials nobody has rotated, and broad grants an agent’s actual activity never exercises</li>
  <li>This is the first project in the series that isn’t a runtime gate — it’s a detective control, a periodic review, not a preventive one blocking anything in real time</li>
</ul>

<h2 id="what-was-actually-built">What was actually built</h2>

<p>A registry of declared agent identities, an activity log of what they actually do, and three audit checks run against the gap between them:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">audit_shared_credentials</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""Flags any credential_login used by more than one declared agent identity."""</span>
    <span class="k">with</span> <span class="bp">self</span><span class="p">.</span><span class="n">engine</span><span class="p">.</span><span class="n">connect</span><span class="p">()</span> <span class="k">as</span> <span class="n">conn</span><span class="p">:</span>
        <span class="n">rows</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">text</span><span class="p">(</span>
            <span class="s">"SELECT credential_login, agent_id FROM dbo.agent_registry "</span>
            <span class="s">"ORDER BY credential_login, agent_id"</span>
        <span class="p">)).</span><span class="n">fetchall</span><span class="p">()</span>

    <span class="n">by_login</span> <span class="o">=</span> <span class="p">{}</span>
    <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">rows</span><span class="p">:</span>
        <span class="n">by_login</span><span class="p">.</span><span class="n">setdefault</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="n">credential_login</span><span class="p">,</span> <span class="p">[]).</span><span class="n">append</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="n">agent_id</span><span class="p">)</span>

    <span class="k">return</span> <span class="p">[</span>
        <span class="n">SharedCredentialFinding</span><span class="p">(</span><span class="n">credential_login</span><span class="o">=</span><span class="n">login</span><span class="p">,</span> <span class="n">agent_ids</span><span class="o">=</span><span class="n">agents</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">login</span><span class="p">,</span> <span class="n">agents</span> <span class="ow">in</span> <span class="n">by_login</span><span class="p">.</span><span class="n">items</span><span class="p">()</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">agents</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">1</span>
    <span class="p">]</span>
</code></pre></div></div>

<p>The scope-creep check is the one I’d point to as the most directly useful in practice: for every agent granted the broadest scope, it compares that grant against the regions its activity log actually shows it touching, and reports what percentage of the grant has simply never been exercised — the same 4%-utilization finding from the research above, computed against this project’s own data instead of cited as an external statistic.</p>

<h2 id="seeing-it-flag-real-patterns">Seeing it flag real patterns</h2>

<p>Five registered agents — three deliberately sharing one credential, one with a credential nobody’s rotated in 270 days, two granted the broadest scope — after three weeks of simulated activity:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>--- Shared-credential findings ---
  RISK: credential 'svc_shared_agent_login' is shared by 3 declared agents:
        billing-agent, migration-agent-west, support-bot-east
        A single compromised or leaked credential here compromises all 3
        agent identities at once.

--- Stale-credential findings (&gt;180 days since rotation) ---
  RISK: agent 'ops-admin-agent' credential 'svc_ops_admin_login' last
        rotated 270 days ago.

--- Scope-creep findings (granted 'ALL', actual usage narrower) ---
  RISK: agent 'ops-admin-agent' declared scope=ALL, but activity history
        only ever touches: US-EAST, US-WEST
        33% of the granted region scope has never actually been used.
  RISK: agent 'reporting-agent' declared scope=ALL, but activity history
        only ever touches: US-EAST
        67% of the granted region scope has never actually been used.
</code></pre></div></div>

<p>Worth being precise about the shared-credential check’s mechanics, because a technically sharp reader will ask: it audits the registry’s <em>declared</em> credential label, not the raw SQL login SQL Server itself observes via <code class="language-plaintext highlighter-rouge">SUSER_SNAME()</code>. In this demo — and in most real deployments routing agents through one shared MCP gateway connection — every agent’s observed SQL login is identical by construction, which is itself the exact anti-pattern being flagged. A deployment provisioning real, distinct SQL logins per agent would get the same signal directly from <code class="language-plaintext highlighter-rouge">SUSER_SNAME()</code> instead of the registry.</p>

<h2 id="the-honest-slightly-anticlimactic-lesson">The honest, slightly anticlimactic lesson</h2>

<p>Every other project in this series had a real bug story: a clock-drift issue in the point-in-time restore, a future-dated post silently dropped, a wrong assumption about how a block predicate fails. This one worked correctly the first time I ran it against SQL Server. That’s worth naming rather than manufacturing drama for consistency — the reason is structural. This project never touches SQL Server’s transactional or predicate machinery; it’s plain table reads and threshold comparisons in Python. That category of code is inherently easier to get right than anything involving transaction boundaries or predicate timing, which is exactly what the earlier three projects kept tripping on.</p>

<p>That’s part of the actual pitch for building this one, not an afterthought: detective controls are cheaper to get right than preventive ones, and this is the control that tells you whether the other three are even pointed at the right identities in the first place. If you’re deciding where to start hardening AI agent database access and can only build one thing first, an audit of what’s already been granted is a reasonable place to begin — before the guardrail, the queue, or the row-level policy, because all three of those inherit whatever the credential layer already got wrong.</p>

<h2 id="what-this-doesnt-solve">What this doesn’t solve</h2>

<p>This finds the gap; it doesn’t close it. Narrowing an over-broad grant or rotating a stale credential is still a human decision this project deliberately doesn’t automate, the same way the schema-approval project surfaces DDL requests rather than resolving them unilaterally. And the shared-credential check specifically depends on the registry being maintained honestly — if nobody updates it when a new agent starts reusing an old credential, the audit won’t catch what it was never told about.</p>

<p>Across all four projects now: the firewall refuses what should never run. The approval queue routes what might be legitimate to a human. Row-level security scopes what an agent can reach by identity. This audits whether that identity — and its grant — still deserves to be trusted at all. An AI agent with real database access needs checks at every one of those layers, because none of them alone covers what the others miss.</p>

<p>The full project — the registry, activity log, and all three audit checks runnable via Docker Compose against a real SQL Server 2025 instance — is open source: <a href="https://github.com/mrivanlima/ai-agent-credential-auditor" target="_blank" rel="noopener noreferrer">github.com/mrivanlima/ai-agent-credential-auditor</a>. If you can’t currently answer “which of our agents’ grants have actually been used in the last 90 days,” that’s worth finding out before an audit happens under worse circumstances. <a href="/about/#contact">Get in touch</a> if you want help running this against your own environment.</p>

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="case-studies" /><category term="ai-agents" /><category term="agent-access" /><category term="database-governance" /><category term="open-source" /><category term="real-incidents" /><summary type="html"><![CDATA[A fourth layer for AI agent database access: auditing the identity layer itself -- shared credentials, stale rotation, and scope creep -- that my SQL Server firewall, approval queue, and row-level security all quietly assume is trustworthy.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/ai-agent-credential-auditor-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/ai-agent-credential-auditor-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI Isn’t Replacing DBAs; It’s Upgrading Us to Agent Orchestrators</title><link href="https://dataplatformadvisory.com/blog/2026/08/19/ai-isnt-replacing-dbas-agent-orchestrators/" rel="alternate" type="text/html" title="AI Isn’t Replacing DBAs; It’s Upgrading Us to Agent Orchestrators" /><published>2026-08-19T09:45:00+00:00</published><updated>2026-08-19T09:45:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/19/ai-isnt-replacing-dbas-agent-orchestrators</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/19/ai-isnt-replacing-dbas-agent-orchestrators/"><![CDATA[<p><img src="/assets/images/ai-isnt-replacing-dbas-agent-orchestrators-01.png" alt="Diagram showing a DBA at the center of an agent orchestration layer, with arrows connecting a relational database, a vector store, and a Python orchestration layer labeled as the thin glue between them" /></p>

<p>If you’re a DBA watching the AI hiring wave and wondering where you fit, here’s the direct answer: you’re not being replaced, you’re being asked to run a bigger system than the one you already run. Multi-agent AI systems are, underneath the marketing, state management, transaction persistence, and retrieval-index problems — the exact domain you’ve spent your career in. Python is the wiring. The database is the machine.</p>

<h2 id="the-misconception-this-post-is-correcting">The misconception this post is correcting</h2>

<p>Most agent frameworks are built by people who’ve never designed a schema under concurrent load. That’s not a jab — it’s a description of the hiring pipeline. Companies staffing “AI engineering” teams default to full-stack and Python developers, because Python is the language every agent framework — LangChain, LangGraph, CrewAI, AutoGen — ships its interface in. So the assumption becomes: agentic AI is a software engineering discipline, and databases are a backend detail someone else handles.</p>

<p>That assumption breaks the moment an agent system leaves the demo. A demo runs one agent, one user, one session, no concurrent load, no failure recovery, no audit trail. Production runs many agents, many sessions, retries, partial failures, and multiple writers touching shared state at the same time. Every one of those requirements has a name, and the name is “database problem.” Transaction persistence, concurrent access management, retrieval consistency, access control across tenants — none of it is Python’s job. Python calls the functions. It doesn’t own the guarantees.</p>

<h2 id="why-the-app-layer-view-falls-apart-under-real-load">Why the app-layer view falls apart under real load</h2>

<p>Watch what actually happens when a multi-agent system scales past a demo. An agent needs to remember what it did three turns ago — that’s session state, and state that doesn’t survive a restart is a data durability problem, not a prompting problem. Two agents need to read and write a shared plan without stepping on each other — that’s concurrency control, the same problem two bank tellers hitting the same account balance have solved for fifty years with row locks and isolation levels. An agent needs to route a query to the right knowledge source out of several — that’s a routing and indexing problem, the same shape as a query planner choosing an index.</p>

<p>Vector databases make this concrete. Raw FAISS — the library most tutorials reach for first — has no API endpoints, no concurrent access management, no multi-tenancy, and no access control built in. It’s an index, not a database. The moment more than one agent needs to query it safely at the same time, or different agents need scoped access to different slices of the corpus, someone has to build the database layer FAISS doesn’t have: connection handling, isolation, permissions (<a href="https://www.starteck.co.uk/blog/vector-databases-choosing-the-right-one" target="_blank" rel="noopener noreferrer">StarTeck Manchester, “Vector Databases in 2026”</a>). Teams that reach for ChromaDB, Qdrant, or Weaviate instead of raw FAISS in production are making exactly the decision a DBA would make: don’t build a database from scratch when correctness matters. Agentic RAG systems increasingly route queries between multiple retrieval strategies — semantic search, keyword match, hybrid — based on confidence scores, with agents passing work to each other the way a query optimizer chooses between an index scan and a table scan based on cost (<a href="https://towardsdatascience.com/multi-agent-sql-assistant-part-2-building-a-rag-manager/" target="_blank" rel="noopener noreferrer">Towards Data Science, “Multi-Agent SQL Assistant”</a>).</p>

<h2 id="you-already-know-this">You Already Know This</h2>

<p>That’s the thesis of this series, and it’s worth naming outright: the skills agentic AI needs aren’t new. They’re the ones you already have, wearing unfamiliar names.</p>

<ul>
  <li><strong>Session memory that survives a restart</strong> is durable state — the same problem a database solves every time a server reboots mid-transaction.</li>
  <li><strong>Two agents editing shared plan data</strong> is concurrency control — locking, isolation levels, optimistic version checks. Nothing here that MVCC didn’t already solve.</li>
  <li><strong>Routing a query to the right retrieval source</strong> is query planning — picking the cheapest correct path to an answer based on cost and selectivity.</li>
  <li><strong>Deciding which agent can read or write which data</strong> is access control — grants, roles, row-level security, the stuff you enforce daily and most application developers have never had to think about.</li>
</ul>

<p>Each of the next two posts in this series works through one of these translations in detail: Pydantic’s structured validation as relational schema constraints in disguise, and LangGraph’s checkpointing as a write-ahead log wearing a new label. This post is the thesis. The next two are the proof. For a concrete look at what happens when agent state isn’t given a durable home in the first place, see <a href="/blog/2026/08/18/why-your-ai-agents-keep-crashing-database-architect/">Why Your AI Agents Keep Crashing</a> — the same failure mode, viewed from the state-durability side rather than the retrieval side.</p>

<h2 id="the-analogy">The analogy</h2>

<p>An agent framework without a database architect designing its state layer is a query optimizer with no statistics — it will run, it will even return answers, and it has no idea whether the path it picked is fast, safe, or correct. It’s making decisions blind. A DBA sitting on that architecture isn’t a nice-to-have; they’re the missing statistics table.</p>

<h2 id="practical-guidance">Practical guidance</h2>

<p>If you’re a DBA looking at the agentic AI wave and wondering where to plant a flag:</p>

<ul>
  <li>Don’t learn Python to become a “real” AI engineer. Learn enough Python to read what an agent framework is doing to your data, then fix the data layer underneath it — that’s the leverage move.</li>
  <li>Ask, for any agent system you’re brought in on, three questions: where does state live when a process dies, what happens when two agents write at once, and who’s allowed to read what. If nobody can answer cleanly, that’s your opening.</li>
  <li>Treat vector databases as databases, not as a separate AI-only category. Concurrency, indexing strategy, and access control apply the same way they do to any other index.</li>
  <li>Push back on the framing that Python developers own “the AI layer” and DBAs own “the storage layer.” In a system that has to survive production, those are the same layer.</li>
</ul>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>Multi-agent AI systems are state management, transaction persistence, and retrieval-index problems wearing new terminology — not a distinct engineering discipline.</li>
  <li>Python is the orchestration glue connecting agents to databases. It is not the architecture itself.</li>
  <li>Raw vector index libraries like FAISS lack the database fundamentals — concurrency control, multi-tenancy, access control — that production multi-agent systems require.</li>
  <li>Agentic RAG routing between retrieval strategies is functionally query planning: choosing the cheapest correct path based on cost.</li>
  <li>This is post one of three in “You Already Know This” — Pydantic-as-schema-constraints and LangGraph-checkpointer-as-WAL are next.</li>
</ul>

<p>If your team is staffing an “AI engineering” effort and hasn’t put a database architect on it, that’s the gap. <a href="/about/#contact">Get in touch</a> or see how we approach it on <a href="/services/">our services page</a>.</p>

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="vector-databases" /><category term="agentic-rag" /><category term="vector-databases" /><category term="multi-agent-systems" /><category term="orchestration" /><category term="you-already-know-this" /><category term="grounded-architect" /><summary type="html"><![CDATA[Multi-agent RAG systems are state management, transactions, and vector databases wearing new names. Python is the glue — DBAs already have the core skill.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/ai-isnt-replacing-dbas-agent-orchestrators-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/ai-isnt-replacing-dbas-agent-orchestrators-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Your PQC Deadline Just Moved Up Four Years. Start With Your Database</title><link href="https://dataplatformadvisory.com/blog/2026/08/19/post-quantum-migration-database-encryption-inventory/" rel="alternate" type="text/html" title="Your PQC Deadline Just Moved Up Four Years. Start With Your Database" /><published>2026-08-19T07:40:00+00:00</published><updated>2026-08-19T07:40:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/19/post-quantum-migration-database-encryption-inventory</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/19/post-quantum-migration-database-encryption-inventory/"><![CDATA[<p><img src="/assets/images/post-quantum-migration-database-encryption-inventory-01.png" alt="Diagram showing a database encryption inventory feeding a crypto-agility layer that can swap TDE, column-level, and backup encryption algorithms without re-architecting the database, positioned against a compressed 2030 post-quantum migration deadline" /></p>

<p>A June 2026 executive order pulled the federal government’s post-quantum cryptography deadline forward by four to five years — key establishment must be migrated by December 31, 2030, not 2035. That timeline compression matters to every database team, not just federal contractors, because the underlying threat it responds to doesn’t check who your customer is. If your database holds data that needs to stay confidential past 2030, an adversary can copy your encrypted backups today and simply wait.</p>

<h2 id="whats-actually-happening">What’s actually happening</h2>

<p>On June 22, 2026, the White House signed an executive order that rewrites the government’s post-quantum cryptography (PQC) timeline. The prior target, set by 2022’s National Security Memorandum 10, gave agencies until 2035 to migrate high-value and high-impact systems. The new order splits that into two hard dates: key establishment — the cryptography that protects data in transit and at rest, including most database-level encryption — must move to quantum-resistant algorithms by <strong>December 31, 2030</strong>, and digital signatures by <strong>December 31, 2031</strong>. The <a href="https://thehackernews.com/2026/06/trump-order-sets-2030-deadline-for.html" target="_blank" rel="noopener noreferrer">Federal Acquisition Regulatory Council has 180 days to write a rule</a> extending the December 2030 deadline to “covered contractors,” which in practice means most software and infrastructure vendors selling into government.</p>

<p>The order didn’t invent new cryptographic requirements — it accelerated the clock on standards that already existed. NIST finalized its first three PQC standards (FIPS 203, 204, and 205) in August 2024, and <a href="https://www.encryptionconsulting.com/6-practical-steps-to-crypto-agile-pqc-in-2026/" target="_blank" rel="noopener noreferrer">NIST Internal Report 8547 already targets 2030 for deprecating RSA and ECC</a> — the two algorithm families underpinning the vast majority of production database encryption today, from TLS connections to transparent data encryption (TDE) key wrapping to column-level encryption. CNSA 2.0 already requires national security systems to begin adopting quantum-resistant cryptography starting in 2027. The executive order is a forcing function, not a new invention — but forcing functions are exactly what change budget priorities.</p>

<p>The reason this can’t wait for 2030 to become urgent is a specific attack pattern with an unglamorous name: harvest now, decrypt later (HNDL). An adversary doesn’t need to break your encryption today. They only need to copy the encrypted bytes — a stolen backup, an intercepted replication stream, an exfiltrated export — and hold them until a cryptographically relevant quantum computer exists to break RSA or ECC retroactively. Three papers published between May 2025 and March 2026 <a href="https://thequantuminsider.com/2026/05/01/harvest-now-decrypt-later-why-should-you-care/" target="_blank" rel="noopener noreferrer">reduced the estimated quantum resource requirement to break RSA-2048 from roughly 20 million qubits to under one million</a>, with some architectures suggesting a path toward 100,000 qubits. That doesn’t mean a quantum computer capable of the attack exists yet — it doesn’t — but it means the theoretical resource bar keeps dropping faster than most risk models assumed when they were written.</p>

<h2 id="who-this-affects">Who this affects</h2>

<p>This is squarely a database and security leadership problem, not a cryptography-researcher problem. Three roles carry direct responsibility:</p>

<p><strong>Database administrators and platform engineers</strong> own the actual encryption configuration — TDE settings, key management service integration, column-level and always-encrypted configurations, backup encryption, replication encryption. They’re the ones who will need to know, concretely, which algorithm every one of those settings currently uses, because “SQL Server handles that” or “it’s whatever the cloud provider defaults to” won’t survive a real audit.</p>

<p><strong>CISOs and security architects</strong> own the crypto-agility strategy and the migration roadmap, and they’re the ones who’ll be asked by a board or a regulator whether the organization has a plan — a question that requires an inventory to answer honestly.</p>

<p><strong>Compliance and legal teams</strong> own the exposure calculation for data with a multi-decade confidentiality requirement: health records, financial account data, government contract data, trade secrets, anything under a data-retention mandate that extends past 2030. If that data is sitting in a database encrypted with RSA-2048 or ECC today, its confidentiality window may already be shorter than its retention requirement.</p>

<p>Notably, none of this is federal-only. The FAR contractor rule extends the 2030 deadline contractually to any vendor selling into government, and the EU’s own PQC roadmap pushes member states and regulated industries to begin migration activity by the end of 2026 — well ahead of the U.S. federal timeline. If your organization sells software, holds government contracts, operates in a regulated industry, or simply stores data with a retention window past 2030, this is your timeline too, whether or not an executive order names you directly.</p>

<h2 id="when-this-becomes-real">When this becomes real</h2>

<p>Break the timeline into three honest phases, because overstating urgency is as unhelpful as understating it.</p>

<p><strong>Already happening</strong>: HNDL collection is not a future risk — assume any sufficiently motivated adversary (nation-state actors are the most credible threat model here) is already harvesting encrypted traffic and backups today, betting on future decryption capability. This phase requires no new quantum hardware to be a live risk; it only requires that your data’s confidentiality window outlasts the arrival of a cryptographically relevant quantum computer, whenever that turns out to be.</p>

<p><strong>Near-term, 2026-2028</strong>: This is the planning and pilot phase, and it’s where most organizations sit right now. NIST’s own guidance frames 2026 as the year to move from planning to pilot-at-scale. Realistically, expect vendor and platform support for PQC key establishment (database engines, cloud KMS providers, HSM vendors) to mature substantially in this window — some of it already has, some is still catching up — which is exactly why an inventory now matters more than a migration now.</p>

<p><strong>2029-2031 and beyond</strong>: This is when the deadlines bite. December 2030 for key establishment, December 2031 for digital signatures, under the new federal order — with the FAR contractor rule following close behind. Estimates for when a cryptographically relevant quantum computer could actually exist <a href="https://www.zerotier.com/blog/harvest-now-decrypt-later-the-breach-already-happened-you-just-havent-seen-it-yet/" target="_blank" rel="noopener noreferrer">remain genuinely uncertain</a>, ranging from the early 2030s to considerably later depending on the source — but the compliance deadlines don’t wait for that uncertainty to resolve, and neither should your migration plan.</p>

<h2 id="how-this-actually-plays-out-in-a-database-environment">How this actually plays out in a database environment</h2>

<p>The mechanics matter more than the deadline, because “post-quantum cryptography” is not a single switch a DBA can flip.</p>

<p>Database encryption touches multiple independent layers, each with its own migration path: TLS/network encryption between application and database, transparent data encryption for data at rest, column-level or “always encrypted” schemes for specific sensitive fields, backup and snapshot encryption, replication-stream encryption, and the key management layer wrapping all of the above — often a cloud KMS or on-prem HSM using RSA or ECC key exchange under the hood. A migration plan has to account for all of them separately, because they don’t upgrade together, and some (backups, especially long-retention ones) are easy to forget entirely.</p>

<p>The most common failure mode is not resistance to PQC — it’s the discovery step. <a href="https://www.encryptionconsulting.com/6-practical-steps-to-crypto-agile-pqc-in-2026/" target="_blank" rel="noopener noreferrer">Cryptographic discovery is consistently the most time-intensive part of a PQC program</a>, because most organizations have never built or maintained a complete inventory of where cryptography lives in their environment — which databases, which key vaults, which third-party integrations, which legacy systems nobody has touched in years but that still hold live, encrypted, long-retention data. A database that was provisioned five years ago with default TDE settings and has quietly accumulated sensitive customer records since is exactly the kind of system that gets missed, because nobody currently owns the question “what encrypts this.”</p>

<p>The second failure mode is architectural rigidity: systems where the cryptographic algorithm is hard-wired into the application or database configuration rather than abstracted behind a swappable interface. That’s the absence of crypto-agility — the ability to rotate algorithms without re-architecting the system around them — and it’s what turns a PQC migration from a configuration change into a multi-quarter engineering project. Systems built without crypto-agility in mind face exactly that kind of forklift upgrade when the deadline arrives, regardless of how much runway existed beforehand.</p>

<p>Legacy systems compound both problems. Older database engines, on-prem HSMs nearing end-of-support, and vendor-managed encryption with no visibility into the underlying algorithm are the hardest and slowest parts of any migration — and they’re disproportionately common in exactly the industries (healthcare, financial services, government) with the longest data-retention requirements and the most HNDL exposure.</p>

<h2 id="actions-to-take-now">Actions to take now</h2>

<p>Start cheap and get more specific as the deadline gets closer. None of this requires waiting for PQC database features to be fully mature — the early steps are about knowing what you have.</p>

<ol>
  <li>
    <p><strong>Inventory every place cryptography touches your database environment.</strong> TLS certificates, TDE key wrapping, column-level encryption, backup encryption, replication encryption, and the KMS or HSM underneath each one. This is the single highest-leverage step and the one most organizations skip. Treat it as a living document, not a one-time audit — new deployments need to be captured continuously.</p>
  </li>
  <li>
    <p><strong>Flag data with a confidentiality window that extends past 2030.</strong> Health records, financial account data, long-retention government or legal data, trade secrets. This is your HNDL priority list — it needs migration attention before anything else, regardless of where it sits in a broader roadmap.</p>
  </li>
  <li>
    <p><strong>Ask your cloud provider and database vendor what their PQC roadmap actually is.</strong> Specifically: when does their KMS support PQC key establishment algorithms, and what’s the migration path for existing encrypted data. Get this in writing, not a sales conversation — you need dates, not intentions.</p>
  </li>
  <li>
    <p><strong>Separate your cryptographic policy from your application code.</strong> If algorithm choice is hard-wired into schema, connection strings, or application logic instead of abstracted behind a key-management interface, that’s the crypto-agility gap that turns this into a rebuild instead of a rotation. Fixing this now, before a hard deadline forces it, is dramatically cheaper.</p>
  </li>
  <li>
    <p><strong>Pilot PQC key establishment on one non-critical system in 2026 or early 2027.</strong> Don’t wait for a mandate to test how your stack actually behaves with hybrid classical/PQC key exchange — vendor support is uneven right now, and you want to find the gaps on a system that doesn’t matter before you find them on one that does.</p>
  </li>
  <li>
    <p><strong>Build the 2028-2030 migration budget and staffing plan this year</strong>, even if execution doesn’t start until next year. Discovery and pilots are the cheap phase; production migration across every database, backup set, and legacy integration is not, and a plan written under deadline pressure is worse than one written with runway.</p>
  </li>
</ol>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>A June 2026 executive order moved the federal PQC deadline for key establishment from 2035 to December 2030, with digital signatures following in December 2031.</li>
  <li>Harvest-now-decrypt-later means encrypted data with a long confidentiality window is at risk today, even though no quantum computer can currently break RSA or ECC.</li>
  <li>Recent research has lowered the estimated quantum-hardware bar for breaking RSA-2048, increasing urgency without changing the fact that a cryptographically relevant quantum computer doesn’t yet exist.</li>
  <li>Cryptographic discovery — knowing exactly where encryption lives across TDE, column-level encryption, backups, and replication — is consistently the hardest and most time-consuming part of a PQC program, and most database teams haven’t done it.</li>
  <li>Crypto-agility, not any single algorithm swap, is what determines whether this migration is a configuration change or a multi-quarter rebuild.</li>
</ul>

<p>If your database environment doesn’t have a current cryptographic inventory, that’s the actual starting line for this deadline — not the migration itself. <a href="/services/">Get in touch</a> if you need help building one.</p>

<hr />

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="quantum" /><category term="quantum" /><category term="post-quantum-cryptography" /><category term="encryption" /><category term="database-security" /><category term="compliance" /><category term="future-outlook" /><summary type="html"><![CDATA[A June 2026 executive order pulled the federal post-quantum crypto deadline to 2030. Most database teams don't have a crypto inventory to start from.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/post-quantum-migration-database-encryption-inventory-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/post-quantum-migration-database-encryption-inventory-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Index Bloat and Fragmentation: When to Rebuild, When to Ignore It</title><link href="https://dataplatformadvisory.com/blog/2026/08/19/index-bloat-and-fragmentation/" rel="alternate" type="text/html" title="Index Bloat and Fragmentation: When to Rebuild, When to Ignore It" /><published>2026-08-19T05:45:00+00:00</published><updated>2026-08-19T05:45:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/19/index-bloat-and-fragmentation</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/19/index-bloat-and-fragmentation/"><![CDATA[<p><img src="/assets/images/index-bloat-and-fragmentation-01.png" alt="Diagram comparing SQL Server index fragmentation signals: a fragmented page at 60% density next to a rebuilt page at 90% density after ALTER INDEX REBUILD" /></p>

<p>Rebuild an index when logical fragmentation is high <strong>and</strong> page density is low – not on logical fragmentation alone. <code class="language-plaintext highlighter-rouge">avg_fragmentation_in_percent</code>, the number most maintenance scripts check by default, measures whether pages are out of physical order, which barely matters on SSD/NVMe storage. <code class="language-plaintext highlighter-rouge">avg_page_space_used_in_percent</code>, the number most scripts never look at because it costs more to compute, measures how full each page actually is – and that’s the one that costs you in buffer pool memory and I/O regardless of storage type. This post reproduces realistic fragmentation from status-column churn on a 1.5-million-row table, shows how to read both signals correctly, applies an online rebuild with storage-appropriate thresholds, and closes with a scheduled way to catch the next index quietly headed the same way.</p>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">avg_fragmentation_in_percent</code> measures logical fragmentation (pages out of order) – a weak signal on modern SSD/NVMe storage, where random and sequential I/O cost about the same</li>
  <li><code class="language-plaintext highlighter-rouge">avg_page_space_used_in_percent</code> measures page density – how full each page is – and drives buffer pool and I/O cost on every storage type; it requires <code class="language-plaintext highlighter-rouge">SAMPLED</code> or <code class="language-plaintext highlighter-rouge">DETAILED</code> mode, not the <code class="language-plaintext highlighter-rouge">LIMITED</code> default most scripts use</li>
  <li>Fragmentation on a real table comes from UPDATEs on indexed columns causing page splits, not from inserts – an ever-increasing key like an identity column barely fragments on its own</li>
  <li>Thresholds should account for storage: REORGANIZE at 10-30% / REBUILD above 30% on spinning disks; on SSD/NVMe, many practitioners push those to 30% / 60% before it’s worth the resource cost</li>
  <li>AI is a good fit for scanning every index on an instance daily and ranking candidates by both fragmentation signals – but which threshold, fill factor, and maintenance window fit a given index stays a human call</li>
</ul>

<h2 id="the-problem">The problem</h2>

<p>An <code class="language-plaintext highlighter-rouge">Orders</code> table has a nonclustered index on <code class="language-plaintext highlighter-rouge">OrderStatus</code>, supporting the dashboard queries that filter orders by status. The table isn’t unusually large – about 1.5 million rows – and nothing about the schema is wrong. But a weekly maintenance job flags the index at 68% fragmented, a report gets forwarded, and someone schedules an emergency rebuild during business hours “before it gets worse.”</p>

<p>That reaction is common, and it’s usually not wrong to rebuild – but it’s frequently wrong about <em>why</em>. The 68% figure comes from <code class="language-plaintext highlighter-rouge">avg_fragmentation_in_percent</code>, which measures <strong>logical fragmentation</strong>: whether the physical order of pages on disk matches their logical order in the index. On a spinning disk, out-of-order pages mean the read head jumps around instead of sweeping in one direction, and that’s genuinely expensive. On the SSD or NVMe storage running most production SQL Server today – on-prem or cloud-managed – there’s no read head. Random and sequential I/O cost is close enough that logical fragmentation alone is a weak predictor of anything.</p>

<p>The number worth checking instead is <code class="language-plaintext highlighter-rouge">avg_page_space_used_in_percent</code> – <strong>page density</strong>, how full each 8KB page actually is. A table with plenty of empty space per page needs more pages to hold the same data, which means more memory to cache it in the buffer pool, more I/O to read it from disk, and larger backups. That cost is real on every storage type, and it’s the one most default maintenance scripts never surface, because computing it requires running <code class="language-plaintext highlighter-rouge">sys.dm_db_index_physical_stats</code> in <code class="language-plaintext highlighter-rouge">SAMPLED</code> or <code class="language-plaintext highlighter-rouge">DETAILED</code> mode instead of the faster <code class="language-plaintext highlighter-rouge">LIMITED</code> mode that ships as the default in Ola Hallengren’s popular maintenance solution and most third-party tools (<a href="https://ola.hallengren.com/sql-server-index-and-statistics-maintenance.html" target="_blank" rel="noopener noreferrer">Ola Hallengren, SQL Server Index and Statistics Maintenance</a>).</p>

<p>Here’s what actually caused the 68%: nothing to do with the identity-column primary key, which only ever grows at the end and barely fragments regardless of maintenance. The cause is the <code class="language-plaintext highlighter-rouge">OrderStatus</code> index itself, sitting on a column that gets UPDATEd constantly as every order moves <code class="language-plaintext highlighter-rouge">Pending</code> -&gt; <code class="language-plaintext highlighter-rouge">Shipped</code> -&gt; <code class="language-plaintext highlighter-rouge">Delivered</code>. Each of those UPDATEs changes an indexed value, which means the row may no longer belong where it currently sits in the index – and when a page doesn’t have room for the row in its new sorted position, SQL Server splits the page in two. Repeat that across a million status transitions and you get exactly the pattern in this table: high logical fragmentation and low page density, both driven by the same underlying cause, but only one of them showing up in the default report.</p>

<h2 id="the-expert-fix">The expert fix</h2>

<p>Pull both DMV columns for the table, not just the one:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">OBJECT_NAME</span><span class="p">(</span><span class="n">ips</span><span class="p">.</span><span class="n">object_id</span><span class="p">)</span>          <span class="k">AS</span> <span class="n">TableName</span><span class="p">,</span>
    <span class="n">i</span><span class="p">.</span><span class="n">name</span>                              <span class="k">AS</span> <span class="n">IndexName</span><span class="p">,</span>
    <span class="n">ips</span><span class="p">.</span><span class="n">avg_fragmentation_in_percent</span>    <span class="k">AS</span> <span class="n">LogicalFragPct</span><span class="p">,</span>
    <span class="n">ips</span><span class="p">.</span><span class="n">avg_page_space_used_in_percent</span>  <span class="k">AS</span> <span class="n">PageDensityPct</span><span class="p">,</span>
    <span class="n">ips</span><span class="p">.</span><span class="n">page_count</span>
<span class="k">FROM</span> <span class="n">sys</span><span class="p">.</span><span class="n">dm_db_index_physical_stats</span><span class="p">(</span><span class="n">DB_ID</span><span class="p">(),</span> <span class="n">OBJECT_ID</span><span class="p">(</span><span class="s1">'dbo.Orders'</span><span class="p">),</span> <span class="k">NULL</span><span class="p">,</span> <span class="k">NULL</span><span class="p">,</span> <span class="s1">'SAMPLED'</span><span class="p">)</span> <span class="n">ips</span>
<span class="k">JOIN</span> <span class="n">sys</span><span class="p">.</span><span class="n">indexes</span> <span class="n">i</span> <span class="k">ON</span> <span class="n">i</span><span class="p">.</span><span class="n">object_id</span> <span class="o">=</span> <span class="n">ips</span><span class="p">.</span><span class="n">object_id</span> <span class="k">AND</span> <span class="n">i</span><span class="p">.</span><span class="n">index_id</span> <span class="o">=</span> <span class="n">ips</span><span class="p">.</span><span class="n">index_id</span>
<span class="k">WHERE</span> <span class="n">ips</span><span class="p">.</span><span class="n">index_level</span> <span class="o">=</span> <span class="mi">0</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">ips</span><span class="p">.</span><span class="n">avg_fragmentation_in_percent</span> <span class="k">DESC</span><span class="p">;</span>
</code></pre></div></div>

<p>On the reproduction table used for this post, <code class="language-plaintext highlighter-rouge">IX_Orders_OrderStatus</code> comes back around 68% logical fragmentation and roughly 60% page density – both signals agree something is worth fixing. That agreement matters: a high fragmentation number with page density still near 90% is a much weaker case for a rebuild than the same fragmentation number paired with page density down in the 50s or 60s.</p>

<p>Which action to take, and at what threshold, should account for storage. Microsoft’s long-standing documented guidance is REORGANIZE between 5% and 30% logical fragmentation, REBUILD above 30% – written with spinning disks in mind. On SSD/NVMe-backed instances, which describes most cloud-managed SQL Server today, that 5% floor triggers on noise, and practitioners have converged on pushing both thresholds higher: REORGANIZE around 10-30%, REBUILD above 30%, or even REORGANIZE at 30% and REBUILD at 60% on the fastest storage, always weighed against page density rather than fragmentation percentage alone (<a href="https://erikdarling.com/because-your-index-maintenance-script-is-measuring-the-wrong-thing/" target="_blank" rel="noopener noreferrer">Erik Darling, Why SQL Server Index Fragmentation Isn’t a Problem on Modern Storage Hardware</a>).</p>

<p>With the diagnosis confirmed, rebuild online with a deliberate fill factor:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">INDEX</span> <span class="n">IX_Orders_OrderStatus</span> <span class="k">ON</span> <span class="n">dbo</span><span class="p">.</span><span class="n">Orders</span>
<span class="n">REBUILD</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">ONLINE</span> <span class="o">=</span> <span class="k">ON</span><span class="p">,</span> <span class="n">FILLFACTOR</span> <span class="o">=</span> <span class="mi">90</span><span class="p">,</span> <span class="n">MAXDOP</span> <span class="o">=</span> <span class="mi">0</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ONLINE = ON</code> (Enterprise Edition or Azure SQL Database) keeps readers and writers unblocked during the rebuild – necessary on a live table this size. <code class="language-plaintext highlighter-rouge">FILLFACTOR = 90</code> leaves 10% of each page empty on purpose, so the next round of status-value UPDATEs has somewhere to land without immediately splitting the page again – fixing today’s fragmentation without guaranteeing tomorrow’s. A fill factor that’s too aggressive (60-70%) wastes memory and disk on empty space that never gets used; one that’s too tight (100%) guarantees the very next write on that page causes a split. The right number depends on how often that specific index takes updates – there’s no single correct value across every table.</p>

<p>Re-running the fragmentation query afterward should show logical fragmentation near 0 and page density near 90%, matching the fill factor. <code class="language-plaintext highlighter-rouge">SET STATISTICS IO, TIME ON</code> around a representative query against the table should show fewer logical reads than before the rebuild – fewer, fuller pages to scan for the same result. The full seed script and before/after evidence are in the <a href="https://github.com/mrivanlima/DbModernizer/tree/main/examples/index-bloat-and-fragmentation" target="_blank" rel="noopener noreferrer">companion example on GitHub</a>.</p>

<p>For lighter cases in the REORGANIZE band, <code class="language-plaintext highlighter-rouge">ALTER INDEX ... REORGANIZE</code> is always online regardless of edition and works incrementally in place – the tradeoff is that it doesn’t update statistics, so pair it with an explicit <code class="language-plaintext highlighter-rouge">UPDATE STATISTICS</code> if the table’s had significant write volume.</p>

<h2 id="the-ai-automation-angle">The AI-automation angle</h2>

<p>Reading the right DMV columns for one table by hand is manageable. Doing it across every index on an instance, on a schedule, and surfacing only the ones actually worth someone’s attention is exactly the kind of scanning-and-ranking work worth automating – while leaving the decision of what to run, and when, with a person.</p>

<p>A scheduled, read-only query can check both fragmentation signals for every index above a minimum size, apply storage-aware thresholds, and log candidates to a review table instead of acting on them directly:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">DECLARE</span> <span class="o">@</span><span class="n">StorageType</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span> <span class="o">=</span> <span class="s1">'SSD'</span><span class="p">;</span>
<span class="k">DECLARE</span> <span class="o">@</span><span class="n">ReorgThreshold</span> <span class="nb">DECIMAL</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span><span class="mi">2</span><span class="p">)</span> <span class="o">=</span> <span class="k">CASE</span> <span class="k">WHEN</span> <span class="o">@</span><span class="n">StorageType</span> <span class="o">=</span> <span class="s1">'SSD'</span> <span class="k">THEN</span> <span class="mi">30</span><span class="p">.</span><span class="mi">0</span> <span class="k">ELSE</span> <span class="mi">5</span><span class="p">.</span><span class="mi">0</span> <span class="k">END</span><span class="p">;</span>
<span class="k">DECLARE</span> <span class="o">@</span><span class="n">RebuildThreshold</span> <span class="nb">DECIMAL</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span><span class="mi">2</span><span class="p">)</span> <span class="o">=</span> <span class="k">CASE</span> <span class="k">WHEN</span> <span class="o">@</span><span class="n">StorageType</span> <span class="o">=</span> <span class="s1">'SSD'</span> <span class="k">THEN</span> <span class="mi">60</span><span class="p">.</span><span class="mi">0</span> <span class="k">ELSE</span> <span class="mi">30</span><span class="p">.</span><span class="mi">0</span> <span class="k">END</span><span class="p">;</span>
<span class="k">DECLARE</span> <span class="o">@</span><span class="n">MinPageCount</span> <span class="nb">INT</span> <span class="o">=</span> <span class="mi">1000</span><span class="p">;</span>
<span class="k">DECLARE</span> <span class="o">@</span><span class="n">MinPageDensity</span> <span class="nb">DECIMAL</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span><span class="mi">2</span><span class="p">)</span> <span class="o">=</span> <span class="mi">75</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">dbo</span><span class="p">.</span><span class="n">IndexFragmentationLog</span>
    <span class="p">(</span><span class="n">DatabaseName</span><span class="p">,</span> <span class="n">SchemaName</span><span class="p">,</span> <span class="n">TableName</span><span class="p">,</span> <span class="n">IndexName</span><span class="p">,</span> <span class="n">LogicalFragPct</span><span class="p">,</span> <span class="n">PageDensityPct</span><span class="p">,</span> <span class="n">PageCount</span><span class="p">,</span> <span class="n">RecommendedAction</span><span class="p">)</span>
<span class="k">SELECT</span>
    <span class="n">DB_NAME</span><span class="p">(),</span> <span class="n">s</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">t</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">i</span><span class="p">.</span><span class="n">name</span><span class="p">,</span>
    <span class="n">ips</span><span class="p">.</span><span class="n">avg_fragmentation_in_percent</span><span class="p">,</span> <span class="n">ips</span><span class="p">.</span><span class="n">avg_page_space_used_in_percent</span><span class="p">,</span> <span class="n">ips</span><span class="p">.</span><span class="n">page_count</span><span class="p">,</span>
    <span class="k">CASE</span>
        <span class="k">WHEN</span> <span class="n">ips</span><span class="p">.</span><span class="n">avg_fragmentation_in_percent</span> <span class="o">&gt;=</span> <span class="o">@</span><span class="n">RebuildThreshold</span>
             <span class="k">OR</span> <span class="n">ips</span><span class="p">.</span><span class="n">avg_page_space_used_in_percent</span> <span class="o">&lt;</span> <span class="o">@</span><span class="n">MinPageDensity</span> <span class="k">THEN</span> <span class="s1">'REBUILD'</span>
        <span class="k">WHEN</span> <span class="n">ips</span><span class="p">.</span><span class="n">avg_fragmentation_in_percent</span> <span class="o">&gt;=</span> <span class="o">@</span><span class="n">ReorgThreshold</span> <span class="k">THEN</span> <span class="s1">'REORGANIZE'</span>
        <span class="k">ELSE</span> <span class="s1">'MONITOR'</span>
    <span class="k">END</span>
<span class="k">FROM</span> <span class="n">sys</span><span class="p">.</span><span class="n">dm_db_index_physical_stats</span><span class="p">(</span><span class="n">DB_ID</span><span class="p">(),</span> <span class="k">NULL</span><span class="p">,</span> <span class="k">NULL</span><span class="p">,</span> <span class="k">NULL</span><span class="p">,</span> <span class="s1">'SAMPLED'</span><span class="p">)</span> <span class="n">ips</span>
<span class="k">JOIN</span> <span class="n">sys</span><span class="p">.</span><span class="n">indexes</span> <span class="n">i</span> <span class="k">ON</span> <span class="n">i</span><span class="p">.</span><span class="n">object_id</span> <span class="o">=</span> <span class="n">ips</span><span class="p">.</span><span class="n">object_id</span> <span class="k">AND</span> <span class="n">i</span><span class="p">.</span><span class="n">index_id</span> <span class="o">=</span> <span class="n">ips</span><span class="p">.</span><span class="n">index_id</span>
<span class="k">JOIN</span> <span class="n">sys</span><span class="p">.</span><span class="n">tables</span> <span class="n">t</span> <span class="k">ON</span> <span class="n">t</span><span class="p">.</span><span class="n">object_id</span> <span class="o">=</span> <span class="n">ips</span><span class="p">.</span><span class="n">object_id</span>
<span class="k">JOIN</span> <span class="n">sys</span><span class="p">.</span><span class="n">schemas</span> <span class="n">s</span> <span class="k">ON</span> <span class="n">s</span><span class="p">.</span><span class="n">schema_id</span> <span class="o">=</span> <span class="n">t</span><span class="p">.</span><span class="n">schema_id</span>
<span class="k">WHERE</span> <span class="n">ips</span><span class="p">.</span><span class="n">index_level</span> <span class="o">=</span> <span class="mi">0</span>
  <span class="k">AND</span> <span class="n">ips</span><span class="p">.</span><span class="n">page_count</span> <span class="o">&gt;=</span> <span class="o">@</span><span class="n">MinPageCount</span>
  <span class="k">AND</span> <span class="p">(</span><span class="n">ips</span><span class="p">.</span><span class="n">avg_fragmentation_in_percent</span> <span class="o">&gt;=</span> <span class="o">@</span><span class="n">ReorgThreshold</span> <span class="k">OR</span> <span class="n">ips</span><span class="p">.</span><span class="n">avg_page_space_used_in_percent</span> <span class="o">&lt;</span> <span class="o">@</span><span class="n">MinPageDensity</span><span class="p">);</span>
</code></pre></div></div>

<p>Run daily against each database that matters, this produces a ranked queue: which indexes cross the threshold on either signal, ordered by severity. A human reviews the queue and decides, per index, whether the resource cost of a rebuild is worth it right now, what fill factor fits that index’s actual write pattern, and whether <code class="language-plaintext highlighter-rouge">ONLINE = ON</code> is even available on that edition. The watcher never issues <code class="language-plaintext highlighter-rouge">ALTER INDEX</code> itself – its job stops at producing a report nobody had to generate by hand. That’s the right boundary for this kind of automation: scanning every index on an instance and applying a consistent, storage-aware rule is tedious, mechanical work AI handles well; deciding the actual maintenance window and fill factor for a specific business-critical table is a judgment call that stays with whoever owns that workload. The full watcher query and a PowerShell scheduling wrapper are in the <a href="https://github.com/mrivanlima/DbModernizer/tree/main/examples/index-bloat-and-fragmentation" target="_blank" rel="noopener noreferrer">companion example</a>.</p>

<p>If your maintenance job is still flagging every index at the default 5%/30% split without checking page density, or a rebuild schedule was set up years ago and never revisited for the storage it’s actually running on, <a href="/services/">see how a modernization engagement addresses this</a> or <a href="/about/#contact">get in touch</a> and we can walk through what your indexes actually need.</p>

<hr />

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="performance" /><category term="performance-engineering" /><category term="sql-server" /><category term="indexing" /><category term="query-tuning" /><summary type="html"><![CDATA[Rebuild when logical fragmentation is high and page density is low -- not on fragmentation percentage alone, which is a weak signal on modern SSD storage.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/index-bloat-and-fragmentation-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/index-bloat-and-fragmentation-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A Perfectly Scoped Query From the Wrong Agent Is Still a Breach</title><link href="https://dataplatformadvisory.com/blog/2026/08/18/ai-agent-row-level-security-sql-server/" rel="alternate" type="text/html" title="A Perfectly Scoped Query From the Wrong Agent Is Still a Breach" /><published>2026-08-18T13:20:00+00:00</published><updated>2026-08-18T13:20:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/18/ai-agent-row-level-security-sql-server</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/18/ai-agent-row-level-security-sql-server/"><![CDATA[<p><img src="/assets/images/ai-agent-row-level-security-01.png" alt="Diagram contrasting an AI agent's in-scope region, where SQL Server Row-Level Security allows reads and writes, against an out-of-scope region, where rows are hidden from SELECT and writes are blocked" /></p>

<p>This is the third piece in a series on database-side controls for AI agents with direct SQL execute access. The <a href="/blog/2026/08/17/ai-agent-database-firewall-sql-server/">first post</a> covered a firewall that refuses badly-shaped statements outright — unscoped writes, schema changes. The <a href="/blog/2026/08/18/schema-change-approval-queue-for-ai-agents/">second</a> covered a human-approval queue for the schema changes that firewall refuses, so a legitimate change doesn’t just get re-run by hand later. Both of those check the <em>shape</em> of an agent’s SQL. This one checks something neither of them can: given a statement that’s perfectly well-formed and perfectly scoped-looking, does the agent issuing it actually have any business touching those rows at all?</p>

<h2 id="the-incident-that-motivates-this-one">The incident that motivates this one</h2>

<p>Between December 2025 and February 2026, a single attacker used Claude Code and GPT-4.1 to breach nine Mexican government agencies — the federal tax authority, Mexico City’s civil registry, the electoral institute — exposing 195 million taxpayer records and 220 million civil records, plus more than 150GB of additional data.</p>

<p>The root cause wasn’t a novel exploit technique. It was excessive, shared permissions never enforced at the data layer itself. Whatever had execute access could see and touch far more than its actual job required, because nothing below the application layer was checking. A <a href="https://www.kiteworks.com/cybersecurity-risk-management/ai-agent-security-incidents-2026/" target="_blank" rel="noopener noreferrer">2026 least-privilege research report</a> analyzing over 3 billion permissions found that on average only about 4% had been used in the trailing 90 days — and nearly one in three could modify or delete sensitive data. Nobody had to break in cleverly. The door was already open wider than anyone was using it.</p>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>A statement-shape guardrail can’t catch this failure mode: <code class="language-plaintext highlighter-rouge">WHERE region = 'US-EAST'</code> is a perfectly scoped clause, and still exactly the wrong thing for an agent with no business in <code class="language-plaintext highlighter-rouge">US-EAST</code> to run</li>
  <li>SQL Server’s native Row-Level Security enforces what an agent can see and touch based on its declared identity, independent of how its SQL is written — a <code class="language-plaintext highlighter-rouge">SELECT</code> with zero WHERE clause, or a deliberately broad <code class="language-plaintext highlighter-rouge">WHERE 1=1</code>, still only returns rows inside that agent’s scope</li>
  <li>RLS has two distinct enforcement mechanisms that fail differently, and conflating them will make your own testing look broken when it isn’t: a <strong>filter</strong> predicate silently hides out-of-scope rows before they can even be matched, while a <strong>block</strong> predicate throws an explicit error when a write’s <em>result</em> would violate the predicate on a row that was visible to begin with</li>
  <li>This complements, not replaces, statement-shape guardrails — an agent correctly scoped to its own data can still submit an unscoped DELETE within that scope, which is a different problem this project doesn’t solve</li>
</ul>

<h2 id="what-was-actually-built">What was actually built</h2>

<p>The core of it is a SQL Server security policy backed by a schema-bound predicate function:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">OR</span> <span class="k">ALTER</span> <span class="k">FUNCTION</span> <span class="n">dbo</span><span class="p">.</span><span class="n">fn_agent_region_predicate</span><span class="p">(</span><span class="o">@</span><span class="n">region</span> <span class="n">NVARCHAR</span><span class="p">(</span><span class="mi">20</span><span class="p">))</span>
<span class="k">RETURNS</span> <span class="k">TABLE</span>
<span class="k">WITH</span> <span class="n">SCHEMABINDING</span>
<span class="k">AS</span>
<span class="k">RETURN</span> <span class="k">SELECT</span> <span class="mi">1</span> <span class="k">AS</span> <span class="n">fn_result</span>
<span class="k">WHERE</span> <span class="o">@</span><span class="n">region</span> <span class="o">=</span> <span class="k">CAST</span><span class="p">(</span><span class="n">SESSION_CONTEXT</span><span class="p">(</span><span class="n">N</span><span class="s1">'agent_region'</span><span class="p">)</span> <span class="k">AS</span> <span class="n">NVARCHAR</span><span class="p">(</span><span class="mi">20</span><span class="p">))</span>
   <span class="k">OR</span> <span class="k">CAST</span><span class="p">(</span><span class="n">SESSION_CONTEXT</span><span class="p">(</span><span class="n">N</span><span class="s1">'agent_region'</span><span class="p">)</span> <span class="k">AS</span> <span class="n">NVARCHAR</span><span class="p">(</span><span class="mi">20</span><span class="p">))</span> <span class="o">=</span> <span class="s1">'ALL'</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">SECURITY</span> <span class="n">POLICY</span> <span class="n">dbo</span><span class="p">.</span><span class="n">AgentRegionPolicy</span>
    <span class="k">ADD</span> <span class="n">FILTER</span> <span class="n">PREDICATE</span> <span class="n">dbo</span><span class="p">.</span><span class="n">fn_agent_region_predicate</span><span class="p">(</span><span class="n">region</span><span class="p">)</span> <span class="k">ON</span> <span class="n">dbo</span><span class="p">.</span><span class="n">demo_accounts</span><span class="p">,</span>
    <span class="k">ADD</span> <span class="n">BLOCK</span> <span class="n">PREDICATE</span> <span class="n">dbo</span><span class="p">.</span><span class="n">fn_agent_region_predicate</span><span class="p">(</span><span class="n">region</span><span class="p">)</span> <span class="k">ON</span> <span class="n">dbo</span><span class="p">.</span><span class="n">demo_accounts</span> <span class="k">AFTER</span> <span class="k">INSERT</span><span class="p">,</span>
    <span class="k">ADD</span> <span class="n">BLOCK</span> <span class="n">PREDICATE</span> <span class="n">dbo</span><span class="p">.</span><span class="n">fn_agent_region_predicate</span><span class="p">(</span><span class="n">region</span><span class="p">)</span> <span class="k">ON</span> <span class="n">dbo</span><span class="p">.</span><span class="n">demo_accounts</span> <span class="k">AFTER</span> <span class="k">UPDATE</span><span class="p">,</span>
    <span class="k">ADD</span> <span class="n">BLOCK</span> <span class="n">PREDICATE</span> <span class="n">dbo</span><span class="p">.</span><span class="n">fn_agent_region_predicate</span><span class="p">(</span><span class="n">region</span><span class="p">)</span> <span class="k">ON</span> <span class="n">dbo</span><span class="p">.</span><span class="n">demo_accounts</span> <span class="k">BEFORE</span> <span class="k">UPDATE</span><span class="p">,</span>
    <span class="k">ADD</span> <span class="n">BLOCK</span> <span class="n">PREDICATE</span> <span class="n">dbo</span><span class="p">.</span><span class="n">fn_agent_region_predicate</span><span class="p">(</span><span class="n">region</span><span class="p">)</span> <span class="k">ON</span> <span class="n">dbo</span><span class="p">.</span><span class="n">demo_accounts</span> <span class="k">BEFORE</span> <span class="k">DELETE</span>
    <span class="k">WITH</span> <span class="p">(</span><span class="k">STATE</span> <span class="o">=</span> <span class="k">ON</span><span class="p">);</span>
</code></pre></div></div>

<p>Agent identity is carried via <code class="language-plaintext highlighter-rouge">SESSION_CONTEXT</code>, set once per connection before any of that agent’s SQL runs — mirroring how a real MCP database server or agent gateway typically authenticates a caller against a shared connection, rather than provisioning a distinct SQL login per agent (the same predicate pattern works against <code class="language-plaintext highlighter-rouge">SUSER_SNAME()</code> instead, if your environment does provision real per-agent logins). A thin Python wrapper, <code class="language-plaintext highlighter-rouge">AgentIdentityGuard</code>, establishes that identity and writes to an audit table — but the actual enforcement is entirely SQL Server’s, not application code the agent could reason its way around.</p>

<h2 id="seeing-it-hold-up-including-the-part-that-surprised-me">Seeing it hold up, including the part that surprised me</h2>

<p>Against a real SQL Server 2025 instance, with an agent scoped to <code class="language-plaintext highlighter-rouge">US-WEST</code> trying two different ways to reach outside its region:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>=== SCENARIO C: a well-scoped-looking write reaching OUTSIDE the agent's identity ===
    (This has a WHERE clause -- a shape-based guardrail would approve it.
     This agent is scoped to US-WEST; the target rows are US-EAST.)
  -&gt; APPROVED, rows_affected=0 -- the filter predicate hid the US-EAST rows
     before the WHERE clause could ever match them. No error, no rows
     touched: the agent's own SQL was syntactically fine, it just had
     nothing it was allowed to see.

=== SCENARIO D: the same agent tries to relabel a row IT CAN see out of its own scope ===
    (Exfiltration by relabeling: not reaching for another region's data,
     but trying to move its own row into a region it isn't scoped to.)
  -&gt; BLOCKED: Refused: agent 'migration-agent-west' (scope=US-WEST)
     attempted a UPDATE that Row-Level Security's block predicate rejected
     -- the target row(s) are outside this agent's identity scope. The
     write never reached the table.
</code></pre></div></div>

<p>My first draft of this demo only had scenario C, and I assumed it would throw the same error as an out-of-scope INSERT. It didn’t — it silently affected zero rows. It took a moment to click: the filter predicate hides out-of-scope rows from ever being matched by a WHERE clause, so the UPDATE never reaches the point where a block predicate would need to fire — there’s nothing left to violate it. The block predicate only fires on a <em>result</em> violation: a row that was visible ending up with a post-operation value that breaks the rule, which is a genuinely different scenario. I had to write scenario D — an agent trying to relabel its own row into a scope it doesn’t hold — to actually exercise that path.</p>

<p>Worth stating plainly rather than glossing over: RLS’s “fail silently” behavior on reads and matches, and its “fail loudly” behavior on result-violating writes, are two different mechanisms doing two different jobs. A demo (or a test suite) that expects one where the other actually applies will look broken even though the security property held the entire time.</p>

<h2 id="what-this-doesnt-solve">What this doesn’t solve</h2>

<p>Row-Level Security enforces scope given an <em>honestly declared</em> identity — it doesn’t itself authenticate that identity. If the gateway setting <code class="language-plaintext highlighter-rouge">SESSION_CONTEXT</code> can be tricked into declaring the wrong agent or the wrong scope, this control doesn’t catch that; it assumes the layer establishing identity is trustworthy, same as any authentication system has to assume somewhere. And it doesn’t replace the first two posts in this series — an agent correctly scoped to exactly the data it should touch can still submit an unscoped DELETE or a DROP TABLE within that scope. That’s still the firewall’s job, not this one’s.</p>

<p>Between the three projects now: the firewall refuses what should never run regardless of who’s asking. The approval queue routes what might be legitimate to a human instead of deciding alone. Row-level security scopes what an agent can reach in the first place, before either of the other two questions is even relevant. Different layer, same underlying problem — an AI agent with real database access needs checks it can’t talk its way around, and no single layer covers all of them.</p>

<p>The full project — the predicate function, the security policy, and all six scenarios runnable via Docker Compose against a real SQL Server 2025 instance — is open source: <a href="https://github.com/mrivanlima/ai-agent-row-level-security" target="_blank" rel="noopener noreferrer">github.com/mrivanlima/ai-agent-row-level-security</a>. If your agents have database access scoped by convention rather than enforced by the engine, that’s worth closing before it’s the reason nine agencies’ worth of records end up on someone else’s list. <a href="/about/#contact">Get in touch</a> if you want help wiring this into your own environment.</p>

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="case-studies" /><category term="ai-agents" /><category term="agent-access" /><category term="database-security" /><category term="open-source" /><category term="real-incidents" /><summary type="html"><![CDATA[A third layer for AI agent database access, alongside my SQL Server firewall and approval queue: native Row-Level Security that enforces what an agent can see and touch based on its identity, not its SQL's shape.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/ai-agent-row-level-security-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/ai-agent-row-level-security-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why Your AI Agents Keep Crashing (And Why You Need a DB Architect)</title><link href="https://dataplatformadvisory.com/blog/2026/08/18/why-your-ai-agents-keep-crashing-database-architect/" rel="alternate" type="text/html" title="Why Your AI Agents Keep Crashing (And Why You Need a DB Architect)" /><published>2026-08-18T09:40:00+00:00</published><updated>2026-08-18T09:40:00+00:00</updated><id>https://dataplatformadvisory.com/blog/2026/08/18/why-your-ai-agents-keep-crashing-database-architect</id><content type="html" xml:base="https://dataplatformadvisory.com/blog/2026/08/18/why-your-ai-agents-keep-crashing-database-architect/"><![CDATA[<p><img src="/assets/images/why-your-ai-agents-keep-crashing-database-architect-01.png" alt="Diagram of the Agent State Ledger schema showing session_id, agent_id, state, and version columns with an optimistic concurrency check preventing two concurrent writers from corrupting shared state" /></p>

<p>Your multi-agent system didn’t crash because LLMs are bad; it crashed because you treated memory like a temporary JSON file instead of a relational state machine. Every “agent forgot everything after a redeploy” incident traces back to the same root cause: the team building it never asked where state lives when the process dies. That’s not a prompting problem. It’s a state management problem, and it has a well-understood, decades-old answer.</p>

<h2 id="the-failure-mode-nobody-wants-to-name">The failure mode nobody wants to name</h2>

<p>Here’s the pattern. A team ships an agent that works beautifully in the demo. It holds context across a long conversation, calls tools, updates a plan, recovers from a bad tool call. Then it goes to production, autoscaling kicks in, a pod restarts, and every bit of that context evaporates. The user has to start over. Nobody can explain why, because nobody ever wrote down where “memory” actually lived. The answer, almost every time, is a Python dictionary sitting in process RAM.</p>

<p>I’ve watched this exact failure take down a multi-agent workflow more than once — always the same root cause. A dict, a class attribute, an in-memory cache library with no persistence layer behind it. It works right up until the process that’s holding it doesn’t exist anymore.</p>

<p>This isn’t a fringe case. Reliability reviews of production agent deployments through 2025 and into 2026 consistently flag memory-related failures as the single most common category of reliability incident in agent systems — agents that forget instructions mid-task, silently lose prior context, or degrade across long sessions, because memory was never given an intentional persistence layer in the first place (<a href="https://atlan.com/know/ai-agent/how-agents-forget-and-how-to-fix-it/" target="_blank" rel="noopener noreferrer">Atlan</a>). The LLM isn’t degrading. The scaffolding around it was never built to survive a restart.</p>

<h2 id="why-add-retries-and-use-a-bigger-context-window-dont-fix-it">Why “add retries” and “use a bigger context window” don’t fix it</h2>

<p>The instinctive fixes are application-layer patches, and they don’t touch the actual defect.</p>

<p>Retries assume the failure is transient — that if you just call the model again, you’ll get a good result. But if the state you needed was never durable, retrying doesn’t recover it. You’re retrying against an empty memory, and you’ll get a coherent-sounding answer built on no history at all, which is worse than an obvious crash.</p>

<p>A bigger context window doesn’t solve persistence either — it solves how much state you can hold <em>within a single running process</em>, for as long as that process happens to stay alive. It does nothing for the moment that process restarts, gets rescheduled onto a different node, or gets killed by an autoscaler mid-request. Context window size and state durability are unrelated problems that keep getting treated as the same one.</p>

<p>And neither fix addresses the failure mode that’s actually more dangerous than losing memory outright: two concurrent requests corrupting shared in-memory state at the same time. Picture two workers both reading the same in-process cache entry for a session, both mutating it based on stale reads, and both writing back — the second write silently clobbers the first with no record that a conflict ever happened. No error. No log line. Just quietly wrong state that the agent will confidently act on next turn. A dict has no isolation levels, no locking, no concurrency control of any kind. It’s not a data store. It’s a variable that happens to hold data until something restarts it or two threads fight over it.</p>

<h2 id="the-database-first-fix-the-agent-state-ledger">The database-first fix: the Agent State Ledger</h2>

<p>This is where database design stops being a “nice to have” and becomes the actual missing requirement for durable agent execution. Production-grade agent workflows need explicit, externalized state, backed by a system that was built from the ground up to survive process death and handle concurrent writers correctly. That system already exists. It’s called a relational database.</p>

<p>Here’s the pattern I use — call it the Agent State Ledger. It’s a real Postgres schema, not a metaphor:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">agent_state_ledger</span> <span class="p">(</span>
    <span class="n">session_id</span>      <span class="n">UUID</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">agent_id</span>        <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="k">state</span>           <span class="n">JSONB</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="k">version</span>         <span class="nb">BIGINT</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="mi">1</span><span class="p">,</span>
    <span class="n">updated_at</span>      <span class="n">TIMESTAMPTZ</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="n">now</span><span class="p">(),</span>
    <span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">session_id</span><span class="p">,</span> <span class="n">agent_id</span><span class="p">)</span>
<span class="p">);</span>

<span class="c1">-- Every write checks the version it read, not just the primary key.</span>
<span class="k">UPDATE</span> <span class="n">agent_state_ledger</span>
<span class="k">SET</span> <span class="k">state</span> <span class="o">=</span> <span class="err">$</span><span class="mi">1</span><span class="p">,</span>
    <span class="k">version</span> <span class="o">=</span> <span class="k">version</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span>
    <span class="n">updated_at</span> <span class="o">=</span> <span class="n">now</span><span class="p">()</span>
<span class="k">WHERE</span> <span class="n">session_id</span> <span class="o">=</span> <span class="err">$</span><span class="mi">2</span>
  <span class="k">AND</span> <span class="n">agent_id</span> <span class="o">=</span> <span class="err">$</span><span class="mi">3</span>
  <span class="k">AND</span> <span class="k">version</span> <span class="o">=</span> <span class="err">$</span><span class="mi">4</span><span class="p">;</span>   <span class="c1">-- fails silently-safe if another writer got here first</span>
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">version</code> column is doing the real work. PostgreSQL has no built-in optimistic locking primitive, but the pattern is simple and well-established: every table that can be written concurrently carries a version column, and every update’s <code class="language-plaintext highlighter-rouge">WHERE</code> clause checks that the version hasn’t moved since the writer last read it (<a href="https://reintech.io/blog/implementing-optimistic-locking-postgresql" target="_blank" rel="noopener noreferrer">Reintech</a>). If the <code class="language-plaintext highlighter-rouge">UPDATE</code> affects zero rows, you know — deterministically, not by guessing — that a concurrent writer got there first, and your agent can reread and retry instead of silently overwriting good state with stale state.</p>

<p>That’s the whole fix for the corruption case. And because the table lives outside the process, a pod restart, a redeploy, or an autoscale event no longer means the agent forgot who it was talking to. The session survives because the session was never actually stored in the session.</p>

<p>This is post one of three on a discipline I’m calling <strong>Database-First Agent Architecture</strong>, built on three pillars: Durable State, Verified Execution, and Bounded Autonomy. This post is about the first pillar. The next two cover getting agents to stop hallucinating SQL against schemas they’ve never seen, and putting hard governance boundaries around what an autonomous agent is allowed to execute.</p>

<h2 id="the-analogy-if-you-need-it">The analogy, if you need it</h2>

<p>Agent memory without a database is a web server holding session state in RAM with no replication. The industry stopped doing that for web applications twenty years ago — not because it was theoretically wrong, but because it kept taking down production. Sticky sessions, lost carts, users logged out mid-checkout when a server rebooted. We solved it by moving session state into Redis, or a database, or anything external to the process. Agent frameworks are relitigating that exact mistake right now, just with a chat history instead of a shopping cart.</p>

<h2 id="practical-guidance">Practical guidance</h2>

<p>If you’re running agents in production, or about to:</p>

<ul>
  <li>Audit every place your agent stores “memory” and ask, specifically, what survives a process restart. If the honest answer is “nothing,” that’s your first fix, not a backlog item.</li>
  <li>Give every stateful table a version column and enforce optimistic concurrency on every write path, not just the ones you’ve already seen fail.</li>
  <li>Separate short-lived working context (safe to lose) from durable session state (not safe to lose) at the schema level, not just in code comments.</li>
  <li>Log every rejected write (<code class="language-plaintext highlighter-rouge">version</code> mismatch) instead of silently retrying — that log is your evidence for whether concurrent corruption is actually happening in your system.</li>
  <li>Treat schema design for agent state as a first-class part of your architecture review, not something the application team bolts on after the demo works.</li>
</ul>

<h2 id="key-takeaways">Key takeaways</h2>

<ul>
  <li>Agents that lose context on restart aren’t suffering an LLM problem — they’re suffering a state persistence problem with a known, boring, database-shaped fix.</li>
  <li>Retries and larger context windows patch symptoms; neither creates durability or concurrency safety.</li>
  <li>The Agent State Ledger pattern — session ID, agent ID, JSONB state, and a version column enforcing optimistic concurrency — gives agents state that survives process death and resists silent corruption from concurrent writers.</li>
  <li>This is the same lesson the web already learned with session state twenty years ago, now playing out again in agent frameworks.</li>
  <li>This is pillar one — Durable State — of Database-First Agent Architecture, with Verified Execution and Bounded Autonomy still to come.</li>
</ul>

<p>If your agent architecture doesn’t have an answer for what happens on restart, it doesn’t have an architecture — it has a demo.</p>

<p>If your team is building agents on state that can’t survive a redeploy, that’s a schema problem before it’s a prompting problem, and it’s fixable. <a href="/about/#contact">Get in touch</a> or see how we approach it on <a href="/services/">our services page</a>.</p>

<p><em>Ivan Lima is a data engineer specializing in database modernization for AI systems. <a href="/about/#contact">Get in touch</a> if your database needs to be ready for what’s next.</em></p>]]></content><author><name>Ivan Lima</name></author><category term="data-engineering" /><category term="agent-access" /><category term="agent-memory" /><category term="state-management" /><category term="database-first-architect" /><category term="grounded-architect" /><summary type="html"><![CDATA[Agent memory built on in-memory Python dicts always fails at restart. Here's the Postgres pattern — the Agent State Ledger — that actually holds up.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://dataplatformadvisory.com/assets/images/why-your-ai-agents-keep-crashing-database-architect-01.png" /><media:content medium="image" url="https://dataplatformadvisory.com/assets/images/why-your-ai-agents-keep-crashing-database-architect-01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>