<?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: Zayd Mulani</title>
    <description>The latest articles on DEV Community by Zayd Mulani (@zaydmulani09).</description>
    <link>https://dev.to/zaydmulani09</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%2F3939133%2Fa61f4cd3-0e64-4bac-9a1e-00ae898bf88f.png</url>
      <title>DEV Community: Zayd Mulani</title>
      <link>https://dev.to/zaydmulani09</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zaydmulani09"/>
    <language>en</language>
    <item>
      <title>I built a tool that finds shrinkflation in your own grocery orders, and it makes zero network requests</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Mon, 12 Oct 2026 11:14:14 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-tool-that-finds-shrinkflation-in-your-own-grocery-orders-and-it-makes-zero-network-1n6m</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-tool-that-finds-shrinkflation-in-your-own-grocery-orders-and-it-makes-zero-network-1n6m</guid>
      <description>&lt;p&gt;Everyone knows shrinkflation is real. Almost nobody can see their own numbers.&lt;/p&gt;

&lt;p&gt;The tools I found fall into three buckets: crowdsourced lists of products that shrank, unit price badges that show today's price per ounce, and one finance tool aimed at analysts. I couldn't find anything that reads &lt;em&gt;your&lt;/em&gt; order history and tells you which of the things &lt;em&gt;you&lt;/em&gt; keep rebuying got smaller. (I could not search the Chrome Web Store directly, so I can't promise it doesn't exist. If you know one, tell me in the comments.)&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Shrinkwatch&lt;/strong&gt;. Paste your grocery orders into the demo, or use the Chrome extension, and it flags repeat items that shrank and shows what the change cost you per year.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Demo (no install): &lt;a href="https://zaydmulani09.github.io/shrinkwatch" rel="noopener noreferrer"&gt;https://zaydmulani09.github.io/shrinkwatch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/zaydmulani09/shrinkwatch" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/shrinkwatch&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Numbers in this post come from the built-in sample data (8 flagged items, about 10% less food, about $148 a year). They are not real shopper data.&lt;/p&gt;

&lt;p&gt;I built it with Claude Code, which is relevant to a few of the stories below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: parsing sizes is the whole game
&lt;/h2&gt;

&lt;p&gt;Retailers write sizes like &lt;code&gt;32 oz&lt;/code&gt;, &lt;code&gt;6 x 12 fl oz&lt;/code&gt;, &lt;code&gt;2 pk&lt;/code&gt;, &lt;code&gt;96 count&lt;/code&gt;, &lt;code&gt;1/2 gallon&lt;/code&gt;, &lt;code&gt;500g&lt;/code&gt;. Before you can compare anything you need a parser that turns all of that into a base unit plus a dimension: mass, volume, or count.&lt;/p&gt;

