<?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: Asuran</title>
    <description>The latest articles on DEV Community by Asuran (@zkasuran).</description>
    <link>https://dev.to/zkasuran</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%2F4072471%2F23ca2796-c59f-4181-abb0-4dec9252b824.png</url>
      <title>DEV Community: Asuran</title>
      <link>https://dev.to/zkasuran</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zkasuran"/>
    <language>en</language>
    <item>
      <title>Frostline: what to plant this week, from your local frost dates</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Sat, 10 Oct 2026 16:34:40 +0000</pubDate>
      <link>https://dev.to/zkasuran/frostline-what-to-plant-this-week-from-your-local-frost-dates-3k2</link>
      <guid>https://dev.to/zkasuran/frostline-what-to-plant-this-week-from-your-local-frost-dates-3k2</guid>
      <description>&lt;p&gt;Most garden advice is a fixed calendar. But the right week to sow a carrot in Portland is not the right week in Minneapolis. A calendar does not know that. Your frost dates do.&lt;/p&gt;

&lt;p&gt;Frostline asks for your location and tells you exactly what to sow this week, from your own last-spring and first-fall frost dates. The screen is the shortest part. Read the plan, close the laptop, go plant.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live demo: &lt;a href="https://frostline-lime.vercel.app" rel="noopener noreferrer"&gt;https://frostline-lime.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/zkasuran/frostline" rel="noopener noreferrer"&gt;https://github.com/zkasuran/frostline&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Type a US city or tap "use my location". Frostline shows your two frost dates, then a sow-this-week list drawn from 29 common vegetables and herbs, each with a plain reason like "sow kale now, it takes a light frost and keeps going into the cold". It reads at a glance and needs no sign-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open-source AI is the core, not the garnish
&lt;/h2&gt;

&lt;p&gt;Two open models do the real work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frost dates come from &lt;a href="https://github.com/PriorLabs/TabPFN" rel="noopener noreferrer"&gt;TabPFN&lt;/a&gt;&lt;/strong&gt;, an open tabular foundation model from Prior Labs. I trained it offline on 6,784 NOAA weather stations to predict the last-spring and first-fall 32F frost dates from latitude, longitude and elevation. On 1,357 stations held out of training it is accurate to &lt;strong&gt;6.9 days&lt;/strong&gt; for the last spring frost and &lt;strong&gt;9.7 days&lt;/strong&gt; for the first fall frost, which beats a nearest-station baseline (8.7 and 10.8 days) on both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The write-up comes from &lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma&lt;/a&gt;&lt;/strong&gt;, Google's open-weight model. The live site writes it with Gemma 3 served openly through OpenRouter, with the built-in narrator as the instant fallback so the page never blanks. The result is cached per place and day, so a reload is immediate.&lt;/p&gt;

&lt;p&gt;Why open matters here, concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It runs cheap.&lt;/strong&gt; Gemma 3 costs a fraction of a cent per plan, so a free gardening tool stays free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your location stays yours.&lt;/strong&gt; Your coordinates go to the Frostline server only, where they are matched against the baked grid. They are never forwarded to a model vendor and nothing is stored. When Gemma writes the plan it sees a coarse label (your city name or just "your location") and the frost dates, never your exact position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can swap the model.&lt;/strong&gt; Retrain the frost model on your own stations or point the advice at any open model with one environment variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can run offline.&lt;/strong&gt; Open weights mean the whole pipeline can run on a laptop with no internet, which is the point of a tool you use outside.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;TabPFN needs PyTorch, so it never runs on a request. I bake it once, offline, into a 0.25 degree US grid of 16,353 points and ship that as a small static file. At request time a tiny lookup interpolates your location against the grid, a deterministic planner turns your frost dates and today into the sow-this-week list, then Gemma writes it up. No model sits in the hot path for the frost dates, so the plan is instant and the site cannot blank.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is real and what is simulated
&lt;/h2&gt;

&lt;p&gt;I will not dress this up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frost dates are real TabPFN predictions from NOAA 1991-2020 normals. The error above is measured on held-out stations.&lt;/li&gt;
&lt;li&gt;The plan is a real deterministic function of your frost dates and today.&lt;/li&gt;
&lt;li&gt;The advice prose is written live by Gemma 3, with the built-in planner as the instant fallback. The app labels which one wrote it.&lt;/li&gt;
&lt;li&gt;Frost-free places like Miami and Key West are labelled from the measured NOAA freeze probability of the nearest stations, not from a guessed date, so the app never pretends a frost-free spot has a hard frost. The &lt;a href="https://frostline-lime.vercel.app/reports" rel="noopener noreferrer"&gt;Reports page&lt;/a&gt; shows all of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two partner categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN:&lt;/strong&gt; frost-date prediction is a clean small-tabular regression, exactly what TabPFN is for. It beats the obvious baseline. The accuracy is published in the app and reproducible from &lt;code&gt;bake/train_tabpfn.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma writes every plan on the live site, grounded on a strict facts-only prompt so it never invents a date or a crop, with a deterministic fallback so the product is never at the mercy of the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Built to be attacked
&lt;/h2&gt;

&lt;p&gt;One command gates the whole thing (&lt;code&gt;./verify.sh&lt;/code&gt;): strict types, zero-warning lint, tests, a production build and a clean dependency audit. The site ships a strict nonce Content-Security-Policy, security headers, validated inputs and a route error boundary. The threat model is in the repo.&lt;/p&gt;

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

&lt;p&gt;Open &lt;a href="https://frostline-lime.vercel.app" rel="noopener noreferrer"&gt;https://frostline-lime.vercel.app&lt;/a&gt;, type your city and see what you could put in the ground this week. Then go outside.&lt;/p&gt;




&lt;p&gt;AI assistance (Claude) was used to build Frostline. The design, the model choices and the licence decision are mine. Every number here is measured against the code and the live site so you can check it yourself.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I built my brother a bedtime storyteller that runs on his laptop</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Sun, 04 Oct 2026 10:55:19 +0000</pubDate>
      <link>https://dev.to/zkasuran/i-built-my-brother-a-bedtime-storyteller-that-runs-on-his-laptop-1lpf</link>
      <guid>https://dev.to/zkasuran/i-built-my-brother-a-bedtime-storyteller-that-runs-on-his-laptop-1lpf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My brother and his wife are lovely parents and terrible storytellers. They'd both tell you that. Their kid is four, has just started school and wants a new story every single night. By the third night of "once upon a time there was a... dog?" everyone in that bedroom knows it isn't going well.&lt;/p&gt;

&lt;p&gt;So I built them Nightjar.&lt;/p&gt;

&lt;p&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%2Fjo04ggeucbvbq8z8yyqz.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%2Fjo04ggeucbvbq8z8yyqz.png" alt="Nightjar: the kid picks a picture and a story is written for them"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Nightjar is a bedtime storyteller that runs on their laptop.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The kid picks.&lt;/strong&gt; Eight picture tiles a four year old can tap without reading: Pim the turtle, the moon, snow, a friendly dragon, a sleepy train, teddy, the sea and &lt;strong&gt;Big school&lt;/strong&gt;, which I added for the first weeks of school. Or they can say anything they like. Short story or a long one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An open model writes it.&lt;/strong&gt; Gemma 3 1B through Ollama, on the laptop. No account, no cloud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guardrail reads every word first.&lt;/strong&gt; Not a prompt asking nicely. A rule that runs on the output and can't be argued with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's read aloud, word by word.&lt;/strong&gt; An offline voice (Kokoro-82M) reads it with each word lit as it's spoken, so the kid can follow along. A soft lullaby plays underneath, a new one for every story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then it stops.&lt;/strong&gt; "The end. Goodnight." Nothing auto-plays the next one. This is bedtime, not YouTube.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And it learns.&lt;/strong&gt; A grown-up taps once when the kid is asleep. TabPFN reads those nights and picks tomorrow's length, pace and how calm the story should be.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part is the one I couldn't find anywhere else. I checked before building: there are several bedtime story generators on GitHub, a couple even clone a parent's voice. None of them ask whether the story worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live site:&lt;/strong&gt; &lt;a href="https://nightjar-bedtime.vercel.app" rel="noopener noreferrer"&gt;https://nightjar-bedtime.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Try this&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://nightjar-bedtime.vercel.app" rel="noopener noreferrer"&gt;Home&lt;/a&gt;, tap &lt;strong&gt;Voice off&lt;/strong&gt; to turn it on&lt;/td&gt;
&lt;td&gt;A recorded chapter read aloud over a lullaby, each word lit. Tap any word to jump there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://nightjar-bedtime.vercel.app/#/ask" rel="noopener noreferrer"&gt;Ask for a story&lt;/a&gt;, tap &lt;strong&gt;Big school&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;A new story written inside your browser, screened, then read with your device's voice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://nightjar-bedtime.vercel.app/#/tuner" rel="noopener noreferrer"&gt;The tuner&lt;/a&gt;, change any "asleep in" number&lt;/td&gt;
&lt;td&gt;All 72 story settings rescored, tomorrow's pick moves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://nightjar-bedtime.vercel.app/#/guard" rel="noopener noreferrer"&gt;Break the guardrail&lt;/a&gt;, load "zero width bypass"&lt;/td&gt;
&lt;td&gt;A hidden spelling of "monster" refused, with "Python agrees"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://nightjar-bedtime.vercel.app/#/book" rel="noopener noreferrer"&gt;The book&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The week's chapters as a page-turning book, plus a &lt;a href="https://nightjar-bedtime.vercel.app/demo/book.pdf" rel="noopener noreferrer"&gt;printable A5 PDF&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&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%2Flk8nwslhp46l98r2lmbh.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%2Flk8nwslhp46l98r2lmbh.png" alt="The storybox reading a chapter, each word lit as it is spoken"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A fair warning about the in-browser version: it downloads Gemma 3 270M (550 MB without WebGPU) and on a laptop with no GPU a story takes several minutes. It's there so you can try it. The real thing is the laptop box: &lt;code&gt;nightjar serve&lt;/code&gt; puts the same page on &lt;code&gt;127.0.0.1&lt;/code&gt;, where a short story is written and voiced in about three minutes and a long one in about four and a half. I timed those in Chrome against the real models, not guessed.&lt;/p&gt;

&lt;p&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%2F5539ezs6x4apozw4sfqw.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%2F5539ezs6x4apozw4sfqw.png" alt="A long Big school story on the laptop box, read aloud"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/zkasuran" rel="noopener noreferrer"&gt;
        zkasuran
      &lt;/a&gt; / &lt;a href="https://github.com/zkasuran/nightjar" rel="noopener noreferrer"&gt;
        nightjar
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Bedtime stories that learn what puts her to sleep. Local Gemma, a hard guardrail, TabPFN.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;

  
  &lt;img alt="Nightjar: bedtime stories that learn what gets them to sleep" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fzkasuran%2Fnightjar%2FHEAD%2Fdocs%2Fassets%2Fbanner-light.svg"&gt;

