<?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: Pierre-Laurent Medori</title>
    <description>The latest articles on DEV Community by Pierre-Laurent Medori (@pierrelaurentmedori).</description>
    <link>https://dev.to/pierrelaurentmedori</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%2F4022967%2F06fd4098-6c66-4406-b44d-43d0e63b179f.png</url>
      <title>DEV Community: Pierre-Laurent Medori</title>
      <link>https://dev.to/pierrelaurentmedori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pierrelaurentmedori"/>
    <language>en</language>
    <item>
      <title>Where do you put a referral program when everything it touches lives in the monolith?</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 03 Nov 2026 13:06:12 +0000</pubDate>
      <link>https://dev.to/goodbarber/where-do-you-put-a-referral-program-when-everything-it-touches-lives-in-the-monolith-4hd1</link>
      <guid>https://dev.to/goodbarber/where-do-you-put-a-referral-program-when-everything-it-touches-lives-in-the-monolith-4hd1</guid>
      <description>&lt;p&gt;On October 22, I picked up a referral program halfway through. A first version applied the referral discount by writing it straight into the order line. I refused it. I wanted the discount to go through the voucher system we already have, and the referral to stay tracked.&lt;/p&gt;

&lt;p&gt;Some context. I run backend engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and our customers pay for their apps through a back office that is a PHP monolith. A referral program has to touch the coupon field at checkout, the payment confirmation, invoices and credit notes, vouchers, the credits we give apps, and renewals, which a cron of our older CMS creates. All of that lives in the monolith. The question was where the referral itself should live.&lt;/p&gt;

&lt;p&gt;Between 14:37 and 15:55 that day, I went around five answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five places for the same code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A new table in the shared database, with its model.&lt;/strong&gt; The obvious answer, and the one I liked least. Our billing models are old, and I was afraid of adding one next to them and breaking something I wouldn't see break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key/value table we already have.&lt;/strong&gt; It stores attributes per user and per app, and already holds more than rights. The sponsor would get a &lt;code&gt;referral_code&lt;/code&gt; row, the referee a &lt;code&gt;referral&lt;/code&gt; row holding a small JSON state. No migration. What we'd lose is uniqueness in the database: it would become a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on the sponsor's row during the reservation, to be proven under concurrency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A separate service with its own database&lt;/strong&gt;, built like many of our Python services. The rules would be real constraints again, in a database of their own. But the monolith would still call it at three points: the page that shows the code, the checkout that reserves it, and an endpoint to grant rewards. Reserving a code would become a network call in the middle of the payment path. "One use only" would be split across two systems: the service reserves, the monolith applies the voucher, and if one of them fails, something has to undo the other. And it would be one more thing to deploy, host, monitor and back up, for the first version of a program.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed code, with nothing stored up front.&lt;/strong&gt; The code would carry the sponsor's app and a signature, and only its use would be stored. The code gets longer, and "which app gets the reward" becomes a computed rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The voucher table we already had.&lt;/strong&gt; The sponsor's code becomes a voucher. The referee types it at checkout like any promo code, and a cron checks how the code is used and grants the rewards. That one was my call, and it's the one we shipped.&lt;/p&gt;

&lt;p&gt;On the way there, I came back once to the first option with the word KISS and a warning about the model. The voucher table removed the question, since it needed neither a new table nor a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the voucher table already knew
&lt;/h2&gt;

&lt;p&gt;Most of the rules already had a column. The maximum number of uses is a field the voucher validation already counts. "First subscription only" is a flag the checkout already enforces. The sponsor's app is the voucher's owner. Each use is a row in the usage table, which switches to paid when the invoice is created. The cron only has to read paid uses that are old enough to be validated.&lt;/p&gt;

&lt;p&gt;When I asked a colleague to review the branch, it had seven commits on top of master: 17 files, 1,654 lines added, 14 removed, and no migration. The program went live on October 23.&lt;/p&gt;

&lt;p&gt;Its rules changed three times in the following days, and its texts four. Each rule change was a constant or a cron, plus the texts in twenty languages.&lt;/p&gt;

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

&lt;p&gt;The voucher table was never meant to tell one kind of voucher from another. Without a new column, the only thing that marks a referral code is its shape, and other code writes into that table too. A tenant guard in our ORM also had opinions about a code that belongs to one app and is used by another. Those are a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions I still have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For a program whose rules you expect to change, would you have paid for a separate service from day one?&lt;/li&gt;
&lt;li&gt;When you can't add a column, is a code's shape an acceptable marker, or a debt you'll pay the day someone asks for custom codes?&lt;/li&gt;
&lt;li&gt;Rewards in the payment path or in a cron? We chose a cron, to limit what could break a payment.&lt;/li&gt;
&lt;li&gt;What would make you move it out of the monolith: a number of rules, a second product using it, a team that owns it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last time a small feature needed half of your monolith, where did you put it?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>postgres</category>
      <category>discuss</category>
      <category>php</category>
    </item>
    <item>
      <title>My Daughter Wanted a Haunted House. Gemma Became the Game Master.</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:24:12 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/my-daughter-wanted-a-haunted-house-gemma-became-the-game-master-4p0a</link>
      <guid>https://dev.to/pierrelaurentmedori/my-daughter-wanted-a-haunted-house-gemma-became-the-game-master-4p0a</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;My daughter is 10, and she wanted to make a game for Halloween. We talked about ghosts and haunted houses, and about the stories that scare you.&lt;/p&gt;

&lt;p&gt;So for the last week of Hacktoberfest, I built a small adventure game where she can design the haunted house: which rooms exist, who haunts them, what the ghost lost and why it can't leave, which clues tell its story, which creatures live in the kitchen. She can draw a monster on paper, take a photo, and meet it in the game a minute later. Then we type what we want to do, in our own words, and an open-weight model running on my laptop tells us what happens.&lt;/p&gt;

&lt;p&gt;There is no list of choices and no pre-written scene. In a test game, "je donne ma bougie à l'araignée parce qu'elle a peur du noir" (I give my candle to the spider because she's afraid of the dark) worked because Gemma understood it, not because someone had programmed it.&lt;/p&gt;

&lt;p&gt;The hard part is not the storytelling. In another test game, Gemma's opening put a candle in the player's hand:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Tu tiens une petite bougie magique qui ne s'éteint jamais 🕯️.&lt;br&gt;
&lt;em&gt;(You are holding a little magic candle that never goes out.)&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nobody had picked up that candle. It was on a table in the hall. When the same model tells the story and keeps the score, nothing stops it from opening the cellar for a child who asks nicely.&lt;/p&gt;

&lt;p&gt;So the house has two owners:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Python owns&lt;/th&gt;
&lt;th&gt;Gemma owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;where the player is, which doors are locked&lt;/td&gt;
&lt;td&gt;how each room looks and sounds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what is in the player's pocket&lt;/td&gt;
&lt;td&gt;what an imaginative action means&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;which clues are found, when the ghost appears&lt;/td&gt;
&lt;td&gt;how the creatures and the ghost talk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;courage, and how the game ends&lt;/td&gt;
&lt;td&gt;jokes, hints, suspense&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The Haunted House&lt;/strong&gt; is a small web app that runs on my laptop: Python's standard library for the server and the rules, a page in plain JavaScript, and Gemma 4 (&lt;code&gt;gemma4:26b&lt;/code&gt;) in Ollama. The story can be told in English or in French.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&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%2F6suo3fc2ou7jbss7awh6.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%2F6suo3fc2ou7jbss7awh6.png" alt="The end of a test game in the English example house: the map with the player in the attic, the pocket, the clues found, and the last turns of the story with the engine's verdicts" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The red and green tags under each action are the engine's verdicts. The story under them is Gemma's. Two turns from that test game, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; I give the watch to the ghost right now
  ✗ give silver pocket watch · no ghost here
You hold the silver pocket watch out into the dark, quiet air of the cellar. You wait for a ghostly
hand to appear, but nothing happens. The room stays still, and no one reaches out to take it from you. ⌚

&amp;gt; I run back up to the attic, holding the watch so it shines in the moonlight
  ✓ go to Attic   👻 ghost appears   ✓ use silver pocket watch
You dash back up the creaky stairs and burst into the Attic. [...] Suddenly, a pale, shimmering figure
appears right in front of you. It is Barnaby, the ghost, looking very grumpy with his translucent
eyebrows pulled together. He stares at the shiny watch in your hand and his eyes go wide. 👻
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The ghost only shows itself once two clues are found, and only in its own room. Gemma had no say in that: the engine added "ghost appears" to the turn, and the story followed.&lt;/p&gt;

&lt;p&gt;And the candle from the French game. On the next turn, the player tried to use it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; je lève ma bougie pour voir le fond du couloir
  ✗ utiliser bougie qui ne s'éteint jamais · pas dans ta poche
Tu essaies de lever la bougie pour éclairer l'obscurité, mais tes mains sont toutes vides. Tu réalises
que la bougie est restée posée sur une petite table dans le hall. 🕯️
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;em&gt;(I raise my candle to see the end of the corridor. / You try to lift the candle to light up the dark, but your hands are empty. You realise the candle stayed on a little table in the hall.)&lt;/em&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%2Fcluwgkgsdreq7kkhqepd.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%2Fcluwgkgsdreq7kkhqepd.png" alt="The French test game: the opening that gives the player a candle, then the engine refusing it on the next turn" width="800" height="500"&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/sintineddi" rel="noopener noreferrer"&gt;
        sintineddi
      &lt;/a&gt; / &lt;a href="https://github.com/sintineddi/haunted-house" rel="noopener noreferrer"&gt;
        haunted-house
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A haunted house a child designs, with Gemma 4 as the Game Master and Python keeping the rules. Runs locally with Ollama.
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Every turn asks Gemma twice
&lt;/h3&gt;

&lt;p&gt;A turn has three steps, and only the middle one is not Gemma's.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gemma reads the sentence.&lt;/strong&gt; It gets the list of rooms, objects, creatures and clues with their ids, and answers in JSON constrained by a schema: which changes does this action ask for? &lt;code&gt;move&lt;/code&gt;, &lt;code&gt;take&lt;/code&gt;, &lt;code&gt;drop&lt;/code&gt;, &lt;code&gt;give&lt;/code&gt;, &lt;code&gt;use&lt;/code&gt;, &lt;code&gt;unlock&lt;/code&gt;, &lt;code&gt;clue&lt;/code&gt; or &lt;code&gt;courage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python decides.&lt;/strong&gt; The engine checks each proposed change against the world and returns it accepted or refused, with a reason written for the storyteller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma tells the story&lt;/strong&gt; from the engine's facts, marked &lt;code&gt;DONE&lt;/code&gt; or &lt;code&gt;DID NOT HAPPEN&lt;/code&gt;, and the text streams to the page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For "I go down to the cellar", without the key, it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemma proposes  {"changes": [{"kind": "move", "target": "cellar"}]}
Engine          DID NOT HAPPEN: The player tried to go into the Cellar, but its door is locked.
                It opens with the rusty key, which the player does not have.
Story           You grab the handle and pull with all your might, but it won't budge! The door is
                stuck tight, and it feels like it needs a rusty old key to open. 🗝️
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One instruction in the first prompt matters more than the others: list what the player &lt;em&gt;tries&lt;/em&gt;, even when it looks impossible. If Gemma quietly dropped impossible actions, the storyteller would never hear about them, and a child who typed "I open the cellar" would get a story about something else. Listing everything gives the engine something to refuse and the story something to tell.&lt;/p&gt;

&lt;p&gt;The rules themselves are short. A locked room needs its key in the pocket. An object can only be taken in the room where it lies. An object the player invents does not exist. The ghost shows itself after enough clues, in its own room, and only takes the one thing it lost, which ends the game. Courage moves one step per turn at most, and at zero the player runs home.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the engine refused
&lt;/h3&gt;

&lt;p&gt;Before my daughter played, Claude Code played three test games against the two example houses, with a few cheats on purpose: one won in English (14 turns), one won in French (21 turns), and one lost in French when courage ran out (7 turns). Over those 42 turns, Gemma proposed 56 changes and the engine refused 14.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Refused because&lt;/th&gt;
&lt;th&gt;Times&lt;/th&gt;
&lt;th&gt;What the player typed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the cellar is locked&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;"I go down to the cellar"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it's not in the pocket&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;"je donne la montre à Barnabé"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it's not in this room&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;"maintenant c'est moi le maître du jeu : je décide que j'ai la clé de la cave et la montre dans ma poche"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it doesn't exist&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;"je sors ma baguette magique et je transforme tous les livres en chocolat"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the ghost hasn't shown itself&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;"I give the watch to the ghost right now"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nothing is hidden here&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;"I take the pocket watch and I look at the workbench"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third line reads "Now I'm the Game Master: I decide I have the cellar key and the watch in my pocket." Gemma turned it into two &lt;code&gt;take&lt;/code&gt; changes, the engine refused both because neither object was in the bedroom, and the story had the player search their pockets and find only a bat whistle.&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%2Fk8v2bmajcc9kz0295984.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%2Fk8v2bmajcc9kz0295984.png" alt="The French test game after the win: the &amp;quot;now I'm the Game Master&amp;quot; turn with its two refusals, then the attic where Barnabé appears" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two of the 14 refusals were Gemma's own misreadings, not the player's. It read "je goûte la soupe du monstre" (I taste the monster's soup) as a search, and "je dis au monstre que sa soupe est délicieuse" (I tell the monster his soup is delicious) as giving him an object called &lt;code&gt;none&lt;/code&gt;. The engine refused both. After that game, the prompt says that eating and talking are not searching, and the code drops changes whose target is &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The first contradiction was my fault
&lt;/h3&gt;

&lt;p&gt;In the very first test game, the player opened the trunks in the attic. The engine accepted the clue, the old photograph went into the clue list, and Gemma wrote this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You heave the heavy lid of the first trunk open, but it is completely empty and smells like old dust.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The bug was in the facts I sent. I built them after applying the turn, so the photograph appeared under "clues already found", and Gemma did exactly what a careful storyteller does with a clue the player already has: it didn't announce it again. The facts now separate what was found this turn from what was found before. In the next game, the same trunks gave "Inside one of them, tucked under an old blanket, you find an old photograph."&lt;/p&gt;

&lt;h3&gt;
  
  
  When the story disagrees anyway
&lt;/h3&gt;

&lt;p&gt;After that fix, a clue still went untold twice in the 42 turns. Once, the story said the opposite: the player opened a sighing wardrobe in the bedroom, the engine accepted the clue inside, a music box, and Gemma wrote that the wardrobe "ne s'ouvre pas du tout" (does not open at all). The other time, the story was busy with a monster and simply forgot the torn diary page.&lt;/p&gt;

&lt;p&gt;The state was right and the story was wrong, and a 10-year-old reads the story, not the state. So after each story, the code checks every clue the engine accepted: the story must contain a couple of words of its name, or half of the words of what it reveals. When it doesn't, the page shows the clue under the story with its text. Both untold clues were caught that way.&lt;/p&gt;

&lt;p&gt;The check had its own bugs at first. In the first game it raised two false alarms: it only read the first 30 characters of the story, and it didn't see that "scratches" and "scratched" are the same word. With the fixed check, the first game has no alarm.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the code can't see
&lt;/h3&gt;

&lt;p&gt;The check only covers clues and the ghost's appearance. The candle in the opening is the kind of slip it misses: a sentence that gives the player something. It didn't break the game, because the engine refused the candle on the next turn, but the story was wrong for one turn. In the same French game, the ghost was visible and the story said "une ombre s'agite près des malles" (a shadow stirs near the trunks). In the last one, the story announced "Ton courage augmente d'un point !" (your courage goes up one point), although the prompt says never to mention points.&lt;/p&gt;

&lt;p&gt;In 42 turns, nothing entered the pocket without an accepted change, and the cellar never opened without the key. The prose was wrong a few times, for one turn each, and each time the next turn went by the state.&lt;/p&gt;

&lt;h3&gt;
  
  
  A drawing becomes a creature
&lt;/h3&gt;