&lt;p&gt;The dimension matters because a "count" going from 12 to 10 is shrinkflation, but you never want to compare a mass to a volume. The parser returns something like this (simplified sketch, not the exact repo code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;dimension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mass&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;volume&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;count&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// "6 x 12 fl oz" -&amp;gt; { value: 72 * 29.5735, dimension: "volume" }  (in ml)&lt;/span&gt;
&lt;span class="c1"&gt;// "2 pk"         -&amp;gt; { value: 2, dimension: "count" }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multipacks (&lt;code&gt;6 x 12 fl oz&lt;/code&gt;) are where most bugs live, so that is where most of the tests live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: deciding two purchases are the same item
&lt;/h2&gt;

&lt;p&gt;If the retailer gives you a product ID (an ASIN, a Walmart item id, an Instacart product id), use it. When it doesn't, you fall back to the normalized name with the size tokens stripped, plus fuzzy token matching.&lt;/p&gt;

&lt;p&gt;The trap is variants. Cereal sold in 12 oz and 18 oz boxes at the same time is not shrinkflation, it is two products. So the matcher never merges different sizes that were available at the same time. It only treats a smaller size as a shrink when it replaces the earlier one in the person's own purchase sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the detector
&lt;/h2&gt;

&lt;p&gt;For each matched item, sort purchases by date and flag when the size dropped by 2 percent or more. Then compute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;old and new price per unit&lt;/li&gt;
&lt;li&gt;the hidden price increase (what you pay per unit now versus before)&lt;/li&gt;
&lt;li&gt;a confidence level: high when the product ID matches, medium when the match was fuzzy&lt;/li&gt;
&lt;li&gt;a "stealth" label when the sticker price didn't move at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From purchase frequency it estimates the extra dollars per year, and sums everything into one headline number. Users can dismiss a flag as "not the same product", and that choice persists.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Nothing leaves your browser" is a claim, so I made it a test
&lt;/h2&gt;

&lt;p&gt;Privacy copy is cheap. I wanted the claim to break the build if it stopped being true.&lt;/p&gt;

&lt;p&gt;Three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A test that fails the build if &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt;, &lt;code&gt;sendBeacon&lt;/code&gt;, or &lt;code&gt;WebSocket&lt;/code&gt; appear anywhere in the extension source.&lt;/li&gt;
&lt;li&gt;A strict Content Security Policy on the demo site.&lt;/li&gt;
&lt;li&gt;A headless test that deliberately tries a &lt;code&gt;fetch&lt;/code&gt; to another host and asserts the CSP blocks it. On the live site I also checked that the only host the page talks to is its own.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The extension also asks for host access only to the retailers it supports, and stores everything in &lt;code&gt;chrome.storage&lt;/code&gt; or IndexedDB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing an extension against sites I can't log into
&lt;/h2&gt;

&lt;p&gt;This was the awkward part. I can't put my real accounts into an automated build, so the retailer adapters (Instacart, Walmart, Amazon, Target, Kroger) were developed against realistic fixture pages I wrote myself.&lt;/p&gt;

&lt;p&gt;To keep that honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The adapters don't rely on brittle class names. They use generic heuristics (price patterns, product links, title text) with small per-site hints.&lt;/li&gt;
&lt;li&gt;A "selector health" view shows exactly what the extension captured on a page, so breakage is visible instead of silent.&lt;/li&gt;
&lt;li&gt;Pasting order text works as a fallback and uses the same core code.&lt;/li&gt;
&lt;li&gt;The README says plainly that adapters are best effort until verified on live pages. I'm saying it here too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the extension itself I used Playwright to load the built extension in headless Chromium in its own profile and click through capture, dismiss, paste import, and the popup. One of my first bugs was my own: a "block all requests" route handler was also aborting the extension's own files. Routing only http and https fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lighthouse rabbit hole
&lt;/h2&gt;

&lt;p&gt;The demo is a static page, so I figured performance would be easy. Mobile scored 80 at first. Here is what actually moved it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prerender the hero.&lt;/strong&gt; The big headline number was rendered by JavaScript, which made it a late largest paint. It is now in the HTML at build time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memoize name tokenization.&lt;/strong&gt; Profiling showed the name tokenizer dominating. The same product names repeat hundreds of times in the sample orders (723 in my profile), so caching it removed a lot of work with no logic change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop using &lt;code&gt;Intl&lt;/code&gt; for money.&lt;/strong&gt; The first call to &lt;code&gt;Intl.NumberFormat&lt;/code&gt; was slow enough to show up as a long task on a throttled phone. Plain formatting did the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load the script with &lt;code&gt;async&lt;/code&gt;, not &lt;code&gt;defer&lt;/code&gt;.&lt;/strong&gt; With &lt;code&gt;defer&lt;/code&gt;, the script ran inside the same task as the first layout, so the profiler counted the layout as blocking time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fonts.&lt;/strong&gt; Cumulative layout shift came from web fonts swapping in. &lt;code&gt;font-display: optional&lt;/code&gt; fixed the shift but meant some first-time visitors never saw the real fonts, so I ended on &lt;code&gt;swap&lt;/code&gt; with local fallback fonts sized to match the web font's width using &lt;code&gt;size-adjust&lt;/code&gt;. I also subset the hero fonts, cutting font weight from roughly 280KB to roughly 97KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;content-visibility: auto&lt;/code&gt;&lt;/strong&gt; on below-the-fold sections, and rendering the long receipt only when it nears the viewport.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the live site, mobile ended at 97 performance and 98 accessibility, desktop at 100, with accessibility and best practices at 100. One lesson: local Lighthouse numbers on my machine were noisy enough that I stopped trusting them and measured on the deployed page instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not looking like every other AI-built site
&lt;/h2&gt;

&lt;p&gt;My first version worked, and looked exactly like what it was. So I had the design redone against a written brief: warm paper background, ink-black text, one accent color reserved for money and size lost, no gradients, and a pack drawing that shows the old size next to the new one. Fonts are self-hosted (Newsreader, Libre Franklin, IBM Plex Mono, all OFL), which also keeps the zero-third-party-requests promise true.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd like from you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Try the demo with your own orders and tell me what it misses. If you use the extension, the selector health view output is the most useful thing you can send me.&lt;/li&gt;
&lt;li&gt;If an adapter breaks on your account, open an issue. Adding a retailer is documented in the README.&lt;/li&gt;
&lt;li&gt;If you know a tool that already does this, I'd genuinely like to see it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the worst shrinkflation you've caught?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I wanted Jepsen's Elle in CI without a JVM, so I wrote adya</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Tue, 06 Oct 2026 22:53:50 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-wanted-jepsens-elle-in-ci-without-a-jvm-so-i-wrote-adya-3i9p</link>
      <guid>https://dev.to/zaydmulani09/i-wanted-jepsens-elle-in-ci-without-a-jvm-so-i-wrote-adya-3i9p</guid>
      <description>&lt;p&gt;Your database documentation says &lt;code&gt;REPEATABLE READ&lt;/code&gt;. Your code assumes it. Have you ever checked?&lt;/p&gt;

&lt;p&gt;I spent the last stretch building &lt;a href="https://github.com/zaydmulani09/adya" rel="noopener noreferrer"&gt;adya&lt;/a&gt;, a black-box checker for transactional isolation. You give it a log of transactions a database ran, and it tells you which isolation guarantees held. For each one that didn't, it prints the transactions involved and the chain of reads and writes that proves the violation.&lt;/p&gt;

&lt;p&gt;It's an independent Rust implementation of the approach from &lt;a href="https://github.com/jepsen-io/elle" rel="noopener noreferrer"&gt;Elle&lt;/a&gt; (Kingsbury and Alvaro, VLDB 2020), the checker behind Jepsen's database analyses. If you know Elle, adya reads the same history formats and takes the same flags. If you don't, keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "it passed the tests"
&lt;/h2&gt;

&lt;p&gt;Isolation bugs don't show up in unit tests. They need two or more transactions to interleave in one particular way, and when they happen nothing crashes. You just get a balance that's off, or two people booked into the same seat.&lt;/p&gt;

&lt;p&gt;The classic example is write skew. Two doctors are on call, and the rule says at least one must stay on call. Each doctor's transaction checks "is the other one still on call?", sees yes, and takes themselves off. Both commit. Now nobody is on call.&lt;/p&gt;

&lt;p&gt;Snapshot isolation allows this. Postgres's &lt;code&gt;REPEATABLE READ&lt;/code&gt; is snapshot isolation, so it allows this. If you thought &lt;code&gt;REPEATABLE READ&lt;/code&gt; meant "safe enough", the bug ships.&lt;/p&gt;

&lt;p&gt;Elle's insight was that you can catch this from the outside. Record what every client asked for and what it got back, infer the dependencies between transactions, and look for cycles that a given isolation level forbids. Atul Adya's 1999 thesis catalogued those cycles (G0, G1c, G-single, G2 and so on), which is where the name comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why another implementation
&lt;/h2&gt;

&lt;p&gt;Elle is excellent. It's also a Clojure library on the JVM. People who want it outside a Jepsen test end up shelling out to &lt;code&gt;elle-cli&lt;/code&gt; from a Go or Python harness. I found two open-source projects doing exactly that in CI (&lt;a href="https://github.com/MongooseMoo/barn/issues/412" rel="noopener noreferrer"&gt;barn&lt;/a&gt;, &lt;a href="https://github.com/gustavoamigo/bytecaskdb/pull/180" rel="noopener noreferrer"&gt;bytecaskdb&lt;/a&gt;), and the bytecaskdb PR lists what went wrong: a blocked Clojars mirror, a crash when graphviz was missing, log lines corrupting the JSON output.&lt;/p&gt;

&lt;p&gt;On my Windows machine, elle-cli 0.1.11 hung with no output on every anomalous history I gave it. On Linux it worked, but in my CI comparison it needed more than five minutes on five of forty random 300-transaction histories, and ran out of a 6 GB heap on one more. adya checked all forty in 0.2 seconds.&lt;/p&gt;

&lt;p&gt;Elle also leaves the workload to you. You have to write the client that generates transactions, runs them against your database and records the history. That's the part most people never get to.&lt;/p&gt;

&lt;p&gt;So adya ships both halves in one binary:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c"&gt;# Run a workload against Postgres at REPEATABLE READ,&lt;/span&gt;
&lt;span class="c"&gt;# then check the history against serializability.&lt;/span&gt;
adya run postgres &lt;span class="nt"&gt;--url&lt;/span&gt; postgres://localhost/test &lt;span class="nt"&gt;-i&lt;/span&gt; repeatable-read &lt;span class="nt"&gt;-c&lt;/span&gt; serializable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;You don't need a database to try it. adya has a built-in simulated database that implements isolation levels the textbook way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;adya run sim &lt;span class="nt"&gt;-i&lt;/span&gt; snapshot-isolation &lt;span class="nt"&gt;-c&lt;/span&gt; serializable &lt;span class="nt"&gt;-n&lt;/span&gt; 300 &lt;span class="nt"&gt;-p&lt;/span&gt; 4 &lt;span class="nt"&gt;--seed&lt;/span&gt; 1
&lt;span class="go"&gt;history.jsonl   false

&lt;/span&gt;&lt;span class="gp"&gt;G2-item #&lt;/span&gt;0
&lt;span class="go"&gt;  Let:
    T234 = {"index":234,"process":1,"type":"ok","value":[["r",10,[4]],["append",3,6],["r",3,[4,5,6]]]}
    T237 = {"index":237,"process":3,"type":"ok","value":[["append",9,13],["append",10,5],["r",3,[4,5]],["r",10,[4,5]]]}
  Then:
    - T234 &amp;lt; T237, because T234 did not observe T237's append of 5 to 10.
    - However, T237 &amp;lt; T234, because T237 did not observe T234's append of 6 to 3: a contradiction!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the doctors problem with list keys. T234 read key 10 before T237 appended to it, so T234 has to come first. T237 read key 3 before T234 appended to it, so T237 has to come first. Both can't be true, so no serial order exists. Snapshot isolation allows that cycle. Serializability doesn't.&lt;/p&gt;

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

&lt;p&gt;The trick that makes this tractable is the workload. adya's default, borrowed from Elle, is list-append: every key holds a list, transactions append unique numbers to lists and read whole lists back. Each read then tells you the order of every append before it. If one client read &lt;code&gt;[1, 2]&lt;/code&gt; and another read &lt;code&gt;[1, 2, 5]&lt;/code&gt;, you know 5 came after 2, without asking the database anything.&lt;/p&gt;

&lt;p&gt;From those orders adya builds a dependency graph over transactions, using Adya's three edge types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ww&lt;/code&gt;: T2 appended right after T1's append to the same key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wr&lt;/code&gt;: T2 read a list ending in T1's append.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rw&lt;/code&gt;: T1 read a list that T2 later appended to, so T1 didn't see T2's write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're checking a model with real-time guarantees (strict serializability), it adds edges for "T1 finished before T2 started", using a transitive reduction so the graph stays roughly linear in the history.&lt;/p&gt;

&lt;p&gt;Then it looks for cycles, one strongly connected component at a time. Each anomaly class is a cycle with constraints. G-single has exactly one &lt;code&gt;rw&lt;/code&gt; edge. G2-item has at least two, with two of them adjacent. G1c has no &lt;code&gt;rw&lt;/code&gt; edges and at least one &lt;code&gt;wr&lt;/code&gt;. Instead of enumerating cycles and classifying them afterwards, adya runs a breadth-first search over pairs of (transaction, path state). The path state is seven bits: how many &lt;code&gt;rw&lt;/code&gt; edges so far (0, 1, or 2+), whether the last edge was &lt;code&gt;rw&lt;/code&gt;, whether the first one was, whether two were adjacent, whether it has seen a &lt;code&gt;wr&lt;/code&gt;, and whether it has used a real-time edge. One BFS then returns the shortest cycle of exactly the shape you asked for.&lt;/p&gt;

&lt;p&gt;Before searching, it runs cheaper existence checks. For each model it asks whether the subgraph that model forbids cycles in has a nontrivial SCC at all. If the &lt;code&gt;ww&lt;/code&gt;/&lt;code&gt;wr&lt;/code&gt;-plus-one-&lt;code&gt;rw&lt;/code&gt; subgraph is acyclic, snapshot isolation holds as far as cycles go, and adya skips every search that could only find SI violations. It also searches the most severe anomalies first and skips anything they imply. A 100,000-transaction history (30 MB of JSON) checks in about 1.6 seconds on my laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it right?
&lt;/h2&gt;

&lt;p&gt;A checker that's wrong is worse than none, so this took most of the work.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Elle's own expected results.&lt;/strong&gt; elle-cli's test suite ships 56 list-append and rw-register histories along with the JSON verdicts Elle produced for them. adya matches all 56: same verdict, same anomaly types, and the same weakest models ruled out. Getting there taught me two Elle details I would never have guessed. It labels an edge that carries several relations by a fixed priority (&lt;code&gt;ww&lt;/code&gt; before &lt;code&gt;wr&lt;/code&gt; before &lt;code&gt;rw&lt;/code&gt; before real-time), but tests whether a cycle &lt;em&gt;exists&lt;/em&gt; using any relation the edge carries. And it counts a composed edge that loops back to the same transaction as a cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elle itself, live.&lt;/strong&gt; On every push, CI generates random histories with adya's simulator and checks each with both tools. In the first full run Elle finished 34 of 40, and adya agreed on all 34.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Databases with known answers.&lt;/strong&gt; The simulator implements serializable, snapshot isolation, read committed (with write locks), read uncommitted, and a deliberately broken "snapshot" that writes back stale state. Tests assert that correct runs produce zero anomalies at their own level and the expected ones above it. That test caught a bug in my simulator, not in the checker: my first read committed took no write locks, which is weaker than any real database.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Postgres and MySQL did
&lt;/h2&gt;

&lt;p&gt;CI runs 4,000 transactions from 10 clients over 6 hot keys against Postgres 17 and MySQL 8.4, at each isolation level, and checks every history against a ladder of models. List-append results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;database, level&lt;/th&gt;
&lt;th&gt;serializable&lt;/th&gt;
&lt;th&gt;snapshot isolation&lt;/th&gt;
&lt;th&gt;read committed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Postgres &lt;code&gt;READ COMMITTED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;G-single, G2-item, internal, lost update&lt;/td&gt;
&lt;td&gt;G-single, internal, lost update&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres &lt;code&gt;REPEATABLE READ&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;G2-item&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres &lt;code&gt;SERIALIZABLE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL &lt;code&gt;REPEATABLE READ&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;G-single, G2-item, internal, lost update&lt;/td&gt;
&lt;td&gt;G-single, internal, lost update&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL &lt;code&gt;SERIALIZABLE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;td&gt;valid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Postgres did what its docs say. &lt;code&gt;REPEATABLE READ&lt;/code&gt; is snapshot isolation, write skew included, and &lt;code&gt;SERIALIZABLE&lt;/code&gt; came out strict serializable. I also restarted Postgres every few seconds during a 6,000-transaction &lt;code&gt;SERIALIZABLE&lt;/code&gt; run (&lt;code&gt;--fault "docker restart -t 0 pg"&lt;/code&gt;). Nine restarts, 2,957 commits, 3,043 failures, and the history still checked clean.&lt;/p&gt;

&lt;p&gt;MySQL's &lt;code&gt;REPEATABLE READ&lt;/code&gt; is weaker than its name. It lets a transaction lose another's update and see part of another transaction's writes, so it isn't snapshot isolation. Jepsen reported the same thing about &lt;a href="https://jepsen.io/analyses/mysql-8.0.34" rel="noopener noreferrer"&gt;MySQL 8.0.34&lt;/a&gt; in 2023. adya reproduces it from a cold start in a few seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing your own database
&lt;/h2&gt;

&lt;p&gt;The built-in drivers cover SQLite, Postgres and MySQL. For anything else there's &lt;code&gt;adya run exec&lt;/code&gt;: adya starts your client once per process, writes one JSON line per transaction to its stdin, and reads back one line saying what happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:[[&lt;/span&gt;&lt;span class="s2"&gt;"append"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="s2"&gt;"r"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:[[&lt;/span&gt;&lt;span class="s2"&gt;"append"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="s2"&gt;"r"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;]]]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo has a 59-line Python client for SQLite as a template. Swap the SQL and you're testing your own database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;adya covers list-append and read-write register workloads. It doesn't do predicate reads, or Elle's bank, set and counter checkers. It prints text proofs instead of Graphviz plots. A clean result means it found no anomaly in that history. It doesn't prove your database is correct, so run it longer, with fewer keys for more contention, and with faults.&lt;/p&gt;

&lt;p&gt;If you run it against something interesting, I'd like to hear what it found. Issues and PRs are open.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/zaydmulani09/adya" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/adya&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;crates.io: &lt;a href="https://crates.io/crates/adya" rel="noopener noreferrer"&gt;https://crates.io/crates/adya&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rust</category>
      <category>database</category>
      <category>postgres</category>
      <category>testing</category>
    </item>
    <item>
      <title>I built a tool that finds the minimal cause of a browser bug by replaying it</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Wed, 30 Sep 2026 04:07:01 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-tool-that-finds-the-minimal-cause-of-a-browser-bug-by-replaying-it-3odg</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-tool-that-finds-the-minimal-cause-of-a-browser-bug-by-replaying-it-3odg</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3s98ovoztiqyspes8bn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3s98ovoztiqyspes8bn.gif" alt=" " width="600" height="338"&gt;&lt;/a&gt;&lt;br&gt;
Some bugs only happen with one combination of API data, timing and local state. The failing run and a working run differ in dozens of ways, and figuring out which difference matters usually means commenting things out until something changes.&lt;/p&gt;

&lt;p&gt;Causality Cage automates that. You give it a failing session and a passing session. It replays the flow in Chromium with the differences switched on and off (API response fields, latency, response order, localStorage, cookies, environment), uses hierarchical delta debugging to shrink the set, then re-checks the answer by removing each piece and replaying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtgbd6plke62sfh1nm8d.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtgbd6plke62sfh1nm8d.gif" alt="demo" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The report is a single offline HTML file with a causal graph. Click a factor to neutralize it and the failure node flips from red to green.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real bug
&lt;/h2&gt;

&lt;p&gt;I ran it on a closed issue in the RealWorld React app: &lt;a href="https://github.com/gothinkster/react-redux-realworld-example-app/issues/187" rel="noopener noreferrer"&gt;gothinkster/react-redux-realworld-example-app#187&lt;/a&gt;. The app crashes with "Cannot read property 'tags' of undefined", and the reporter traced it to a stale token in localStorage. The original API host is dead, so I pointed the app at a public RealWorld API.&lt;/p&gt;

&lt;p&gt;On the issue's commit, cage returned the full chain in 19 experiments (25.1 s): the stale token AND the API rejecting the two requests it triggers.&lt;/p&gt;

&lt;p&gt;My first version said "the token" alone. Its minimal configuration crashed with a different error, and it counted any crash as the bug. So it now fingerprints the original failure and only counts runs that fail the same way. &lt;code&gt;--loose-oracle&lt;/code&gt; restores the old behavior, which names the token alone in 10 experiments. The same run also exposed a redaction leak in my own report, which is fixed. The raw output is in &lt;a href="https://github.com/zaydmulani09/causality-cage/blob/main/launch/real-world.md" rel="noopener noreferrer"&gt;launch/real-world.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not claim
&lt;/h2&gt;

&lt;p&gt;A result is necessary and sufficient only within the factors it models, under your flow and your oracle, at the measured repeat rates. It is 1-minimal, not guaranteed globally smallest. It does not model server-side state, WebSockets, IndexedDB or static assets. When the cause is out there, it says "unexplained" instead of naming something. Every report prints that scope box.&lt;/p&gt;

&lt;p&gt;On a benchmark of planted bugs in my own demo app it matched ground truth on 10 of 10 rows, with caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The OR row (two causes that crash with different messages) needs &lt;code&gt;--loose-oracle&lt;/code&gt;. In strict mode it finds only one.&lt;/li&gt;
&lt;li&gt;The flaky-oracle row failed after I added failure matching. It passes because of a rule I added afterwards: runs that crash before reaching the target failure are excluded from the fail rate.&lt;/li&gt;
&lt;li&gt;The latency race flip point was 808 ms against an expected 600 to 900 ms band.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app is mine, so treat that as a self-check, not evidence it works on yours.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install -g causality-cage
npx playwright install chromium
cage doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Everything runs locally. There are no LLM calls (the verdict text is a template), and it can export a standalone Playwright test that fails until the bug is fixed.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/zaydmulani09/causality-cage" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/causality-cage&lt;/a&gt; (MIT)&lt;/p&gt;

&lt;p&gt;I'd most like to hear about bugs where it gives a wrong or useless answer.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I stream row changes out of SQLite without touching the app (reading the WAL)</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Mon, 28 Sep 2026 18:16:54 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/how-i-stream-row-changes-out-of-sqlite-without-touching-the-app-reading-the-wal-2abb</link>
      <guid>https://dev.to/zaydmulani09/how-i-stream-row-changes-out-of-sqlite-without-touching-the-app-reading-the-wal-2abb</guid>
      <description>&lt;p&gt;SQ&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmhclfhei75y21ay76sv4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmhclfhei75y21ay76sv4.png" alt=" " width="800" height="410"&gt;&lt;/a&gt;Lite has no way to tell another process "row 42 in users just changed." I wanted that,&lt;br&gt;
so I built &lt;a href="https://github.com/zaydmulani09/tailite" rel="noopener noreferrer"&gt;tailite&lt;/a&gt;: it follows a live SQLite&lt;br&gt;
database from outside and emits every committed insert, update and delete with before and&lt;br&gt;
after values. The writing app doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options that didn't work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sqlite3_update_hook&lt;/code&gt; and the preupdate hook only run inside the process doing the
write.&lt;/li&gt;
&lt;li&gt;The session extension has to be turned on by the writer.&lt;/li&gt;
&lt;li&gt;Trigger-based CDC adds triggers and a log table, which is fine for your own app and not
an option for a database some other app owns.&lt;/li&gt;
&lt;li&gt;Litestream and LiteFS replicate pages. That's great for backups, but they can't tell
you which row changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: read the write-ahead log myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: which transactions are committed?
&lt;/h2&gt;

&lt;p&gt;A WAL is a 32-byte header plus frames (24-byte header + one page image). Each frame's&lt;br&gt;
checksum chains from the previous one, and a frame with a non-zero "db size" field ends&lt;br&gt;
a transaction. I accept frames by the same rule SQLite's recovery uses: salts match the&lt;br&gt;
header, the checksum chain is unbroken, and the run ends in a commit frame. Anything&lt;br&gt;
still being written or rolled back just fails the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: pages → rows
&lt;/h2&gt;

&lt;p&gt;A transaction is a bag of page images. To get rows you need to know which table each page&lt;br&gt;
belongs to, and SQLite pages don't point back to their parent. tailite builds a&lt;br&gt;
page → table and page → parent map once, then updates it per transaction by walking only&lt;br&gt;
the paths from changed pages up to their roots.&lt;/p&gt;

&lt;p&gt;The surprise was deletes. When SQLite frees a page it skips writing it&lt;br&gt;
(&lt;code&gt;sqlite3PagerDontWrite&lt;/code&gt;), so after a big &lt;code&gt;DELETE&lt;/code&gt; the freed leaves aren't in the WAL at&lt;br&gt;
all. Their old images still hold the deleted rows, though. So the tracker looks for&lt;br&gt;
&lt;em&gt;orphans&lt;/em&gt;: pages that were on a changed path and didn't show up again when re-walking the&lt;br&gt;
new tree. It reads their old images.&lt;/p&gt;

&lt;p&gt;My first version assumed "parent page unchanged → page still in the tree." &lt;code&gt;DELETE FROM t&lt;/code&gt;&lt;br&gt;
broke that: the table's root got rewritten, and pages below unchanged-but-freed parents&lt;br&gt;
went stale. The randomized tests caught it. The index self-check (rebuild from scratch&lt;br&gt;
after every commit, compare) pointed straight at the drifted pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: two gotchas in the record format
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same-size blob updates.&lt;/strong&gt; SQLite overwrites overflow pages in place, so the leaf page
stays byte-for-byte identical and only an overflow page changes. I keep an overflow
page → row index for exactly this case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REAL affinity.&lt;/strong&gt; Store &lt;code&gt;140.0&lt;/code&gt; in a &lt;code&gt;REAL&lt;/code&gt; column and SQLite writes the integer &lt;code&gt;140&lt;/code&gt;
to disk, then converts it back when you read. The decoder has to do the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: checkpoints will ruin your day
&lt;/h2&gt;

&lt;p&gt;This is the part that matters. A checkpoint copies WAL frames into the main database&lt;br&gt;
file, and once everything is copied the next writer resets the WAL. If that happens&lt;br&gt;
before you've read a transaction, you lose its frames, or you lose the old version of a&lt;br&gt;
page you needed for the "before" image.&lt;/p&gt;

&lt;p&gt;SQLite already protects readers from this: it never checkpoints past the snapshot of an&lt;br&gt;
open read transaction. So tailite holds one (I call it the pin) at a point it has&lt;br&gt;
already processed. Each poll it opens a new pin, reads everything in the log, then drops&lt;br&gt;
the old pin. At every moment some pin sits at or behind what's been processed. Litestream&lt;br&gt;
uses the same trick.&lt;/p&gt;

&lt;p&gt;To make sure this wasn't just theory, I wrote a test where a writer thread commits and&lt;br&gt;
runs PASSIVE/RESTART/TRUNCATE checkpoints while tailite polls. I disabled the pin and ran&lt;br&gt;
it again: it failed every run (frames vanishing mid-read, wrong before-images, duplicate&lt;br&gt;
inserts). With the pin it passes on Linux, macOS and Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing against SQLite itself
&lt;/h2&gt;

&lt;p&gt;The main test is differential. Random workloads (inserts, range deletes, &lt;code&gt;DELETE FROM&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;INSERT OR REPLACE&lt;/code&gt;, VACUUM, auto_vacuum, 512-byte pages, WITHOUT ROWID tables, DDL)&lt;br&gt;
run through real SQLite. Every event gets replayed into a mirror, and after every poll the&lt;br&gt;
mirror must equal &lt;code&gt;SELECT *&lt;/code&gt; of every table. Updates and deletes also have to carry the&lt;br&gt;
exact previous row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cargo install tailite
tailite watch app.db --json      # JSON Lines, one object per row
tailite watch app.db --snapshot  # existing rows first, then the stream
tailite diff old.db new.db       # row diff of two files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Or as a library: &lt;code&gt;Tail::open&lt;/code&gt;, &lt;code&gt;poll&lt;/code&gt;, &lt;code&gt;snapshot&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits (being honest)
&lt;/h2&gt;

&lt;p&gt;WAL mode only for &lt;code&gt;watch&lt;/code&gt;. You get the net effect per transaction, not statement order.&lt;br&gt;
There's no resume across restarts yet. Attaching reads the whole database once.&lt;/p&gt;

&lt;p&gt;Repo and architecture notes: &lt;a href="https://github.com/zaydmulani09/tailite" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/tailite&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>sql</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a tool that pulls the power cord on your program (thousands of times)</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Fri, 25 Sep 2026 02:06:50 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-tool-that-pulls-the-power-cord-on-your-program-thousands-of-times-3ejp</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-tool-that-pulls-the-power-cord-on-your-program-thousands-of-times-3ejp</guid>
      <description>&lt;p&gt;&lt;code&gt;write()&lt;/code&gt; returning doesn't mean your data is on disk. Even &lt;code&gt;fsync()&lt;/code&gt; doesn't cover everything. A file system may persist writes out of order, tear them at block boundaries, save a file's new size without its data, and drop a new directory entry unless you fsync &lt;em&gt;the directory&lt;/em&gt;. Code that passes every test can still lose data, or corrupt its state, when the power fails at the wrong moment.&lt;/p&gt;

&lt;p&gt;These bugs are common in serious software. Pillai et al. (OSDI '14) found 60 of them across 11 applications, including databases and version-control systems. But the tool from that paper, ALICE, is unmaintained. So I built &lt;strong&gt;unsynced&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record&lt;/strong&gt; what a program does to a directory: &lt;code&gt;strace&lt;/code&gt; for any program on Linux, or a &lt;code&gt;Recorder&lt;/code&gt; API for Rust code on any OS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compile&lt;/strong&gt; each operation into the pieces a file system can persist independently (directory entries, 4 KiB write chunks, size changes), each tagged with the fsync that makes it durable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enumerate&lt;/strong&gt; every on-disk state a crash could leave under a persistence model (the weakest POSIX allows, or ext4's default).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check&lt;/strong&gt; each unique state with &lt;em&gt;your&lt;/em&gt; checker. It gets the directory plus what the program had acknowledged (printed) before the crash.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Explain.&lt;/strong&gt; Each failure is minimized to the lost operations that cause it, classified, and turned into a fix.&lt;/p&gt;

&lt;p&gt;unsynced run --init seed/ \&lt;br&gt;
  --check 'c=$(cat {dir}/cfg); [ "$c" = v2 ] || { ! grep -q saved {marks} &amp;amp;&amp;amp; [ "$c" = v1 ]; }' \&lt;br&gt;
  -- sh -c 'printf v2 &amp;gt; {dir}/tmp &amp;amp;&amp;amp; mv {dir}/tmp {dir}/cfg &amp;amp;&amp;amp; echo saved'&lt;/p&gt;

&lt;p&gt;[1] reordering (7 failing states)&lt;br&gt;
  lost          #1 write tmp [0..2)&lt;br&gt;
  but persisted #2 rename tmp -&amp;gt; cfg&lt;br&gt;
  fix           fsync &lt;code&gt;tmp&lt;/code&gt; before &lt;code&gt;rename tmp -&amp;gt; cfg&lt;/code&gt; (op #2); the rename reached disk before the data it points to&lt;/p&gt;

&lt;p&gt;[2] unsynced (1 failing states)&lt;br&gt;
  lost          #2 rename tmp -&amp;gt; cfg&lt;br&gt;
  fix           fsync the directory &lt;code&gt;.&lt;/code&gt; after &lt;code&gt;rename tmp -&amp;gt; cfg&lt;/code&gt; and before acknowledging ("saved")&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;To see whether the model was realistic, I ran SQLite under every journal mode and &lt;code&gt;synchronous&lt;/code&gt; level, with a checker demanding integrity plus every acknowledged commit. unsynced knows nothing about SQLite:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;mode&lt;/th&gt;
&lt;th&gt;unsynced&lt;/th&gt;
&lt;th&gt;SQLite docs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DELETE + FULL&lt;/td&gt;
&lt;td&gt;commit lost: journal unlink not dir-fsynced&lt;/td&gt;
&lt;td&gt;EXTRA exists to sync the dir after that unlink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DELETE + EXTRA&lt;/td&gt;
&lt;td&gt;clean&lt;/td&gt;
&lt;td&gt;ACID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAL + NORMAL&lt;/td&gt;
&lt;td&gt;commit lost: WAL not fsynced before commit returns&lt;/td&gt;
&lt;td&gt;"might roll back following a power loss"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAL + FULL&lt;/td&gt;
&lt;td&gt;clean&lt;/td&gt;
&lt;td&gt;ACID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OFF&lt;/td&gt;
&lt;td&gt;&lt;code&gt;database disk image is malformed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"might become corrupted"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row matches the docs, and CI re-runs this on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crashing the recovery
&lt;/h2&gt;

&lt;p&gt;Recovery code writes to disk too. With &lt;code&gt;--recover&lt;/code&gt;, each repair is traced and crashed at every step, then run again. The example in the repo is a log whose torn-tail repair rewrites the file: correct in every normal test, but it loses acknowledged data if the power dies mid-repair. The same repair done with &lt;code&gt;set_len&lt;/code&gt; survives.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cargo install unsynced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Repo, model spec and limitations: &lt;a href="https://github.com/zaydmulani09/unsynced" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/unsynced&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>testing</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Semantic code search without a vector index</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Sat, 19 Sep 2026 15:18:10 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/semantic-code-search-without-a-vector-index-3elj</link>
      <guid>https://dev.to/zaydmulani09/semantic-code-search-without-a-vector-index-3elj</guid>
      <description>&lt;p&gt;Every "AI code search" tool released this year does the same thing under the hood: chunk your repo, embed every chunk, stuff the vectors into an index, and keep that index in sync every time you commit. GitLab Duo does it. Sourcegraph does it. JetBrains shipped a whole RAG pipeline for it. It works, but it means you're maintaining a second copy of your codebase that can drift out of sync with the first one, and on a repo that changes fast, "rebuild the index" becomes its own chore.&lt;/p&gt;

&lt;p&gt;I wanted semantic search that didn't need that. So I built jevgrep, and it skips the index entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual mechanism
&lt;/h2&gt;

&lt;p&gt;jevgrep does two passes, and only one of them touches a model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass one&lt;/strong&gt; is plain old ripgrep, doing a fast keyword sweep over the repo to pull maybe 30 to 50 candidate lines. This part is dumb on purpose. It doesn't need to understand your query, it just needs to narrow a whole repo down to a short list fast, which is the one thing keyword search is genuinely good at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass two&lt;/strong&gt; hands that shortlist to &lt;a href="https://typesafe.ai" rel="noopener noreferrer"&gt;Jev&lt;/a&gt;, TypeSafe's decision model, in a single batched call. Jev isn't a chat model, it's what they call a System One model: it doesn't generate text token by token, it reads probabilities over a fixed set of options in one forward pass. So instead of asking an LLM to write out a ranked list (slow, and it'll happily hallucinate a line number that doesn't exist), you hand it the query plus the candidates as typed choice options, and it hands back a probability distribution over which candidate actually answers the query. No decoding loop, no JSON the model might get wrong, just a ranking.&lt;/p&gt;

&lt;p&gt;That's the whole trick. Keyword search does recall, Jev does relevance, and there's nothing to rebuild when you push a commit because there's no index in the first place. The tradeoff is honest: you're bounded by whatever ripgrep's keyword pass surfaces in the first place, and Jev's context window caps you around 50 candidates per call. This isn't a replacement for a real embedding index on a codebase where the right answer literally doesn't share a single word with the query. It's a much cheaper tool for the far more common case, where the right answer is a few keyword-adjacent hops away from what you typed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it actually work
&lt;/h2&gt;

&lt;p&gt;I ran it against Flask's real source, not a toy repo, because Flask's &lt;code&gt;config.py&lt;/code&gt; has genuine config-parsing logic (&lt;code&gt;from_pyfile&lt;/code&gt;, &lt;code&gt;from_prefixed_env&lt;/code&gt;, &lt;code&gt;from_envvar&lt;/code&gt;) that isn't reducible to a single obvious keyword.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query: "where do we load environment variables"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flask/cli.py:701    27%  Load "dotenv" files to set environment variables...
flask/config.py:129 12%  Load any environment variables that start with FLASK_
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain &lt;code&gt;rg "load environment variables"&lt;/code&gt; returns 2 hits, both docstrings, missing the actual mechanism. &lt;code&gt;rg "os.environ"&lt;/code&gt; returns 14 hits with no ranking, meaning you already need to know the exact symbol and then eyeball all 14 yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query: "where do we parse the config file"&lt;/strong&gt;&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;flask&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;209&lt;/span&gt;  &lt;span class="mi"&gt;68&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="nf"&gt;exec&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="n"&gt;config_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)...)&lt;/span&gt;
&lt;span class="n"&gt;flask&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;270&lt;/span&gt;   &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flask&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;273&lt;/span&gt;   &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.toml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tomllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That top hit is the line that actually reads and executes a Python config file. The words "parse" and "config file" don't appear on it together anywhere. &lt;code&gt;rg "parse config file"&lt;/code&gt; returns zero results, because those words never co-occur in Flask's source at all. That's the actual gap between text matching and understanding intent, on a real production codebase, not a cherry-picked demo repo.&lt;/p&gt;