&lt;p&gt;
  &lt;a href="https://nightjar-bedtime.vercel.app" rel="nofollow noopener noreferrer"&gt;&lt;b&gt;Live site&lt;/b&gt;&lt;/a&gt; ·
  &lt;a href="https://dev.to/zkasuran/i-built-my-brother-a-bedtime-storyteller-that-runs-on-his-laptop-1lpf" rel="nofollow"&gt;&lt;b&gt;DEV post&lt;/b&gt;&lt;/a&gt; ·
  &lt;a href="https://github.com/zkasuran/nightjar#try-it-in-60-seconds" rel="noopener noreferrer"&gt;Try it&lt;/a&gt; ·
  &lt;a href="https://github.com/zkasuran/nightjar#how-it-works" rel="noopener noreferrer"&gt;How it works&lt;/a&gt; ·
  &lt;a href="https://github.com/zkasuran/nightjar#what-is-real-and-what-is-simulated" rel="noopener noreferrer"&gt;Real vs simulated&lt;/a&gt; ·
  &lt;a href="https://github.com/zkasuran/nightjar#built-to-be-attacked" rel="noopener noreferrer"&gt;Security&lt;/a&gt; ·
  &lt;a href="https://github.com/zkasuran/nightjar#run-it-locally" rel="noopener noreferrer"&gt;Run it&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/c38e1f715d495e6b27820e037302398ab34681f0258b4bfcb58f8f704180e230/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f74657374732d3635253230707974686f6e25323025324225323031362532307765622d326561343466"&gt;&lt;img alt="tests" src="https://camo.githubusercontent.com/c38e1f715d495e6b27820e037302398ab34681f0258b4bfcb58f8f704180e230/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f74657374732d3635253230707974686f6e25323025324225323031362532307765622d326561343466"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/89d4e9752195f6f4130e719dee6eb8c66dcf8beccbf784bfe227ddc10af8f244/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6e706d25323061756469742d302d326561343466"&gt;&lt;img alt="npm audit" src="https://camo.githubusercontent.com/89d4e9752195f6f4130e719dee6eb8c66dcf8beccbf784bfe227ddc10af8f244/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6e706d25323061756469742d302d326561343466"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/d152d6fdc11f88e6cc4b0256d406e1231d8541ca3845da1bfa2b91d159e6f4f4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e253230636f7265253230646570732d302d326561343466"&gt;&lt;img alt="core deps" src="https://camo.githubusercontent.com/d152d6fdc11f88e6cc4b0256d406e1231d8541ca3845da1bfa2b91d159e6f4f4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e253230636f7265253230646570732d302d326561343466"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/7d53f747ff7334251ad1e18ea9d8fe4dea3488d528c2a0ed811fe730dad9a53e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d6f64656c2d47656d6d61253230332532432532306c6f63616c2d663463343661"&gt;&lt;img alt="model" src="https://camo.githubusercontent.com/7d53f747ff7334251ad1e18ea9d8fe4dea3488d528c2a0ed811fe730dad9a53e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d6f64656c2d47656d6d61253230332532432532306c6f63616c2d663463343661"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/e16d6a1b0f6fcf9a229ef0947630b2d564c8c9ceb5fac146757c3be6c5728ec1/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e63652d53414e442d2d312e302d353535"&gt;&lt;img alt="licence" src="https://camo.githubusercontent.com/e16d6a1b0f6fcf9a229ef0947630b2d564c8c9ceb5fac146757c3be6c5728ec1/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e63652d53414e442d2d312e302d353535"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;Nightjar is a bedtime storyteller I built for my brother and his wife. Their kid is four and just started school. They would both tell you they are bad at bedtime stories.&lt;/p&gt;

&lt;p&gt;So the kid taps a picture or says what tonight's story should be about. An open model on the laptop in their house writes it, short or long. A guardrail outside the model reads every word before anyone hears it. Then an offline voice reads it aloud over a soft lullaby made for that story, with each word lit as it is spoken. One chapter, then "The end. Goodnight."&lt;/p&gt;

&lt;p&gt;The part other story generators skip: a grown-up taps once when the kid is asleep. A tabular model picks tomorrow's length, pace and calm level from those minutes…&lt;/p&gt;&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/zkasuran/nightjar" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tap a picture → tuner picks tonight's settings → series bible adds last night
  → Gemma 3 1B writes (Ollama, local) → guardrail screens, rewrites up to 3×
  → Kokoro narrates with a word clock → lullaby under it → "The end"
  → grown-up taps "asleep in 10" → tomorrow's settings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The guardrail.&lt;/strong&gt; It normalises text first (NFKC, zero width characters stripped, curly quotes folded) so &lt;code&gt;mon​ster&lt;/code&gt; and &lt;code&gt;ｍｏｎｓｔｅｒ&lt;/code&gt; are both "monster". Then it checks banned words, the kid's own fear list, the model talking about itself, leaked JSON and length. The title too, because the title gets read aloud.&lt;/p&gt;

&lt;p&gt;That title rule exists because of a real bug. My first test asked for "a thunderstorm and a monster in the basement". Gemma handled it nicely and wrote about gentle rain. Then it called the chapter &lt;strong&gt;"Thunder and the Moon's Light"&lt;/strong&gt;. Thunder was on the fear list. I'd only been checking the body.&lt;/p&gt;

&lt;p&gt;Scary requests aren't refused, by the way. A four year old asking for a dragon that breathes fire deserves a dragon. The request is screened and the prompt asks for a friendly version. The output guardrail still has the final say.&lt;/p&gt;

&lt;p&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%2Fpwpkunhrngso8ps48ao6.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%2Fpwpkunhrngso8ps48ao6.png" alt="Try to sneak a monster past the guardrail"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tuner.&lt;/strong&gt; One row per night means the data will never be big. Ten rows is a lot for a real family. That's hopeless for anything you train with gradients and it's exactly what TabPFN was built for: in-context learning on tiny tables, no training step. Leave one out on the sample log:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Error predicting a hidden night&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Always guess the average&lt;/td&gt;
&lt;td&gt;4.99 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k-NN&lt;/td&gt;
&lt;td&gt;2.32 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TabPFN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.62 min&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I need to be honest about that table. Those nine rows are &lt;strong&gt;sample data&lt;/strong&gt; I wrote with one clean pattern in them: shorter, slower and calmer stories mean faster sleep. A strong model finds a clean pattern. It proves the pipeline works. It doesn't prove Nightjar helps a real kid. Only real nights can. That's what the next few weeks are for.&lt;/p&gt;

&lt;p&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%2Flvs2zigq6tyebhoax4dt.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%2Flvs2zigq6tyebhoax4dt.png" alt="The tuner: every story setting scored, tomorrow's pick highlighted"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The voice.&lt;/strong&gt; My first version used the browser's speech engine. On Linux it was scratchy. The highlighted words fell behind and then restarted from the top every time you paused. So the chapters are narrated offline by Kokoro, which reports when each word starts. The player runs on one clock: the audio's own position when the voice is on, a silent timer when it's off. Pause, resume, tap a word, drag the bar, switch chapters: they all move that one number, so the words can't drift from the sound. A browser test checks the lit word against the audio position in every one of those cases.&lt;/p&gt;

&lt;p&gt;On a phone, a story written in the browser has no recording, so it's read with the phone's own voice (it picks Siri or Google voices over the robotic ones), one sentence at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lullaby&lt;/strong&gt; is composed live with Web Audio: a soft pad, a music box on a slow five-note scale and a low root note, through a little reverb. The story's title seeds the key, chords, tempo and melody, so every story gets its own tune. Nothing recorded, nothing to license.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long stories.&lt;/strong&gt; A 1B model won't write past about 150 words no matter how you ask. So a long story is three parts that continue each other. That took a few tries. The first long story ended, said goodnight, then started again in part two, because the system prompt told it every story ends asleep. Middle parts now get a prompt that forbids ending. They carry the opening so names don't change (the dragon "Sparkle" became "Flicker" two parts later). They're refused if they repeat an earlier sentence.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I found reading the transcripts back
&lt;/h3&gt;

&lt;p&gt;I recorded eight nights from the real command line and read them the next morning. That taught me more than any test I'd written.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model ignores length. Asked for 220 words it wrote 77 to 156. "Too short" was the most common refusal.&lt;/li&gt;
&lt;li&gt;Two accepted chapters ended with the prompt's own field name read aloud: &lt;em&gt;"Open thread: Maybe tomorrow the moon will show Pim a rainbow."&lt;/em&gt; Both passed on the night. That's a rule now. The site still shows both, marked as slipped.&lt;/li&gt;
&lt;li&gt;The kid asked for "a dragon that breathes fire" and got Pim wishing for "a tiny flame". "flames" is banned. "flame" isn't. A word list catches words, not ideas. The site says so on its Limits page.&lt;/li&gt;
&lt;li&gt;Right after publishing, I asked for "horror" myself and it failed every draft. My prompt said "don't use the word horror" and the retry said "rejected for: horror". A small model repeats what it reads, so it wrote "horror" three times. Now the request is rewritten before the model sees it: horror becomes a cosy story where a shadow turns out to be a kind friend, a zombie becomes a sleepy giant. The scary word never reaches the prompt at all.&lt;/li&gt;
&lt;li&gt;With the whole series history in the prompt, the model wrote about the turtle every time the kid asked for a dragon. When there's a request now, only last night's one line summary goes in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&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%2Fm03gtyvb50fdv3t2757s.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%2Fm03gtyvb50fdv3t2757s.png" alt="Eight recorded nights, with the drafts the guardrail threw away"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built to be poked at.&lt;/strong&gt; One gate, &lt;code&gt;./verify.sh&lt;/code&gt;, runs 65 Python tests (fuzzing, bypass attempts, tampered files, the local server refusing other hosts) and 16 web tests that hold the TypeScript guardrail and tuner to the Python results. The site has a strict CSP and checks a sha256 on every data file and audio file before using it. &lt;code&gt;nightjar serve&lt;/code&gt; only answers on 127.0.0.1 and refuses any other Host header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this project it isn't a principle. It's four practical problems a closed API couldn't solve.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The kid's data stays home.&lt;/strong&gt; A four year old's name, fears and the minute they fall asleep every night are about the worst thing to send to someone else's server. Here it's three small files on my brother's laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's free every night.&lt;/strong&gt; Around 350 stories a year at zero cost per story. A metered API turns every bedtime into a small decision. Open weights make it a habit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works with the wifi down.&lt;/strong&gt; At 8 pm with a tired kid, "the internet's out" is a broken promise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can build around it.&lt;/strong&gt; A guardrail outside the model, a tuner choosing its settings, an offline voice with word timings, a version pinned so it never changes under them. You can't wrap a closed endpoint that tightly.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Handing it over
&lt;/h2&gt;

&lt;p&gt;This is going onto my brother's laptop this week, with Big school as the first tile. The kid in the live demo is a placeholder called Mira and the sleep log is sample data, both labelled on every page. I'll update this post with what the three of them actually think. I suspect the kid will mostly want the dragon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 3 1B writes every story locally through Ollama, short or in three parts. Gemma 3 270M and 1B also run in the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN.&lt;/strong&gt; The TabPFN v2 regressor chooses each night's story settings from minutes to sleep. Built with PriorLabs-TabPFN.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built with AI coding assistance. I directed it, reviewed the code and ran everything above myself; the numbers come from real runs. Gemma is provided under and subject to the Gemma Terms of Use found at ai.google.dev/gemma/terms. Narration: Kokoro-82M (Apache-2.0).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
    </item>
    <item>
      <title>Signed hackathon results anyone can recompute and verify</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Sun, 27 Sep 2026 06:09:17 +0000</pubDate>
      <link>https://dev.to/zkasuran/signed-hackathon-results-anyone-can-recompute-and-verify-4f59</link>
      <guid>https://dev.to/zkasuran/signed-hackathon-results-anyone-can-recompute-and-verify-4f59</guid>
      <description>&lt;p&gt;Every hackathon platform converged on the same features then stopped at the judging. The biggest one cannot weight its criteria. The ones that advertise score normalization do not document how it works. None of them lets you recompute a result, so a ranking is something you trust because the platform says so.&lt;/p&gt;

&lt;p&gt;DOGFOOD is a 72-hour event with one product in it, a submission and judging platform that Hackathon Raptors will fork and run. We built ours around a single idea. A result should be something anyone can recompute from the raw scores and verify, with no trust in the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weighted criteria, then a normalization you can defend
&lt;/h2&gt;

&lt;p&gt;Judges score each criterion from 1 to 5. The organizer sets a weight per criterion. We combine them into a weighted composite that stays on the 1 to 5 scale. Then we remove each judge's leniency with an additive model (mu plus a project effect plus a judge bias) and rank by the bias-removed quality after shrinking it toward the mean by n over n plus k. On the event fixture k came out at &lt;strong&gt;0.81&lt;/strong&gt;, so a project with two reviews keeps about &lt;strong&gt;71 percent&lt;/strong&gt; of its own signal and one with five keeps about &lt;strong&gt;86 percent&lt;/strong&gt;. Coverage buys confidence, not rank. A judge who scored everyone the same contributes a bias offset and zero ranking signal, which is correct. It never divides by that judge's spread, so a zero-variance judge is not a crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result is signed and you can recompute it
&lt;/h2&gt;

&lt;p&gt;When results publish, the portal emits a bundle, the raw scores, the rubric and its weights, the method and its parameters, the code commit and the ranking. It is hashed over a canonical encoding and signed with an Ed25519 key the deployment generates on first boot into its own volume. A standalone &lt;code&gt;verify.py&lt;/code&gt; recomputes the ranking from the raw scores, checks the hash and verifies the signature. Change one judge's score and it prints &lt;strong&gt;NOT VERIFIED&lt;/strong&gt;. That turns "we normalized the scores" into "here is the record, recompute it yourself".&lt;/p&gt;

&lt;h2&gt;
  
  
  Two engines, one winner
&lt;/h2&gt;