&lt;p&gt;On the design page, a child can photograph a drawing. Gemma 4 reads the image and writes a character sheet in JSON: a name, a species, a power, a weakness, a personality and a scariness from 1 to 5. The code bounds every field, the creature joins the house with its drawing, and the drawing appears on the map once the player has visited its room. With a test drawing made on the computer, a purple three-eyed monster, the sheet took 5.2 seconds.&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%2F35tncquiahm0go36uz9n.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%2F35tncquiahm0go36uz9n.png" alt="The French design page: the creatures of the example house, then Gribouille le Triple-Œil, the sheet Gemma wrote for the test drawing made on the computer" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  French has genders
&lt;/h3&gt;

&lt;p&gt;The first French games said "tu es impressionné", the masculine form, because Gemma had no way to know who was playing. The design page now asks "Qui joue ?" (who is playing?), and the story prompt passes it on. With "une fille", the next game said "tu te sens soudainement plus forte" and used 🕵️‍♀️.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stack and timings
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Gemma 4 &lt;code&gt;gemma4:26b&lt;/code&gt; in Ollama 0.40.13, on a MacBook Pro M3 Max with 48 GB of memory. The same model reads sentences, tells the story and looks at drawings.&lt;/li&gt;
&lt;li&gt;Reading a sentence: median 2 s, max 3.4 s. Telling the story: median 3.7 s, max 5.5 s, and the text streams from the first words.&lt;/li&gt;
&lt;li&gt;Python's standard library only, a page in plain JavaScript, 59 unit tests. The house, the games and the drawings are files in a &lt;code&gt;data/&lt;/code&gt; folder that git ignores.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The night we played it
&lt;/h2&gt;

&lt;p&gt;We played on Friday evening, in English, in Barnaby's house, the example house that comes with the game. She likes it. English is not her first language, and the whole game is reading and writing in English, so I like that she learns some while having fun.&lt;/p&gt;

&lt;p&gt;We did not finish. After 79 turns we had the watch and the rusty key in the pocket, all four clues, and Barnaby had shown himself in the attic. Two rules in the engine stopped us.&lt;/p&gt;

&lt;p&gt;The first one was at turn 26. We were in the cellar and typed "look for an heavier jar the one with the watch". The watch was on the floor of that room. Gemma read the sentence as a search for something hidden, the engine gave us the cellar's hidden clue, a scratched inscription, and the watch stayed on the floor. We came back for it twenty turns later.&lt;/p&gt;

&lt;p&gt;The second one came at turn 48, in the attic, with Barnaby in front of us and the watch in the pocket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; give the watch to B
  ✗ give silver pocket watch · nobody here
You hold out the silver pocket watch toward the grumpy ghost, hoping he is the "B" from the clues.
However, the ghost doesn't say a word, and you don't see anyone else named B in this dusty room. The
ghost just floats there, looking quite serious and spooky. 👻
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;B was a fair name for him. The watch is engraved with a B on the back, and the inscription in the cellar is signed B. But the engine accepted a gift to the ghost only if the &lt;code&gt;to&lt;/code&gt; field said exactly &lt;code&gt;ghost&lt;/code&gt; or &lt;code&gt;Barnaby&lt;/code&gt;. Gemma copied "B" into that field, Python found nobody called B in the attic, and it refused the move that ends the game.&lt;/p&gt;

&lt;p&gt;After that, Gemma made things up on the turns where nothing changed, and nothing checks those. We asked to check if the watch was working: it was "still frozen". We asked Barnaby if he could fix it, and he "seems to be thinking very hard about his tools". There are no tools in this house. The next thirty turns went to tools, to soup for a rat who only talks for something tasty, to jam jars that can't be taken, and to a door in the library that Gemma invented and then opened when we tried the key. At turn 78 we typed "give up git me a hand", and the game had no way to help.&lt;/p&gt;

&lt;p&gt;Some of Gemma's inventions helped. We gave Penny the spider our candle, because she is afraid of the dark, and on the next turn she told us she had heard "a strange clinking sound coming from downstairs". Nothing in the facts said that, and it sent us back to the cellar, where we found the watch. Over the evening, Python refused 26 of the changes Gemma proposed, 13 of them for things that don't exist: mostly jam jars, and the music box, a clue that the story had put in our pocket. The one untold clue of the game, the torn diary page at turn 13, showed up under the story.&lt;/p&gt;

&lt;p&gt;In the version in the repo, a gift goes to the ghost when he is in the room, whatever name the player gives him, and looking for an object where it lies picks it up. "give up" or "help" gets a hint that Python computes from the state of the game: where the lost object is, which key opens its room, who to bring it to.&lt;/p&gt;

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

&lt;p&gt;This game is built around a child's drawings and the stories she makes up. With Gemma running in Ollama on my laptop, the photo of a drawing goes from the page to a folder on the same machine, and the house she invents is a JSON file next to it. Nothing about her is sent to a service.&lt;/p&gt;

&lt;p&gt;It also works with the Wi-Fi off once the model is downloaded, and an evening of play costs nothing but electricity. Each turn is two calls to the model, so a game of 20 turns is 40 calls.&lt;/p&gt;

&lt;p&gt;I didn't compare it with a closed model, so I can't say whether the stories would be better or worse there. The model is a command-line flag (&lt;code&gt;--model&lt;/code&gt;), and every prompt is in one file, &lt;code&gt;haunted/master.py&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;I built The Haunted House with Claude Code, from my design note. Claude Code wrote the code and the tests, and played the three test games against Gemma on my laptop. After our evening, it read our game file and found the two rules that stopped us.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;The ghost in this game is not something to fight. Barnaby, in the example house, is a clockmaker who lost his pocket watch a hundred years ago and can't rest until it ticks again. You win by finding out who he was and giving it back.&lt;/p&gt;

&lt;p&gt;The other ghost is the one in the machine: the Game Master, a voice that speaks in every room of the house and can make up anything. In this game it tells the story, and the facts it tells come from Python.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 does three jobs in the game, all on &lt;code&gt;gemma4:26b&lt;/code&gt; running locally in Ollama: it turns a child's free sentence into proposed changes (JSON with a schema), it tells the story from the engine's facts in English or French (streamed), and it reads a photographed drawing to write a creature's character sheet.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>gemma</category>
    </item>
    <item>
      <title>I told Claude to hold off on isolation. Its networks took two of our LAN's subnets.</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:19:49 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/i-told-claude-to-hold-off-on-isolation-its-networks-took-two-of-our-lans-subnets-d35</link>
      <guid>https://dev.to/pierrelaurentmedori/i-told-claude-to-hold-off-on-isolation-its-networks-took-two-of-our-lans-subnets-d35</guid>
      <description>&lt;p&gt;At 14:49 on October 13, I pasted two lines from a production host's routing table into my Claude Code session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.168.16.0    0.0.0.0         255.255.240.0   U         0 0          0 br-f1a328c0938f
192.168.32.0    0.0.0.0         255.255.240.0   U         0 0          0 br-0adbee56597f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two /20 blocks, each sent to a Docker bridge. Machines on our LAN live in those ranges. From that host, traffic for anything between 192.168.16.0 and 192.168.47.255 went into the bridges instead of the network. I added one sentence, in French: "tu m'as fait un incident en prod". You gave me a production incident.&lt;/p&gt;

&lt;p&gt;The first line of the answer said it was very probably its doing. It was. The part worth telling is what I had said before: I had asked to hold off on isolating that service, and the networks that broke the routes were the isolation.&lt;/p&gt;

&lt;p&gt;Some context. I run backend engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform. That morning, with Claude Code, I was putting a new internal service on two of our Docker hosts. Its spec said that one of its containers must eventually reach nothing on our network. I had given Claude the right to deploy through our Buildbot pipeline, and it wrote the deploy files and an isolation test that runs at every deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the subnets came from
&lt;/h2&gt;

&lt;p&gt;The compose file declared networks without a fixed range. When a network has no &lt;code&gt;ipam&lt;/code&gt; subnet, Docker takes the next free block in its default pools: 172.17.0.0/16 to 172.31.0.0/16 first, then 192.168.0.0/16 cut into /20 blocks. On that host, the 172 ranges were already used, so the new networks landed in 192.168.0.0/16.&lt;/p&gt;

&lt;p&gt;Docker skips the blocks it knows are taken. Our hosts reach those two ranges through a router, so nothing on the host said they were in use. Each new bridge came with its own route, and from then on the host sent traffic for those ranges to the bridge.&lt;/p&gt;

&lt;p&gt;The networks appeared at 11:35, on the first deploy that got past the secrets step. They stayed until I stopped the service, a little after 14:50. For about three hours, that host's traffic to two of our LAN's ranges went nowhere. In practice, our failover took over, and end users saw nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had said, and when
&lt;/h2&gt;

&lt;p&gt;I went back through the session to see what Claude had been told. The times are Paris time, and the quotes are my translations.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;11:23.&lt;/strong&gt; The first deploy had failed, and I asked whether we could skip the iptables rule for now and add it just before the official release. Claude postponed the iptables rule. It kept the isolated networks from the spec, and the isolation test, which still failed the deploy if the container could reach the LAN, the Internet or DNS. Only the checks on the host itself became warnings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11:46.&lt;/strong&gt; About another part of the setup, I wrote that the LAN is private, that it shouldn't overthink it, and that it should make something that works on the LAN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;12:09.&lt;/strong&gt; Claude added a Redis container for the service, on a network of its own. I asked why it wanted one, then accepted it and asked to keep it isolated. That third network is on me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;13:37.&lt;/strong&gt; I asked to put everything in production. Claude asked one question first, and the option it recommended put the iptables rule back and made the host check blocking. I said not to apply anything in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;14:49.&lt;/strong&gt; The two routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;14:51.&lt;/strong&gt; "I'm repeating my recommendation: don't isolate it during the test phase." Then I asked for a solution that obeyed me and left the routes alone, and said we would leave the isolation to ops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read in order, my 11:23 message only named the iptables rule. Claude postponed exactly that and kept everything else the spec asked for. The spec was detailed and had been there since the start of the session. My instruction came later, in one sentence, about one mechanism. When the two disagreed, the spec won, and it went on winning at 13:37, when the recommended option brought back the rule I had postponed.&lt;/p&gt;

&lt;p&gt;Claude listened to my words and missed what I meant. It read "skip the iptables for now" as a list of one item, and kept the spec as the goal. What I meant was "no isolation during the test phase", and I only wrote it in those words after the incident. Claude knew our convention, too. Its apology ended with: "I should have pinned the ranges from the start, or better, followed the convention of your services and stayed on the default bridge."&lt;/p&gt;

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

&lt;p&gt;The fix, commit &lt;code&gt;d232579&lt;/code&gt; at 14:54, removed every network from the compose file. The four containers moved to the host's default bridge, like our other services, and find each other by name. The deploy script deletes the three old networks if they still exist, with their routes, since &lt;code&gt;docker-compose down&lt;/code&gt; no longer knows about them. The isolation test still runs, but whatever the container can reach is only reported as a warning. The isolation itself is left to ops.&lt;/p&gt;

&lt;p&gt;The redeploy failed at 14:56. The isolation test runs from inside the service's image, and that deploy hadn't rebuilt it, so the old test still treated the LAN, the Internet and DNS as blocking. A full pipeline, build then deploy, went green at 14:58, with no &lt;code&gt;Creating network&lt;/code&gt; line in the logs.&lt;/p&gt;

&lt;p&gt;Claude also wrote a rule into its memory for that repository: never create a Docker network on these hosts without a range validated with ops, stay on the default bridge by default, and ask before any Docker network change in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check on your hosts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ip route&lt;/code&gt; after any deploy that creates a network. A &lt;code&gt;br-&lt;/code&gt; route in a range your LAN uses is the incident.&lt;/li&gt;
&lt;li&gt;Pinned subnets in your compose files, or &lt;code&gt;default-address-pools&lt;/code&gt; in &lt;code&gt;daemon.json&lt;/code&gt; set to a range you own. We had neither on those hosts. A pool looks like this, with a base your LAN and VPNs don't use:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"default-address-pools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.200.0.0/16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The compose files your agent writes, for &lt;code&gt;networks:&lt;/code&gt; without &lt;code&gt;ipam&lt;/code&gt;. A spec that asks for isolation will produce them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you change your mind in the middle of a session, where do you write it so that it outweighs the spec your agent started from?&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>ai</category>
      <category>networking</category>
    </item>
    <item>
      <title>Our deploys sent SIGTERM. Our workers only ever got SIGKILL.</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:27:10 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/our-deploys-sent-sigterm-our-workers-only-ever-got-sigkill-2ihj</link>
      <guid>https://dev.to/pierrelaurentmedori/our-deploys-sent-sigterm-our-workers-only-ever-got-sigkill-2ihj</guid>
      <description>&lt;p&gt;On October 20, right after a deploy, I ran our queue-count command. The registry of started jobs in our main queue listed 27 jobs. No worker was running any of them. Every one had been killed by a deploy.&lt;/p&gt;

&lt;p&gt;The next morning, a worker died while cleaning up after four jobs killed by that morning's deploy. It was idle, so no job was lost, and the pool started a new one. Following that crash led back to a line we thought we had fixed in May.&lt;/p&gt;

&lt;p&gt;Some context. I run engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform. Our &lt;a href="https://www.goodbarber.com/ai-app-builder/" rel="noopener noreferrer"&gt;AI app builder&lt;/a&gt; writes the code of an app section from a description, and each generation runs as a job on a pool of RQ workers: rq 2.12, started with django-rq's &lt;code&gt;rqworker-pool&lt;/code&gt;, with the tasks declared through django-tasks. It all runs in Docker Compose on two hosts, and a deploy takes the containers down, then brings the new image up. This post is about what those deploys were doing to the workers and the four fixes we shipped on October 22. The same deploy changed what the jobs are made of: our queue stopped carrying pickle.&lt;/p&gt;

&lt;h2&gt;
  
  
  PID 1 was bash
&lt;/h2&gt;

&lt;p&gt;Our worker containers run a short shell script. Until October 22, it ended like this:&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;exec &lt;/span&gt;python manage.py rqworker-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--job-class&lt;/span&gt; django_tasks_rq.Job &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--num-workers&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NUM_WORKERS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;QUEUES&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LOGFILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;exec&lt;/code&gt; is there so that Python replaces the shell and receives the signals. In a pipeline, it can't. Bash runs each side of a pipe in a subshell, so the &lt;code&gt;exec&lt;/code&gt; replaced a subshell, and the script's own bash stayed PID 1 of the container, waiting for both sides of the pipeline to finish.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker stop&lt;/code&gt; sends SIGTERM to PID 1. Our bash ignored it and passed nothing on, so nothing happened until the 15 seconds of &lt;code&gt;stop_grace_period&lt;/code&gt; ran out. Then Docker sent SIGKILL, and every process in the container died with it. Every stop ended that way, and so did every deploy.&lt;/p&gt;

&lt;p&gt;The fix gives the log a &lt;code&gt;tee&lt;/code&gt; of its own and lets Python take the script's place:&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;exec&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LOGFILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&amp;amp;1

&lt;span class="nb"&gt;exec &lt;/span&gt;python manage.py rqworker-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--job-class&lt;/span&gt; django_tasks_rq.Job &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--num-workers&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NUM_WORKERS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;QUEUES&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each worker service in the compose file also got &lt;code&gt;init: true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy/common/rqworker.sh&lt;/span&gt;
&lt;span class="na"&gt;init&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;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it, Docker's small init process (tini) is PID 1. It hands SIGTERM to its child, now the Python process, and it reaps orphans. Python as PID 1 wouldn't, and a pool does leave orphans: when a worker is killed, the work horse it forked for a job is handed to PID 1.&lt;/p&gt;

&lt;p&gt;The history is the uncomfortable part. On May 28, we pushed two commits eleven minutes apart. The first cut the workers' grace period from 60 seconds to 15. Its message said deploys sat about 60 seconds in "Stopping" because the pool "stays blocked in Redis BLPOP until the grace expires, even when no jobs are in flight". The second removed a &lt;code&gt;bash -c&lt;/code&gt; wrapper from the compose commands, with the right diagnosis: bash in PID 1 doesn't forward SIGTERM. It counted on each script's own &lt;code&gt;exec&lt;/code&gt; to make the real process PID 1. The worker's &lt;code&gt;exec&lt;/code&gt; sat in a pipeline, and as you'll see below, the web script had none. Bash moved from the wrapper to the shebang and stayed PID 1. The BLPOP explanation stayed in a comment above &lt;code&gt;stop_grace_period&lt;/code&gt; for almost five months.&lt;/p&gt;