&lt;p&gt;Worth being straight about the failure mode too: run jevgrep on its own repo (which has no real config-file parser) and it correctly surfaces argument-parsing code instead, because that's the closest real match that exists. It doesn't invent a config parser that isn't there. For a tool whose whole pitch is trustworthy ranking, that matters more than a good demo would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and speed
&lt;/h2&gt;

&lt;p&gt;Jev's list price is $0.042 per million input tokens with output free, and each jevgrep query is maybe 30 to 60 tokens of state. End to end it ran at 945ms in my test, which was a cold call on Windows without ripgrep on PATH, falling back to a pure Python scan for pass one. With ripgrep actually resolving, the shortlist pass drops to single-digit milliseconds and the Jev call itself is typically 70 to 500ms, so the realistic number is well under a second, consistently.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/zaydmulani09/jevgrep
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jevgrep &lt;span class="s2"&gt;"where do we parse the config file"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need a free &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt; API key to call Jev (new accounts get $1 in credit, which at Jev's pricing is on the order of tens of thousands of queries). Full setup is in the README.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/zaydmulani09/jevgrep" rel="noopener noreferrer"&gt;github.com/zaydmulani09/jevgrep&lt;/a&gt;, MIT licensed.&lt;/p&gt;

&lt;p&gt;If you're building something in this space, I'd genuinely like to hear what tradeoffs you hit, especially anywhere the shortlist-then-rank approach falls over on a codebase bigger than what I've tested against so far.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>My "secure" sandbox detected an attack and reported success anyway. Here's the marshaling bug that caused it.</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Mon, 24 Aug 2026 21:36:24 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/my-secure-sandbox-detected-an-attack-and-reported-success-anyway-heres-the-marshaling-bug-that-lap</link>
      <guid>https://dev.to/zaydmulani09/my-secure-sandbox-detected-an-attack-and-reported-success-anyway-heres-the-marshaling-bug-that-lap</guid>
      <description>&lt;p&gt;I built a tool (mri) that runs untrusted code snippets inside a sandbox, checked against an execution allowlist derived from a dependency graph. Before I trusted it for anything, I wrote a demo: three attacks, all blocked. Then I stopped testing.&lt;/p&gt;

&lt;p&gt;That was a mistake, and I only found out because I went back and wrote a second benchmark whose entire purpose was to disprove the first one — 19 cases of ordinary code that should just run, and 14 attack cases that weren't in my original three.&lt;/p&gt;

&lt;p&gt;The legitimate-code suite failed first, and badly. 5 of 19 normal patterns got blocked, including a plain function declaration with parameters and a try/catch block. The static scanner registered local bindings for arrow-function params but never for function_declaration params or catch-clause bindings — so those names looked like unresolved external references to the scanner, and unresolved means blocked, by design. A tool that blocks ordinary code isn't safer than one that doesn't. It's the kind of tool that gets a comment added to disable it.&lt;/p&gt;

&lt;p&gt;The attack suite found something worse than a missed attack. One case: a getter that doesn't touch process.env until the return value gets read on the way out of the sandbox. That access triggered the runtime's real violation check — the detection fired correctly. Then the code responsible for serializing that return value back to the host caught the resulting error, converted it to a string, and handed back a clean "executed" result. Nothing in the breach log. The check worked and the report lied about it. I'd rather have a tool that misses something and says so than one that catches something and hides it.&lt;/p&gt;

&lt;p&gt;Also found: the sandbox wasn't a real boundary in the first place. I was using node:vm, and console.log.constructor.constructor (or the same trick through any host-provided reference) reaches the host process's actual Function constructor. This is documented — Node's own docs say vm doesn't provide security isolation — but "documented" and "tested against my own code" are different things, and I hadn't done the second one.&lt;/p&gt;

&lt;p&gt;Fixed the sandbox by moving to isolated-vm, a real separate V8 isolate rather than a shared-realm context. Fixed the marshaling bug by making sure any violation, wherever it's triggered from, produces a blocked verdict before a return value ever gets serialized. Re-ran the full 33-case benchmark after both fixes: zero regressions, both cases now correctly blocked.&lt;/p&gt;

&lt;p&gt;Still true after the fixes: no OS-level sandboxing underneath the isolate, no taint tracking between two resources that are each individually granted, and it only evaluates one snippet at a time — no whole-program analysis.&lt;/p&gt;

&lt;p&gt;Repo, including the benchmark harness and raw per-case results: &lt;a href="https://github.com/zaydmulani09/mri" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/mri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a static + runtime security scanner for MCP servers (Rust)</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Wed, 19 Aug 2026 04:05:33 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-static-runtime-security-scanner-for-mcp-servers-rust-4498</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-static-runtime-security-scanner-for-mcp-servers-rust-4498</guid>
      <description>&lt;p&gt;I built a static + runtime security scanner for MCP servers (Rust)&lt;/p&gt;

&lt;p&gt;TL;DR: MCP (Model Context Protocol) has had 40+ CVEs disclosed just this year, and there wasn't a purpose-built scanner for it, so I built one. sentrymcp does static analysis (path traversal, injection, tool poisoning, missing auth) plus a runtime proxy mode that catches "rug pulls" — servers that silently change a tool's description after you've already approved it. Rust, MIT, Docker one-liner. Repo: &lt;a href="https://github.com/zaydmulani09/sentrymcp" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/sentrymcp&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;I've been building MCP tooling for a while (a reverse proxy, some agent infra), and at some point I went looking for something to check my own servers for basic security mistakes before shipping them. There wasn't really anything.&lt;/p&gt;

&lt;p&gt;That surprised me, because the CVE numbers aren't small. Endor Labs analyzed over 2,600 real MCP implementations and found 82% use file operations prone to path traversal, 67% use APIs related to code injection, and 34% use APIs susceptible to command injection. Separately, researchers have put the no-auth rate at around 38-40% of scanned servers — meaning close to 2 in 5 MCP servers in the wild have no authentication at all.&lt;/p&gt;

&lt;p&gt;The vulnerability classes aren't exotic either. Anthropic's own reference implementation, &lt;code&gt;mcp-server-git&lt;/code&gt;, shipped a path traversal bug where a &lt;code&gt;repo_path&lt;/code&gt; argument was never validated against a configured boundary — the exact "developer forgot one check" pattern that shows up constantly in these audits.&lt;/p&gt;

&lt;p&gt;So: static analysis for the code-level stuff, and since MCP has an attack class that literally can't exist in source code (a server that changes its tool description mid-session, after the user already approved the original), a runtime mode to catch that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it catches
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What it detects&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code vulnerabilities&lt;/td&gt;
&lt;td&gt;Path traversal, command/shell injection, unsafe eval/code-injection sinks&lt;/td&gt;
&lt;td&gt;Unvalidated file path args, string-interpolated &lt;code&gt;exec()&lt;/code&gt; calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool poisoning&lt;/td&gt;
&lt;td&gt;Hidden instructions embedded in tool descriptions, unicode/homoglyph obfuscation&lt;/td&gt;
&lt;td&gt;"ignore previous instructions" phrasing, Cyrillic lookalike characters hiding in description text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth &amp;amp; permissions&lt;/td&gt;
&lt;td&gt;Missing auth on HTTP/SSE transports, hardcoded credentials, credentials leaked via logging&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;api_key = "sk-..."&lt;/code&gt; as a literal, unauthenticated SSE endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime (proxy mode)&lt;/td&gt;
&lt;td&gt;Tool description changes after initial approval ("rug pulls"), unexpected outbound connections&lt;/td&gt;
&lt;td&gt;A tool's description silently changes between the first and second &lt;code&gt;tools/list&lt;/code&gt; call in a session&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tool-poisoning detection is the one I'm most interested in feedback on. MCP tool descriptions get read directly into the model's context as trusted content, so an attacker who controls a description can hide instructions the LLM will act on while the user only sees a benign-looking label. sentrymcp checks for imperative hidden-instruction phrasing, zero-width/bidi-control unicode, and homoglyph mixing (Latin text with Cyrillic or Greek lookalike characters slipped in).&lt;/p&gt;

&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;

&lt;p&gt;Static scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcpaudit scan ./some-mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output is ranked by severity with a CWE or OWASP MCP Top 10 reference and a one-line remediation for each finding, split into three sections — code vulnerabilities, tool poisoning, and auth/permissions.&lt;/p&gt;

&lt;p&gt;Runtime proxy mode, sitting between your MCP client and the real server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sentrymcp proxy &lt;span class="nt"&gt;--&lt;/span&gt; &amp;lt;your-server-command&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This baselines every tool definition it sees on session start and diffs every later &lt;code&gt;tools/list&lt;/code&gt; response against it. Any change gets flagged and logged as a JSON event, with a human-readable summary at session end.&lt;/p&gt;

&lt;p&gt;Docker, no local Rust toolchain needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;docker build -t sentrymcp .
docker run --rm -v $(pwd):/scan sentrymcp scan /scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;Rust workspace, four crates: a core scan engine, a rules crate that loads detection patterns from TOML files (so adding a new check doesn't require recompiling), a CLI, and the proxy. Rule-driven design was a deliberate choice — path traversal, injection, and tool-poisoning heuristics are all just declarative pattern definitions with a severity, a reference (CWE or OWASP MCP Top 10 id), and remediation text, which makes the ruleset easy to extend as new attack patterns show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations (being upfront about these)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Detection is regex/pattern-based, not AST or taint-tracking. It'll miss vulnerabilities that flow through an intermediate variable before hitting a sink — this is a known, documented gap, and proper taint analysis is the natural next step.&lt;/li&gt;
&lt;li&gt;Description extraction for tool-poisoning checks is targeted, not a full parser — it handles the common registration patterns in Python/TS/JS but isn't exhaustive.&lt;/li&gt;
&lt;li&gt;The runtime proxy's network-connection monitoring is currently Windows-only and best-effort (polls &lt;code&gt;netstat&lt;/code&gt;, not a real sandbox).&lt;/li&gt;
&lt;li&gt;Heuristic findings (like "no visible auth pattern anywhere in the file") are marked with a confidence flag separate from severity, since absence-of-pattern checks are inherently noisier than presence-based ones.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Repo's here: &lt;a href="https://github.com/zaydmulani09/sentrymcp" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/sentrymcp&lt;/a&gt; — MIT licensed, contributions welcome. If you run MCP servers, I'd genuinely appreciate people trying the scanner against their own and telling me what it misses or false-positives on. The corpus of test cases is still small and real-world feedback is the fastest way to make the ruleset actually useful instead of just theoretically correct.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>rust</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a local LLM that runs entirely in your browser. No install, no GPU, no server</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Mon, 27 Jul 2026 18:34:05 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-local-llm-that-runs-entirely-in-your-browser-no-install-no-gpu-no-server-3c3c</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-local-llm-that-runs-entirely-in-your-browser-no-install-no-gpu-no-server-3c3c</guid>
      <description>&lt;p&gt;A few months ago I got obsessed with a question: can you run a real LLM entirely inside a browser tab, with zero backend, zero GPU, and zero install?&lt;/p&gt;

&lt;p&gt;The answer is yes. Here's what I built.&lt;/p&gt;

&lt;p&gt;ghost is a single HTML file that downloads a quantized language model into your browser's cache on first visit, then runs inference locally in WebAssembly forever after. Fully offline after that first download. No API key. No npm. No build step. Open the file, pick a model, chat.&lt;/p&gt;

&lt;p&gt;How it works&lt;/p&gt;

&lt;p&gt;The inference engine is wllama — a WebAssembly binding for llama.cpp. It runs GGUF quantized models directly in the browser using WASM SIMD. I pin it to a specific version so the JS and WASM files always match (learned this the hard way after a fun debugging session involving mismatched memory imports).&lt;/p&gt;

&lt;p&gt;Models are downloaded from HuggingFace on first load and cached via the browser's Cache API. On every subsequent visit they load instantly from cache, no network needed.&lt;/p&gt;

&lt;p&gt;Features&lt;/p&gt;

&lt;p&gt;Three models: Qwen2.5 1.5B (smart), Qwen2 0.5B (fast), TinyLlama (lightweight)&lt;br&gt;
Markdown rendering from scratch — no library, just regex transforms&lt;br&gt;
RAG: drag a .txt or .pdf onto the chat window. It chunks the text, embeds each chunk using wllama's embedding API, stores vectors in memory, and retrieves the top-3 relevant chunks on each message. Fully local, fully offline&lt;br&gt;
Voice input via the Web Speech API — mic button auto-sends on silence&lt;br&gt;
Multi-turn conversation memory capped at 10 turns&lt;br&gt;
PWA installable — works on mobile home screen too&lt;/p&gt;

&lt;p&gt;The hard parts&lt;/p&gt;

&lt;p&gt;Getting wllama to load from a cached model was genuinely tricky. Blob URLs created in the main thread aren't accessible from wllama's internal Web Worker. IndexedDB chunk reconstruction hit a 2GB ArrayBuffer limit on Windows Chrome. The final solution was using wllama's built-in loadModelFromHF with useCache: true which handles everything internally.&lt;/p&gt;

&lt;p&gt;The embeddings API requires toggling a flag (embeddings: true) that conflicts with normal chat completion — so I toggle it on before each embedding call and back off in a finally block.&lt;/p&gt;

&lt;p&gt;Try it&lt;/p&gt;

&lt;p&gt;Live: &lt;a href="https://zaydmulani09.github.io/ghost/ghost.html" rel="noopener noreferrer"&gt;https://zaydmulani09.github.io/ghost/ghost.html&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/zaydmulani09/ghost" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/ghost&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Speed on CPU is ~3-8 tokens/sec depending on model. Not fast, but it works, and every token stays on your machine.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a local-first coverage-guided fuzzer in Rust with LLM seed bootstrap</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Tue, 14 Jul 2026 13:27:34 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-local-first-coverage-guided-fuzzer-in-rust-with-llm-seed-bootstrap-4c4a</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-local-first-coverage-guided-fuzzer-in-rust-with-llm-seed-bootstrap-4c4a</guid>
      <description>&lt;p&gt;I built a local-first coverage-guided fuzzer in Rust with LLM seed bootstrap&lt;/p&gt;

&lt;h1&gt;
  
  
  rust #opensource #security #fuzzing
&lt;/h1&gt;




&lt;p&gt;The problem that kept bothering me: I wanted to fuzz a custom binary protocol parser. Not something with a public corpus of real traffic. Just a service my team built that speaks a length-prefixed binary format over TCP.&lt;/p&gt;

&lt;p&gt;AFL++ requires a C harness. cargo-fuzz wraps libFuzzer but you still need to write the harness and you're starting from random bytes. Neither tool helps you when the first thing the parser does is check a 4-byte magic header and exit if it doesn't match. You spend hours getting past that check before you find anything real.&lt;/p&gt;

&lt;p&gt;So I built Phaedra.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LLM seeding idea
&lt;/h2&gt;

&lt;p&gt;The core insight is that you don't need random bytes to start. You need bytes that look plausible. If you describe what your target parses in plain English, a language model can generate a structured initial corpus.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;phaedra fuzz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt; ./my_parser &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"TLV binary protocol with PHDR magic header, u8 type, u16be length, payload bytes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Phaedra sends that description to a local Ollama instance and gets back hex-encoded seed inputs. No API key, no data leaving your machine. OpenAI and Anthropic work as drop-in backends if you want them.&lt;/p&gt;

&lt;p&gt;The seed generation prompt asks for variety: valid inputs, boundary values, truncated records, inputs where the length field exceeds the payload. The parser gets past the magic check immediately because the seeds include valid magic bytes. Campaign starts finding new coverage on the first execution instead of the hundredth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage via SanCov edge bitmaps
&lt;/h2&gt;

&lt;p&gt;Once you have seeds, you need coverage feedback. I chose SanCov edge bitmaps over shared memory rather than LLVM &lt;code&gt;.profraw&lt;/code&gt; files for one reason: speed. Reading a profraw file after each execution means a file write, a file read, and a parse. Shared memory is a single &lt;code&gt;shmat&lt;/code&gt; call and a memcpy.&lt;/p&gt;

&lt;p&gt;The setup: Phaedra allocates a 65536-slot edge bitmap in POSIX shared memory, passes the segment ID to the target via &lt;code&gt;__PHAEDRA_SHM_ID&lt;/code&gt;, and the target's SanCov runtime shim writes edge hit counts directly into that segment on every execution. After the process exits, Phaedra reads the bitmap, XORs against the global "seen" bitmap, and if new edges fired, the input goes into the corpus.&lt;/p&gt;

&lt;p&gt;The shim is a small C file that gets compiled into the target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;__sanitizer_cov_trace_pc_guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!*&lt;/span&gt;&lt;span class="n"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;phaedra_map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;phaedra_map&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;guard&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="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;PHAEDRA_MAP_SIZE&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Schema-aware mutation
&lt;/h2&gt;

&lt;p&gt;Random byte mutation gets past the magic check once you have good seeds. But it still struggles with structured fields. If your protocol has a u16be length prefix followed by exactly that many payload bytes, random mutation almost never produces a valid record because it corrupts the length without adjusting the payload.&lt;/p&gt;

&lt;p&gt;The schema DSL solves this. You describe your protocol in TOML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"my_protocol"&lt;/span&gt;

&lt;span class="nn"&gt;[[fields]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"magic"&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"magic"&lt;/span&gt;
&lt;span class="py"&gt;length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"50484452"&lt;/span&gt;
&lt;span class="py"&gt;mutable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="nn"&gt;[[fields]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"length"&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"u16_be"&lt;/span&gt;

&lt;span class="nn"&gt;[[fields]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"payload"&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"lp_bytes16_be"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;30% of mutations operate at the field level: corrupting the length prefix specifically, setting it to 0, to max value, or to a value larger than the actual payload. The other 70% are still raw byte mutations. The split keeps the campaign from getting stuck in schema-valid inputs only.&lt;/p&gt;

&lt;p&gt;If you have a corpus but no schema, &lt;code&gt;phaedra infer&lt;/code&gt; analyzes the samples with six heuristics and writes a starting schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;phaedra infer &lt;span class="nt"&gt;--corpus-db&lt;/span&gt; ./phaedra-corpus/corpus.db &lt;span class="nt"&gt;--name&lt;/span&gt; my_proto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Crash triage
&lt;/h2&gt;

&lt;p&gt;When Phaedra finds a crash it deduplicates by signal number and input fingerprint. SIGSEGV and SIGABRT are CRITICAL. SIGILL and SIGFPE are HIGH. No-signal crashes are LOW. Each unique signature gets one entry in SQLite with a hit counter so if the same bug triggers 500 times you see one entry with hit_count=500, not 500 files in your crash directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;phaedra crashes

ID  SEVERITY   HITS  SIGNATURE     STATUS
1   LOW        47    crash_3f2a1b  Crash { signal: None }
2   LOW        12    crash_7e4d2c  Crash { signal: None }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;phaedra minimize&lt;/code&gt; runs delta minimization to reduce a crashing input to the smallest bytes that still trigger it. Found a 200-byte crash? It will usually get it down to under 10.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;phaedra
phaedra fuzz &lt;span class="nt"&gt;--demo&lt;/span&gt; tlv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TLV demo target has a real bounds-check bug. Phaedra finds it within the first few seconds.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/zaydmulani09/phaedra" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/phaedra&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>opensource</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>I built a local token cost tracker for Claude Code in Rust</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Mon, 13 Jul 2026 21:58:53 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-local-token-cost-tracker-for-claude-code-in-rust-500h</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-local-token-cost-tracker-for-claude-code-in-rust-500h</guid>
      <description>&lt;p&gt;After one too many surprise Claude Code bills, I built agentwatch.&lt;br&gt;
It's a local HTTP proxy written in Rust. Point your agent at localhost:7878, and it intercepts every request, parses the token usage, and shows a live Ratatui TUI dashboard of what you're spending — in real time.&lt;br&gt;
Everything stays local. SQLite database, zero signup, single binary via cargo install agentwatch.&lt;br&gt;
Supports Claude Code, Codex, and Gemini CLI.&lt;br&gt;
github.com/zaydmulani09/agentwatch&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ai</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built a self-hosted reverse proxy for MCP servers in Rust</title>
      <dc:creator>Zayd Mulani</dc:creator>
      <pubDate>Thu, 02 Jul 2026 18:52:40 +0000</pubDate>
      <link>https://dev.to/zaydmulani09/i-built-a-self-hosted-reverse-proxy-for-mcp-servers-in-rust-45a1</link>
      <guid>https://dev.to/zaydmulani09/i-built-a-self-hosted-reverse-proxy-for-mcp-servers-in-rust-45a1</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Every AI tool that talks to MCP servers (Claude, Cursor, etc.) &lt;br&gt;
connects directly — no auth, no rate limiting, no observability. &lt;br&gt;
You have no idea what's hitting your servers or how often.&lt;/p&gt;

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

&lt;p&gt;MCP Gateway sits in front of all your MCP servers and handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Routing&lt;/strong&gt; — /mcp/server-name/* proxies to the right backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt; — per-server API keys encrypted AES-256-GCM in memory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; — token bucket per client, 429 + Retry-After&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging&lt;/strong&gt; — SQLite log of every request with latency + status&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage tracking&lt;/strong&gt; — token counts from Anthropic/OpenAI responses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI&lt;/strong&gt; — mcpgw server add/list/remove, mcpgw logs show, mcpgw stats&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quickstart
&lt;/h2&gt;

&lt;p&gt;MCPGW_MASTER_SECRET=your-secret docker compose up --build&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;Rust, Axum, SQLite (rusqlite), DashMap, AES-GCM&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/zaydmulani09/mcp-gateway" rel="noopener noreferrer"&gt;https://github.com/zaydmulani09/mcp-gateway&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