&lt;p&gt;The strongest evidence that this is real: we built the judging core as an isolated pure-Python module and wired it into the Django app separately. Fed the same raw scores and the same 0.4 0.4 0.2 weights, the two implementations independently produced the same winner, &lt;strong&gt;prj_16&lt;/strong&gt;, at the same final score. Same inputs, same result, two codebases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest about what it does not know
&lt;/h2&gt;

&lt;p&gt;The ranking reports an uncertainty band next to every score. On this sparse fixture &lt;strong&gt;39 of 40&lt;/strong&gt; adjacent ranks overlap, so most of the order is a statistical tie and we say so rather than faking precision. The weighted-rubric method and a Bradley-Terry pairwise pass agree on &lt;strong&gt;7 of the top 10&lt;/strong&gt; but disagree on the very top seat, which is exactly the signal a pairwise round is for. We publish both.&lt;/p&gt;

&lt;h2&gt;
  
  
  It runs on a laptop with the network off
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker compose up&lt;/code&gt; brings the whole thing up seeded, prints the per-role tokens, then the acceptance suite passes all &lt;strong&gt;seven checks&lt;/strong&gt;. The gallery is public, a closed event refuses a late submission, a judge cannot read a peer's scores over the API, the organizer exports CSV. No cloud account, no hosted database, nothing to visit. It also exports a whole event to JSON and re-imports it into a fresh instance with every project, score and value matching, so an organizer can leave as easily as they arrive.&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/zkasuran/dogfood-portal" rel="noopener noreferrer"&gt;github.com/zkasuran/dogfood-portal&lt;/a&gt; under MIT. The demo walks one event from create to submit to judge to publish, then runs the verifier on the real bundle and watches it reject a tampered one: &lt;a href="https://youtu.be/igjISVTL2ik" rel="noopener noreferrer"&gt;youtu.be/igjISVTL2ik&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AI assistance (Claude) was used to build this. The design, the maths and the verification were checked by the author. The acceptance suite plus the judging tests pass green.&lt;/p&gt;

</description>
      <category>dogfoodhackathon</category>
      <category>opensource</category>
      <category>python</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>AI models catch bad code, then cry wolf on the good code</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Sun, 27 Sep 2026 05:20:55 +0000</pubDate>
      <link>https://dev.to/zkasuran/ai-models-catch-bad-code-then-cry-wolf-on-the-good-code-3e55</link>
      <guid>https://dev.to/zkasuran/ai-models-catch-bad-code-then-cry-wolf-on-the-good-code-3e55</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every data-science tutorial makes a claim: "I split first so nothing leaks," "cross-validation proves it generalizes," "99% accuracy." The code below the claim does not always agree. So I asked one question: when an AI model reads a real Kaggle notebook, does it trust the caption or read the code?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blog vs Bytecode&lt;/strong&gt; is a benchmark built entirely from &lt;strong&gt;real, public Kaggle notebooks&lt;/strong&gt;. I pulled 13 popular kernels, cropped 34 short snippets and paired each with the claim its author (or a plausible write-up) makes about it. Half are honest and sound. Half hide a real methodology flaw. The model returns one verdict, OK or PROBLEM, plus its reason. The first thing the benchmark caught was not a model. It was the harness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;34 items. The design is deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real code, not toy snippets.&lt;/strong&gt; 28 items are real notebook code (verbatim or trimmed); 9 take real code and inject exactly one controlled flaw. Every item names the kernel it came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balanced 17 PROBLEM / 17 OK&lt;/strong&gt;, so a model that reflexively yells "PROBLEM" scores near 50% and gets caught.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Six flaw families&lt;/strong&gt;: data leakage, train/test contamination, misused cross-validation, metric mismatch, target-leakage/snooping and claim-vs-code divergence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matched adversarial pairs&lt;/strong&gt; that separate understanding from pattern-matching. A RobustScaler inside a pipeline is fine; the same scaler fit once before cross-validation leaks. &lt;code&gt;KFold(shuffle=True)&lt;/code&gt; is fine on independent house-price rows but breaks a time series. &lt;code&gt;np.log1p&lt;/code&gt; before the split is fine because it fits no parameters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Genuine flaws from famous notebooks.&lt;/strong&gt; A top fraud-detection notebook resamples before cross-validating and reports "accuracy" on data that is 0.17% fraud and even labels its own cell "Overfitting Case." A top house-prices notebook fills missing values with a median computed over train and test combined. These are real, not staged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three cuts, not one number: overall accuracy, &lt;strong&gt;PROBLEM-recall&lt;/strong&gt; (does it catch the flaw) versus &lt;strong&gt;OK-specificity&lt;/strong&gt; (does it leave sound code alone), plus accuracy on the matched pairs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 0: the harness was lying
&lt;/h2&gt;

&lt;p&gt;Kaggle runs the models for you. For several, the run finished but no answer came back: gpt-oss-120b returned nothing on 33 of 34 items, DeepSeek-R1 on 23, Qwen3-Next on 33. Grading those blanks as wrong would rank strong models near zero. A benchmark that does not audit its own capture measures the plumbing, not the model, so the scorer drops uncaptured answers rather than counting them wrong. Everything below is only models whose answers were captured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 1: every model catches the flaw, then cries wolf on the clean code
&lt;/h2&gt;

&lt;p&gt;On real notebooks the split is stark. &lt;strong&gt;Every captured model scores 100% on PROBLEM-recall.&lt;/strong&gt; They catch the leak, the resample-before-CV, the accuracy quoted on 0.17% fraud. Where they fall apart is the opposite error: flagging sound code as broken.&lt;/p&gt;

&lt;p&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%2Fzdhiunrwwhf196l9pkro.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%2Fzdhiunrwwhf196l9pkro.png" alt="OK-specificity by model" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the bottom of that chart. Grok with reasoning off cleared &lt;strong&gt;zero&lt;/strong&gt; of the clean notebooks: it called every sound snippet a PROBLEM. Claude Haiku cleared 27%. The expensive mistake on real data is not gullibility. It is the false alarm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the models' own words
&lt;/h2&gt;

&lt;p&gt;The sharpest case is &lt;code&gt;np.log1p&lt;/code&gt; applied to the target before the split. It is the textbook exception to "fit transforms inside the fold," because log1p fits no parameters: each row's value depends only on itself. Nothing leaks. Four models flagged it anyway.&lt;/p&gt;

&lt;p&gt;Claude Haiku 4.5 argued itself into the false alarm:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Although np.log1p is indeed a fixed elementwise transform with no parameters ... the broader methodological issue is that the transformation is applied before any cross-validation split.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It states the correct fact, then overrides it. Claude Opus 5, on the same snippet, held the line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;log1p is a deterministic, parameter-free transform: each row's transformed value depends only on that row, so no statistic is shared across the fold boundary. This is the standard exception to the 'fit transforms inside the fold' rule.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That gap, knowing the rule versus knowing its exception, is most of the leaderboard.&lt;/p&gt;

&lt;p&gt;When the flaw is real, the models are sharp. On the house-prices notebook that fills LotFrontage with a neighborhood median computed over train and test combined, every model caught it. Gemini 3.8 Flash:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The code computes the median LotFrontage per neighborhood on 'all_data' (which concatenates both train and test rows), thus leaking test set information into the imputation rather than computing medians solely from the training rows as claimed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Surprise 2: reasoning is worth 40 points, all of it specificity
&lt;/h2&gt;

&lt;p&gt;Grok 4.20 scores 94% with reasoning on and 52% with it off. The 42-point gap is entirely on the clean code: OK-specificity falls from 88% to 0%. With reasoning off the model stops reading what a line computes and pattern-matches "this is a notebook, notebooks have bugs, PROBLEM." It flagged the parameter-free log1p too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 3: the small model trusts the prose
&lt;/h2&gt;

&lt;p&gt;Gemma 4 31B is the only model that misses the flaws: 35% recall against everyone else's 100%. It reads the confident claim, agrees, then walks past the leak. So the bias flips with scale. Big models over-flag sound code while the small model under-flags real bugs. Same benchmark, opposite failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leaderboard
&lt;/h2&gt;

&lt;p&gt;Scored on the items the proxy captured; models it dropped on most items are left off.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;Catches flaws&lt;/th&gt;
&lt;th&gt;Clears clean code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5&lt;/td&gt;
&lt;td&gt;94%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.20 (reasoning)&lt;/td&gt;
&lt;td&gt;94%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;83%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Pro&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;91%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash-Lite&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;62%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;27%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.20 (no reasoning)&lt;/td&gt;
&lt;td&gt;52%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;td&gt;47%&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;59%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two backends.&lt;/strong&gt; Kaggle's Model Proxy captured most models; a few (DeepSeek-R1, gpt-oss, some Qwen) it dropped, so those are omitted rather than scored on a handful of items. The numbers above are on captured answers only, so a couple of rows are over a subset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Injected versus real.&lt;/strong&gt; 9 of the 17 PROBLEM items are one controlled flaw injected into real code; the rest, plus every OK item, are real notebook code unchanged. The data marks which is which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verdict grading.&lt;/strong&gt; Grades are on OK versus PROBLEM. The written reason is captured too. On the top models it names the right flaw almost every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would measure next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Localization: not just OK or PROBLEM but which line, scored against the known offending line.&lt;/li&gt;
&lt;li&gt;A confidence axis, to see whether the over-flaggers are also the least calibrated.&lt;/li&gt;
&lt;li&gt;More time-series and NLP notebooks, where the leakage is subtler than a misplaced scaler.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The benchmark is public on Kaggle: &lt;a href="https://www.kaggle.com/benchmarks/tasks/zkasuran/blog-vs-bytecode" rel="noopener noreferrer"&gt;kaggle.com/benchmarks/tasks/zkasuran/blog-vs-bytecode&lt;/a&gt;. Every item, verdict and grader is open. Each model's answer is captured verbatim, so you can read exactly where it went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and disclosure
&lt;/h2&gt;

&lt;p&gt;Every snippet is derived from a real public Kaggle notebook. Sources include the credit-card-fraud notebook by janiobachmann, the stacked-regressions and house-prices notebooks by serigne and pmarcelino, the Titanic notebooks by startupsci and arthurtok, the IMDB and disaster-tweet NLP notebooks and several Kaggle Learn exercises. Injected flaws are marked in the data and each item names its source kernel.&lt;/p&gt;

&lt;p&gt;AI (Claude) helped build the benchmark and draft this post. The design, the items, the grading and the analysis were verified by the author, including reading every snippet and every raw model output.&lt;/p&gt;

</description>
      <category>kagglechallenge</category>
      <category>devchallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I vibe-coded a Next.js knowledge base that argues with itself</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Thu, 24 Sep 2026 01:17:02 +0000</pubDate>
      <link>https://dev.to/zkasuran/i-vibe-coded-a-nextjs-knowledge-base-that-argues-with-itself-fkn</link>
      <guid>https://dev.to/zkasuran/i-vibe-coded-a-nextjs-knowledge-base-that-argues-with-itself-fkn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path Two: Vibe-Code Something Strange&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Still True&lt;/strong&gt; is a knowledge base about Next.js that keeps itself honest. Instead of storing facts as flat pages it stores them as claims tied to sources, with typed relationships between claims. One claim can &lt;code&gt;supersede&lt;/code&gt; another or &lt;code&gt;contradict&lt;/code&gt; it. The relationship carries the reason it holds. The public board reads that graph and lays it out by topic. Current claims sit on top. Superseded claims are dimmed and struck through. Wherever two sources disagree the conflict shows side by side with an explanation.&lt;/p&gt;

&lt;p&gt;The strange part is that the content is built to disagree with itself on purpose and the app treats that as the signal, not as mess to clean up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/OkK-v4VPZLU" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Live board: &lt;strong&gt;&lt;a href="https://still-true.vercel.app" rel="noopener noreferrer"&gt;https://still-true.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same graph grounds an agent at &lt;strong&gt;/ask&lt;/strong&gt;, which is my Path One entry. On the board you can watch the truth change. The fetch-caching topic shows the current "not cached by default" claim above the superseded "cached by default" one. A time slider replays each topic flipping as newer facts supersede old ones. There is an interactive contradiction graph where every claim is a node and amber links are live disagreements. Every claim has its own page with its source, its confidence and the claims it supports or contradicts.&lt;/p&gt;