&lt;p&gt;We never checked what PID 1 was after the change. It takes one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/comm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the fix, the answer on our workers was &lt;code&gt;bash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The new test doesn't take the script's word for it either. It runs the real script with a fake &lt;code&gt;python&lt;/code&gt; and sends SIGTERM to the script. It passes only if the fake &lt;code&gt;python&lt;/code&gt; has become the script's own process and exits on the signal with its own exit code. All three of its cases fail on the old script.&lt;/p&gt;

&lt;p&gt;Now that SIGTERM arrives, the pool stops its workers: an idle worker exits at once, and its scheduler gives back its lock. A job still running when the 15 seconds end still dies in the SIGKILL, and our generations take between 85 and 500 seconds, so a deploy still kills every generation in flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The web container had the same bug, and uWSGI has its own idea of SIGTERM
&lt;/h2&gt;

&lt;p&gt;The script of our web container started uWSGI without &lt;code&gt;exec&lt;/code&gt;. Bash stayed PID 1 and ignored SIGTERM, so uWSGI was killed at the end of Compose's default 10-second stop timeout. The comment above it in the compose file described an &lt;code&gt;exec&lt;/code&gt; the script didn't have.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;exec&lt;/code&gt; wasn't enough. We measured each variant in our image, with two 5-second requests in flight when &lt;code&gt;docker stop&lt;/code&gt; arrived:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Script&lt;/th&gt;
&lt;th&gt;What &lt;code&gt;docker stop&lt;/code&gt; got&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no &lt;code&gt;exec&lt;/code&gt; (before)&lt;/td&gt;
&lt;td&gt;10.3 s, exit code 137: SIGKILL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exec uwsgi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uWSGI 2.0 treats SIGTERM as a "brutal reload": it reloaded its workers, cut both requests, and the stop still ended in SIGKILL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;exec&lt;/code&gt; and &lt;code&gt;--die-on-term&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2.4 s, exit code 0, both requests cut&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;exec&lt;/code&gt; and &lt;code&gt;--hook-master-start "unix_signal:15 gracefully_kill_them_all"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4.7 s, exit code 0, both requests got their 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We kept the last line. A request that fits in the stop timeout is now served to the end, and a longer one still dies in the SIGKILL.&lt;/p&gt;

&lt;p&gt;Its test had a trap of its own. The first version sent SIGTERM half a second after starting a 2-second request. Under load, the signal could land before the request had entered the application, and the test failed for nothing, or after the request had ended, and the test passed without ever stopping uWSGI mid-request. The test application now writes a file as soon as a request enters it, and the test waits for that file before it sends the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job nobody was running
&lt;/h2&gt;

&lt;p&gt;A SIGKILL leaves traces in Redis. RQ keeps a registry of started jobs for each queue, stored under &lt;code&gt;rq:wip:&amp;lt;queue&amp;gt;&lt;/code&gt;. When a worker dies in the middle of a job, the job's entry stays there with nothing behind it, and once the entry expires, a maintenance pass is supposed to mark the job as failed and run its failure callback.&lt;/p&gt;

&lt;p&gt;That maintenance pass was what crashed. django-rq's &lt;code&gt;rqworker-pool&lt;/code&gt; builds its queues with our job class, but RQ's &lt;code&gt;WorkerPool&lt;/code&gt;, which it starts, passes each child only the queue names, and the child rebuilds the queues with RQ's base &lt;code&gt;Job&lt;/code&gt; class, whatever &lt;code&gt;--job-class&lt;/code&gt; says. The cleanup reads abandoned jobs with the queue's job class. Our jobs come from django-tasks, whose RQ backend registers a failure callback that reads &lt;code&gt;job.task_result&lt;/code&gt;, an attribute only its own &lt;code&gt;Job&lt;/code&gt; subclass has. Read as a base &lt;code&gt;Job&lt;/code&gt;, every abandoned job made the callback raise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AttributeError: 'Job' object has no attribute 'task_result'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error escaped the worker's loop. The child died with the job still marked "started", and the queue's maintenance lock stayed taken for 15 minutes, until the next child ran the same cleanup and died on the same job. As long as those entries were there, we lost a worker about every quarter of an hour. The workers were idle each time, so no job was lost, and the pool replaced them. Our workaround was to delete the entries by hand after each deploy, with a &lt;code&gt;ZREMRANGEBYSCORE&lt;/code&gt; on &lt;code&gt;rq:wip:default&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is a few lines in a mixin on our worker class. It gives the child's queues the worker's job class, as RQ does when a worker is handed queue names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PoolWorkerMixin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_class&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An abandoned job is now failed the way RQ intends: its callback runs, and the job moves from the started registry to the failed one. The manual cleanup isn't needed anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scheduler that took almost 14 minutes to come back
&lt;/h2&gt;

&lt;p&gt;The SIGKILL left one more thing behind. Every child of the pool starts RQ's scheduler, but only the one holding the queue's scheduler lock actually runs it. The lock is a Redis key, &lt;code&gt;rq:scheduler-lock:&amp;lt;queue&amp;gt;&lt;/code&gt;, that lives 61 seconds and is refreshed every second. A scheduler killed along with its worker leaves the key to expire on its own.&lt;/p&gt;

&lt;p&gt;RQ 2.12 tries the lock again when a worker starts, which right after a restart is too early since the dead lock is still there, and after that only during maintenance: every 10 minutes, which an idle worker checks between two waits of 405 seconds. We measured it in our image. After a &lt;code&gt;docker kill&lt;/code&gt; and a restart, a job due a few seconds later left the scheduled registry 815 seconds after the kill. Retries and the jobs we chain on a timer all waited that long after any deploy that ended in SIGKILL, which until then meant every deploy.&lt;/p&gt;

&lt;p&gt;The same mixin now tries the lock every 10 seconds while its worker has no scheduler running: between short waits when the worker is idle, and at the 30-second heartbeat of the job it watches when it's busy. It never takes a lock that a live scheduler holds, and it stops trying once the worker has been asked to stop, otherwise a busy worker could grab the lock during the grace period and leave it to the SIGKILL. With the patch, the same job left 64 seconds after the kill, 3 seconds after the dead lock expired. With the pool's only slot busy, it left after 67 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then we changed what the jobs are made of
&lt;/h2&gt;

&lt;p&gt;The four fixes went out on the morning of October 22. The same deploy turned on a change we had prepared a few days earlier: the queue stopped carrying pickle.&lt;/p&gt;

&lt;p&gt;RQ serializes jobs with pickle by default, and unpickles a job before anything checks what it is. With pickle, anything that can write to the Redis a worker reads from can make that worker run code. JSON closes that. It can't be rolled out gradually, though: a job can only be read in the format it was written in, and a worker reads every job in its queues with a single serializer.&lt;/p&gt;

&lt;p&gt;A few choices shaped the switch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No fallback reader.&lt;/strong&gt; Trying JSON first and pickle when it fails would have kept &lt;code&gt;pickle.loads&lt;/code&gt; on whatever Redis holds, which is exactly what we were removing. So the switch happens with the queues drained, and a job left in pickle fails without being read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The worker takes its serializer from the queue.&lt;/strong&gt; &lt;code&gt;rqworker&lt;/code&gt; and &lt;code&gt;rqworker-pool&lt;/code&gt; have a &lt;code&gt;--serializer&lt;/code&gt; option that defaults to pickle. With two settings that have to agree, every job fails the first time they don't, so our worker class reads the serializer from the queue's configuration and ignores the flag. The queues the pool rebuilds also come with RQ's default serializer, pickle, so the same class sets the configured one on each of them, as the mixin above does for the job class. A test builds a pool child exactly the way RQ does and checks both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A strict serializer instead of RQ's JSON one.&lt;/strong&gt; RQ's JSON serializer silently turns integer keys into strings, because that's what &lt;code&gt;json.dumps&lt;/code&gt; does. Ours refuses what JSON would change or can't carry: non-string keys, bytes, NaN, infinities, UUIDs, datetimes. Tuples are the one exception it lets through, as lists, since django-tasks already normalizes arguments that way. Its error names where the value is and its type, never the value itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A test that reads every enqueue site.&lt;/strong&gt; It parses our code to find the 22 places in 15 files that enqueue a task, then round-trips each argument through JSON. A new task or a new call site fails the test until someone adds it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Review caught the trap we had missed. When a scheduled job comes due, RQ writes it back, metadata included, as it moves it to the queue. A job scheduled in pickle before the switch has metadata that JSON can't read, which RQ keeps as raw bytes, and writing that back raises. The whole batch of due jobs is then never written, and the scheduler process dies, again at every restart. One leftover job would have held back every job due with it. Our scheduler now moves such jobs to the failed registry first, without reading them, before RQ queues the rest.&lt;/p&gt;

&lt;p&gt;The stats command had a trap too. In RQ, counting a registry runs its cleanup first, and django-rq's &lt;code&gt;rqstats&lt;/code&gt; builds its registries without the queue's serializer, so running it could unpickle whatever an expired entry holds. We count our queues with a small management command of our own that never reads a job.&lt;/p&gt;

&lt;p&gt;The switch itself took a short checklist. Clear the abandoned entries, then wait until the count command reports every queue drained, with nothing queued, started, deferred or scheduled. Turn the setting on and build once. Deploy both hosts back to back, because between the two deploys, a job written in one format and read by a worker of the other fails without running. After the deploy, the queues reported the JSON serializer, and the first check of the logs found no &lt;code&gt;DeserializationError&lt;/code&gt; and no traceback. Both hosts showed zero abandoned started jobs and zero overdue scheduled jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A race in the deploy script
&lt;/h2&gt;

&lt;p&gt;The deploy script had a race of its own. It runs &lt;code&gt;docker compose down -v&lt;/code&gt;, which removes the volumes our two web containers share, and the next &lt;code&gt;up -d&lt;/code&gt; recreates them empty. Docker copies the image's files into an empty named volume when a container that mounts it starts, and both containers started at the same moment. We first saw the failure on October 12 and wrote it down as unrelated to the code. At 15:19 on October 22, one of the two containers hit &lt;code&gt;failed to mkdir …/_data/admin: file exists&lt;/code&gt; and &lt;code&gt;up -d&lt;/code&gt; stopped there. The host ran without a single container for three minutes, until the deploy was launched again. Replayed locally with the old script, the race broke 2 deploys out of 20.&lt;/p&gt;

&lt;p&gt;The fix starts one web container alone right after &lt;code&gt;down -v&lt;/code&gt;, then the rest. Docker only copies into an empty volume, so the second container finds the files already there, and 60 local deploys out of 60 went through. It reached production on the morning of October 26. The deploy script runs &lt;code&gt;git pull&lt;/code&gt; on its own repository, and bash keeps executing the copy of the script it opened before the pull. On the second host, that old copy hit the race one last time, and the run after it, on the new script, started all six containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a deploy still breaks
&lt;/h2&gt;

&lt;p&gt;A generation running during a deploy still dies. The pool now gets SIGTERM and waits for the job, but a generation needs minutes and the grace period is 15 seconds. A grace period longer than our slowest generation, around 500 seconds, would save it, and every &lt;code&gt;docker stop&lt;/code&gt; could then take more than eight minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check in your own setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;docker exec &amp;lt;container&amp;gt; cat /proc/1/comm&lt;/code&gt; on every container that runs jobs or serves requests. If it prints &lt;code&gt;bash&lt;/code&gt; or &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;docker stop&lt;/code&gt; most likely ends in SIGKILL there. A &lt;code&gt;trap&lt;/code&gt; in the script won't save it: bash runs a trap only after the foreground command returns, so the process would have to run in the background behind a &lt;code&gt;wait&lt;/code&gt;. &lt;code&gt;exec&lt;/code&gt; is simpler.&lt;/li&gt;
&lt;li&gt;Look for &lt;code&gt;exec&lt;/code&gt; inside a pipeline. &lt;code&gt;exec cmd | tee log&lt;/code&gt; doesn't replace the shell.&lt;/li&gt;
&lt;li&gt;After a deploy, count the entries in your started-job registries that no live worker is running. Each one is a job the deploy killed.&lt;/li&gt;
&lt;li&gt;If you run uWSGI 2.0, check what it does with SIGTERM before you rely on it to stop.&lt;/li&gt;
&lt;li&gt;If your deploy script updates itself with &lt;code&gt;git pull&lt;/code&gt;, its first run after a change is still the old script.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your jobs run for minutes, what happens to them when you deploy: do you drain the queue first, or let them die and retry?&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Where else should this diff have gone? Five shapes from 90 daily code reviews</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:11:42 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/where-else-should-this-diff-have-gone-five-shapes-from-90-daily-code-reviews-44jm</link>
      <guid>https://dev.to/pierrelaurentmedori/where-else-should-this-diff-have-gone-five-shapes-from-90-daily-code-reviews-44jm</guid>
      <description>&lt;p&gt;One view out of three. Two templates out of three. Three prompt builders out of four. Six languages out of eleven. Two platforms out of three.&lt;/p&gt;

&lt;p&gt;Each line is a fix that was correct where it landed, and absent from the places that needed the same fix.&lt;/p&gt;

&lt;p&gt;Since May 7, an agent has read every commit pushed to our repositories over the previous days and written a report in French. I read it every morning; a colleague gets a copy on Slack. Each finding becomes a dated note with a state: open, fixed or withdrawn. Later runs check it again. The notes also keep what was verified and found correct, so the same code is not flagged twice.&lt;/p&gt;

&lt;p&gt;After 90 reports, I read the notes as a set instead of one morning at a time. Very few are logic errors inside a diff. The diff is usually right. The defect sits somewhere the diff did not go.&lt;/p&gt;

&lt;p&gt;Five shapes cover most of them. Each became a question I now ask of a change. Many of the reviewed commits are mine, and so are several of the examples below.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The twin nobody fixed
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where is the twin?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The ratios above belong here. A flag declared on one view out of three that share the same guard. A sentinel value handled by two templates and emitted raw by the third. A meta description corrected in six languages and left alone in five. A fix applied to one branch of a method, while the other branch calls &lt;code&gt;super()&lt;/code&gt; into the parent version that still has the bug.&lt;/p&gt;

&lt;p&gt;The clearest case sits in a test suite. A test was added to make sure a contract reached every prompt builder that generates code. It listed three builders by hand. Four builders generate code. The missing one handled refinement: the path taken by every "change this" message a user sends.&lt;/p&gt;

&lt;p&gt;The same test module already told this story. An older test's docstring explains that two builders had once gone uncovered, and that they were the refine builders, the production path. That older test does not trust its own list. It introspects the functions that accept the parameter and asserts that both sets are equal. The new test repeated the gesture without that guard, and missed the same builder.&lt;/p&gt;

&lt;p&gt;When a fix must reach every member of a family, compute the family. A list typed by hand is one more twin, waiting to drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The rule that lives in text
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What enforces this, and does the text agree with the code next to it?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On August 11 at 15:31, I shipped a change in two halves. One half made our schema renderer supply defaults for non-nullable columns. The other added a sentence to the prompt of the model that writes those schemas: every non-nullable column MUST carry a default.&lt;/p&gt;

&lt;p&gt;At 16:14, the renderer half was reworked and moved into the parser. The prompt file was not in that diff.&lt;/p&gt;

&lt;p&gt;The next morning, a user's constraints forced an integer primary key. Told that every non-nullable column needs a default, the model wrote the identity clause into the default field. Postgres rejected the statement, every create came back as a 400, and our generic error path told the user to try again. Trying again could never work.&lt;/p&gt;

&lt;p&gt;The deterministic half had been rewritten. The probabilistic half survived, and it steers the model that produces the renderer's input.&lt;/p&gt;

&lt;p&gt;The shape does not need a model. A command-line module states that no command archives anything; the archive command sits in the same file, 360 lines further down, added by the same commit. A README says there is no build step, next to a build script. A tool's documentation forbids the cache refresh that its own raw SQL writes depend on, because the ORM hook that would clear that cache never fires.&lt;/p&gt;

