<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tan Tran</title>
    <description>The latest articles on DEV Community by Tan Tran (@tantn89).</description>
    <link>https://dev.to/tantn89</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4220829%2F7bf22fd4-d2a9-441d-94d0-d81907323dc0.png</url>
      <title>DEV Community: Tan Tran</title>
      <link>https://dev.to/tantn89</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tantn89"/>
    <language>en</language>
    <item>
      <title>Peeking at your A/B test turns 5% false positives into 25%, and what mSPRT costs you</title>
      <dc:creator>Tan Tran</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:46:32 +0000</pubDate>
      <link>https://dev.to/tantn89/peeking-at-your-ab-test-turns-5-false-positives-into-25-and-what-msprt-costs-you-1cj0</link>
      <guid>https://dev.to/tantn89/peeking-at-your-ab-test-turns-5-false-positives-into-25-and-what-msprt-costs-you-1cj0</guid>
      <description>&lt;p&gt;Most teams I have worked with check the experiment dashboard every day. It is just what you do when a campaign is running and someone asks how it is going. The t-test on that dashboard, though, was designed for one look at a pre-agreed sample size, and the daily habit quietly breaks it.&lt;/p&gt;

&lt;p&gt;This post measures how badly, shows the fix I used (a mixture sequential probability ratio test, mSPRT), and is explicit about what the fix costs. All numbers come from a seeded script in a public repo, linked at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why checking daily breaks the t-test
&lt;/h2&gt;

&lt;p&gt;A 5% significance level means: if there is no effect and you run the test once, at the planned sample size, you will wrongly reject 5% of the time. That guarantee is about one decision.&lt;/p&gt;

&lt;p&gt;If you look every day and stop the first time p &amp;lt; 0.05, you have given the random walk of your estimate many chances to wander across the line. Under no true effect, the difference between arms drifts up and down as data arrives. The chance that at least one look crosses the line grows with the number of looks, and the nominal 5% describes none of it.&lt;/p&gt;

&lt;p&gt;Worse, the failure is silent. The p-value on the screen is computed correctly for a single look. It is the stopping rule that is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The simulation
&lt;/h2&gt;

&lt;p&gt;I wanted a number, not an argument. The setup, from the repo's sequential report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bernoulli outcomes, 10% base rate.&lt;/li&gt;
&lt;li&gt;20 looks of 500 users per arm each, so a 10,000-per-arm horizon.&lt;/li&gt;
&lt;li&gt;alpha 0.05, 10,000 seeded runs per scenario.&lt;/li&gt;
&lt;li&gt;For the A/B scenario, the true effect is the fixed test's 80%-power minimum detectable effect (MDE), +1.19 points.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three rules are scored on exactly the same simulated data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;False-positive rate (A/A)&lt;/th&gt;
&lt;th&gt;Power (A/B)&lt;/th&gt;
&lt;th&gt;Median n per arm at rejection (A/B)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Naive t-test at every look&lt;/td&gt;
&lt;td&gt;0.248&lt;/td&gt;
&lt;td&gt;0.874&lt;/td&gt;
&lt;td&gt;3,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mSPRT (always valid)&lt;/td&gt;
&lt;td&gt;0.0098&lt;/td&gt;
&lt;td&gt;0.465&lt;/td&gt;
&lt;td&gt;6,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One test at the horizon&lt;/td&gt;
&lt;td&gt;0.0499&lt;/td&gt;
&lt;td&gt;0.787&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single final test lands at 4.99%, as it should. Peeking at every look with the same t-test rejects 24.8% of A/A experiments. A quarter of the experiments where nothing happened would have been reported as wins. With &lt;code&gt;--large&lt;/code&gt; (100 looks of 100 users instead of 20 of 500, same horizon, 20,000 runs), the naive rate rises to 0.377 while mSPRT stays at 0.014.&lt;/p&gt;

&lt;h2&gt;
  
  
  How mSPRT works
&lt;/h2&gt;

&lt;p&gt;The idea is to make the stopping rule part of the statistic, so the guarantee holds however often you look.&lt;/p&gt;