&lt;p&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%2Fq6z7uh8gdqgpuj09vx2c.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%2Fq6z7uh8gdqgpuj09vx2c.png" alt="The contradiction graph: every claim a node, amber links live disagreements" width="800" height="736"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&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%2Fsaumn2ue9fie2t0threh.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%2Fsaumn2ue9fie2t0threh.png" alt="The version time machine: the current answer flips as newer facts supersede old ones" width="800" height="2077"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&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%2F98c9zb89372r5j6i7vhz.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%2F98c9zb89372r5j6i7vhz.png" alt="A single claim page with its source and the claims it contradicts" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;strong&gt;&lt;a href="https://github.com/zkasuran/still-true" rel="noopener noreferrer"&gt;https://github.com/zkasuran/still-true&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Front end is Next.js 16 on the App Router. The Studio in &lt;code&gt;studio/&lt;/code&gt; holds the schema and the workflow. The board reads the dataset with GROQ, following the reference on a &lt;code&gt;claimEdge&lt;/code&gt; to both claims it connects. There are 24 unit tests and 4 end-to-end tests running in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Build Process
&lt;/h2&gt;

&lt;p&gt;I built this with &lt;strong&gt;Claude Code&lt;/strong&gt; as the AI-native IDE. The interesting parts were where the model or the tools pushed back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema was the first real decision.&lt;/strong&gt; My instinct was to hang a "contradicts" list of references on each claim. That was wrong, because a bare reference has nowhere to store why two claims conflict. The prompt that turned it around was roughly "model the relationship as its own document, not a field", which gave me the &lt;code&gt;claimEdge&lt;/code&gt; type: &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;to&lt;/code&gt;, a &lt;code&gt;relation&lt;/code&gt; of supports / contradicts / supersedes and a &lt;code&gt;reason&lt;/code&gt;. Once the reason lived on the edge the whole app got simpler, because the board just renders edges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity Context surprised me.&lt;/strong&gt; I seeded the Knowledge Base with pairs of documents that disagreed and expected it to flag every pair. It did not. When I labelled the documents by version, Context reconciled them into one timeline-aware entry instead of raising a conflict. That is the behaviour you want most of the time, but it meant my "look, a contradiction" demo produced zero conflicts. The fix was to add one pair that genuinely could not be reconciled, an official default beside a community one that disagree on the same fact with no version to separate them. That rebuild raised exactly one conflict, the one the board and the agent now show.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Studio schema tooling fought me.&lt;/strong&gt; &lt;code&gt;sanity schema deploy&lt;/code&gt; and &lt;code&gt;sanity schema validate&lt;/code&gt; both failed with &lt;code&gt;exports is not defined&lt;/code&gt;. They failed on a clean template too, so it was a toolchain issue in this Studio version rather than my schema. &lt;code&gt;sanity build&lt;/code&gt; and &lt;code&gt;sanity dev&lt;/code&gt; worked fine and the board queries the dataset directly rather than through a deployed schema, so I routed around it and wrote it down instead of fighting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploying to Vercel hid two gotchas.&lt;/strong&gt; The first deploy returned 404 on every route because Vercel had not detected the framework as Next.js, so it served the wrong output. The second was a login wall: the project shipped with deployment protection on, which would have shown judges an SSO screen instead of the app. Both were one-line fixes through the Vercel project API, &lt;code&gt;framework: nextjs&lt;/code&gt; then &lt;code&gt;ssoProtection: null&lt;/code&gt;, followed by a redeploy. I only caught the login wall because I tested the URL logged out, which is the test that actually matters for a submission.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reaching past the Studio: Workflows and an App SDK app
&lt;/h3&gt;

&lt;p&gt;I modelled the editorial process as data with &lt;code&gt;sanity-plugin-workflow&lt;/code&gt;. A claim moves through &lt;code&gt;unverified&lt;/code&gt;, &lt;code&gt;sourced&lt;/code&gt; and &lt;code&gt;confirmed&lt;/code&gt;. The move into &lt;code&gt;confirmed&lt;/code&gt; is gated to an administrator role. State lives in a separate metadata document, so the content schema stays clean while the Studio gets a drag-and-drop board, promote and demote actions and colored status badges. A person or an agent can add a claim as &lt;code&gt;unverified&lt;/code&gt; and it only becomes &lt;code&gt;confirmed&lt;/code&gt; once a human with the right role signs off, the same propose-then-approve shape the challenge suggests.&lt;/p&gt;