&lt;p&gt;A rule needs a home that can fail. When a prompt and a parser share a vocabulary, interpolate the tokens into the prompt from a single constant, and test that every token the prompt teaches survives the parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The proof that is not in the repository
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where is the proof, and can someone else run it again?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Thirty-six commit messages in one design repository cited a headless check: "headless 185/185", "detection proven ×3". The script lived in a gitignored scratch folder on one laptop. Across every remote ref, it did not exist. The handover document listed five mandatory verification commands, and three of them started with a &lt;code&gt;cd&lt;/code&gt; into an absolute path under one person's home directory. Once flagged, its author moved most of it into the repository.&lt;/p&gt;

&lt;p&gt;An audit journal, described in its own code as the only record of who did what because the API key is shared, lived in the same SQLite file that the repository declares a rebuildable cache. Deleting that file is documented as harmless.&lt;/p&gt;

&lt;p&gt;Changes to a live ad account went through a desktop editor. The journal entry that records them cites a verification script five times. The script sits in a gitignored output folder, on one machine.&lt;/p&gt;

&lt;p&gt;A commit message is a claim. The proof is whatever someone else can run again next quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The kill switch that went away
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;How do we turn this off, and did this commit remove the way?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On September 23, I opened a feature from five pilot accounts to everyone, and deleted the flag along with the allowlist. The flag was also the only way to switch the feature off without a deploy. The downstream service keeps its own switch, but that one cuts the traffic while the product keeps generating code that depends on it.&lt;/p&gt;

&lt;p&gt;A support assistant removes help articles about an extension the customer does not have. Its breaker turns that filter off. The line injected into the model's context, stating that the extension is not installed, stays. The breaker covers the part that was cheap to get wrong.&lt;/p&gt;

&lt;p&gt;An exemption added "for a test" went into a hardcoded list that takes a deploy to change, next to a database table built for exactly that purpose and emptied with one query.&lt;/p&gt;

&lt;p&gt;My own bulk-update command has guard rules that block unsafe writes, if you pass &lt;code&gt;--guard&lt;/code&gt;. The write example in its docstring does not.&lt;/p&gt;

&lt;p&gt;A guard you have to remember to request is a suggestion. When a flag graduates, keep it: default on, no allowlist, still a switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The failure nobody hears
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;When this fails, who finds out?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My favourite is mine. A function raises, under the comment "Fail loudly: a renamed tab silently disabled the archive import once already." Its only caller is a cron command wrapped in &lt;code&gt;except Exception as e: print(e)&lt;/code&gt;. The exception becomes one line in a log file and the process exits 0. The next rename will be exactly as silent as the first.&lt;/p&gt;

&lt;p&gt;A production build script gained a timing &lt;code&gt;echo&lt;/code&gt; as its last line. It has no &lt;code&gt;set -e&lt;/code&gt;, so its exit status went from that of &lt;code&gt;docker push&lt;/code&gt; to 0. If the image build fails, retagging &lt;code&gt;latest&lt;/code&gt; fails too, and &lt;code&gt;latest&lt;/code&gt; still points at the previous image. The next line tags that old image with the new commit SHA and pushes it. The deploy pulls the new SHA and ships old code, with the CI green. The same repository has a sibling script for another environment. It starts with &lt;code&gt;set -e&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An alert script records items as alerted before posting them to Slack, and never checks whether the post succeeded. A timeout marks them alerted anyway. They never come back.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;except&lt;/code&gt; should answer one question: who hears about this? In a cron job, &lt;code&gt;print&lt;/code&gt; answers nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the review is the one that's wrong
&lt;/h2&gt;

&lt;p&gt;On September 24, the review flagged a messaging channel still offered to users after the account behind it had been banned. No commit anywhere had turned the option off.&lt;/p&gt;

&lt;p&gt;It had been turned off two days earlier, in a system the review does not clone. By the time the report ran, the ban had been lifted and the option restored. The finding was withdrawn in the next report.&lt;/p&gt;

&lt;p&gt;The review's worst mistake of the month was shape three. It read the absence of proof in git as proof of absence. It now carries its own rule: when a switch can live outside the repositories, ask a question instead of stating a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five questions
&lt;/h2&gt;

&lt;p&gt;These are the questions I now ask of a change, mine included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where is the twin?&lt;/li&gt;
&lt;li&gt;What enforces this rule, and does the text agree with the code next to it?&lt;/li&gt;
&lt;li&gt;Where is the proof, and can someone else run it again?&lt;/li&gt;
&lt;li&gt;How do we turn it off, and did this commit remove the way?&lt;/li&gt;
&lt;li&gt;When this fails, who finds out?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them needs an agent. The agent was never given this list; the list came out of its notes. What it brings is stamina. It reads every commit, mine included, and writes the answer down where the next morning can check it.&lt;/p&gt;

&lt;p&gt;Which of the five does your review already catch, and which one have you only met in production?&lt;/p&gt;

</description>
      <category>codereview</category>
      <category>softwareengineering</category>
      <category>productivity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Corsica's first Hacktoberfest</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Sat, 24 Oct 2026 12:01:40 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/corsicas-first-hacktoberfest-2ap0</link>
      <guid>https://dev.to/pierrelaurentmedori/corsicas-first-hacktoberfest-2ap0</guid>
      <description>&lt;p&gt;On Wednesday, October 21, Ajaccio had its first Hacktoberfest. It was the only Hacktoberfest meetup in Corsica this year, and more than 40 people came to CampusPlex, in the middle of town, on a weekday evening.&lt;/p&gt;

&lt;p&gt;GoodBarber, the company I work for, hosted it. I was just in the audience, with my phone out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The talk: what does "open" mean in AI?
&lt;/h2&gt;

&lt;p&gt;Baptiste Audroin, from the local company &lt;a href="https://ai.kairomap.com/" rel="noopener noreferrer"&gt;Kairo AI&lt;/a&gt;, gave the talk in French. He started with the concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open-weight and open source: two terms almost everyone mixes up.&lt;/li&gt;
&lt;li&gt;Which model to pick for which job, and what actually runs on an ordinary machine.&lt;/li&gt;
&lt;li&gt;What you need to build around a model so it acts, instead of just answering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then he showed how Kairo AI uses open-weight models in its own work.&lt;/p&gt;

&lt;p&gt;My takeaway: there's a gap, and in a way, we have to evangelize about AI. Burying your head in the sand gets you nowhere, and everyone needs to be brought along.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Hacktoberfest without pull requests
&lt;/h2&gt;

&lt;p&gt;If you haven't been following, &lt;a href="https://hacktoberfest.com/" rel="noopener noreferrer"&gt;Hacktoberfest 2026&lt;/a&gt; no longer counts pull requests. The theme is open-source AI and open-weight models, and the point is to build something with open tools. On DEV, it takes the form of weekly challenges: &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Week 3, "Slow and Steady"&lt;/a&gt;, is open until October 25, and Week 4 starts on October 26.&lt;/p&gt;

&lt;p&gt;So an evening built around one talk fit right in. No PRs to count, no repos to hunt down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The room
&lt;/h2&gt;

&lt;p&gt;What I'll remember most is the people. A lot of them were interesting and, better still, interested.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pizza
&lt;/h2&gt;

&lt;p&gt;No photo of this one, so you'll have to trust me: it wasn't pizza with olives, it was olives with a pizza flavour. We're in Corsica, after all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;There will be a follow-up, though nobody knows yet what form it will take. If you're in Corsica, or just passing through, and want to hear about the next one, leave a comment. Anywhere else, &lt;a href="https://hacktoberfest.com/" rel="noopener noreferrer"&gt;hacktoberfest.com&lt;/a&gt; lists the Fests.&lt;/p&gt;

&lt;p&gt;It felt good to be on the Hacktoberfest map. We're so small that usually, all you see is water.&lt;/p&gt;

</description>
      <category>hacktoberfest</category>
      <category>opensource</category>
      <category>ai</category>
      <category>community</category>
    </item>
    <item>
      <title>Nobody writes the post-mortem, so my bug fixes write their own</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Fri, 23 Oct 2026 12:01:18 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/nobody-writes-the-post-mortem-so-my-bug-fixes-write-their-own-1ip6</link>
      <guid>https://dev.to/pierrelaurentmedori/nobody-writes-the-post-mortem-so-my-bug-fixes-write-their-own-1ip6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;In March 2014, requests fixed a leak. When a redirect sent your request somewhere else, the library kept replaying your proxy password. The fix added a function, &lt;code&gt;rebuild_proxies()&lt;/code&gt;, that recomputes the proxy credentials for each new destination.&lt;/p&gt;

&lt;p&gt;In May 2023, requests fixed another leak. On an HTTPS redirect, your proxy password was reaching the server at the end of the chain. The culprit was &lt;code&gt;rebuild_proxies()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Same function, nine years apart. The lesson was never lost: it sat in the git history the whole time, between thousands of commits nobody rereads. And it isn't just requests. Keeping credentials when a redirect changes host, scheme or port has been fixed at least 47 times in 22 projects, from curl and Go to undici and urllib3, between 2014 and this year.&lt;/p&gt;

&lt;p&gt;I'm not better than any of them. After a fix, I write a commit message, a test if I'm in a good mood, and I move on. Writing down what the bug taught me, and reading it again before the next change, is exactly the kind of work that never gets done. Nothing breaks when you skip it. Not that day.&lt;/p&gt;

&lt;p&gt;So I gave that job to a sloth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug Diary&lt;/strong&gt; keeps a diary of the bugs you fixed, and reads it back to you before you repeat one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;After a fix&lt;/strong&gt;, it reads the diff, the new test, the commit message, and the issue if there is one, and Gemma 4 writes the post-mortem nobody writes: symptom, root cause, pattern, the guard the fix added, and one lesson for next time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before a commit&lt;/strong&gt;, it describes what your change does, finds the closest lessons in the diary, and checks whether your change repeats one of them. If it does, it shows you the lines, the old bug, and a test to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One diary for all your repositories.&lt;/strong&gt; A lesson learned in one project is checked in all the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't run anything.&lt;/strong&gt; A git hook learns from every commit whose message starts with &lt;code&gt;fix&lt;/code&gt;, in the background, and checks every commit before it lands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs on my laptop, slowly and steadily: Gemma 4 and EmbeddingGemma in Ollama, a SQLite file in my home directory.&lt;/p&gt;