&lt;p&gt;Start with the likelihood ratio for "the true difference is exactly delta" against "the difference is 0". You do not know delta, so instead of picking one, average the ratio over a prior on delta. I use a normal prior, &lt;code&gt;delta ~ N(0, tau^2)&lt;/code&gt;. That average is the mixture likelihood ratio, and for a difference in means it has a closed form. With &lt;code&gt;d&lt;/code&gt; the estimated difference and &lt;code&gt;V&lt;/code&gt; its estimated variance (&lt;code&gt;s_t^2/n_t + s_c^2/n_c&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lambda = sqrt(V / (V + tau^2)) * exp(tau^2 * d^2 / (2 V (V + tau^2)))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties do the work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Under "no effect", &lt;code&gt;Lambda&lt;/code&gt; is a non-negative martingale starting at 1. Informally, its expected next value is always its current value, so it has no upward drift.&lt;/li&gt;
&lt;li&gt;For such a process, Ville's inequality says the chance it ever reaches &lt;code&gt;1/alpha&lt;/code&gt; is at most &lt;code&gt;alpha&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the rule "stop and reject the first time &lt;code&gt;Lambda &amp;gt;= 1/alpha&lt;/code&gt;" has type-I error at most &lt;code&gt;alpha&lt;/code&gt;, however many times you inspect, whenever you stop looking, and with no correction for the number of looks.&lt;/p&gt;

&lt;p&gt;One honest caveat: the martingale property is exact for normal data with known variance. The code plugs in sample variances, the usual large-sample approximation, so the guarantee is approximate at small &lt;code&gt;n&lt;/code&gt;. The simulation is where I check that it holds in practice.&lt;/p&gt;

&lt;p&gt;The code is short. This is the repo's implementation, trimmed to the arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;msprt_log_lr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tau&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tau2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tau&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tau&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;variance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;log_lr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tau2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tau2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tau2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log_lr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;msprt_reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tau&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;log_lr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;msprt_log_lr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tau&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nan_to_num&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_lr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The always-valid p-value is the running minimum of &lt;code&gt;1/Lambda&lt;/code&gt; over the looks, so it can only go down, which is exactly what you want from something you glance at every day.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting the mixing scale
&lt;/h3&gt;

&lt;p&gt;Validity holds for any &lt;code&gt;tau &amp;gt; 0&lt;/code&gt;. The choice only moves power. The mixture concentrates its power near &lt;code&gt;|delta| ~ tau&lt;/code&gt;, so a &lt;code&gt;tau&lt;/code&gt; that is too small makes early stopping slow, and one that is too large spends evidence on implausibly big effects.&lt;/p&gt;

&lt;p&gt;I set &lt;code&gt;tau&lt;/code&gt; before the experiment to the effect the test is designed to detect: the MDE. In the simulation that is the fixed test's 80%-power MDE of +1.19 points, fixed before any data is seen. That is a design decision, not something to tune after watching the dashboard. Tuning &lt;code&gt;tau&lt;/code&gt; on the data would quietly reintroduce the problem.&lt;/p&gt;

&lt;p&gt;I also checked the closed form against numerical integration of the mixture in a test (&lt;code&gt;test_log_lr_equals_numerical_mixture&lt;/code&gt;), and a second test confirms that mSPRT keeps type-I error under the null while naive peeking exceeds three times alpha.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price
&lt;/h2&gt;

&lt;p&gt;Here the table above turns uncomfortable. At the same 10,000-per-arm cap, mSPRT has power 0.465 against 0.787 for the fixed test. You cannot have both "look any time" and "same power at the same sample size".&lt;/p&gt;

&lt;p&gt;The repo measures what it takes to get the power back (&lt;code&gt;uplift-sequential-report&lt;/code&gt;, the price-of-peeking section):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A larger cap.&lt;/strong&gt; mSPRT reaches 80% power once the cap is raised to 18,000 per arm, 1.8x the fixed horizon. Its false-positive rate at that cap is 0.016, so the bigger cap costs no validity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More units on average, not fewer.&lt;/strong&gt; mSPRT stops as soon as the evidence is strong, but under the alternative it still uses 11,190 per arm on average: about 12% more units than the fixed test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Near the cap under the null.&lt;/strong&gt; When there is no effect it rarely stops, so it runs to 17,848 per arm on average (1.78x), close to the cap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade is plain: budget for a larger maximum sample and somewhat more units on average, and in exchange you may look whenever you like without inflating false positives.&lt;/p&gt;

&lt;p&gt;The second bullet is the one people do not expect. With this design, centred on the MDE, "sequential tests save sample" does not hold on average. I only claim this design; I have not simulated other effect sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A detour: honest CUPED
&lt;/h2&gt;

&lt;p&gt;The same repo covers CUPED, the other thing teams reach for to get answers faster. It replaces the outcome &lt;code&gt;y&lt;/code&gt; with &lt;code&gt;y - (X - mean(X)) @ theta&lt;/code&gt;, where &lt;code&gt;X&lt;/code&gt; is a pre-experiment covariate and &lt;code&gt;theta&lt;/code&gt; its regression coefficient. The estimate stays unbiased and the variance falls by roughly the share of outcome variance the covariate explains.&lt;/p&gt;

&lt;p&gt;The word that matters is "predicts". On the Hillstrom e-mail RCT (64,000 customers), the pre-period covariates are past-year spend, recency, product lines, newbie and channel. They explain 2.6% of visit variance, and under 0.3% for conversion and spend. CUPED reduced the variance of the visit estimate by 2.6%. A 10% relative visit lift needs 13,217 users per arm without CUPED and 12,873 with it. The report says this plainly instead of promising a bigger number.&lt;/p&gt;

&lt;p&gt;The method is not the problem. In the seeded simulation, where I control how well the covariate predicts the outcome, 1,000 experiments with 2,000 users per arm give:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Correlation rho&lt;/th&gt;
&lt;th&gt;Variance ratio CUPED / raw (theory 1 - rho^2)&lt;/th&gt;
&lt;th&gt;CI coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;0.998 (1.00)&lt;/td&gt;
&lt;td&gt;0.948&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;0.796 (0.75)&lt;/td&gt;
&lt;td&gt;0.939&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;td&gt;0.512 (0.51)&lt;/td&gt;
&lt;td&gt;0.957&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;0.183 (0.19)&lt;/td&gt;
&lt;td&gt;0.944&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At rho = 0.7 the variance ratio is 0.512, and the same power needs about half the users. Coverage stays near 95% throughout, so CUPED is not buying precision with validity.&lt;/p&gt;

&lt;p&gt;When it helps: a metric with a stable history, such as weekly spend for returning customers, where last period strongly predicts this period. When it does not: the two-week visits of a one-off e-mail, where the pre-period barely says anything about who clicks. Check the correlation before you promise anyone a variance reduction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same habit, one level up
&lt;/h2&gt;

&lt;p&gt;The uplift half of the repo has a decision problem of the same shape: four learners (S, T, X and a transformed-outcome baseline) whose test-set Qini coefficients are within noise of each other. Picking the highest point estimate is peeking in a different form: you let the data choose the winner after the fact.&lt;/p&gt;

&lt;p&gt;So I wrote the champion rule into code (&lt;code&gt;uplift/champion.py&lt;/code&gt;). Test the best learner against each rival with a paired bootstrap, Holm-correct the p-values, and if it is not a clear winner, take the simplest tied learner in a fixed order (S, then T, then X, then transformed outcome). On the full 13,979,592-row Criteo run with 1,000 bootstrap draws, the transformed outcome had the highest Qini point estimate, but its paired differences against S, T and X all covered 0 (Holm-adjusted p 0.35, 0.65, 0.35), so the rule registered the S-learner.&lt;/p&gt;

&lt;p&gt;I should be precise about the timing. The rule was committed before that 1,000-draw run, but after a first full-data run had already shown the learners within noise. So it was not written blind. What it does give is a rule that is fixed in code, with its reasoning, and applied the same way to every future run, instead of a judgement call each time. The principle is the same as fixing &lt;code&gt;tau&lt;/code&gt; in advance: decide how you will decide, ideally before the data can influence you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If anyone looks at a running experiment, assume they will act on it. A fixed-horizon t-test checked daily produced 24.8% false positives in my simulation, and 37.7% with 100 looks.&lt;/li&gt;
&lt;li&gt;If peeking is unavoidable, use a method built for it. mSPRT held 0.98% in the same setting and keeps its guarantee for any inspection schedule.&lt;/li&gt;
&lt;li&gt;Plan the cost up front. At the same cap, power was 0.465 against 0.787. Getting to 80% power needed about 1.8x the cap, and the average sample under the alternative was about 12% larger than the fixed test's.&lt;/li&gt;
&lt;li&gt;Fix &lt;code&gt;tau&lt;/code&gt; at the MDE before the data arrives. It does not affect validity, but choosing it after looking would defeat the point.&lt;/li&gt;
&lt;li&gt;Measure before you promise CUPED. Variance falls by about the share of outcome variance the covariate explains: 2.6% on Hillstrom visits, 49% when rho is 0.7.&lt;/li&gt;
&lt;li&gt;Write down the decision rule before you see the results, whether that is a stopping rule or a model-selection rule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code, the seeded scripts that regenerate every number above, and the evidence table mapping each README claim to its source are at &lt;a href="https://github.com/tantn89/uplift-experimentation" rel="noopener noreferrer"&gt;https://github.com/tantn89/uplift-experimentation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>statistics</category>
      <category>abtesting</category>
      <category>python</category>
    </item>
    <item>
      <title>Proving exactly-once by killing the stream mid-backlog</title>
      <dc:creator>Tan Tran</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:46:28 +0000</pubDate>
      <link>https://dev.to/tantn89/proving-exactly-once-by-killing-the-stream-mid-backlog-8j3</link>
      <guid>https://dev.to/tantn89/proving-exactly-once-by-killing-the-stream-mid-backlog-8j3</guid>
      <description>&lt;p&gt;Most pipelines I have reviewed claim "exactly-once" somewhere: a README line, an architecture slide, a job title. Very few have a test that fails if the claim stops being true.&lt;/p&gt;

&lt;p&gt;Exactly-once is a property of a failure, not of a happy path. To test it you have to cause the failure at an awkward moment, restart, and count. That is more work than a unit test, so it rarely gets written.&lt;/p&gt;

&lt;p&gt;So I wrote a script that kills the streaming job with &lt;code&gt;SIGKILL&lt;/code&gt; mid-backlog, restarts it, and checks every layer. It runs in CI on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/tantn89/realtime-lakehouse" rel="noopener noreferrer"&gt;realtime-lakehouse&lt;/a&gt;. The flow is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wikimedia &lt;code&gt;recentchange&lt;/code&gt; events arrive over server-sent events.&lt;/li&gt;
&lt;li&gt;A Python producer validates each event against a JSON schema and publishes it to Redpanda. Events that break the contract go to a dead-letter topic.&lt;/li&gt;
&lt;li&gt;Spark Structured Streaming lands the raw records in an Iceberg bronze table, with the Kafka partition and offset kept as columns.&lt;/li&gt;
&lt;li&gt;A second stream parses and deduplicates into &lt;code&gt;silver.edits&lt;/code&gt;. A third builds editor sessions.&lt;/li&gt;
&lt;li&gt;dbt on DuckDB reads the Iceberg tables and builds four gold marts. Dagster schedules dbt, runs four asset checks and compacts tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each hop has its own guarantee, and they are not all the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where duplicates and gaps come from
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay after a crash.&lt;/strong&gt; A micro-batch reads a range of Kafka offsets, writes files, and commits. If the process dies after the write but before the checkpoint is updated, the restart reads the same range again. A sink that simply appends will write those rows twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offsets against commits.&lt;/strong&gt; The opposite failure is a gap. If the checkpoint says "batch 12 is done" but the table commit never landed, the restart skips rows that were never stored. The checkpoint and the table commit have to agree, or the sink has to be able to tell that it is ahead of the checkpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A non-idempotent sink and an at-least-once source.&lt;/strong&gt; My producer is at-least-once on purpose. It saves its resume position (&lt;code&gt;Last-Event-ID&lt;/code&gt;) only after Kafka acknowledges everything before it. After a producer crash, a few events are sent again. Those carry the same Wikimedia event id, but they are new Kafka messages with new offsets.&lt;/p&gt;

&lt;p&gt;So there are two different problems. Replayed batches must not double-write. Resent events must not double-count. I handle them in two different places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism the repo uses
&lt;/h2&gt;

&lt;p&gt;For Kafka to bronze, I rely on Spark's checkpoint plus the Iceberg sink. The docstring in &lt;code&gt;streaming_queries.py&lt;/code&gt; says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;each query has its own checkpoint, and the Iceberg sink commits a micro-batch atomically and records the batch id, so a batch replayed after a crash is not committed twice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The bronze query is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_bronze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;kafka&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka.bootstrap.servers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subscribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TOPIC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startingOffsets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;earliest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxOffsetsPerTrigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LAKEHOUSE_MAX_OFFSETS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;kafka_to_bronze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;writeStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iceberg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;outputMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;append&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;queryName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processingTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TRIGGER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkpointLocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CHECKPOINTS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/bronze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lake.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BRONZE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;foreachBatch&lt;/code&gt;, no hand-written MERGE and no dedup in bronze. The guarantee comes from how the two halves fit together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Before a micro-batch runs, Spark writes its offset range to the checkpoint's offset log. It marks the batch done in the commit log only after the sink's commit returns. A restart re-runs any batch that has offsets but no commit marker, with the same batch id and the same offsets. That closes gaps.&lt;/li&gt;
&lt;li&gt;Iceberg's streaming sink stores the query id and the batch id in the summary of the snapshot it commits. Before committing, it looks up the last batch id that query committed and skips the commit if this one is not newer. So a batch that reached the table before the crash, but not the commit log, is not written twice. That closes duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not write that logic; I use it, and the test is what tells me whether I am using it correctly. It also depends on the checkpoint surviving: the query id lives there, so a lost or reset checkpoint starts a new query that Iceberg cannot match to the old commits.&lt;/p&gt;

&lt;p&gt;For silver, the problem is the resent events, and a checkpoint cannot help with those because they are genuinely different Kafka messages. Silver deduplicates on the Wikimedia event id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deduplicate_edits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edits_df&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;edits_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withWatermark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WATERMARK&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dropDuplicatesWithinWatermark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WATERMARK&lt;/code&gt; is 10 minutes: the newest event time seen, minus 10 minutes. The dedup state for an event id is kept until the watermark has moved 10 minutes past that event's time, which keeps state bounded. A resent copy carries the same event time as the original, so by the time its state is gone the copy is already behind the watermark and is dropped as late rather than written twice. That is my reading of how Spark orders the two steps; the crash test only resends within the window.&lt;/p&gt;

&lt;p&gt;The cost is lateness. The watermark drops every event more than 10 minutes behind, duplicate or not, from silver only. Bronze keeps them, and &lt;code&gt;ops.stream_progress&lt;/code&gt; counts them in &lt;code&gt;rows_dropped_by_watermark&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the claim has two parts, and I keep them separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kafka to bronze: every Kafka offset appears once.&lt;/li&gt;
&lt;li&gt;Bronze to silver: every unique on-time edit appears once, within the watermark.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;code&gt;scripts/prove_exactly_once.py&lt;/code&gt; runs in its own compose project with no published ports. An overlay file sets small batches (&lt;code&gt;LAKEHOUSE_MAX_OFFSETS&lt;/code&gt; 500, a 2 second trigger) so the backlog takes many micro-batches and the kill lands in the middle of it, not after everything is committed.&lt;/p&gt;

&lt;p&gt;The scenario, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Publish 50 loops of a 400-event recorded sample, each with unique ids.&lt;/li&gt;
&lt;li&gt;Resend the 400 events of loop 0, as a resume would.&lt;/li&gt;
&lt;li&gt;Publish 25 contract violations.&lt;/li&gt;
&lt;li&gt;Wait 12 seconds, then &lt;code&gt;SIGKILL&lt;/code&gt; the stream container, and read the committed counts.&lt;/li&gt;
&lt;li&gt;Restart the same container on the same checkpoint volume.&lt;/li&gt;
&lt;li&gt;Publish 100 events stamped 30 minutes in the past.&lt;/li&gt;
&lt;li&gt;Run the verifier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Trimmed to the part that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--loops&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOOPS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poison&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;POISON&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# let a few micro-batches commit, then crash mid-backlog
&lt;/span&gt;&lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kill&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIGKILL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;at_crash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;marker_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--rm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-deps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wiki_lakehouse.reliability.verify_exactly_once&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--counts-only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;capture&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COUNTS &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LOOPS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;sample_size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sample_size&lt;/span&gt;  &lt;span class="c1"&gt;# every event reaches bronze, duplicates too
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;at_crash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze_rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inconclusive: the crash did not land in the middle of the backlog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# same container and checkpoint volume
&lt;/span&gt;&lt;span class="nf"&gt;wait_for_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;started queries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;late&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LATE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--rm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-deps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wiki_lakehouse.reliability.verify_exactly_once&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--expect-kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;LATE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--expect-silver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOOPS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;silver_per_loop&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--expect-dlq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;POISON&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One guard in there is worth the effort. After the kill, the script reads the committed counts and refuses to continue unless bronze has more than zero rows and fewer than the total. If the crash lands before anything is written, or after everything is written, the test reports "inconclusive" instead of passing. A crash test that can pass without crashing anything is worse than none.&lt;/p&gt;

&lt;p&gt;The verifier reads the same Iceberg tables with Spark and compares them with Kafka itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka_has_expected_messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka_main_messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expect_kafka&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze_has_every_offset_once&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze_rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka_main_messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze_distinct_offsets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silver_has_every_event_once&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silver_rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expect_silver&lt;/span&gt;
    &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silver_distinct_events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;late_events_dropped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;late_events_in_silver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contract_violations_in_dlq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kafka_dlq_messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expect_dlq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what is compared. Duplicates are &lt;code&gt;bronze_rows&lt;/code&gt; against &lt;code&gt;count(DISTINCT kafka_partition, kafka_offset)&lt;/code&gt;. Gaps are &lt;code&gt;bronze_rows&lt;/code&gt; against the number of messages Kafka actually holds, read with an &lt;code&gt;endingOffsets=latest&lt;/code&gt; batch read. A gap shows up as fewer rows than offsets; a duplicate shows up as more rows than distinct offsets.&lt;/p&gt;

&lt;p&gt;The expectations come from the scenario, not from numbers I typed in. Kafka should hold 50 x 400 + 400 resent + 100 late = 20,500 messages. The script counts 196 &lt;code&gt;edit&lt;/code&gt; or &lt;code&gt;new&lt;/code&gt; events in the recording, so silver should hold 50 x 196 = 9,800.&lt;/p&gt;

&lt;p&gt;The latest CI run on &lt;code&gt;main&lt;/code&gt; killed the stream with 4,484 of the 20,400 events published before the crash committed to bronze. After the restart the verifier saw 20,500 Kafka messages, 20,500 bronze rows and 20,500 distinct offsets, 9,800 silver rows and 9,800 distinct event ids, no late events in silver and 25 messages in the dead-letter topic. Bronze had taken 43 commits to get there. Every check passed: 0 duplicates and 0 gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers around it
&lt;/h2&gt;

&lt;p&gt;The same repo has two measured runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freshness, from the benchmark&lt;/strong&gt; (&lt;code&gt;benchmarks/results.md&lt;/code&gt;, 45 seconds per phase, 10 second trigger, local run). Freshness is edit time to Iceberg commit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Publish rate&lt;/th&gt;
&lt;th&gt;In silver&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 ev/s&lt;/td&gt;
&lt;td&gt;2,156&lt;/td&gt;
&lt;td&gt;20.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000 ev/s&lt;/td&gt;
&lt;td&gt;21,952&lt;/td&gt;
&lt;td&gt;20.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000 ev/s&lt;/td&gt;
&lt;td&gt;110,152&lt;/td&gt;
&lt;td&gt;20.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9,596 ev/s (target was 15,000)&lt;/td&gt;
&lt;td&gt;330,652&lt;/td&gt;
&lt;td&gt;21.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every phase had all expected edit and new events in silver. The producer, one Python process, topped out near 10k events/s, so this does not saturate the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AWS run&lt;/strong&gt; (&lt;code&gt;benchmarks/aws-run.md&lt;/code&gt;): one &lt;code&gt;t3.xlarge&lt;/code&gt; in ap-southeast-2 with S3 and Glue, live Wikimedia stream, 2 h 10 min. 868,073 recentchange events ingested, 0 duplicate Kafka offsets in bronze and 0 duplicate events in silver, freshness p95 20.0 s measured as &lt;code&gt;processed_at - event_time&lt;/code&gt; in Athena, and all 42 Dagster runs green. The estimated cost was about $0.52. This run predates the change that keeps only &lt;code&gt;edit&lt;/code&gt; and &lt;code&gt;new&lt;/code&gt; in silver, so its counts cover every event type.&lt;/p&gt;

&lt;p&gt;The AWS run checked for duplicates over real traffic, but nothing crashed during it. It answers a different question from the crash proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Make the test refuse to pass vacuously.&lt;/strong&gt; Any fault-injection test needs a check that the fault happened where you meant it to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify against the source of truth, not against the pipeline.&lt;/strong&gt; The verifier counts offsets in Kafka and compares them with bronze. If I had compared bronze with my own earlier count, a bug in my counting would have hidden a bug in the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say which hop each guarantee belongs to.&lt;/strong&gt; "The pipeline is exactly-once" is not a statement I can defend. "Each Kafka offset lands once in bronze" and "each on-time event id lands once in silver" are, and they have different mechanisms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reader can lie too.&lt;/strong&gt; While building the verifier, a polling reader stalled at 5,479 rows. Iceberg's caching catalog expires an entry after it was last accessed, so a verifier that polled every few seconds kept seeing the same snapshot. Readers of tables that other processes write now disable the cache. A wrong verifier would have looked like a pipeline bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the test does not prove
&lt;/h2&gt;

&lt;p&gt;I would rather list these than have a reviewer find them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It kills the Spark stream, not the producer.&lt;/strong&gt; The producer's resume path is imitated by resending loop 0, not exercised by a real producer crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One kill, one crash point.&lt;/strong&gt; The kill lands somewhere in the backlog, 12 seconds in. I did not kill during the commit itself, repeat the kill several times, or kill Kafka, the catalog or the object store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup is bounded by the watermark.&lt;/strong&gt; The test does not resend an event after its dedup state has expired. Late events are dropped by design and counted, and replay from bronze is manual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It assumes the checkpoint survives.&lt;/strong&gt; The restart reuses the same checkpoint volume. Losing the checkpoint is a different failure, and this test does not cover it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small data, small batches.&lt;/strong&gt; The test deliberately uses 500-offset batches and a 2 second trigger to force a mid-backlog crash. The default is 100,000 offsets per trigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It proves one scenario.&lt;/strong&gt; CI shows that this scenario passes on every push. It is not a proof that no scenario fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gold is not part of this test.&lt;/strong&gt; A dbt reconciliation test and a Dagster duplicate check cover gold and silver separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The point of the exercise was not the number. It was to turn a sentence in a README into an assertion that can fail, and to write down where it stops being true.&lt;/p&gt;

&lt;p&gt;To run it: &lt;code&gt;python scripts/prove_exactly_once.py&lt;/code&gt;. It needs Docker and nothing else.&lt;/p&gt;

&lt;p&gt;Code, tests, benchmarks and the AWS write-up: &lt;a href="https://github.com/tantn89/realtime-lakehouse" rel="noopener noreferrer"&gt;https://github.com/tantn89/realtime-lakehouse&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>spark</category>
      <category>iceberg</category>
      <category>kafka</category>
    </item>
    <item>
      <title>An alias that walked past my SQL guard: red-teaming an MCP server for warehouse access</title>
      <dc:creator>Tan Tran</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:46:25 +0000</pubDate>
      <link>https://dev.to/tantn89/an-alias-that-walked-past-my-sql-guard-red-teaming-an-mcp-server-for-warehouse-access-1a52</link>
      <guid>https://dev.to/tantn89/an-alias-that-walked-past-my-sql-guard-red-teaming-an-mcp-server-for-warehouse-access-1a52</guid>
      <description>&lt;p&gt;I wrote an MCP server that lets an AI agent query a warehouse. A second model reviewed it and found a way past my SQL guard with a one-line alias. This post covers the design, the bug, the fix, and the other gaps a red team found.&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/tantn89/warehouse-mcp" rel="noopener noreferrer"&gt;warehouse-mcp&lt;/a&gt;. It serves DuckDB, Postgres and BigQuery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Agents are good at writing SQL and bad at knowing what they should not read. Giving one a warehouse connection raises three worries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It reads personal data it should not see.&lt;/li&gt;
&lt;li&gt;It writes, or reads files and system state, because a prompt told it to.&lt;/li&gt;
&lt;li&gt;It runs a query that scans terabytes and sends you the bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot fix this by asking the model to behave. A prompt-injected agent does what the injected text says. The controls have to sit outside the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design
&lt;/h2&gt;

&lt;p&gt;Three decisions shaped the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parse, do not pattern-match.&lt;/strong&gt; The guard parses agent SQL with sqlglot in the backend's dialect. It accepts one statement, a SELECT only, over declared datasets only, with a function allowlist and a capped LIMIT. A function the parser does not model is denied by default, because dialect-specific functions are where &lt;code&gt;pg_read_file&lt;/code&gt; and sleeps hide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pseudonymize at the source.&lt;/strong&gt; The guard rewrites every dataset reference into a policy subquery. That subquery projects only declared columns, replaces personal columns with a salted hash, and applies the role's row filter. All of it runs inside the warehouse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pseudonym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Expr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A stable, salted pseudonym: equal inputs stay equal (joins, counts), names never leave.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CASE WHEN &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; IS NULL THEN NULL ELSE CONCAT(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pii_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUBSTRING(MD5(CONCAT(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, CAST(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; AS TEXT))), 1, 12)) END&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sqlglot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duckdb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;count(DISTINCT editor)&lt;/code&gt; stays exact and joins on the pseudonym work. A probe like &lt;code&gt;WHERE editor = 'Alice'&lt;/code&gt; matches nothing, because the comparison runs against the hash. Masking the output after lineage analysis can be bypassed with aliases and expressions. Here the raw column is not reachable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The guard is not the boundary.&lt;/strong&gt; Each backend is locked down on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DuckDB: read-only, no file access.&lt;/li&gt;
&lt;li&gt;Postgres: read-only transaction plus grants.&lt;/li&gt;
&lt;li&gt;BigQuery: dry run plus a byte cap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Postgres, the tests send writes straight to the backend with the guard out of the way and expect the engine to refuse them: first the read-only transaction, then, with that bypassed too, the grants. A parser gap alone should not be a breach. That goal mattered, because I then found a parser gap.&lt;/p&gt;

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

&lt;p&gt;An independent review by a second model read the guard and found something my own suite had missed. Here is the shape of it, taken from a regression test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;wiki&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;edits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;editor&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;edits_raw&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;edits_raw&lt;/code&gt; is a physical table, not a declared dataset.&lt;/p&gt;

&lt;p&gt;The guard has to tell two kinds of table node apart. A reference to a real dataset must be rewritten into the policy subquery. A reference to a CTE must be left alone, because the CTE body was already guarded.&lt;/p&gt;

&lt;p&gt;My version decided this by looking up the table's alias in the scope's sources. This is the line that the fix (commit &lt;code&gt;d1369b3&lt;/code&gt;) removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias_or_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cte_refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;alias_or_name&lt;/code&gt; returns the alias when there is one. In the query above, the physical table &lt;code&gt;edits_raw&lt;/code&gt; is aliased &lt;code&gt;x&lt;/code&gt;, and the scope has a CTE named &lt;code&gt;x&lt;/code&gt;. The lookup says "this is a CTE" and the node is skipped. No allowlist check, no policy rewrite. The physical table reaches the warehouse as written.&lt;/p&gt;

&lt;p&gt;The fix commit states the impact: raw PII, undeclared columns, and rows outside the role filter.&lt;/p&gt;

&lt;p&gt;I had fixed a neighbouring case before. In &lt;code&gt;WITH t AS (SELECT * FROM t)&lt;/code&gt; the inner &lt;code&gt;t&lt;/code&gt; is the real table, so a global name match would wave it through. I moved to scope-based resolution and thought that was the end of it. I had swapped a name match for an alias match, and the alias match has the same flaw.&lt;/p&gt;

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

&lt;p&gt;A table refers to a CTE only if its name is a CTE visible in its own scope. If the name does not match but the alias does, the query is refused.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cte_sources&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;catalog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# qualified: never a CTE; rejected later
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cte_refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;QueryRejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alias &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt; hides the CTE of the same name; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;use a different alias.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refusing is simpler than resolving. No legitimate query needs to alias a real table to the name of a CTE in scope, so the guard removes the ambiguity instead of interpreting it.&lt;/p&gt;

&lt;p&gt;The commit adds three cases that must be rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WITH x AS (SELECT wiki FROM edits) SELECT editor FROM edits_raw x, x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WITH x AS (SELECT wiki FROM edits) SELECT x.editor FROM edits x JOIN x y ON true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WITH x AS (SELECT wiki FROM edits) SELECT * FROM salaries AS x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also adds a positive test: a CTE referenced by its own name still works, including a self-join like &lt;code&gt;FROM a AS b JOIN a&lt;/code&gt;. A guard that rejects everything is easy to write and useless to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else the red team caught
&lt;/h2&gt;

&lt;p&gt;The adversarial suite is separate from the review. It runs attempts through the real service on a snapshot of about 100k Wikipedia edits. Each attempt must be refused, or come back with no raw editor names and no rows outside the role's filter. It found three gaps outside the SQL guard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ReDoS in redaction.&lt;/strong&gt; The email regex used unbounded repeats and backtracked quadratically on a long string with no &lt;code&gt;@&lt;/code&gt;. The README's estimate: a 1 MB string from &lt;code&gt;repeat('x', 1000000)&lt;/code&gt; would have held the server for about 12 minutes. Redaction ran after the warehouse timeout, so no timeout covered it. The fix bounds the repeats to RFC 5321 lengths and cuts cells before redaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_EMAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regression test asserts that a 40,000 character cell finishes in under half a second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single huge row skipped the result cap.&lt;/strong&gt; The cap trimmed rows, so a result of one oversized row went out whole. The fix halves the longest text cells until the row fits, and the test checks that a 5,000 character cell comes back under a 1,000 character cap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pseudonyms relinked through titles.&lt;/strong&gt; On Wikipedia, editing your own &lt;code&gt;User:&lt;/code&gt; page produces a row where the editor column holds a pseudonym and the page title holds the real name. That pairs the two. In the snapshot where I found it, this re-identified 5.7% of editors. The fix adds conditional PII, &lt;code&gt;pii_when: "namespace IN (2, 3)"&lt;/code&gt;, so user-page titles are pseudonymized, and edit summaries are pseudonymized too. The regression test takes ground truth from a cleared role and checks that no pseudonymized row still carries the name in its title.&lt;/p&gt;

&lt;p&gt;The independent review also found a leak the suite had missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engine errors quoted data.&lt;/strong&gt; An error like &lt;code&gt;Could not convert string '&amp;lt;value&amp;gt;'&lt;/code&gt; skipped result redaction, because errors take a different path than rows. It reached both the agent and the audit log. The fix scrubs quoted values, redacts what is left, and caps the length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_QUOTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;(?:[^&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]|&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="s"&gt;)*&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;|\"(?:[^\"]|\"\")*\"&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scrub_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_QUOTED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;…&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phones&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_chars&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;max_chars&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent still sees the error kind and where it happened, which is enough to fix a query. My earlier red-team test had asserted that a cast error showed the pseudonym, and I had treated that as acceptable. The test now asserts that no value comes back, for the analyst role and for the cleared role.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;104 red-team cases per engine, 0 leaks.&lt;/strong&gt; 103 are attacks; one is a positive control that checks the cleared role still sees real names, so the PII checks are not passing vacuously. The attacks cover exfiltration, row-filter escapes, PII probes, file and system access, statement smuggling and resource abuse. Locally the suite runs on DuckDB, and on Postgres when a DSN is set. CI sets the DSN, so every push runs all 208 cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parity.&lt;/strong&gt; The same 15 queries return identical results on DuckDB, Postgres and BigQuery. The BigQuery run was live and is not part of CI, because it needs credentials. &lt;code&gt;docs/bigquery-run.md&lt;/code&gt; has the details, including a query that would scan 4.65 GB. The server refused it before billing: "above the 1.00 GB cap".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent eval.&lt;/strong&gt; A Claude Haiku 5.5 agent answered 26 cases, 3 trials each, graded against ground-truth SQL with no LLM judge. Result: 78/78 correct, including 6 governance cases, at $0.0016 per question, with p95 latency of 6.3 s. All 10 rejected tool calls were self-corrected from the error message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The BigQuery run also found one more bug. It was a correctness problem, not a leak: time literals were cast to &lt;code&gt;DATETIME&lt;/code&gt; against &lt;code&gt;TIMESTAMP&lt;/code&gt; columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Layers matter when one fails.&lt;/strong&gt; The engines were locked down separately, and the Postgres tests prove it with the guard bypassed. I do not want to overstate that. The alias bypass still skipped the row filter and the pseudonymization, because those live in the guard. Defense in depth limited what a parser gap could do. It did not make the gap harmless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every finding gets a regression test.&lt;/strong&gt; Each fix above came with a test that fails without it. Those regressions are part of the 104 cases above, so a fix cannot quietly come undone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Independent review finds what your own suite cannot.&lt;/strong&gt; I wrote the guard and the red-team suite with the same assumptions. The reviewer did not share them, and it found the alias gap and the error leak. A passing suite tells you nothing about attempts you never thought of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer refusing over interpreting.&lt;/strong&gt; When a construct is ambiguous and never needed, reject it. The alias rule is a few lines and removes a whole class of confusion.&lt;/p&gt;

&lt;p&gt;To try it, the repo has a demo that needs no keys: &lt;code&gt;uvx warehouse-mcp --demo&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/tantn89/warehouse-mcp" rel="noopener noreferrer"&gt;github.com/tantn89/warehouse-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/warehouse-mcp/" rel="noopener noreferrer"&gt;pypi.org/project/warehouse-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Questions or findings are welcome on Telegram: @tanngoctr.&lt;/p&gt;

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