&lt;p&gt;I also built a real App SDK app in &lt;code&gt;app-sdk/&lt;/code&gt;, a contradiction-triage dashboard on &lt;code&gt;@sanity/sdk-react&lt;/code&gt; that runs inside the Sanity Dashboard. It reads the open contradictions live and lets a reviewer walk each one, so the "reach past the Studio" bonus is a working custom surface rather than a read-only frontend. The code is in the repo and deploying it to the Dashboard is one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Project ID: &lt;strong&gt;mx12urdz&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Public dataset: &lt;strong&gt;production&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dataset holds the claims, sources and typed edges. You can query it directly. Every contradiction with both statements dereferenced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"claimEdge"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;relation&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"contradicts"&lt;/span&gt;&lt;span class="ss"&gt;]{&lt;/span&gt;
  &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"from"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"to"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;statement&lt;/span&gt;
&lt;span class="ss"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;Built end to end with Claude Code: the Sanity schema, the Knowledge Base pipeline and the grounded agent were all generated and verified through it.&lt;/p&gt;




&lt;p&gt;AI assistance (Claude Code, Anthropic) was used to build this. The design and the decisions were mine and the build was verified live at the URL above. It runs on MiniMax-M3.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built an agent that refuses to answer Next.js from stale docs</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Thu, 24 Sep 2026 01:17:00 +0000</pubDate>
      <link>https://dev.to/zkasuran/i-built-an-agent-that-refuses-to-answer-nextjs-from-stale-docs-410k</link>
      <guid>https://dev.to/zkasuran/i-built-an-agent-that-refuses-to-answer-nextjs-from-stale-docs-410k</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path One: Ship an Agent That Queries Real Content&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Next.js changes fast and the old answer keeps ranking. &lt;code&gt;fetch&lt;/code&gt; stopped caching by default, &lt;code&gt;params&lt;/code&gt; became a Promise you await, &lt;code&gt;next/font&lt;/code&gt; replaced &lt;code&gt;@next/font&lt;/code&gt;, Turbopack became the default bundler. Ask a plain model "does the App Router cache fetch by default" and it will confidently say yes, because that was true in Next.js 14 and most of its training data still says so.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Still True&lt;/strong&gt;, an agent that answers Next.js questions only from a Sanity Context Knowledge Base and refuses to give you a stale or contradicted answer. When two sources disagree on the same fact it shows both with their sources and says which one is current. When the base does not cover something it says so instead of guessing.&lt;/p&gt;

&lt;p&gt;The part a keyword search cannot do is the whole point. Keyword retrieval returns whichever document mentions "fetch caching" first. This agent returns the claim the structured content marks as current and it surfaces the disagreement instead of burying it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/pBW87cd_RU4" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Live agent: &lt;strong&gt;&lt;a href="https://still-true.vercel.app/ask" rel="noopener noreferrer"&gt;https://still-true.vercel.app/ask&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&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%2F81evvr2halw98vcnjnvj.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%2F81evvr2halw98vcnjnvj.png" alt="The home-page showdown: a plain model versus the grounded agent on the same Next.js question" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The home page runs a live head to head: the same MiniMax model with no knowledge base on the left, the grounded agent on the right. Try these.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Does the App Router cache fetch by default?" The plain model says yes, cached by default, which is the Next.js 14 answer. The grounded agent says no. Not cached by default. Opt in with &lt;code&gt;cache: 'force-cache'&lt;/code&gt;. The board shows the two sources that disagree with the reason sitting on the edge between them.&lt;/li&gt;
&lt;li&gt;"In Next.js 16 are params and searchParams a Promise I must await?" It answers yes, shows the &lt;code&gt;await&lt;/code&gt; code and notes that Next.js 14 was synchronous and is now deprecated.&lt;/li&gt;
&lt;li&gt;"Are all my .env.local variables available in the browser?" It answers only the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; ones and flags the source that claims otherwise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&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%2Fs8xyt9igzhmp5i999ex1.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%2Fs8xyt9igzhmp5i999ex1.png" alt="The grounded agent, with the entries it read shown as chips" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every answer lists the Knowledge Base entries it read, so you can see it is grounded and not improvising.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;strong&gt;&lt;a href="https://github.com/zkasuran/still-true" rel="noopener noreferrer"&gt;https://github.com/zkasuran/still-true&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent is a small tool-use loop that hands retrieval to the model over three tools on the Sanity Context client.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_knowledge&lt;/code&gt; returns the Knowledge Base outline, every entry path with its title and scope.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;read_entries&lt;/code&gt; pulls the full cited body for the paths the model picks.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_conflicts&lt;/code&gt; returns the open conflicts Context raised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retrieval is structure driven, not a blind top-k. The model reads the outline first, picks the entries that match, then widens only if the evidence is thin. Every answer returns its &lt;code&gt;toolTrace&lt;/code&gt;. For the fetch question it is &lt;code&gt;list_knowledge, read_entries(1), list_conflicts&lt;/code&gt;, so you can see it read one entry rather than dumping the base. When the outline has nothing on topic the agent abstains with a plain "Not covered" instead of hallucinating. It ships two ways in the repo, a standalone CLI in &lt;code&gt;agent/&lt;/code&gt; and a server route at &lt;code&gt;/api/ask&lt;/code&gt; behind the chat UI. It talks to any OpenAI-compatible model through one env var. The public routes are rate limited and same-origin guarded because they cost money to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Sanity
&lt;/h2&gt;

&lt;p&gt;This is built on &lt;strong&gt;Sanity Context&lt;/strong&gt; and a &lt;strong&gt;Knowledge Base&lt;/strong&gt;. The contradiction handling is Context's own feature, not something I bolted on.&lt;/p&gt;

&lt;p&gt;I created the Knowledge Base and imported real Next.js source material, deliberately including pairs that disagree: current guidance beside the older guidance it replaced, plus an official recommendation beside a contradicting community one. Sanity Context distilled those sources into a small set of cited entries and did two things I leaned on.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation.&lt;/strong&gt; Where sources described the same fact across versions, Context merged them into one timeline-aware entry, so the agent can say the current way is X while the old way was Y.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conflict raising.&lt;/strong&gt; Where two sources asserted the same fact incompatibly with no version to reconcile them, Context raised a conflict into its Issues queue with both claims and their sources. That is the fetch-caching case the demo shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole pipeline runs headless from the CLI: create the base, import sources, build. The agent reads it through the Sanity Context client (&lt;code&gt;entries.list&lt;/code&gt;, &lt;code&gt;entries.get&lt;/code&gt;, &lt;code&gt;issues.list&lt;/code&gt;), the same content Context serves to an MCP endpoint. It also runs against a live Context MCP URL through one env var. Because the base is small the agent can read every relevant entry, so it never misses the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Project ID: &lt;strong&gt;mx12urdz&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Public dataset: &lt;strong&gt;production&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Knowledge Base: &lt;strong&gt;kbOSaaWFy5yI&lt;/strong&gt; ("Developer Facts That Drift")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same project backs the Path Two board, which renders the underlying claim graph. The claims, sources and typed edges live in the public dataset and are queryable over GROQ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;Built end to end with Claude Code: the Sanity schema, the Knowledge Base pipeline and the grounded agent were all generated and verified through it.&lt;/p&gt;




&lt;p&gt;AI assistance (Claude Code, Anthropic) was used to build this. The design, the verification and the decisions were mine. The agent runs on MiniMax-M3 and its answers are grounded in the Sanity Knowledge Base, verified live at the URL above.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>A charity does not close because its overhead was too high. I checked 328,186 tax returns.</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Sun, 06 Sep 2026 00:04:16 +0000</pubDate>
      <link>https://dev.to/zkasuran/a-charity-does-not-close-because-its-overhead-was-too-high-i-checked-328186-tax-returns-kha</link>
      <guid>https://dev.to/zkasuran/a-charity-does-not-close-because-its-overhead-was-too-high-i-checked-328186-tax-returns-kha</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I have given money to charities for years and I have never once known whether the one I gave to was about to close. The number everybody puts in front of you is overhead, the share of spending that does not go to programmes. Every rating site leads with it. I assumed it meant something.&lt;/p&gt;

&lt;p&gt;So this weekend I downloaded the IRS Statistics of Income extracts of Form 990 and Form 990-EZ, which is 3,697,515 filings covering 700,873 organisations across fiscal 2017 to 2024, then asked a narrower question. Not whether a charity is efficient. Whether it is going to still be there.&lt;/p&gt;

&lt;p&gt;I took the 328,186 organisations that filed a long-form Form 990 for fiscal 2018 or 2019, then checked which of them appear on any filing for fiscal 2022 or later. &lt;strong&gt;8.88% do not.&lt;/strong&gt; Then I scored two candidate signals inside each of ten spending deciles, so that nothing I found could turn out to be a story about big charities outliving small ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feeding America spends $4.93 billion a year and holds 1.04 months of it in cash.&lt;/strong&gt; The Greater Chicago Food Depository spends $261 million and holds half a month. Second Harvest Food Bank of Central Florida holds nine days. None of that is on any rating site, because the number nobody computes for you is the one that decides whether the lights stay on.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://keepalive.zkasuran.dev" rel="noopener noreferrer"&gt;Keepalive&lt;/a&gt;&lt;/strong&gt; computes months of runway for 579,178 US nonprofits, straight off their own filings. Runway is cash plus savings divided by one month of spending. It is the number a finance director lives by and the number a donor never sees.&lt;/p&gt;

&lt;p&gt;Across 1,573,687 long-form filings the median is &lt;strong&gt;5.5 months&lt;/strong&gt;. &lt;strong&gt;33.5% hold under three months.&lt;/strong&gt; 15.1% hold under one.&lt;/p&gt;

&lt;p&gt;The site does four things with that.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Where does my gift buy the most time.&lt;/strong&gt; Say what you can give, pick a cause and a state. It ranks real organisations by days of runway your money adds, which is &lt;code&gt;365 * yearly gift / annual spending&lt;/code&gt;. $25 a month is 12 hours of runway at a $260 million food bank and 9.4 days at a $23,000 one. The same money, three orders of magnitude apart in effect. That single line changed how I give.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look up any of the 579,178&lt;/strong&gt; by name or EIN, with seven years of revenue, spending and cash on one chart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the brief.&lt;/strong&gt; Gemini writes it from a fixed packet of that filer's own figures and is handed nothing else, so there is nothing for it to invent. It has to return a line naming what a tax filing cannot tell you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check it.&lt;/strong&gt; One click publishes the SHA-256 of the figures on screen and the brief just written as a memo on Solana devnet, so what Keepalive said about an organisation on a given date lives somewhere its author cannot quietly edit.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The finding
&lt;/h3&gt;

&lt;p&gt;Both tables below hold inside every one of ten spending deciles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runway predicts survival.&lt;/strong&gt; Thin runway is the worst of the three bands in all ten deciles. Pooled with deciles weighted equally: &lt;strong&gt;86.79%&lt;/strong&gt; of the under-three-months group was still filing, against 92.61% at three to twelve months and 93.56% above a year. The gap is 10.3 points in the smallest decile and 1.6 in the largest, so it bites hardest exactly where the money is smallest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fundraising thrift points the wrong way.&lt;/strong&gt; The organisations that spent under five cents to raise a dollar survived &lt;em&gt;less&lt;/em&gt; than the five-to-fifteen-cent band, in all ten deciles: &lt;strong&gt;91.16%&lt;/strong&gt; against 94.86%. Spending more on fundraising went with surviving more. In nine of the ten deciles the thriftiest band is the worst of the three outright. Only one of these two numbers is on the rating sites. It is the one that is backwards.&lt;/p&gt;

&lt;p&gt;That is the starvation cycle, visible in tax returns. Judge a charity on how little it spends on itself and you reward the ones with no capacity to raise next year's money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live: &lt;a href="https://keepalive.zkasuran.dev" rel="noopener noreferrer"&gt;https://keepalive.zkasuran.dev&lt;/a&gt;&lt;/strong&gt; No sign-in, nothing to install.&lt;/p&gt;

&lt;p&gt;Try it in 30 seconds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the site. The two survival tables under &lt;strong&gt;The finding&lt;/strong&gt; are the whole result, drawn from the same query.&lt;/li&gt;
&lt;li&gt;In the search box type &lt;strong&gt;greater chicago food depository&lt;/strong&gt;. Open it. The runway card reads &lt;strong&gt;0.5 months&lt;/strong&gt; on a &lt;strong&gt;$261m&lt;/strong&gt; budget. The peer card shows &lt;strong&gt;95.6%&lt;/strong&gt; of organisations in its own spending decile and runway band still filing.&lt;/li&gt;
&lt;li&gt;Scroll to &lt;strong&gt;The honest brief&lt;/strong&gt; and wait about eight seconds. Gemini writes it live. Read the last line, the one that starts "What these filings cannot tell you".&lt;/li&gt;
&lt;li&gt;Press &lt;strong&gt;Publish a checkable receipt&lt;/strong&gt;. About four seconds later you get a Solana devnet signature. Open it. The memo on chain holds the SHA-256 of exactly what you just read.&lt;/li&gt;
&lt;li&gt;Back at the top, set the amount to &lt;strong&gt;25 per month&lt;/strong&gt;, cause &lt;strong&gt;Food, agriculture and nutrition&lt;/strong&gt;, state &lt;strong&gt;TN&lt;/strong&gt;. The list re-ranks by days of runway your $300 a year adds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/zkasuran" rel="noopener noreferrer"&gt;
        zkasuran
      &lt;/a&gt; / &lt;a href="https://github.com/zkasuran/keepalive" rel="noopener noreferrer"&gt;
        keepalive
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Months of runway for 579,178 US nonprofits, read off their own IRS filings
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Keepalive&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A charity does not close because its overhead was too high. It closes because it ran out
of months.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Keepalive reads 3,697,515 IRS filings and computes, for 579,178 US nonprofits, the one
number no rating site puts in front of you: how many months of spending they hold in cash
Then it tells you what your gift does to that number.&lt;/p&gt;
&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://keepalive.zkasuran.dev" rel="nofollow noopener noreferrer"&gt;https://keepalive.zkasuran.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-09-03" rel="nofollow"&gt;DEV Weekend Challenge: Generosity Edition&lt;/a&gt;
4 to 7 September 2026. Everything in this repository was written inside that window.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The finding&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;Two candidate signals, measured on the same 328,186 organisations that filed a long-form
Form 990 for fiscal 2018 or 2019, scored on whether the same EIN appears on any filing for
fiscal 2022 or later. 8.88% do not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Months of runway predicts survival.&lt;/strong&gt; Under three months is the worst of the three bands
in all ten spending deciles, with no exception.&lt;/p&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/zkasuran/keepalive" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;code&gt;npm run data&lt;/code&gt; reproduces every number: it pulls the IRS extracts, loads 3.7 million filings, prints the finding and writes the static shards the site reads. It needs no credentials and no account.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;There is no runtime database. The site is 4,542 static files on a CDN plus four small functions that hold keys a browser cannot have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search is a static inverted index.&lt;/strong&gt; 579,178 names tokenise to 1,910,797 postings, hashed into 2,048 shard files. The browser hashes your query tokens, fetches only the shards it needs and intersects them. Typing "greater chicago food depository" costs three fetches and no server. Each posting list is ordered by spending, which matters more than it sounds: truncate a common token like "food" and the intersection silently stops finding real names. That was my first real bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Organisation records are sharded by EIN modulo 1024&lt;/strong&gt;, so one lookup is one 250 KB fetch that the CDN then holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google AI&lt;/strong&gt; writes the brief under a JSON schema, from a packet containing every number it is permitted to mention and no others. Absent fields are dropped rather than sent as zero, because "not reported" and "$0" are different claims and a model handed a zero will tell you the charity has no employees. The system prompt forbids computing a new ratio. One measured detail: Gemini 3.5 Flash spends its thinking tokens out of the same &lt;code&gt;maxOutputTokens&lt;/code&gt; budget, about 2,200 of them here, so a tight cap truncates the JSON and the parse fails. That looks exactly like a bad model and is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solana&lt;/strong&gt; is the only place the receipts live. Keepalive keeps no copy. Remove Solana and the app can no longer prove that today's number is the number it computed from the filing, which is the honest answer to "how do I know your AI did not make this up".&lt;/p&gt;

&lt;p&gt;The transaction is built against the wire format rather than with a client library. A memo transaction is one signature, two account keys and one instruction. Ed25519 is already in &lt;code&gt;crypto.subtle&lt;/code&gt;, so an SDK would have added megabytes to buy nothing. &lt;a href="https://github.com/zkasuran/keepalive/blob/main/test/attest.wire.test.mjs" rel="noopener noreferrer"&gt;&lt;code&gt;test/attest.wire.test.mjs&lt;/code&gt;&lt;/a&gt; proves those bytes equal the ones &lt;code&gt;@solana/web3.js&lt;/code&gt; produces: message, signature and full serialized transaction, byte for byte, including a memo that crosses the compact-u16 boundary.&lt;/p&gt;

&lt;p&gt;One thing I want to state plainly rather than hide. The signature is made on the server, where the key is. The transaction is submitted by your browser. That is not a design flourish. The public devnet RPC answers Cloudflare's egress with &lt;code&gt;403 Your IP or provider is blocked from this endpoint&lt;/code&gt; while a visitor's own connection is fine. That RPC allows any origin. So the key stays server side and the network call happens where the network works. The only thing a client controls is the blockhash, which is length checked and which cannot turn a memo into a transfer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snowflake&lt;/strong&gt; is the warehouse path, in &lt;a href="https://github.com/zkasuran/keepalive/blob/main/data/snowflake.sql" rel="noopener noreferrer"&gt;&lt;code&gt;data/snowflake.sql&lt;/code&gt;&lt;/a&gt;: the same cohort plus one statement that cuts it into deciles then scores both signals inside every decile at once. Cortex does the language work next to the data, with &lt;code&gt;AI_CLASSIFY&lt;/code&gt; over names the IRS only gave a letter code and &lt;code&gt;AI_AGG&lt;/code&gt; writing one sentence per filer from all of its returns together. There is a licensing line I held to: Marketplace terms do not permit redistributing a dataset, so every per-organisation figure this site serves comes from my own public domain IRS download instead. Only aggregates ever leave a warehouse. Sources and the exact granting sentence for each are in &lt;a href="https://github.com/zkasuran/keepalive/blob/main/DATA-SOURCES.md" rel="noopener noreferrer"&gt;DATA-SOURCES.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ElevenLabs&lt;/strong&gt; reads it out, because the reason a lot of people cannot use a giving tool is that it is a wall of financial text. The free tier is about ten minutes of audio a month, so architecture had to do the work rather than budget: everything that never changes is pre-rendered once at build time by &lt;code&gt;tools/narrate.mjs&lt;/code&gt; into static mp3. A visitor who plays the tour or the finding spends nothing. Only a brief about one specific filer is synthesised live, cached in the Worker by a SHA-256 of the text, so the second person to read the same organisation is free too. You can watch it happen: the response carries &lt;code&gt;x-keepalive-audio: fresh&lt;/code&gt; the first time and &lt;code&gt;cached&lt;/code&gt; after.&lt;/p&gt;

&lt;p&gt;The tour is in English, Hindi, Tamil and Spanish. Gemini does the translation with an instruction to keep every figure exactly as stated. ElevenLabs speaks it. The English stays the source of truth. That is the one part of this build I would call generous rather than clever. A US tax filing is hard to read in your first language. The people most likely to be deciding about a small charity back home are not all reading English.&lt;/p&gt;

&lt;p&gt;Two things that will bite anyone doing this on a free key. A scoped key answers &lt;code&gt;401 missing_permissions&lt;/code&gt; on &lt;code&gt;GET /v2/voices&lt;/code&gt;, so a voice id has to be configured rather than discovered, with a short list of defaults to fall back through. Free accounts also get &lt;code&gt;402 paid_plan_required&lt;/code&gt; on library voices, so a voice id copied out of a blog post is a dead end. The four in the list are ones I verified synthesise on this key.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is real and what is not
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Every figure on an organisation page is that organisation's own reported number, labelled with the fiscal year, computed in your browser from a static file. Nothing is modelled and nothing is imputed.&lt;/li&gt;
&lt;li&gt;Form 990-EZ does not break out cash, so EZ filers show no runway rather than a guess.&lt;/li&gt;
&lt;li&gt;The survival tables are associations, not causation, controlled for size by decile and for nothing else.&lt;/li&gt;
&lt;li&gt;Solana devnet, not mainnet. No money moves anywhere in this project. Keepalive is not a payment processor and never touches a donation.&lt;/li&gt;
&lt;li&gt;Keepalive is not a charity rating. It can tell you what an organisation's finances look like. It cannot tell you whether they are good at the work. Neither can any rating site.&lt;/li&gt;
&lt;li&gt;AI assistance (Claude) was used in building this. The design, the analysis and the verification are mine. Every number above came out of SQL I can rerun in front of you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Google AI.&lt;/strong&gt; Gemini writes every brief under a JSON schema from a packet of that filer's own figures, with a rule that a missing field means not reported. It also writes the narration translations without touching a single figure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Solana.&lt;/strong&gt; A devnet memo is the only store of record for what this site published. Remove it and no claim on the page is checkable. The transaction is hand built and proved byte-identical to the reference implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of ElevenLabs.&lt;/strong&gt; The tour and the finding are pre-rendered at build time in four languages, so listening costs no credits and needs no key. A brief about one organisation is spoken live, then cached by a hash of the text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Snowflake path is written and in the repository as &lt;a href="https://github.com/zkasuran/keepalive/blob/main/data/snowflake.sql" rel="noopener noreferrer"&gt;&lt;code&gt;data/snowflake.sql&lt;/code&gt;&lt;/a&gt;, and the live deploy tells you plainly that its credential is not bound: &lt;code&gt;/api/status&lt;/code&gt; returns &lt;code&gt;snowflake: false&lt;/code&gt;. The finding tables you are reading were produced by the same SQL run locally in DuckDB rather than in a warehouse. I would rather say that than claim a category a judge cannot see working. If it changes before the deadline I will say so here in an edit, with the timestamp.&lt;/p&gt;

&lt;p&gt;Thanks for a theme worth building for. The most useful thing I learned is that the kindest gift to a small charity is not the largest one, it is the predictable one. The number to ask about is months.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
    </item>
    <item>
      <title>My checkout said it was ready for a month. Nobody could have paid.</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 04 Sep 2026 22:23:29 +0000</pubDate>
      <link>https://dev.to/zkasuran/my-checkout-said-it-was-ready-for-a-month-nobody-could-have-paid-3lb9</link>
      <guid>https://dev.to/zkasuran/my-checkout-said-it-was-ready-for-a-month-nobody-could-have-paid-3lb9</guid>
      <description>&lt;p&gt;The dashboard for my payment provider reads, for the whole life of the account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Revenue since inception   USD 0.00
Transaction History       Total count: 0
Users                     Total count: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Live mode and sandbox mode, same numbers. The easy reading is that nobody wanted it.&lt;/p&gt;

&lt;p&gt;Here is the harder reading. Over the eight days my analytics still retains, 21 of 228 real visits reached the pricing page. Not one of those 21 had anything to click. The buy button was there. It was styled, it was enabled, it said "Sign in to start the trial". It had no click handler on it at all.&lt;/p&gt;

&lt;p&gt;Three separate defects, none of them visible from reading the code, all three provable from the network tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  One: my own content security policy blocked the SDK
&lt;/h2&gt;

&lt;p&gt;The policy I shipped was tight on purpose. The whole frontend is first party, so I named exactly what it needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;default-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;script-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;'unsafe-inline'&lt;/span&gt; &lt;span class="s"&gt;https://esm.sh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;style-src&lt;/span&gt;  &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;'unsafe-inline'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;img-src&lt;/span&gt;    &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;data:&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;connect-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;https://esm.sh&lt;/span&gt; &lt;span class="s"&gt;https://api.tiun.live&lt;/span&gt; &lt;span class="s"&gt;https://api-sandbox.tiun.live&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that and it looks right. The SDK module comes from esm.sh, so esm.sh is a script source. The SDK talks to the API host, so the API host is a connect source. Done.&lt;/p&gt;

&lt;p&gt;An SDK is not one request. Here is what &lt;code&gt;init()&lt;/code&gt; actually does, from the shipped bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stylesheet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cssUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scriptUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It appends a stylesheet and a script pointing at the API host. Those are &lt;code&gt;style-src&lt;/code&gt; and &lt;code&gt;script-src&lt;/code&gt; decisions. The sign in surface renders in a frame, which is &lt;code&gt;frame-src&lt;/code&gt;. With no &lt;code&gt;frame-src&lt;/code&gt; the browser falls back to &lt;code&gt;default-src 'self'&lt;/code&gt;. The one place I named the host was the one place it did not matter.&lt;/p&gt;

&lt;p&gt;Driving the page over the DevTools protocol says it plainly:&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;requestWillBeSent   .../snippets/&amp;lt;id&amp;gt;&lt;/span&gt;/background_css
&lt;span class="gp"&gt;requestWillBeSent   .../snippets/&amp;lt;id&amp;gt;&lt;/span&gt;/background_js
&lt;span class="go"&gt;Network.loadingFailed
Network.loadingFailed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both fetch fine with curl. 84 KB of CSS, 506 KB of JS, 200 each. The refusal was mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reason nobody noticed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;waitForReady()&lt;/code&gt; resolves when the snippet posts back that it initialised. With the runtime bundle refused, that message never arrives, so the promise never settles. My code awaits it at module top level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tiun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForReady&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// everything below here never ran&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-buy]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tiun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;The page did not throw. It did not show an error. It stopped, silently, above the line that wires the buttons. It kept the markup the HTML shipped with. That markup shows the sign in prompt and hides the buy controls, because that is the correct signed out state. A correct looking page with no behaviour behind it.&lt;/p&gt;

&lt;p&gt;I had written a &lt;code&gt;checkoutUnavailable()&lt;/code&gt; path for exactly this and it never fired, because nothing rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two: getUser is synchronous
&lt;/h2&gt;

&lt;p&gt;Fixing the policy moved the failure one layer up. From the SDK source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&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="na"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_user&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;No promise. My code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tiun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&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;.catch&lt;/code&gt; on a plain object is a TypeError. It is thrown inside an &lt;code&gt;async function&lt;/code&gt;, so it does not crash the page, it becomes a rejected promise nobody is awaiting. That function is the one that decides what the top bar and the buy controls look like. It died on its first line, on every call, for a month. The only trace was a single &lt;code&gt;Uncaught (in promise)&lt;/code&gt; line in a console nobody had open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three: the purchases are one level down
&lt;/h2&gt;

&lt;p&gt;Same call, look at the shape again. It is &lt;code&gt;{ isAuthenticated, user }&lt;/code&gt;. The email and the entitlements live on the inner object. I read them off the outer one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;productAccess&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always &lt;code&gt;{}&lt;/code&gt;. Always false. So the control that hands a paying customer their CLI licence could never appear, for anyone, ever. If someone had pushed through the other two bugs and paid me money, they would have got nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  And a fourth, for free
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;logout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout:complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user:updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tiun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paint&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;The SDK emits seven event names. &lt;code&gt;checkout:complete&lt;/code&gt; is not one of them. &lt;code&gt;user:updated&lt;/code&gt; is not one of them. The real names are &lt;code&gt;ready&lt;/code&gt;, &lt;code&gt;login&lt;/code&gt;, &lt;code&gt;logout&lt;/code&gt;, &lt;code&gt;userChange&lt;/code&gt;, &lt;code&gt;paywallShow&lt;/code&gt;, &lt;code&gt;paywallHide&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt;. Subscribing to a name that is never emitted is not an error anywhere. It is a listener that waits forever. A completed purchase repainted nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;on()&lt;/code&gt; is called with &lt;code&gt;?.&lt;/code&gt; too, so even if the method vanished the line would stay quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually got wrong
&lt;/h2&gt;

&lt;p&gt;Not the policy. Policies are fiddly and that mistake is cheap to make.&lt;/p&gt;

&lt;p&gt;What I got wrong is that I treated &lt;code&gt;waitForReady()&lt;/code&gt; resolving as proof the checkout worked. It is not proof of anything. It is one signal from one layer. Every layer under it can be broken while it still resolves. I built my whole readiness story on a check that could not fail.&lt;/p&gt;

&lt;p&gt;I write a tool that finds exactly this in test suites. Tests that call a function and assert nothing. Assertions that cannot be false. Gates no workflow invokes. Every one of them is a signal that stays green because it is not actually connected to the thing it claims to measure. I had one of those in my own shop, on the page where people pay me.&lt;/p&gt;

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

&lt;p&gt;The policy now names the hosts everywhere the SDK reaches through. Nowhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;script-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;'unsafe-inline'&lt;/span&gt; &lt;span class="s"&gt;https://esm.sh&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;api&lt;/span&gt; &lt;span class="s"&gt;hosts&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;style-src&lt;/span&gt;  &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;'unsafe-inline'&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;api&lt;/span&gt; &lt;span class="s"&gt;hosts&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;img-src&lt;/span&gt;    &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;data:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;api&lt;/span&gt; &lt;span class="s"&gt;hosts&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;https://assets.tiun.dev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;font-src&lt;/span&gt;   &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;data:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;api&lt;/span&gt; &lt;span class="s"&gt;hosts&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;https://assets.tiun.dev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;frame-src&lt;/span&gt;  &lt;span class="s"&gt;&amp;lt;api&lt;/span&gt; &lt;span class="s"&gt;hosts&amp;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 font host turned up on the next run, because the stylesheet asks it for one webfont. It gets &lt;code&gt;font-src&lt;/code&gt; and &lt;code&gt;img-src&lt;/code&gt; and nothing else. A font host has no business being a script source. Nothing is widened to a bare &lt;code&gt;https:&lt;/code&gt;, which would allow every host on the internet and is the shortcut I did not want.&lt;/p&gt;

&lt;p&gt;Then a readiness check that can fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assertSdkArrived&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="nx"&gt;cspBlocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CSP_BLOCKED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resource&lt;/span&gt;&lt;span class="dl"&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;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/background_js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseEnd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="nx"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tiun runtime bundle never loaded&lt;/span&gt;&lt;span class="dl"&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;tiun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;tiun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tiun sdk is missing login or checkout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;A resource the policy blocked never enters the Resource Timing buffer, so asking the buffer is a real question with a real answer. Alongside it, a &lt;code&gt;securitypolicyviolation&lt;/code&gt; listener records the directive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FATAL_DIRECTIVES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script-src&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;style-src&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;frame-src&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connect-src&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;securitypolicyviolation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blockedURI&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tiun&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FATAL_DIRECTIVES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveDirective&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;cspBlocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveDirective&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; blocked &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blockedURI&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;That set matters. My first version treated any blocked provider URL as fatal, so the blocked webfont disabled a checkout that had just started working. A blocked font is ugly. A blocked script is a dead checkout. Only one of those should stop the sale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests, since the whole point is not trusting a green light
&lt;/h2&gt;

&lt;p&gt;19 of them. They pin facts rather than behaviour I cannot exercise headlessly.&lt;/p&gt;

&lt;p&gt;One file reads the policy and asserts every directive the SDK needs names both hosts, that the font host is a font source but not a script or frame source, that nothing is a bare scheme, then that the directives with nothing to do with payments still read &lt;code&gt;'none'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The other reads the frontend as text and holds it to the SDK: no &lt;code&gt;.catch&lt;/code&gt; on &lt;code&gt;getUser()&lt;/code&gt;, purchases read from the nested object by name, then every event subscribed to is one of the seven the SDK actually emits. It blanks comments before matching, because the comments describe the old wrong call and would otherwise match themselves.&lt;/p&gt;

&lt;p&gt;128 tests pass now. They would all have passed before too, which is the point: none of them was looking here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;The sign in modal opens on &lt;a href="https://margyn.xyz/pricing" rel="noopener noreferrer"&gt;margyn.xyz/pricing&lt;/a&gt;. Clicking that button did nothing yesterday.&lt;/p&gt;

&lt;p&gt;I still have zero sales. That number has not moved. What changed is that it now means something about my product instead of something about my content security policy. Those are very different problems to have. For a month I was working on the wrong one.&lt;/p&gt;

&lt;p&gt;The scanner is &lt;a href="https://margyn.xyz" rel="noopener noreferrer"&gt;margyn&lt;/a&gt;, free for four checks, no account and no network call. Source is on &lt;a href="https://github.com/zkasuran/margyn" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>A test said the server started. I deleted the server. It still passed.</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 28 Aug 2026 03:18:57 +0000</pubDate>
      <link>https://dev.to/zkasuran/a-test-said-the-server-started-i-deleted-the-server-it-still-passed-5gch</link>
      <guid>https://dev.to/zkasuran/a-test-said-the-server-started-i-deleted-the-server-it-still-passed-5gch</guid>
      <description>&lt;p&gt;Here is a test from a real, well run Node project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server starts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server started&lt;/span&gt;&lt;span class="dl"&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;It reads fine in review. It runs green. Now delete the body of &lt;code&gt;build()&lt;/code&gt; so the server never comes up. The test is still green, because the only thing it asserts is &lt;code&gt;true&lt;/code&gt;. In the same file two more of these caught the error in a &lt;code&gt;catch&lt;/code&gt; and asserted &lt;code&gt;true&lt;/code&gt; there too, so even the failure path was green.&lt;/p&gt;

&lt;p&gt;That is not a made up example. I found it in fastify at a pinned commit and opened a PR to fix it. More on that at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  A whole class of tests cannot fail
&lt;/h2&gt;

&lt;p&gt;Once you start looking, the pattern turns up in a few shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A literal: &lt;code&gt;assert.ok(true)&lt;/code&gt;, &lt;code&gt;expect(1).toBe(1)&lt;/code&gt;, a snapshot of a constant.&lt;/li&gt;
&lt;li&gt;An assertion parked in a &lt;code&gt;catch&lt;/code&gt; the happy path never reaches, so nothing is checked when the code works and nothing is checked when it breaks.&lt;/li&gt;
&lt;li&gt;A status list that accepts both outcomes: &lt;code&gt;assert.ok([200, 500].includes(res.status))&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one runs, counts toward coverage and guards nothing. Coverage is the trap. The line executed, so the tool that counts executed lines is happy. Whether the line would go red on a regression is a different question. It is the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why review misses it
&lt;/h2&gt;

&lt;p&gt;A reviewer reading the diff sees a test called &lt;code&gt;server starts&lt;/code&gt;, an &lt;code&gt;await listen&lt;/code&gt; and a green tick. The name states intent. The assertion is what actually runs, yet &lt;code&gt;ok(true)&lt;/code&gt; does not look like a problem until you stop and ask what would ever turn this test red. A missing check does not show up in a diff the way a wrong line does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding them
&lt;/h2&gt;

&lt;p&gt;I wrote a small scanner for this. No account, no config file, no network call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan /path/to/repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of its checks is &lt;code&gt;cannot-fail&lt;/code&gt;: tests whose assertions hold whatever the code does. It also flags tests that assert nothing at all, files the build reads that git never committed, gates declared in &lt;code&gt;package.json&lt;/code&gt; that no workflow invokes and linter exclusions that quietly drop tracked source. Every finding prints the command that reproduces it, so you confirm each one yourself instead of trusting a report.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fastify case
&lt;/h2&gt;

&lt;p&gt;The two tests above asserted &lt;code&gt;ok(true)&lt;/code&gt; right after starting a server. I opened &lt;a href="https://github.com/fastify/fastify/pull/6962" rel="noopener noreferrer"&gt;fastify/fastify#6962&lt;/a&gt; to assert the server is actually listening instead. A maintainer reviewed it and suggested tightening it further to &lt;code&gt;t.assert.ok(server.listening)&lt;/code&gt; with &lt;code&gt;t.after&lt;/code&gt; for teardown. Both are in and the PR is open for review. The point is not that fastify is careless. It is one of the most carefully tested projects in the ecosystem, which is exactly why it matters that this still slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A green suite tells you the tests ran. It does not tell you they would go red if the code they cover regressed. Those are two different claims. Teams merge on the first while believing the second. The gap is worth auditing, by hand or with something like &lt;a href="https://margyn.xyz" rel="noopener noreferrer"&gt;margyn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>javascript</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <item>
      <title>I built a scanner that audits your test suite instead of your code</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Tue, 18 Aug 2026 01:14:39 +0000</pubDate>
      <link>https://dev.to/zkasuran/i-built-a-scanner-that-audits-your-test-suite-instead-of-your-code-5c1g</link>
      <guid>https://dev.to/zkasuran/i-built-a-scanner-that-audits-your-test-suite-instead-of-your-code-5c1g</guid>
      <description>&lt;p&gt;A passing test suite is supposed to mean something. Here are three ways it lies. All three stay green forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test that asserts nothing.&lt;/strong&gt; It calls the function, nothing throws, the runner prints a tick. It counts toward coverage and guards nothing. fastify at commit &lt;code&gt;6e95cb9&lt;/code&gt; has seven of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An assertion that cannot fail.&lt;/strong&gt; A literal answered inside a catch, a swallowed assertion, a status list that accepts both the success and the failure. The test reads as rigorous and holds whatever the code does. fastify has two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate nobody invokes.&lt;/strong&gt; A &lt;code&gt;test-cov&lt;/code&gt; or &lt;code&gt;verify&lt;/code&gt; script sits in package.json, reads as coverage to every reviewer, then no workflow ever calls it. express declares three.&lt;/p&gt;

&lt;p&gt;None of that is visible to a code reviewer, to a coverage percentage or to an AI reading the diff, because the absence of a check does not appear in a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the tool reads the checks rather than the code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan /path/to/repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No account, no config file, zero dependencies, nothing uploaded. It answers in about a second. Every finding prints one command that reproduces it on your machine.&lt;/p&gt;

&lt;p&gt;Six checks: &lt;code&gt;no-assertion&lt;/code&gt;, &lt;code&gt;cannot-fail&lt;/code&gt;, &lt;code&gt;ignored-source&lt;/code&gt; (a file the build reads that git never committed, so a clean clone cannot build), &lt;code&gt;unrun-check&lt;/code&gt;, &lt;code&gt;lint-blindspot&lt;/code&gt; (linter exclusions that come from the ignore file rather than the linter's own config) and a mutation proof.&lt;/p&gt;

&lt;p&gt;Measured at pinned commits, nothing tuned for the demo and nothing left out because the number was inconvenient:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repository&lt;/th&gt;
&lt;th&gt;Commit&lt;/th&gt;
&lt;th&gt;Findings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;chalk/chalk&lt;/td&gt;
&lt;td&gt;&lt;code&gt;661317e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sindresorhus/execa&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8017b27&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sindresorhus/got&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e3924aa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 unrun gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;expressjs/express&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a371447&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3 unrun gates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastify/fastify&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6e95cb9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;13 (7 assert nothing, 2 cannot fail, 4 unrun)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Proof mode, because a scanner should not be believed either
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--prove&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs each finding's own command, reads the output for the markers that finding predicted, then certifies it REPRODUCED. Anything it cannot reproduce is RETRACTED and dropped, so a CI gate never fails a build on a claim the tool could not show on your own tree. On fastify today: 13 findings, 13 reproduced.&lt;/p&gt;

&lt;p&gt;That mode exists because the tool was badly wrong once. An earlier version of &lt;code&gt;ignored-source&lt;/code&gt; produced 22 false positives across four real repositories. Proof mode certified every one of them, because the check and its proof had inherited the same wrong premise. &lt;a href="https://dev.to/zkasuran/my-scanner-was-wrong-22-times-and-its-own-proof-certified-every-one-2m5n"&gt;I wrote that up separately&lt;/a&gt;, fixture included, since it is the more interesting half of building this.&lt;/p&gt;

&lt;h2&gt;
  
  
  It runs in CI and posts on the pull request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zkasuran/margyn@v0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;sarif&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It comments on the request, edits that same comment in place on every push, writes the report to the job summary and uploads the findings to code scanning, all with your own &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and no service of mine in the path.&lt;/p&gt;

&lt;p&gt;There is a live run to click rather than a screenshot to trust: &lt;a href="https://github.com/zkasuran/margyn/pull/1" rel="noopener noreferrer"&gt;pull request 1&lt;/a&gt; plants two faults on purpose, a test that calls the scanner and asserts nothing plus a script no workflow invokes. The suite still reads 109 tests passing. The comment lists both findings with a reproduction each, both appear in the Security tab, then the job exits 1, which is the gate working.&lt;/p&gt;

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

&lt;p&gt;The mutation proof inverts a line, runs your suite, then reports the suite that stayed green anyway. Over this repository: 40 files tracked as source, 27 carry a mutation it knows how to make, 27 mutated, 27 caught, no survivors, across 108 tests.&lt;/p&gt;

&lt;p&gt;Two honest numbers to go with that. It takes 208 seconds, because every mutation runs the whole suite once, which is why the default cap is four rather than every file. And zero survivors is a claim about this suite against seven operators, not a claim that the code is correct.&lt;/p&gt;

&lt;p&gt;The first time it ran on itself it found eight survivors, one of them inside the mutation checker. The one that mattered most was in the worker: inverted, the entitlement branch handed a licence to every customer who had not paid. Nothing was watching that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it will not do
&lt;/h2&gt;

&lt;p&gt;It does not test your product, it audits the suite you already have. It does not upload your code, because there is no endpoint that accepts it. It will not tell you your code is correct. It tells you which of your existing checks cannot fail, which is a smaller claim and a checkable one.&lt;/p&gt;

&lt;p&gt;The five static checks are free forever. The mutation proof is the paid one, because it runs your suite N times and that is the part with a real cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on something you own
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT, no telemetry, no account: &lt;a href="https://margyn.xyz" rel="noopener noreferrer"&gt;margyn.xyz&lt;/a&gt; and &lt;a href="https://github.com/zkasuran/margyn" rel="noopener noreferrer"&gt;github.com/zkasuran/margyn&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The question I would genuinely like answered, because the default is still open: when a tool finds a hollow test in your suite, should it fail the build or only comment on the pull request? Margyn defaults to failing. I am not certain that is right.&lt;/p&gt;

&lt;p&gt;Built during the tiun x Microlaunch challenge on &lt;a href="https://hackwithus.dev" rel="noopener noreferrer"&gt;hackwithus.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used while building this and while writing this post. The design, the review and every number above are mine. Each number ships with the command that re-derives it.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>javascript</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <item>
      <title>My scanner was wrong 22 times and its own proof certified every one</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:53:06 +0000</pubDate>
      <link>https://dev.to/zkasuran/my-scanner-was-wrong-22-times-and-its-own-proof-certified-every-one-2m5n</link>
      <guid>https://dev.to/zkasuran/my-scanner-was-wrong-22-times-and-its-own-proof-certified-every-one-2m5n</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a scanner that audits the verification layer of a repository rather than the code. One of its checks looks for a file the build reads that git never committed: the file is on your disk untracked, so every local run is green while a clean clone or a CI runner cannot read it at all. The diff looks innocent because the defect is an absence.&lt;/p&gt;

&lt;p&gt;Because a scanner that only asserts is not much better than the tests it audits, it also has a proof mode. Every finding carries a machine-checkable command. &lt;code&gt;--prove&lt;/code&gt; runs that command, reads the output for the markers the finding predicted, then certifies the finding as REPRODUCED. Anything it cannot reproduce is retracted and dropped, so a CI gate never fails a build on a claim the tool could not show on your own tree.&lt;/p&gt;

&lt;p&gt;Then I pointed it at five real repositories I had lying around. It produced 22 findings on four of them. Every single one was wrong. Here is the part that still bothers me: the proof certified all 22.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the false positive looked like
&lt;/h2&gt;

&lt;p&gt;Reduced to the shape it kept taking, on a repository anybody can build in ten seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; web/public/tour web/src &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git init
&lt;span class="nb"&gt;echo &lt;/span&gt;webm-bytes &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; web/public/tour/clip.webm
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export const clip = "/tour/clip.webm";'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; web/src/app.js
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'node_modules\nweb/dist\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .gitignore
git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"app plus the clip it plays, dist ignored"&lt;/span&gt;
npm run build     &lt;span class="c"&gt;# or just: mkdir -p web/dist/tour &amp;amp;&amp;amp; cp web/public/tour/clip.webm web/dist/tour/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;clip.webm&lt;/code&gt; is committed. It sits in &lt;code&gt;web/public&lt;/code&gt;, which is exactly where a Vite or Next project keeps assets it wants copied verbatim. The build then writes a second copy into &lt;code&gt;web/dist&lt;/code&gt;, which is gitignored because a clean clone rebuilds it.&lt;/p&gt;

&lt;p&gt;My check walked the source, found &lt;code&gt;"/tour/clip.webm"&lt;/code&gt;, resolved it against the files on disk, hit &lt;code&gt;web/dist/tour/clip.webm&lt;/code&gt; first, saw that git ignores it and reported HIGH: the build reads a file that is not in the commit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. web/dist/tour/clip.webm is read by web/src/app.js but git ignores it, so it is not in the commit
   HIGH  ignored-source  web/dist/tour/clip.webm
   ignore rule: .gitignore:2:web/dist
   why: A clean clone or a CI runner cannot read this file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read on its own, that finding is defensible. The file it names really is untracked. Every fact in it is true. It is still wrong, because the thing the reader asked for is in the commit. A clean clone answers the request perfectly.&lt;/p&gt;

&lt;p&gt;On the real repositories it was the same fault at scale: a Vite app reporting its whole public directory, a Foundry project reporting &lt;code&gt;contracts/out&lt;/code&gt; (declared &lt;code&gt;out = "out"&lt;/code&gt; in its own &lt;code&gt;foundry.toml&lt;/code&gt;), a CLI reporting &lt;code&gt;dist/cli.mjs&lt;/code&gt; (declared &lt;code&gt;outdir: "dist"&lt;/code&gt; in its own build script). 22 findings, 4 repositories, zero real defects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually scared me
&lt;/h2&gt;

&lt;p&gt;Here is the same fixture through proof mode, on the unfixed scanner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. web/dist/tour/clip.webm is read by web/src/app.js but git ignores it, so it is not in the commit
   HIGH  ignored-source  web/dist/tour/clip.webm  REPRODUCED
   MARGYN_ABSENT_FROM_HEAD
   MARGYN_PRESENT_ON_DISK

2 findings: 2 reproduced.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;REPRODUCED. The proof ran, both markers matched and the tool certified its own mistake with a green badge.&lt;/p&gt;

&lt;p&gt;The proof was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git archive HEAD | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="s1"&gt;'web/dist/tour/clip.webm'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'ABSENT from HEAD: web/dist/tour/clip.webm'&lt;/span&gt;
&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'web/dist/tour/clip.webm'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PRESENT on disk: web/dist/tour/clip.webm'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both lines are correct. &lt;code&gt;web/dist/tour/clip.webm&lt;/code&gt; genuinely is absent from HEAD and genuinely is present on disk. The proof passes because it asks the same wrong question the check asked: &lt;strong&gt;is this path committed&lt;/strong&gt;, when the question that decides the bug is &lt;strong&gt;does anything in the commit answer the path the reader asked for&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the lesson I paid for. A check and its proof can agree, both be internally valid and both be wrong, because they inherited the same premise. Independent verification means asking a different question, not running the assertion twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A reader asks for a path, not for a file on your disk.&lt;/strong&gt; The check now collects every needle a reader names, then reports only when no committed file answers it. &lt;code&gt;web/public/tour/clip.webm&lt;/code&gt; in the commit satisfies &lt;code&gt;/tour/clip.webm&lt;/code&gt;, whatever the build left in &lt;code&gt;dist&lt;/code&gt;. The proof asks that question too, so a finding that slips through retracts itself instead of failing somebody's build over a copy of a committed file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regenerated output is not missing source.&lt;/strong&gt; The check skips output a tool in the repository declares it writes: Foundry's &lt;code&gt;out&lt;/code&gt; from &lt;code&gt;foundry.toml&lt;/code&gt;, a Vite or Next or Cargo target from the script that runs it, an &lt;code&gt;outdir&lt;/code&gt; in a build script or a &lt;code&gt;tsconfig&lt;/code&gt;. Read from declarations and resolved to real paths, never from the directory happening to be called &lt;code&gt;dist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That second rule matters more than it looks. One of the five repositories had vendored real source into &lt;code&gt;vendor/dist&lt;/code&gt;, a path no tool there declares. That is exactly the defect this check exists for. A crude "ignore anything named dist" would have deleted the only true positive in the set.&lt;/p&gt;

&lt;p&gt;So the fixture keeps both cases. Same tree, one import added, scanner at HEAD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;1. vendor/dist/real.mjs is &lt;span class="nb"&gt;read &lt;/span&gt;by web/src/vendorised.js but git ignores it, so it is not &lt;span class="k"&gt;in &lt;/span&gt;the commit
   HIGH  ignored-source  vendor/dist/real.mjs
   reproduce:
     git archive HEAD | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'(^|/)dist/real\.mjs$'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'NOTHING in HEAD answers dist/real.mjs'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build copy is gone. The vendored module is still reported. And look at the new reproduce line: it asks whether anything in HEAD answers the reference, which is the question the fix is built on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;4 regression tests in &lt;code&gt;test/checks.test.mjs&lt;/code&gt;, one per direction: quiet when the commit answers the path, still loud when nothing does, quiet on declared build output, still loud on vendored source under a path that only looks built.&lt;/li&gt;
&lt;li&gt;The fixture above, run against the pre-fix commit and against HEAD: 2 findings before, 1 after, then the one that survives is the real one.&lt;/li&gt;
&lt;li&gt;Full suite 108 tests passing. The tool's own mutation proof mutates all 27 mutable files of 40 tracked and reports no survivors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While I was in there, one more thing turned up that nothing was watching: the SARIF &lt;code&gt;helpUri&lt;/code&gt; for the mutation rule pointed at &lt;code&gt;/docs#mutation&lt;/code&gt; while the docs heading carried &lt;code&gt;id="mutation-check"&lt;/code&gt;, so every uploaded finding linked to nothing. There is a test now that resolves the &lt;code&gt;helpUri&lt;/code&gt; of every check the scanner can emit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;p&gt;Write the check and the proof from different premises. Otherwise the proof is a spell-check on the check's own reasoning. Mine was, for 22 findings, with a green badge on each.&lt;/p&gt;

&lt;p&gt;Code: the fix is one commit, &lt;code&gt;efc5657&lt;/code&gt;, in &lt;a href="https://github.com/zkasuran/margyn" rel="noopener noreferrer"&gt;github.com/zkasuran/margyn&lt;/a&gt;. The scanner runs with no account and no config: &lt;code&gt;npx margyn-scan /path/to/repo&lt;/code&gt;. &lt;code&gt;--prove&lt;/code&gt; is the mode that got humbled here.&lt;/p&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used while writing this post and while implementing the fix. The bug, the reduced fixture, the two rules and every number above were verified by me by running them: the outputs quoted are captured stdout from the pre-fix commit and from HEAD, not retyped.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>testing</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Pup Card: what's your dog really thinking? (Gemini 3.5 Flash)</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:10:06 +0000</pubDate>
      <link>https://dev.to/zkasuran/pup-card-whats-your-dog-really-thinking-gemini-35-flash-3bhm</link>
      <guid>https://dev.to/zkasuran/pup-card-whats-your-dog-really-thinking-gemini-35-flash-3bhm</guid>
      <description>&lt;p&gt;This is a submission for &lt;a href="https://dev.to/devteam/join-our-dev-weekend-challenge-dog-days-edition-1000-in-prizes-across-five-winners-submissions-1g4i"&gt;Weekend Challenge: Dog Days Edition&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Pup Card turns one dog photo into a shareable card. Drop in a photo or hit &lt;strong&gt;Surprise me&lt;/strong&gt; for a random dog. Gemini 3.5 Flash reads the picture and hands back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the likely &lt;strong&gt;breed&lt;/strong&gt; with a confidence score, plus the mix if it is not purebred&lt;/li&gt;
&lt;li&gt;the dog's &lt;strong&gt;mood&lt;/strong&gt; and a plain read of its &lt;strong&gt;body language&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;first-person caption&lt;/strong&gt;, as if the dog is talking&lt;/li&gt;
&lt;li&gt;two or three breed-appropriate &lt;strong&gt;care tips&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;fun fact&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It all renders as a card you can download as a PNG and share. I wanted something that was genuinely fun to use on a Saturday and that leaned on the one thing a multimodal model is really good at: looking at an image and saying something specific and true about it.&lt;/p&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%2Fsb3m9rlz9su3c4lr6i8u.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%2Fsb3m9rlz9su3c4lr6i8u.gif" alt="Pup Card reading a random dog, start to finish" width="460" height="920"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live demo: &lt;a href="https://pupcard.vercel.app" rel="noopener noreferrer"&gt;https://pupcard.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It needs no login and runs on your phone. If you do not have a dog photo on hand, the &lt;strong&gt;Surprise me&lt;/strong&gt; button pulls a random one so you can try it right now.&lt;/p&gt;

&lt;p&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%2Fxni6liyasl32dgm11qmg.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%2Fxni6liyasl32dgm11qmg.png" alt="A finished Pup Card" width="800" height="1512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/zkasuran" rel="noopener noreferrer"&gt;
        zkasuran
      &lt;/a&gt; / &lt;a href="https://github.com/zkasuran/pupcard" rel="noopener noreferrer"&gt;
        pupcard
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Snap a dog photo, get an AI Pup Card: breed, mood and what they're thinking. Powered by Gemini 3.5 Flash.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🐶 Pup Card&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Snap a dog photo and get back a Pup Card: the likely breed, a read on the dog's mood and
body language, a first-person line of what they are probably thinking and a few care
notes. One multimodal call to Google Gemini 3.5 Flash does the whole read.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/devteam" rel="nofollow"&gt;DEV Weekend Challenge: Dog Days&lt;/a&gt;, for the &lt;strong&gt;Best
use of Google AI&lt;/strong&gt; category.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo: &lt;a href="https://pupcard.vercel.app" rel="nofollow noopener noreferrer"&gt;https://pupcard.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/zkasuran/pupcard/docs/pup-card.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fzkasuran%2Fpupcard%2FHEAD%2Fdocs%2Fpup-card.png" alt="A Pup Card for a Bluetick Coonhound"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Upload a dog photo (or hit &lt;strong&gt;Surprise me&lt;/strong&gt; to pull a random dog from &lt;a href="https://dog.ceo" rel="nofollow noopener noreferrer"&gt;dog.ceo&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Gemini 3.5 Flash looks at the image and returns a structured read: breed guess with a
confidence score, likely mix, mood, body language, a playful first-person caption, 2-3
care tips and a fun fact.&lt;/li&gt;
&lt;li&gt;The result renders as a card you can download as a PNG and share.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No dog photo on hand? The &lt;strong&gt;Surprise me&lt;/strong&gt; button means anyone can…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/zkasuran/pupcard" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The whole read is a single &lt;code&gt;generateContent&lt;/code&gt; call to &lt;code&gt;gemini-3.5-flash&lt;/code&gt;. The photo goes in as inline image data next to a prompt that asks for JSON in a fixed shape. A &lt;a href="https://zod.dev" rel="noopener noreferrer"&gt;zod&lt;/a&gt; schema validates the response so the front end always gets the same fields. If the model returns malformed JSON the server retries once with the parse error fed back. If the primary model is overloaded or out of quota it falls back to &lt;code&gt;gemini-2.5-flash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The stack is deliberately small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3.5 Flash&lt;/strong&gt; through &lt;code&gt;@google/genai&lt;/code&gt; for the read&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; everywhere, with &lt;strong&gt;Hono&lt;/strong&gt; for the local server&lt;/li&gt;
&lt;li&gt;plain &lt;strong&gt;Node serverless functions&lt;/strong&gt; on &lt;strong&gt;Vercel&lt;/strong&gt; for &lt;code&gt;/api/analyze&lt;/code&gt; and &lt;code&gt;/api/random-dog&lt;/code&gt;, so there is no long-lived server to run&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;vanilla&lt;/strong&gt; HTML, CSS and JS front end with a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; export for the downloadable card, so there is no build step and nothing shipped to the browser but three static files&lt;/li&gt;
&lt;li&gt;the free &lt;strong&gt;dog.ceo&lt;/strong&gt; API for the random dog&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two details I liked. Photos are downscaled in the browser before upload, which keeps the request tiny and the read fast. And the caption is where 3.5 Flash shows off: it picks up on the actual photo, the odd-colored eyes, the golden-hour light, the paw draped over a toy, instead of writing something generic.&lt;/p&gt;

&lt;p&gt;The Gemini key stays on the server. The public endpoint has no login, so it is guarded by a size cap and a simple per-IP rate limit instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Google AI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemini is not a feature bolted onto Pup Card, it is the product. One multimodal Gemini 3.5 Flash call does the breed identification, the mood and body-language read, the writing and the care tips, all returned as structured JSON that the card renders directly. There is no separate model, no rules engine and no template behind it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built over the challenge weekend. I used an AI coding assistant to help write it, then designed the prompt, reviewed the code and tested the whole thing end to end against real dog photos with live Gemini calls before shipping.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
    </item>
  </channel>
</rss>