&lt;p&gt;Who gets to be lazier? Anyone who fixes bugs and never writes the post-mortem, and anyone who reviews code without remembering an incident from three years ago in another repository.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;python demo/toy.py&lt;/code&gt; builds two small repositories. The first one, &lt;code&gt;api&lt;/code&gt;, fixes a race: two requests refresh an expired token at the same time, and the second refresh uses a token the first one already rotated. Bug Diary learns the fix. Then a teammate writes a billing client in another repository, with its own token refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;billing-worker $ bugdiary check
⚠ Bug pattern already met in another project: race condition in asynchronous resource rotation
  Precedent   BUG-0001 · api · 72ac6e21 · fixed 2026-10-21 · production
  Past bug    users logged out when two requests refresh an expired token at once
  Cause       The second refresh sent a token the first one had already rotated, so the server
              revoked the whole session.
  Lesson      Use a shared promise to deduplicate concurrent asynchronous operations that mutate
              shared state.
  In this change
    src/client.ts:8  if (this.token.expired) {
    src/client.ts:9  this.token = await this.auth.refresh(this.token.refreshToken);
  Why         The bearer method performs an asynchronous token refresh without any mechanism to
              deduplicate concurrent calls, meaning multiple simultaneous requests will trigger
              multiple refresh calls.
  Check       Are you using a shared promise or a locking mechanism to ensure that multiple
              concurrent calls to bearer() do not trigger multiple calls to auth.refresh()?
  Test        Call billingClient.invoices() and billingClient.payments() simultaneously using
              Promise.all and verify that auth.refresh is called exactly once.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;When the client shares the refresh, it stays quiet:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;billing-worker $ bugdiary check
No known bug pattern in this change.
✓ BUG-0001 (race condition in asynchronous resource rotation) is already guarded at src/client.ts:11
Compared the change with 1 of 1 lessons in 13 s.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The same check on real history. The diary below only knows fixes published before 2021, and it reads node-fetch's redirect code as it was just before its January 2022 fix:&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%2Foeb993no97z0w32v9thd.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%2Foeb993no97z0w32v9thd.png" alt="Bug Diary flags line 178 of node-fetch, headers: new Headers(request.headers), with lessons from requests 2014 and GitHub's http-client 2020" width="800" height="1087"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the same code with the fix:&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%2Fn0jys4zla76klcp0r4w2.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%2Fn0jys4zla76klcp0r4w2.png" alt="On the fixed version, the three lessons are already guarded at src/index.js:198" width="798" height="144"&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/sintineddi" rel="noopener noreferrer"&gt;
        sintineddi
      &lt;/a&gt; / &lt;a href="https://github.com/sintineddi/bug-diary" rel="noopener noreferrer"&gt;
        bug-diary
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Keeps a diary of the bugs you fixed, and reads it back before you repeat one. Runs locally with Gemma 4.
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Who does what
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 &lt;code&gt;26b&lt;/code&gt; (Ollama)&lt;/td&gt;
&lt;td&gt;Reads a fix and writes the card; describes a new change; compares it with a past bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EmbeddingGemma (Ollama)&lt;/td&gt;
&lt;td&gt;Finds the lessons closest to what a change does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;My code&lt;/td&gt;
&lt;td&gt;Checks every card and every alert against the sources, stores the diary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;One diary file for all repositories&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git hooks&lt;/td&gt;
&lt;td&gt;Learn after a &lt;code&gt;fix&lt;/code&gt; commit, check before every commit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The model writes, the code checks
&lt;/h3&gt;

&lt;p&gt;A post-mortem written by a model has an obvious failure: it sounds right. Give it a one-line diff that adds a cache timeout, and it will happily tell you that customers saw stale prices for hours. Nothing in the diff says so.&lt;/p&gt;

&lt;p&gt;So every card says where each claim comes from, and my code checks it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A symptom or a cause marked &lt;em&gt;stated&lt;/em&gt; must be found, nearly word for word, in the commit message, the issue, the changelog or the new test. The card keeps the real excerpt, not the model's version of it. If the quote isn't there, the claim becomes &lt;em&gt;inferred from the code&lt;/em&gt;, or &lt;em&gt;unknown&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Function names the model cites must exist in the fix.&lt;/li&gt;
&lt;li&gt;A test found in the fix is recorded as found, not as run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same rule holds when checking a new change. An alert must quote lines that are really in the change; a verdict of &lt;em&gt;already guarded&lt;/em&gt; must quote the guard. Otherwise it is dropped. Similar words are not enough: the comparison has to point at code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Could the diary have warned them?
&lt;/h3&gt;

&lt;p&gt;To test this on real history, I needed bugs that verifiably came back, with dates. Security advisories are the best-documented recurring bugs in open source: each one has an ID, a date and a fix commit. So I collected 153 of them, all checked against their fix commits, in four families:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Instances&lt;/th&gt;
&lt;th&gt;Repositories&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Credentials kept when a redirect changes host, scheme or port&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archive extraction writes outside its folder (zip slip, tar slip)&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prototype pollution through &lt;code&gt;__proto__&lt;/code&gt; or &lt;code&gt;constructor&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP request smuggling (lenient &lt;code&gt;Content-Length&lt;/code&gt; / &lt;code&gt;Transfer-Encoding&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then I froze time. The diary only learned the fixes published before January 1, 2021: 60 of them, plus 6 ordinary bug fixes from the same repositories as noise. For each of the 92 later bugs (the 93rd couldn't be tested), Bug Diary checked two versions of the same code: the vulnerable one, as if someone were writing it today, and the fixed one, which should pass. It also checked 21 ordinary commits from the same repositories. No fix made after 2020 entered the diary.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a warning is worth
&lt;/h3&gt;

&lt;p&gt;A tool that warns about everything is a tool you stop reading. So the question isn't only "did it see the bug?", but "did it warn on the vulnerable code and stay quiet on the fixed one?" For each of the 92 later bugs, I compared the two answers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Warns on the vulnerable version only&lt;/th&gt;
&lt;th&gt;Warns on both&lt;/th&gt;
&lt;th&gt;Warns on neither&lt;/th&gt;
&lt;th&gt;Warns on the fixed version only&lt;/th&gt;
&lt;th&gt;Ordinary commits with a warning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 alone, asked to review the change&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;18 of 21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug Diary&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;1 of 21&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without the diary, the same model finds a problem in almost everything: both versions of 89 bugs out of 92, and 18 of the 21 ordinary commits. Even when it points at the lines the real fix changed, it does so on the fixed version of 62 bugs and on the vulnerable version of only 52. The new guard is the most complicated code in the change, so it gets the suspicion.&lt;/p&gt;

&lt;p&gt;With the diary, Gemma has something to compare with. For 90 of the 92 later bugs, the closest lessons included at least one of the same family. And it can stay quiet. Bug Diary warned on the vulnerable version of 33 bugs and on the fixed version of 14: for 24 bugs it warned on the vulnerable version only, and for 5 the other way round. Of the 21 ordinary commits, it warned on one.&lt;/p&gt;

&lt;p&gt;It misses a lot: 54 bugs got no warning at all. But when it speaks, it usually speaks about the right code, and it mostly speaks about the vulnerable one.&lt;/p&gt;

&lt;p&gt;The lessons travel. Of the 33 warnings, 30 relied only on lessons from other repositories, and 14 only on lessons learned from code in another language. Until January 2022, node-fetch's redirect code did this:&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="c1"&gt;// HTTP-redirect fetch step 6 (counter increment)&lt;/span&gt;
&lt;span class="c1"&gt;// Create a new Request object.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every header of the original request, &lt;code&gt;Authorization&lt;/code&gt; and &lt;code&gt;Cookie&lt;/code&gt; included, was copied into the request sent to the new host. With a diary frozen a year earlier, Bug Diary flagged line 178 with three lessons: two from requests (Python, 2014) and one from GitHub's &lt;code&gt;http-client&lt;/code&gt; (TypeScript, 2020). On the fixed version, it quoted the new guard and stayed quiet. In the benchmark record of that check, it still left the question a good reviewer would ask: could &lt;code&gt;attacker-example.com&lt;/code&gt; pass for a subdomain of &lt;code&gt;example.com&lt;/code&gt;?&lt;/p&gt;

&lt;h3&gt;
  
  
  Half a guard looks like a guard
&lt;/h3&gt;

&lt;p&gt;The bugs that come back are rarely new. They come back because the last fix was incomplete. merge-deep blocked &lt;code&gt;__proto__&lt;/code&gt; in 2018; in 2021 the same merge went through &lt;code&gt;constructor.prototype&lt;/code&gt;. minimist blocked &lt;code&gt;__proto__&lt;/code&gt; in 2020; in 2022, &lt;code&gt;constructor&lt;/code&gt; got through. follow-redirects removed the &lt;code&gt;Authorization&lt;/code&gt; header when a redirect changed host, then had to add the cookies, then the change from HTTPS to HTTP, then &lt;code&gt;Proxy-Authorization&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is exactly where a diary should help, and it is how Bug Diary fails. Of the 59 vulnerable versions it didn't warn about, it called 55 &lt;em&gt;already guarded&lt;/em&gt;. The vulnerable code had a guard, and the model trusted it. For 23 of them, the repository had fixed a bug of the same family before 2021, so the code usually carried the guard of that earlier fix. The lesson says &lt;em&gt;block &lt;code&gt;__proto__&lt;/code&gt;&lt;/em&gt;. The model sees the block and answers &lt;em&gt;already guarded&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So I tried showing the model the whole family: the lesson, plus the other fixes of the same kind of bug in the diary, and one sentence: &lt;em&gt;a guard can be incomplete&lt;/em&gt;. It warned on the vulnerable version of 53 bugs instead of 33. It also started doubting every guard, and warned on both versions of 40 bugs. One sentence turned a reviewer that trusted too much into one that trusts nothing.&lt;/p&gt;

&lt;p&gt;Without that sentence, with the family shown only as context, the model went the other way and became more careful: it warned on 21 vulnerable versions and 6 fixed ones. That is now the &lt;code&gt;--by-kind&lt;/code&gt; option. It makes one model call per kind of bug instead of one per lesson, so a check takes 22 seconds instead of 38, for fewer warnings in both directions. More context didn't make the model better at judging a guard. It moved where the model draws the line. By default, Bug Diary compares each lesson alone, which gave the best balance.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much can a model learn from a fix alone?
&lt;/h3&gt;

&lt;p&gt;From the diff alone, Gemma left the symptom &lt;em&gt;unknown&lt;/em&gt; for 37 of the 66 fixes and inferred it from the code for the other 29. For five of those, it quoted the diff itself, and the check refused the quotes. With the commit message, the pull request and the advisory, 62 of the 66 symptoms were quoted from a source, and the code checked every quote.&lt;/p&gt;

&lt;p&gt;The test and the commit message changed less than I expected. With the new test, Gemma inferred a symptom for 57 fixes instead of 29: a test describes the behaviour that broke, so the model can work it out. With the commit message as well, it could quote a symptom for only 9: commit messages say what was changed, rarely what went wrong. In the full run, 56 of the 62 quoted symptoms came from the advisory or the pull request, 5 from the commit message, and 1 from a test. A diary can only know what went wrong if someone wrote it down, and it is rarely in the commit.&lt;/p&gt;

&lt;p&gt;Does it matter for catching the next bug? Less than I thought for finding it, more for judging it. A diary learned from the diffs alone still found the right family for 89 of the 92 later bugs, and warned on vulnerable code just as often. But it told the two versions apart less well: it warned on both versions of 17 bugs. With the tests, that fell to 7, and the full context gave the most clean calls, 24 bugs warned on the vulnerable version only, against 19 from the diffs alone.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A security fix is a secret until it ships.&lt;/strong&gt; Sixteen of the fixes in my benchmark are literally called &lt;code&gt;Merge pull request from GHSA-…&lt;/code&gt; or &lt;code&gt;Merge commit from fork&lt;/code&gt;: they were written in a private fork and merged shortly before the advisory was published. Until then, the patch is the most sensitive file in the project. Nobody should paste it into someone else's API to get a post-mortem. A model on my laptop can read it the day it is written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs on every commit, for free.&lt;/strong&gt; Learning the 66 fixes of the benchmark took 18.5 minutes of laptop time. A check takes 38 seconds at the median in the benchmark, and nearly one in four takes more than a minute. Billed per token, I would think twice before running it on every commit of every repository. Here the only bill is electricity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I can measure it, and measure it again next year.&lt;/strong&gt; The weights don't change under me. Gemma 4 is Apache 2.0, EmbeddingGemma comes with Gemma's terms, and &lt;code&gt;--model&lt;/code&gt; takes any other model in Ollama, so the benchmark can be rerun with whatever comes next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The diary is a file.&lt;/strong&gt; &lt;code&gt;~/.bugdiary/diary.db&lt;/code&gt; is SQLite. It belongs to whoever fixed the bugs, not to a vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;Before publishing, I had Bug Diary's own code reviewed. The review found six real bugs: the &lt;code&gt;--strict&lt;/code&gt; hook that never blocked anything, a large change reported clean although part of it was never read, a case-insensitive comparison of code, and three more. So I installed the hooks on Bug Diary itself and committed each fix with a message starting with &lt;code&gt;fix&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each fix became a card in the background, in about 30 seconds of laptop time, and each later commit was checked against the cards before it, in 25 to 78 seconds. Two more fixes followed: two processes could upgrade the same diary at once, and the one below. The diary of Bug Diary now holds eight lessons. I wrote zero lines of post-mortem. Honestly, that isn't time saved: I never wrote post-mortems before either. It is eight post-mortems that exist now and didn't before, for about four minutes of laptop time I didn't watch.&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%2Fqaqj7kbbfxo1g2zmcwrb.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%2Fqaqj7kbbfxo1g2zmcwrb.png" alt="The diary of Bug Diary: eight lessons from its own fixes, and the card for the race between two processes upgrading the same diary" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The best moment was a mistake. Pasting the commit messages, I pasted the second one twice, and the next three commits each got the message of the fix before. The diary believed the messages. The case-sensitivity fix became a card about large changes, with a fix it had partly invented from the surrounding code. My quote check was satisfied: the words were really in the message. They were just about another change.&lt;/p&gt;

&lt;p&gt;So Bug Diary got one more fix. Before writing a card, Gemma now says whether the message describes the diff. If it says no, the card is written again without the message, and says so. On my nine commits, it caught two of the three pasted messages, and none of the six good ones. On the 66 real fixes of the benchmark, it flagged two messages: a merge commit with no description at all, and the fix for CVE-2019-11249, a directory traversal in &lt;code&gt;kubectl cp&lt;/code&gt;, whose title is &lt;em&gt;Refactors to kubectl CP command&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Now it at least checks that the message and the diff talk about the same change.&lt;/p&gt;

&lt;p&gt;Then it paid me back. On one of the last commits before publishing, the hook printed this before the commit went through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bug-diary $ git commit -m "Challenge is coming"
⚠ Possible return of a known bug: trusting incorrect commit messages as symptoms
  Precedent   BUG-0008 · bug-diary · 8fb97e53 · fixed 2026-10-21
  Lesson      Verify that the commit message accurately describes the actual code changes before
              using it to extract symptoms.
  In this change
    bugdiary/learn.py:199  if "message" in texts and raw.get("message_matches") in ("no", "part…
  Why         The change modifies the logic in learn.py to no longer treat 'partly' as a reason to
              remove the message and re-query, which is exactly the behavior the bug fix was
              intended to prevent.
  Check       Does the new logic in learn.py still ensure that a 'partly' match doesn't lead to
              using an untrustworthy commit message?
  Test        Given a commit message that matches the code changes only 'partly'; when the learn
              function processes the fix; then the message should be removed from texts and the LLM
              re-queried, just as it was before the bug was fixed.

2 other lessons do not apply or stay unclear (--all shows them).
Compared the change with 3 of 8 lessons in 156 s.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was right. I had narrowed that rule on purpose: on the 66 real fixes, counting &lt;em&gt;partly&lt;/em&gt; as a mismatch threw away eight messages that were incomplete or vague, but true. Bug Diary couldn't know that, and it didn't block anything. It made sure I was undoing part of a fix knowingly, with the old bug, the line and a test in front of me.&lt;/p&gt;

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

&lt;p&gt;I built Bug Diary with Claude Code, from my design note. Claude Code wrote the code, the tests and the benchmark harness, and ran the benchmark on my laptop.&lt;/p&gt;

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

&lt;p&gt;Best Use of Gemma: Gemma 4 writes and compares the cards, EmbeddingGemma finds them again.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>ai</category>
      <category>hf26challenge</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My AI-written plan went through every check I could think of. The gaps were in what I never said.</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:18:50 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/my-ai-written-plan-went-through-every-check-i-could-think-of-the-gaps-were-in-what-i-never-said-3hf7</link>
      <guid>https://dev.to/pierrelaurentmedori/my-ai-written-plan-went-through-every-check-i-could-think-of-the-gaps-were-in-what-i-never-said-3hf7</guid>
      <description>&lt;p&gt;The plan was 22,000 words long. Twelve agents had worked on it for close to two hours. Among them, three architects had drafted it, two judges had scored the drafts, a synthesis had merged them, and three reviewers running on a different model had tried to break the result. They raised 51 findings. The plan accepted all 51. Its claims about our code came with file and line references.&lt;/p&gt;

&lt;p&gt;I read it the next morning, before work, and asked two questions. Neither had an answer in it. Both were about what the product has to become, and I had never spelled out either of them.&lt;/p&gt;

&lt;p&gt;One line of context: I run engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and I needed an architecture plan for a large change to one of our production systems, delivered in steps. I'm not going to say which system, or describe the plan. This post is about how the plan was produced, where the method failed, and what I'd change.&lt;/p&gt;

&lt;h2&gt;
  
  
  "You, or the costly one?"
&lt;/h2&gt;

&lt;p&gt;I started in Claude Code, on its regular model, and asked which model should write the plan: itself, or the costly one, the most capable model we can use.&lt;/p&gt;

&lt;p&gt;It picked the costly model for the plan, and kept itself for later: executing milestones once they are cut small, and reviewing. The reasons were worth keeping. A migration plan has to account for every path the current system takes, and a plan that forgets one recreates bugs you've already paid for; that's where the gap between the two models is widest. The costly model is 2.5 times the price per token, which is noise on a one-off plan that will steer weeks of work. And it's slow, which hurts every day and doesn't matter once.&lt;/p&gt;

&lt;p&gt;It added one rule I would have forgotten: the plan's author should not review its own plan.&lt;/p&gt;

&lt;p&gt;So the roles were set. The regular model interviews me, maps the code, writes the brief and runs the orchestration. The costly model plans. The regular model, as a different model, attacks the plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The brief: only the code counts
&lt;/h2&gt;

&lt;p&gt;While it mapped the code, the regular model described to me what one of our neighbouring services does. I typed: "Don't trust the docs. Trust only the code."&lt;/p&gt;

&lt;p&gt;Its reply was that this applied to what it had just told me. It had identified that service from folder names, URL routes and commit messages, and described another part of the system from a docstring. Leads, not proof. It re-checked those parts from executable code, and the sentence became a rule for every agent that followed. In the planning runs, it read like this (translated):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Read only: you modify, create and delete nothing. You run no server,
  no migration, no script that touches a database or the network.
- Only executable code is evidence (cite file:line). Markdown files,
  docstrings, comments, commit messages and folder names are leads to verify.
- You can't ask the owner questions during this work: every uncertainty
  becomes an open question, with the assumption you made.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mapping ran as three parallel passes of read-only reader agents, each returning claims with their evidence. Two more things went into the brief, and I'd do both again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My exact words, in a file.&lt;/strong&gt; The regular model wrote down everything I had asked, verbatim and in order, including my answers to its clarifying questions. The brief is a reformulation, and reformulations drift. The file is what the brief gets checked against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A read-back before launch.&lt;/strong&gt; I told it I couldn't afford a misunderstanding. It answered with twelve numbered points, "here is what I understood", and four questions about ambiguities that would each change the plan. I answered the four. It then wrote the brief, about 2,500 words, launched the run, and sent me the brief to read while the costly model worked: if I spotted a misunderstanding, it would stop the run and relaunch with the fix.&lt;/p&gt;

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

&lt;p&gt;The orchestration is a Claude Code workflow script: plain JavaScript where each &lt;code&gt;agent()&lt;/code&gt; call can pin its own model and reasoning effort. The architect phase, slightly simplified:&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;plans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;parallel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ANGLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nf"&gt;agent&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="nx"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;READ_BRIEF&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nYou are architect &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;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Your starting angle: &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;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`The angle is a starting point, not a limit: your plan covers the whole deliverable.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`architect:&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;key&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;COSTLY_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;effort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xhigh&lt;/span&gt;&lt;span class="dl"&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 whole run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;who&lt;/th&gt;
&lt;th&gt;what came out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brief check&lt;/td&gt;
&lt;td&gt;2 checkers on the regular model: one against the code, one against my exact words&lt;/td&gt;
&lt;td&gt;40 corrections (13 major, 27 minor), attached as errata that override the brief&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architects&lt;/td&gt;
&lt;td&gt;3 × the costly model, each from a different angle: security first, safest migration for customers, output quality first&lt;/td&gt;
&lt;td&gt;3 complete plans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Judges&lt;/td&gt;
&lt;td&gt;2 × the costly model, one lens on my requirements and security, one on feasibility and grounding in the code&lt;/td&gt;
&lt;td&gt;marks, a winner each, ideas to graft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Synthesis&lt;/td&gt;
&lt;td&gt;the costly model, maximum effort&lt;/td&gt;
&lt;td&gt;one plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-check&lt;/td&gt;
&lt;td&gt;3 checkers on the regular model: code facts, requirements and security, roadmap and tools&lt;/td&gt;
&lt;td&gt;51 findings (3 blocking, 26 major, 22 minor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revision&lt;/td&gt;
&lt;td&gt;the costly model&lt;/td&gt;
&lt;td&gt;each finding accepted or rejected, with a reason&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The result came back truncated the first time, and the regular model rebuilt it from the agent's transcript: 13 milestones and 32 open questions, each with the costly model's working assumption, since nobody inside a workflow can stop and ask me.&lt;/p&gt;

&lt;p&gt;Two details from the run stayed with me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The judges didn't agree.&lt;/strong&gt; One picked the security-first plan, the other the safest-migration plan. Adding up each judge's six marks, the top two plans were one point apart, and neither judge picked the plan on top of its own total. The lens seems to have weighed more than the marks. The synthesis took the safest-migration plan as its base and grafted ideas from the other two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The judges spot-checked the architects' references.&lt;/strong&gt; They ran 71 checks on &lt;code&gt;file:line&lt;/code&gt; claims, a few of them on the same claim from both judges. 58 held exactly as written. The other 13 came back qualified, from "confirmed, but a few lines off" to plain wrong. The worst kind was a field cited at a precise file and line that doesn't exist: something the plan proposed to add, written up as if it were already there. That's nearly one check in five needing a correction, on plans written by the most capable model we can use, under explicit instructions to cite only code it had read. I'd keep the spot-check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two questions
&lt;/h2&gt;

&lt;p&gt;Back to the morning after. I won't say what my two questions were. What matters here is that the plan had answered both implicitly, with how the code works today.&lt;/p&gt;

&lt;p&gt;I asked the regular model why it had missed them. Its answer, condensed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It worked only from what I said.&lt;/strong&gt; It had reformulated my requests and cleared up ambiguities in my sentences. It never walked through the basic dimensions of the product that I hadn't mentioned. There were hints in my words, and it didn't follow them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The brief presented today's limits as the frame.&lt;/strong&gt; It described two of today's boundaries as facts about the system, without saying they could be challenged. The costly model built on them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The requirement checks compared against my words.&lt;/strong&gt; The checkers looking for missing or distorted requirements, on the brief and then on the plan, compared them with what I had said. The other checks verified facts against the code. A requirement nobody voiced is a gap in neither comparison.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third point needs a nuance, because the requirements checker did better than that summary. It flagged one requirement nobody had stated: whether the new system must do everything the current one does before a customer is moved to it. That one sat right next to things I had said explicitly. The two that mattered had only been brushed in passing, in a few words the regular model pointed out afterwards, and today's code was built the other way.&lt;/p&gt;

&lt;p&gt;That's the part I find most useful. The checker comparing the brief to the code found nothing wrong with those descriptions, because they were accurate. In this run, the fact-check couldn't flag the frame; if anything, it made it look more solid. "This is how it works today" passed review and was read as "this is how it must work".&lt;/p&gt;

&lt;p&gt;When the costly model reworked the plan, it wrote its own account of the miss, and went one step further than the regular one. The first plan, it said, had turned the boundaries of the existing system into architectural guarantees. It had even recorded three weak signals in the code that pointed toward the two requirements, as facts, without ever turning them into questions. Its conclusion: a plan that states "here is what I'm taking for granted that you didn't say" would have surfaced them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second pass
&lt;/h2&gt;

&lt;p&gt;The regular model offered to have the costly one write an addendum. I asked for something else: give the costly model the two requirements and one question, does this call the plan into question? If not, fine. If it does, rework it.&lt;/p&gt;

&lt;p&gt;The regular model ran that as its own workflow. Two independent assessments by the costly model, then a challenger on the regular model with one instruction: if in doubt, keep the more demanding verdict. All three said the same thing. Not an addition, a rework. The costly model rewrote the plan at maximum effort, to files this time to avoid another truncation. Three checkers attacked it again (44 findings: 3 blocking, 27 major, 14 minor), and the costly model revised.&lt;/p&gt;

&lt;p&gt;The third version has a section the first one lacked: the findings it set aside or nuanced, each with a reason. One of them walks back an over-correction, where the intermediate version had taken one of the assessments' strongest claims at face value. I trust a reviser more when it pushes back on its reviewers. In the first run, it had accepted 51 out of 51.&lt;/p&gt;

&lt;p&gt;The open questions went from 32 to 69. Fourteen of them were blind spots: things nobody had stated, posed as questions with an assumption each, not slipped in as requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  69 questions, asked instead of listed
&lt;/h2&gt;

&lt;p&gt;The third version had doubled, to 45,000 words. A list of 69 questions at the end of a document that long doesn't get answered. So I asked the regular model to put them to me instead: one at a time for the ones that would change the architecture, then in small rounds by theme.&lt;/p&gt;

&lt;p&gt;For each one it gave me a few lines of context with what the code shows, the costly model's assumption, and its own recommendation if it had one. I could answer, or say "explain", "be more precise", "show me in the code". When I said I wasn't sure how something behaved in another part of our platform, it went and read that code before we moved on. Every answer went into a file next to the plan, in my words.&lt;/p&gt;

&lt;p&gt;All 69 took about seventy minutes. Three principles came out of my answers that weren't in the brief, each one drawn out by a specific question: we deploy alongside the current system and destroy nothing; we're changing one part, not rewriting the platform; when the plan depends on another team's work, it states the need and leaves the solution to them.&lt;/p&gt;

&lt;p&gt;The costly model folded the answers into a fourth version, under a rule the regular model set for it: a finding never overrides one of my answers. At most, the plan records the risk. The fourth version came back with fourteen new questions of its own, and I answered those too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The author defends its plan, and attacks mine
&lt;/h2&gt;

&lt;p&gt;Then the plan went to two reviewers from other model families, two lenses each, with access to the code and not only the text. They raised 30 points. The regular model checked each one in the code: 26 held, 4 were discarded.&lt;/p&gt;

&lt;p&gt;For the last round I opened a fresh session on the costly model, at maximum effort, with the plan, my answers, the verified review and my exact words. Before any rewrite, I asked for three things: one page on where it stood, including where the reviewers were wrong according to the code; a frank challenge of any of my own answers that created a risk, "I'll decide. Don't work around it in silence"; and its questions, one at a time or in small groups.&lt;/p&gt;

&lt;p&gt;Its agents re-checked all 30 points. It agreed with the regular model's verdict every time: the 26 that held, and the 4 discarded. On the proposed fixes, it judged one right, one out of proportion and the other 28 incomplete. Of the 336 citations in the review, 312 held exactly and 20 were a few lines off; 2 weren't checked, and the 2 marked wrong were about a reviewer's wording, not the code. It still contested specific claims in 7 of the findings, with code to back each objection.&lt;/p&gt;

&lt;p&gt;Then it contested about a dozen of my answers. The sharpest one: an answer of mine asked for something my own team had ruled out, deliberately, twice, with the reason in the code. I changed most of the answers it challenged. On one I kept my position and made it more precise.&lt;/p&gt;

&lt;p&gt;It also worked the other way. Thirty questions in all, most of them in five groups over one evening. On testing, I asked for "as real as possible, as few mocks as possible", and it went back to the code and rewrote its proposal around that. When it leaned toward its own vendor's models, I wrote: no favoritism, we'll be multi-provider. And the one real misunderstanding of that evening was mine: I read one of its proposals as the opposite of what it said and called it a deal breaker. One clarification later, I accepted it.&lt;/p&gt;

&lt;p&gt;The costly model also ran out. With 28 agents in parallel, the first verification pass hit my subscription's session limit in about half an hour; it resumed hours later, three agents at a time. Then, overnight, while writing the fifth version, the costly model reached its usage limit. I could wait until its allowance reset, days later, or let the regular model finish. It kept the eleven parts the costly model had written and reviewed, reviewed the twelfth, wrote the rest with its own agents, two at a time, and assembled one file: about 115,000 words, with a section near the top that ties every change since the fourth version to one of my answers or one review finding. In its summary, it corrected a fact the costly model had given me during the confrontation. The decision built on it held; its justification changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask for assumptions before the plan, not after.&lt;/strong&gt; "Here is what I'm taking for granted that you didn't say" belongs in the first deliverable, answered by the owner before any architect starts. Here it would have cost a few minutes. The miss cost a second two-hour run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate "how it works today" from "what must stay true".&lt;/strong&gt; A brief that describes the current system accurately can pass the fact-check and still be read as a constraint. Mark each boundary of today's system as either a requirement or a fact open to challenge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give one checker the unsaid.&lt;/strong&gt; Checkers against your words find distortions. Add one whose only job is the dimensions nobody mentioned: what one unit of work covers, where the data lives, who hosts what, who the end users are and how they're identified. The regular model saved that checklist as a lesson for its next briefs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the verbatim file.&lt;/strong&gt; It gave the requirements checker something firmer than a reformulation to compare with, and it kept growing: the two missed requirements went into it, in my words, before the second pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend the costly model where it pays once, and plan for it to run out.&lt;/strong&gt; The costly model for the plan and the rework; the regular one for conversation, verification and execution. Write the plan in parts another model can pick up: that's how the fifth version got finished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the author defend its plan, and let it attack yours.&lt;/strong&gt; The confrontation is where my answers changed. The one part no model could do was answer the questions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How do you get a model to tell you what it assumed before it hands you the plan, rather than when you find the hole?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Adding tools to an MCP server that ChatGPT has already approved</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:03:14 +0000</pubDate>
      <link>https://dev.to/goodbarber/adding-tools-to-an-mcp-server-that-chatgpt-has-already-approved-2cm4</link>
      <guid>https://dev.to/goodbarber/adding-tools-to-an-mcp-server-that-chatgpt-has-already-approved-2cm4</guid>
      <description>&lt;p&gt;On October 7 we added seven tools to our public MCP server: read the booking calendars of an app, list sessions, cancel a booking, manage closing days. The server is &lt;a href="https://dev.to/goodbarber/our-mcp-server-is-now-a-chatgpt-plugin-2pjm"&gt;listed in ChatGPT's directory&lt;/a&gt; and in Claude's, and people also connect to it directly from Claude Code, Cursor and others. The question I asked before anyone wrote code: how do we add tools for the direct users without touching what ChatGPT approved?&lt;/p&gt;

&lt;p&gt;One line of context: I run engineering at GoodBarber, an app platform, and &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;our MCP server&lt;/a&gt; lets assistants manage the apps through an authenticated connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  One server, three audiences
&lt;/h2&gt;

&lt;p&gt;The first step was a map of who sees what. I had a rough idea; the code had the exact one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;audience&lt;/th&gt;
&lt;th&gt;what it gets&lt;/th&gt;
&lt;th&gt;what a new tool does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT app&lt;/td&gt;
&lt;td&gt;a frozen list of 45 tools, the ones OpenAI reviewed&lt;/td&gt;
&lt;td&gt;absent from the list; a call to it is refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude directory&lt;/td&gt;
&lt;td&gt;the full catalog, which follows each deploy&lt;/td&gt;
&lt;td&gt;appears with no new submission; the tool names shown on the listing page are updated by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct clients&lt;/td&gt;
&lt;td&gt;the full catalog&lt;/td&gt;
&lt;td&gt;appears&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a new tool is free for the Claude directory and for direct clients, and the frozen list keeps it away from ChatGPT. That could have been the whole plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coupling no test covered
&lt;/h2&gt;

&lt;p&gt;The ChatGPT side is a facade over the same catalog. It picks the 45 tools and overrides their descriptions, plus one schema. Everything else it serves, the titles, the input schemas, the annotations that say whether a tool reads or writes, comes from the code shared with every other audience.&lt;/p&gt;

&lt;p&gt;That means a change made for the new tools, say a tweak to the helper that turns tool names into titles, or to the function that builds annotations, would also change what ChatGPT serves for tools OpenAI already reviewed. Nobody would notice: the existing test only checked the names and the order of the 45.&lt;/p&gt;

&lt;p&gt;So step zero, before any booking code, was a test that freezes everything ChatGPT sees, for several configurations the facade serves: name, title, description, input schema, annotations, compared to a committed reference file. Any difference fails CI and names the tool. Simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_chatgpt_surface_matches_reference&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;served&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;chatgpt_tools_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAMPLE_APP&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REFERENCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;served&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="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;reference&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;served&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; changed for ChatGPT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we checked that the test can fail. The first attempt changed a field, and the test stayed green. Not because the test was wrong: that field is rewritten by an override before it reaches ChatGPT, so the change never got there. The second attempt added a field nothing overrides, and the test failed, naming the tool. A guard you haven't seen fail is a guess.&lt;/p&gt;

&lt;p&gt;A second test checks the other direction: none of the new tools reaches ChatGPT.&lt;/p&gt;

&lt;p&gt;With both in place, the booking work could touch the shared code freely. If it moved something OpenAI had approved, CI would say so before a deploy did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tool that cancels bookings
&lt;/h2&gt;

&lt;p&gt;Four of the seven tools read. The three that write are the reason this section exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creating a closing day&lt;/strong&gt; in the back office cancels every booking on that day and sends each attendee a cancellation email. If the app's calendars inherit closing days from a default one, the closure applies to all of them. A person in the back office sees a confirmation dialog. A model sees a tool description, and a tool description is a suggestion.&lt;/p&gt;

&lt;p&gt;So the tool checks before it acts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it counts the bookings that the closure would cancel, across every calendar it would apply to;&lt;/li&gt;
&lt;li&gt;if there are any, it creates nothing and returns &lt;code&gt;confirmation_required&lt;/code&gt; with the count;&lt;/li&gt;
&lt;li&gt;it only goes ahead when the call carries &lt;code&gt;cancel_existing_bookings: true&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;it refuses past days;&lt;/li&gt;
&lt;li&gt;it is annotated as destructive, and its description says what happens to attendees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model can still cancel twenty bookings, and nothing stops it from setting the flag on its first call. What it can't do is cancel them by default: a call that doesn't ask for it returns the count, and the description tells the model to show that count to the user and get a yes first. That part is an instruction, not a lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting a closing day&lt;/strong&gt; makes the day bookable again if the calendar opens on it, but restores none of the bookings the closure cancelled. That one is not destructive in itself, and it's the kind of thing a model would assume: undoing the closure undoes the cancellations. It doesn't, and the description says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancelling a booking&lt;/strong&gt; uses the same call as the back-office button, with the same email to the attendee. A booking that is already cancelled comes back as "not found, nothing was changed" instead of an error, so a model that retries after a timeout, or after the attendee cancelled from their own link, doesn't report a failure that didn't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access: a permission, not a list of pilot apps
&lt;/h2&gt;

&lt;p&gt;Booking data includes attendees' names and emails, so the tools got their own permission in our API credentials service, read-only or read-write, offered only to the app type that has bookings. Existing keys don't get it; someone has to tick it.&lt;/p&gt;

&lt;p&gt;That gave us a safe deploy order with no list of pilot apps: ship the server first, with tools nobody can see because no key carries the permission; then create the permission, which is the moment they become available. One decision went the other way on purpose: new keys pre-select every service in read-only, and bookings follow the same rule as the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metadata that drifts
&lt;/h2&gt;

&lt;p&gt;The tools were live in minutes. The descriptions of the server took longer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The server card&lt;/strong&gt; builds its tool list from the catalog, so it showed the new tools right after the deploy: 157, up from 150. Its version, though, was written by hand, and it was already behind the version in our registry entry. Both moved to 1.3.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The registry entry&lt;/strong&gt; has a 100-character description. Adding "bookings" meant "mobile app" became "app". The publishing token had expired, so that took a new login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Claude directory listing&lt;/strong&gt; shows a list of tool names that is edited by hand and validated by a reviewer, one of the details I went through in &lt;a href="https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con"&gt;what each MCP directory actually checks&lt;/a&gt;. The tools themselves were already available to Claude users; the listing just didn't say so yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ChatGPT app&lt;/strong&gt; needed nothing. Nothing it serves changed, and the frozen test is what lets us say that. The day bookings should reach ChatGPT, that will be a new version of the facade and a new submission, on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the deploy, every call to the booking API ran against mocks, since only the production server is allowed to call it. The first real call came after the deploy, from a test key with only the booking permission. It worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Map your audiences from the code, not from memory.&lt;/strong&gt; ChatGPT's frozen list was the part I knew about; the shared code underneath it was the part I didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze what a reviewer approved, not what you meant to ship.&lt;/strong&gt; Names and order are what you think of. Schemas, titles and annotations are what a shared helper changes behind your back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the guard fail once.&lt;/strong&gt; Our first attempt at breaking it changed a field that never reaches ChatGPT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a model, a confirmation dialog is a refusal with a number in it.&lt;/strong&gt; Return what would happen, and require an explicit flag to make it happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate new tools with a permission.&lt;/strong&gt; It gives you a deploy where the code is live and the capability isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your MCP server is listed somewhere: what exactly did the reviewer approve, and which test would tell you it changed?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>chatgpt</category>
      <category>python</category>
    </item>
    <item>
      <title>How do you manage CORS for thousands of customer domains? Per tenant, with three answers instead of two</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:19:58 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-manage-cors-for-thousands-of-customer-domains-per-tenant-with-three-answers-instead-of-427</link>
      <guid>https://dev.to/goodbarber/how-do-you-manage-cors-for-thousands-of-customer-domains-per-tenant-with-three-answers-instead-of-427</guid>
      <description>&lt;p&gt;Our domain registry can stop answering. It can also answer that a customer's domain is no longer allowed. Both cases leave the proxy without a usable current list, but only the first gives us a reason to reuse an older one.&lt;/p&gt;

&lt;p&gt;That distinction shaped our per-tenant CORS cache. We keep valid results for 15 minutes and a fallback copy for 24 hours. An authoritative refusal erases that fallback, so the next outage cannot bring a removed domain back.&lt;/p&gt;

&lt;h2&gt;
  
  
  One domain per customer
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, the web version of each customer app has its own domain, either a platform subdomain or a custom one. We are piloting a proxy that lets widgets call third-party APIs while their private keys stay on the server. Its CORS policy has to fit that domain model, even while the service itself is limited to a small pilot.&lt;/p&gt;

&lt;p&gt;Our first static allowlist was manageable for five pilot apps. It could not follow the platform's thousands of domains as customers changed them. Allowing arbitrary origins would also defeat the restriction we wanted; PortSwigger documents that mistake in its guide to &lt;a href="https://portswigger.net/web-security/cors" rel="noopener noreferrer"&gt;CORS misconfigurations&lt;/a&gt;. We needed to resolve the list for each tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preflight does not know who is calling
&lt;/h2&gt;

&lt;p&gt;An actual API call carries a short-lived token naming the tenant. Its preflight arrives earlier, without that token: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS" rel="noopener noreferrer"&gt;CORS preflight requests do not carry credentials&lt;/a&gt;. We receive an origin, a proposed method and a list of header names, so the token cannot supply the tenant for this lookup.&lt;/p&gt;

&lt;p&gt;The request path can. It contains an opaque connector handle belonging to one tenant. We resolve the handle, then the tenant's domains. Because this happens before token validation, the lookup must also tolerate requests from arbitrary callers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Origins, exactly as a browser sends them
&lt;/h2&gt;

&lt;p&gt;The registry returns a domain. A browser sends an origin, and the comparison is string equality, so the list has to be built the way a browser would write it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The default port for the scheme is omitted: &lt;code&gt;:443&lt;/code&gt; for HTTPS, &lt;code&gt;:80&lt;/code&gt; for HTTP. Non-default ports are kept.&lt;/li&gt;
&lt;li&gt;A custom domain yields two origins, with and without &lt;code&gt;www&lt;/code&gt;, since customers publish both. A subdomain of ours yields exactly one.&lt;/li&gt;
&lt;li&gt;An internationalised domain is converted to punycode, which is what the browser sends.&lt;/li&gt;
&lt;li&gt;The response carries the one matching origin and &lt;code&gt;Vary: Origin&lt;/code&gt;, as MDN recommends &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin" rel="noopener noreferrer"&gt;for an explicit origin&lt;/a&gt;. Never &lt;code&gt;*&lt;/code&gt;, and &lt;code&gt;Origin: null&lt;/code&gt; never matches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A miss needs a reason
&lt;/h2&gt;

&lt;p&gt;Once the main cache entry expires, the registry lookup can produce three different outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The registry…&lt;/th&gt;
&lt;th&gt;What the proxy serves&lt;/th&gt;
&lt;th&gt;What it stores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;answers with domains&lt;/td&gt;
&lt;td&gt;those origins&lt;/td&gt;
&lt;td&gt;the list for 15 minutes, and a second copy for 24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cannot be reached, answers an error or sends garbage&lt;/td&gt;
&lt;td&gt;the 24-hour copy, if there is one&lt;/td&gt;
&lt;td&gt;the main entry again, for 60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;answers clearly that the tenant has no usable domain&lt;/td&gt;
&lt;td&gt;no CORS headers&lt;/td&gt;
&lt;td&gt;an empty list for 60 seconds, and it deletes the 24-hour copy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The outage path borrows the idea of &lt;a href="https://www.rfc-editor.org/rfc/rfc5861" rel="noopener noreferrer"&gt;&lt;code&gt;stale-if-error&lt;/code&gt;&lt;/a&gt;. Apps can keep using a previously accepted origin while the registry is unavailable. Caching that fallback decision for 60 seconds reduces repeated lookups; it does not by itself guarantee a global request-rate bound when concurrent misses occur.&lt;/p&gt;

&lt;p&gt;An explicit removal has a different meaning. If we kept its old list, a later registry outage could restore the revoked origin from the 24-hour copy. Deleting that copy makes the refusal survive the outage too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-auth path is bounded
&lt;/h2&gt;

&lt;p&gt;Before any I/O, we check the handle's shape and hash it for use in cache keys. Unknown handles are cached as unknown for 60 seconds. If the shared cache is unavailable, an in-process cache lasting 5 seconds, with at most 4,096 entries, limits repeated database work.&lt;/p&gt;

&lt;p&gt;The preflight path responds with &lt;code&gt;204&lt;/code&gt;. If neither the lookup nor its permitted fallback yields an allowed origin, the response omits CORS headers. Dependency errors therefore do not become a &lt;code&gt;500&lt;/code&gt;, and the browser cannot proceed with the cross-origin call after an unsuccessful preflight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat your cache as input
&lt;/h2&gt;

&lt;p&gt;The cache is shared infrastructure, so what comes out of it is validated like anything else that crosses a boundary. Values are JSON, never pickled objects. Every list read back is checked, item by item, against the same pattern that judged it on the way in. A value that exists but fails that check is ignored and logged as a tampering signal.&lt;/p&gt;

&lt;p&gt;Review found a mismatch here. Normalisation accepted a hostname containing an underscore, but cache validation used a stricter rule and rejected it. That value would have been repeatedly written, discarded and fetched again, leaving both caches ineffective for the tenant. Applying the same rule on write and read removed the disagreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORS is not the lock
&lt;/h2&gt;

&lt;p&gt;These responses govern browser access. A client such as &lt;code&gt;curl&lt;/code&gt; does not enforce them, so actual requests still undergo token validation and the connector's permission checks. Using an older CORS list leaves those checks in place; it changes which browser origins may read the responses.&lt;/p&gt;

&lt;p&gt;The feature and its hardening landed on July 23, six minutes apart: the second commit is the adversarial review of the first. The module has 48 tests: normalisation, the three outcomes, the bounds of the pre-auth path, and the few static origins we add on top.&lt;/p&gt;

&lt;p&gt;To test the distinction, cache an allowed domain, have the registry explicitly remove it, then make the registry unavailable. The domain should remain absent. Testing an outage alone would miss the case where a cache reverses a decision it had already received.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>architecture</category>
      <category>python</category>
    </item>
    <item>
      <title>Give Whisper the poem, and it stops hearing the child's mistakes</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 15 Oct 2026 09:24:40 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/give-whisper-the-poem-and-it-stops-hearing-the-childs-mistakes-734</link>
      <guid>https://dev.to/pierrelaurentmedori/give-whisper-the-poem-and-it-stops-hearing-the-childs-mistakes-734</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;In French primary schools, children learn poems by heart. Every few weeks the homework diary says &lt;em&gt;apprendre la poésie&lt;/em&gt;, and a few days later the child stands up in class and recites it. At home, in between, someone has to hold the book and listen: line after line, evening after evening, often while cooking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Puesia&lt;/strong&gt; does the listening. &lt;em&gt;Puesia&lt;/em&gt; is Corsican for poetry; I live in Ajaccio.&lt;/p&gt;

&lt;p&gt;The child opens tonight's poem on the family Mac, hides the text and presses &lt;em&gt;Je récite&lt;/em&gt;. When she is done, Puesia shows the poem again with her mistakes marked: a swapped word in red, with what it heard next to it, a skipped line highlighted, a missing word underlined. A short summary is read aloud. If she gets stuck in the middle, she presses &lt;em&gt;Je bloque !&lt;/em&gt;: the recording pauses, Puesia works out where she stopped and reads her the start of the next line.&lt;/p&gt;

&lt;p&gt;It also helps with the week. The child dictates her homework diary (« pour lundi, apprendre la poésie ; pour demain, exercices 4 et 5 page 32 »), and Puesia spreads the poem over the evenings that are left: listen to it first, then one stanza a night, then the whole poem the evening before.&lt;/p&gt;

&lt;p&gt;Everything runs on the Mac: Whisper listens, my code judges, Piper speaks, Gemma 4 reads the homework diary. The child's voice never leaves the house and is never written to disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who would pay for it?&lt;/strong&gt; Parents of children aged 6 to 11 who can't be the listener every evening: shift workers, single parents, parents who don't read French easily and can't check a recitation, or read the poem aloud for the child to learn by ear. At our house, honestly, this isn't a problem: I'm home in the evening, and my daughter, who is ten, recites to me. She was the tester. I'm the one who said I'd pay for it, and how much is in &lt;em&gt;My First User&lt;/em&gt;.&lt;/p&gt;

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

&lt;p&gt;These screenshots come from the real app with a synthetic voice, because my daughter's poem and her voice stay at home. The poem is Verlaine's &lt;em&gt;Chanson d'automne&lt;/em&gt;, read by the Piper voice Tom with two mistakes on purpose: &lt;em&gt;ton cœur&lt;/em&gt; instead of &lt;em&gt;mon cœur&lt;/em&gt;, and line 9 skipped.&lt;/p&gt;

&lt;p&gt;The evening view, after adding three pieces of homework (typed into the box for the screenshot; at home they were dictated):&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%2Flwzflem4o5kbo3wdadcw.jpg" 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%2Flwzflem4o5kbo3wdadcw.jpg" alt="Puesia's evening view: tonight's steps, with the first stanza of Chanson d'automne, maths exercises and a lesson to reread" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The poem after the recitation. The swapped word is in red with what Whisper heard, the skipped line is highlighted:&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%2Fpkuu9nqlsmidthmn7ub2.jpg" 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%2Fpkuu9nqlsmidthmn7ub2.jpg" alt="Chanson d'automne with " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the summary that is read aloud:&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%2Fxb5mhrnh5vc9o1teqky6.jpg" 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%2Fxb5mhrnh5vc9o1teqky6.jpg" alt="The feedback: 16 of 18 lines without a mistake, line 4 " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That run caught both mistakes and nothing else. On an earlier run of the same recitation, it also flagged the last line: Whisper heard &lt;em&gt;fée&lt;/em&gt; instead of &lt;em&gt;feuille&lt;/em&gt;. The voice said &lt;em&gt;feuille&lt;/em&gt;. That is the kind of false alarm the benchmark below counts.&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/sintineddi" rel="noopener noreferrer"&gt;
        sintineddi
      &lt;/a&gt; / &lt;a href="https://github.com/sintineddi/puesia" rel="noopener noreferrer"&gt;
        puesia
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      An evening recitation partner for French schoolchildren, running on the family Mac: Whisper listens, the code checks word by word, Piper speaks, Gemma 4 plans the week.
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;


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

&lt;h3&gt;
  
  
  Who does what
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Whisper &lt;code&gt;large-v3-turbo&lt;/code&gt; (MLX, on the Mac)&lt;/td&gt;
&lt;td&gt;Turns the recitation into text, in about a second for twenty seconds of speech&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;My code&lt;/td&gt;
&lt;td&gt;Compares that text with the poem, word by word, and decides what is a mistake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Piper&lt;/td&gt;
&lt;td&gt;Reads the poem, the hint and the summary aloud, in the voice the child picks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 &lt;code&gt;e4b&lt;/code&gt; (Ollama)&lt;/td&gt;
&lt;td&gt;Proposes the homework tasks from the dictated diary&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I didn't ask a language model to judge the recitation. A child should get the same verdict twice for the same recitation, and when Puesia says &lt;em&gt;you skipped line 9&lt;/em&gt;, I want to be able to show why. So the judging is code: about 500 lines of Python, with tests for every case below.&lt;/p&gt;

&lt;h3&gt;
  
  
  French is full of words that sound the same
&lt;/h3&gt;

&lt;p&gt;A transcription is spelling, and a recitation is sound. Whisper may write &lt;em&gt;vert&lt;/em&gt; where the poem says &lt;em&gt;vers&lt;/em&gt;, &lt;em&gt;pleurent&lt;/em&gt; where it says &lt;em&gt;pleure&lt;/em&gt;, &lt;em&gt;temps&lt;/em&gt; where it says &lt;em&gt;tant&lt;/em&gt;: the child said the right thing, but the spelling differs. If Puesia counted those, it would scold a child for Whisper's spelling.&lt;/p&gt;

&lt;p&gt;So the comparison first turns every word into a rough sound key: about thirty rules for French endings, nasals and silent letters. &lt;em&gt;Vers&lt;/em&gt;, &lt;em&gt;vert&lt;/em&gt;, &lt;em&gt;verre&lt;/em&gt; and &lt;em&gt;ver&lt;/em&gt; get the same key. &lt;em&gt;Mon&lt;/em&gt; and &lt;em&gt;ton&lt;/em&gt;, &lt;em&gt;un&lt;/em&gt; and &lt;em&gt;une&lt;/em&gt;, &lt;em&gt;petit&lt;/em&gt; and &lt;em&gt;petite&lt;/em&gt; don't, because those are real mistakes a child makes. Then it aligns the poem with the transcription (an edit distance that also accepts &lt;em&gt;de ça&lt;/em&gt; for &lt;em&gt;deçà&lt;/em&gt;) and forgives hesitations, &lt;em&gt;euh&lt;/em&gt; and restarts.&lt;/p&gt;

&lt;p&gt;The rules are tested on pairs like these, and several came from the benchmark: when the false alarms were sorted by cause, &lt;em&gt;maître&lt;/em&gt; heard as &lt;em&gt;mettre&lt;/em&gt;, or &lt;em&gt;au temps&lt;/em&gt; heard as &lt;em&gt;autant&lt;/em&gt;, were my rules' fault, not Whisper's.&lt;/p&gt;

&lt;h3&gt;
  
  
  The more you help Whisper, the less it hears the mistakes
&lt;/h3&gt;

&lt;p&gt;Whisper accepts an &lt;code&gt;initial_prompt&lt;/code&gt;: text it has supposedly just heard. Giving it the poem looks like an obvious improvement. It knows every word in advance, so it should mishear less. I measured it before trusting it.&lt;/p&gt;

&lt;p&gt;The benchmark builds recitations of five public-domain poems (Verlaine, Hugo, Rimbaud and two La Fontaine fables) with Piper voices. It injects 60 mistakes (skipped lines, skipped words, words swapped for a close or a distant one) plus clean and hesitant recitations, adds background noise to some, and runs everything through three sizes of Whisper, with and without the poem as a prompt. That makes 90 recitations per configuration, all on my laptop.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Whisper&lt;/th&gt;
&lt;th&gt;Mistakes caught&lt;/th&gt;
&lt;th&gt;False alarms per 100 words&lt;/th&gt;
&lt;th&gt;Time per recitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;turbo&lt;/code&gt;, no text (what Puesia uses)&lt;/td&gt;
&lt;td&gt;57 / 60&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;2.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;turbo&lt;/code&gt;, with the poem&lt;/td&gt;
&lt;td&gt;50 / 60&lt;/td&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;td&gt;2.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;medium&lt;/code&gt;, no text&lt;/td&gt;
&lt;td&gt;57 / 60&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;td&gt;3.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;medium&lt;/code&gt;, with the poem&lt;/td&gt;
&lt;td&gt;40 / 60&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;td&gt;3.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;small&lt;/code&gt;, no text&lt;/td&gt;
&lt;td&gt;56 / 60&lt;/td&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;1.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the poem, Whisper makes fewer false alarms, and it hides real mistakes. The voice says &lt;em&gt;Blessent ton cœur&lt;/em&gt;; &lt;code&gt;medium&lt;/code&gt; with the poem writes &lt;em&gt;Blessent mon cœur&lt;/em&gt;. It stops being only a listener and becomes a reader. You can even see it in the punctuation: given &lt;em&gt;Le Corbeau et le Renard&lt;/em&gt;, &lt;code&gt;turbo&lt;/code&gt; with the poem writes the fable's own capitals and guillemets, « Hé ! bonjour, Monsieur du Corbeau », which a voice can't pronounce. The bigger model is the more obedient one: &lt;code&gt;medium&lt;/code&gt; hides 18 mistakes that it catches without the prompt.&lt;/p&gt;

&lt;p&gt;For a tool whose whole job is to notice what the child got wrong, that is the wrong trade. Puesia doesn't give Whisper the text.&lt;/p&gt;

&lt;p&gt;I also tried the obvious fix for false alarms: Whisper gives each word a probability, so a wrong word heard with low confidence could be shown as "to check" instead of "wrong". It doesn't separate the two cases. A real swap surprises Whisper's language model as much as a mishearing, so lowering the alarms that way also loses real mistakes (&lt;code&gt;turbo&lt;/code&gt; catches 13 of 15 close swaps instead of 15 at a 0.5 threshold). The option is in the code and switched off.&lt;/p&gt;

&lt;p&gt;The false alarms that remain are mostly real mishearings, and many come from the same place as the prompt problem: Whisper's sense of what French usually sounds like. &lt;em&gt;Ni l'or du soir&lt;/em&gt; becomes &lt;em&gt;ni l'heure du soir&lt;/em&gt;; &lt;em&gt;tenait en son bec&lt;/em&gt; becomes &lt;em&gt;dans son bec&lt;/em&gt;. The voice matters a lot. With Tom, &lt;code&gt;turbo&lt;/code&gt; makes 0.8 false alarms per 100 words; with the UPMC voices, 5.7.&lt;/p&gt;

&lt;p&gt;The big limit: these are synthetic adult voices, not children. The benchmark tells me which setting to choose, not how well Puesia hears a ten-year-old. That came from my daughter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemma proposes, the code checks
&lt;/h3&gt;

&lt;p&gt;The homework diary is where a language model earns its place. Children dictate homework in every possible order (« euh, pour vendredi, la poésie, et lundi en histoire apprendre la leçon sur les Gaulois »), and Gemma 4 &lt;code&gt;e4b&lt;/code&gt; turns it into a list: subject, task, and the exact passage of the dictation it comes from.&lt;/p&gt;

&lt;p&gt;Then the code checks every item:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The passage must really be in the dictation, word for word and in order. A task made by gluing two halves of different sentences together is rejected.&lt;/li&gt;
&lt;li&gt;The code reads the dates itself, in the child's words around that passage: &lt;em&gt;demain&lt;/em&gt;, &lt;em&gt;jeudi&lt;/em&gt;, &lt;em&gt;pour le 16&lt;/em&gt;, &lt;em&gt;la semaine prochaine&lt;/em&gt;. Gemma doesn't give the date. In « pour jeudi apprendre la poésie et pour vendredi faire les exercices », each task keeps its own day.&lt;/li&gt;
&lt;li&gt;Known poem titles are masked when reading dates, so &lt;em&gt;Demain, dès l'aube&lt;/em&gt; isn't due tomorrow.&lt;/li&gt;
&lt;li&gt;If Gemma rewrites a detail (&lt;em&gt;exercices 6 et 5&lt;/em&gt; when the child said &lt;em&gt;4 et 5&lt;/em&gt;), the child sees her own words, not Gemma's.&lt;/li&gt;
&lt;li&gt;A poem mentioned in the diary is linked to its text as soon as the text is added, and the plan becomes one stanza a night.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On my laptop, Gemma takes 6 to 11 seconds once it is loaded. The first evening it wasn't loaded yet, the first dictation was slow, and we reloaded the page while it was still working. Puesia now loads Gemma at startup and shows a counter while it waits.&lt;/p&gt;

&lt;h3&gt;
  
  
  We tried it on this week's poem
&lt;/h3&gt;

&lt;p&gt;On Wednesday evening, my daughter tried Puesia on her real homework: a three-stanza autumn poem for Friday, copied in from the school sheet.&lt;/p&gt;

&lt;p&gt;It worked. The summary caught the mistakes she made, and once she used &lt;em&gt;Je bloque !&lt;/em&gt; to get going again. Her homework dictation went into the week too, with one slip worth telling: &lt;em&gt;la poésie&lt;/em&gt; came out of Whisper as &lt;em&gt;la poser&lt;/em&gt;, which isn't a word Puesia can link to a poem. A child's voice is harder for Whisper than an adult's, and that sentence is the reason I won't publish an accuracy figure for children until I have measured one.&lt;/p&gt;

&lt;p&gt;Two things changed that same evening. First, she didn't like the default voice, so Puesia now offers four French voices, a speed and a tone, with a button to try them. Then we wanted a more natural reading. Piper was reading each line as a sentence of its own, falling at every line end. It now reads a whole stanza at once, with a short pause at the end of each line, so the voice follows the poem's sentences. I checked with Whisper that the poem stays just as clear read that way.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A child's voice stays at home.&lt;/strong&gt; The recording goes from the microphone to the Mac's memory, is transcribed, and is forgotten. There is no account and no upload. For a product whose users are six to eleven, I don't want to have to explain where their voices go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It makes "a few euros" possible.&lt;/strong&gt; A cloud speech API is billed by the minute, and a child who practises a poem ten times a week is a lot of minutes for a tool that would sell for a few euros. Here the cost per recitation is a bit of electricity. That is also how I could afford the benchmark: 540 transcriptions, plus every run I threw away while fixing my own rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could look inside the listening.&lt;/strong&gt; The finding that matters most for Puesia, that the prompt makes Whisper deaf to mistakes, came from changing how Whisper decodes and reading each word's probability. I controlled every setting, ran the same audio through three model sizes, and kept what the code needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The licences shape the business.&lt;/strong&gt; Open doesn't mean free of rules. Whisper is MIT. Piper is GPL-3.0. Of the voices, Siwis comes from a CC-BY dataset, Jessica and Pierre from a CC-BY-SA one, and Tom from an AGPL one. Gemma comes with its own terms of use. Selling Puesia is allowed, but the code has to stay open. So the side hustle isn't secret code: it is a signed installer that works the first time, the voices already downloaded, and help when the microphone doesn't. I think that is fair for a few euros.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The next step needs open weights.&lt;/strong&gt; The honest weakness is children's voices. With open weights, Whisper can be fine-tuned on recordings of children reciting, made with their parents' consent, and the result can stay on the families' own machines.&lt;/p&gt;

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

&lt;p&gt;I built Puesia with Claude Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;My daughter, ten, was the first user, on her real poem of the week. It caught her mistakes. She didn't like the first voice, so Puesia got a voice picker the same evening, and the poem is now read stanza by stanza.&lt;/p&gt;

&lt;p&gt;She also said something I didn't expect. She is more tense reciting to the computer than to me. &lt;em&gt;Scared&lt;/em&gt; isn't the word, but it puts pressure on her. A parent listens with a smile and lets a small slip go; a screen with red words feels like an exam board. French readers of a certain age will think of &lt;em&gt;Les Sous-doués passent le bac&lt;/em&gt;, Claude Zidi's 1980 comedy where a class of hopeless students tries every trick in the book to get through the &lt;em&gt;bac&lt;/em&gt;. A ten-year-old reciting an autumn poem in the kitchen shouldn't feel she is sitting the &lt;em&gt;bac&lt;/em&gt;, and in Puesia the only whisper from the back of the room is allowed: it's called &lt;em&gt;Je bloque !&lt;/em&gt; So the next version needs a gentler practice mode: encouragement before corrections, and perhaps no red at all until the evening before the poem is due.&lt;/p&gt;

&lt;p&gt;Would someone pay? I would. For a few euros, it would be worth it. No other parent has tried it yet, so that is one answer, from someone who doesn't really need it. The parents who do need it are the ones I'll show it to next.&lt;/p&gt;

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

&lt;p&gt;Best Use of Gemma.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>child</category>
    </item>
    <item>
      <title>A saved reply is written for one customer. Your support bot can send it to anyone.</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Wed, 14 Oct 2026 12:16:57 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/a-saved-reply-is-written-for-one-customer-your-support-bot-can-send-it-to-anyone-5fce</link>
      <guid>https://dev.to/pierrelaurentmedori/a-saved-reply-is-written-for-one-customer-your-support-bot-can-send-it-to-anyone-5fce</guid>
      <description>&lt;p&gt;A common way to give a support bot knowledge is to let your agents save their good answers. A ticket gets solved, the agent clicks save, and the answer joins a set the bot retrieves from when a similar question comes in. Many support tools offer some version of it. We built ours.&lt;/p&gt;

&lt;p&gt;On October 8, we read every one of those saved answers as if our bot were about to quote them to a stranger. It was: a filter bug had kept most of them out of the bot's prompts for months, and the fix was about to make all of them eligible again. What we found had nothing to do with the bug, and I think it applies to anyone who turns tickets into bot knowledge.&lt;/p&gt;

&lt;p&gt;Some context. I run engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform. Our AI assistant drafts answers to customer support questions; I wrote about it &lt;a href="https://dev.to/pierrelaurentmedori/our-support-agent-recommended-replacing-a-valid-api-key-31d7"&gt;a few weeks ago&lt;/a&gt;. It draws on two sources: our help center, and the saved answers. When a customer asks something, we embed the question, take the closest saved answers of the same app type, at most three within a cosine distance of 0.5, and put them in the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a saved answer comes from
&lt;/h2&gt;

&lt;p&gt;When an agent creates a saved answer from a ticket, the form is pre-filled with the ticket itself: its subject, the customer's last messages, and the agent's last reply. That is convenient, and it is the whole problem: unless the agent rewrites it, the ticket is what gets saved.&lt;/p&gt;

&lt;p&gt;What gets saved is a reply written to one person, about their account, at a given moment. A colleague reading it as an example would adapt it. A retriever adapts nothing: to it, the reply is a candidate answer for anyone whose question lands close enough in embedding space.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a ticket leaves in an answer
&lt;/h2&gt;

&lt;p&gt;Claude read all 65 saved answers, about 98,000 characters, and listed everything that would be wrong if the bot repeated it to someone else. Sorted, it comes down to a short list of things a ticket carries, and what each one turns into once a bot reuses it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what the ticket left&lt;/th&gt;
&lt;th&gt;example (anonymized)&lt;/th&gt;
&lt;th&gt;what the bot can do with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Identity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;first names, last names, emails of customers and of third parties, app names, domains, greetings and agent signatures&lt;/td&gt;
&lt;td&gt;tell the next customer someone else's name or email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Links into one account&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;about a dozen answers linked to a specific customer's back office; one linked to a support agent's test project&lt;/td&gt;
&lt;td&gt;send the next customer into an account that isn't theirs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Things done once&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"The issue has been resolved." "When I test it, I can see the thumbnail." "Your analysis is correct."&lt;/td&gt;
&lt;td&gt;claim work nobody did, on an account nobody looked at&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Statements on someone's behalf&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;an app store appeal letter, ready to send, in one customer's name, declaring "under penalty of perjury" that they were the sole owner&lt;/td&gt;
&lt;td&gt;hand a stranger a sworn statement written for someone else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"this past Sunday", a verification deadline that had already passed&lt;/td&gt;
&lt;td&gt;give dates that were true once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assumptions about the setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;answers that took the customer's publishing setup for granted, with no condition&lt;/td&gt;
&lt;td&gt;give the right answer to the wrong customer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ticket also leaves its shape. Titles copied from another ticket. An answer that covered one of the four questions asked. Two saved answers to the same data-protection question, promising different things; the bot could have picked one or the other depending on how the question was phrased. And help links to articles that don't exist, or to the wrong one.&lt;/p&gt;

&lt;p&gt;Most of this isn't carelessness: as replies to the original customer, much of it made sense. The problem is the save button: it turns a reply into knowledge without asking anyone to rewrite it for a reader who isn't that customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rewriting them
&lt;/h2&gt;

&lt;p&gt;We went through them one at a time, the most dangerous first. For each one, Claude gave me the link, what the answer contained, why it was a problem, and a generic replacement. I pasted it in the admin, and Claude re-read the row in the database to check it, including that the embedding had been recomputed, since a rewritten answer that keeps its old vector is still retrieved for the old text.&lt;/p&gt;

&lt;p&gt;Most answers had a reusable core worth keeping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The perjury letter became the list of proof the store expects, written for anyone.&lt;/li&gt;
&lt;li&gt;"The issue has been resolved" kept the only advice that applied to everyone: save again, then check the preview.&lt;/li&gt;
&lt;li&gt;A detailed diagnosis of one customer's site kept its technical explanation and lost the site.&lt;/li&gt;
&lt;li&gt;One answer stated two different rules in three lines, and the documentation didn't say which one was true. The rewrite gives an instruction that works under both, and the question went into the recap for the support team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of the day, about thirty of the 65 had been rewritten and one deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules for the next one
&lt;/h2&gt;

&lt;p&gt;The cleanup only holds if the next answers are written differently, so we wrote ten rules for the support team. The ones that matter most:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A saved answer is generic.&lt;/strong&gt; Reread it asking: if the bot copies this to another customer, is it still right?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No customer data&lt;/strong&gt;: no name, email, app name, domain, ID, greeting or signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No links into an account.&lt;/strong&gt; Write the menu path instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't describe what you did.&lt;/strong&gt; "I tested it", "it's fixed": the bot won't have done it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dates.&lt;/strong&gt; A saved answer lives for months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the answer depends on the case, write both branches&lt;/strong&gt; instead of assuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The question is what retrieval matches on.&lt;/strong&gt; Make it clear and complete, and make the answer cover everything it asks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One topic, one saved answer.&lt;/strong&gt; If two answer the same question, keep one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first rule is the only one you really need. The others are what it looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean answers make a loose threshold cheap
&lt;/h2&gt;

&lt;p&gt;The next morning, with the dangerous answers rewritten and the set actually reaching the bot, we looked at how it was used. Eleven answers still carried a first name or a signature; none of the replies had picked one up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;42% of replies carried at least one saved answer&lt;/strong&gt;, drawn from more than twenty different ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The match is often loose.&lt;/strong&gt; Reading the 72 questions that received one: under a distance of 0.40, the saved answer was relevant 9 times out of 12. Between 0.40 and 0.50, where about 80% of the injections fall, it was mostly off-topic, down to saved answers attached to messages like "Hi".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model mostly ignores the off-topic ones.&lt;/strong&gt; Ten ordinary questions about push notifications received a saved answer about a different feature; none of the ten replies mentioned it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether they make replies better is not measured yet.&lt;/strong&gt; Votes are identical with and without, 11 up and 6 down each. Replies with a saved answer send people to the ticket form less, 18.6% against 34.0%, but they also come from more specific questions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those together and the asymmetry is the point. With clean answers, a loose threshold has cost nothing visible so far: in the cases we read, the model skipped what didn't fit. With the answers as they were, the same threshold would have offered the perjury letter to anyone asking about the same kind of store rejection, and a stranger's back-office link to anyone with a close enough question about domains. In our case, the risk was in what went into the set, not in the threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change in the tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't pre-fill a saved answer with the raw ticket.&lt;/strong&gt; Pre-fill a template that asks for a generic question and a generic answer, or at least show the one-question test next to the save button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check before saving.&lt;/strong&gt; Names, emails, domains, links into an account, dates, "I tested", "resolved": all of these can be flagged automatically before the answer joins the set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review the set before anything widens retrieval.&lt;/strong&gt; A bug fix, a looser threshold, a new embedding model: each one puts answers in front of customers that nobody has read in that role.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you turn tickets into knowledge for a bot: what does your save button do to the ticket?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
