<?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: Forrest Miller</title>
    <description>The latest articles on DEV Community by Forrest Miller (@forrestmiller).</description>
    <link>https://dev.to/forrestmiller</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3872475%2F1b38c5a4-9313-4bc3-8bfd-a21db422888e.jpg</url>
      <title>DEV Community: Forrest Miller</title>
      <link>https://dev.to/forrestmiller</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/forrestmiller"/>
    <language>en</language>
    <item>
      <title>Date-Scoped Travel Pages Turned Into ValidaTrip’s First Unbranded Search Wedge</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 02 Jun 2026 12:57:04 +0000</pubDate>
      <link>https://dev.to/forrestmiller/date-scoped-travel-pages-turned-into-validatrips-first-unbranded-search-wedge-3679</link>
      <guid>https://dev.to/forrestmiller/date-scoped-travel-pages-turned-into-validatrips-first-unbranded-search-wedge-3679</guid>
      <description>&lt;p&gt;On June 2, 2026, I pulled fresh Search Console data for &lt;a href="https://www.validatrip.com/" rel="noopener noreferrer"&gt;ValidaTrip&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The site had 319 clicks and 11,459 impressions across the prior 28 days. Visible unbranded query rows accounted for 40 clicks and 1,493 impressions.&lt;/p&gt;

&lt;p&gt;That mattered because the prior baseline was branded-only.&lt;/p&gt;

&lt;p&gt;The first unbranded wedge was not a generic AI travel term. It was date-scoped city event search.&lt;/p&gt;

&lt;p&gt;Queries like “madrid events july 2026” and “stockholm events july 2026” sent clicks. That is small, but it is real.&lt;/p&gt;

&lt;p&gt;The pages receiving that demand were built as city-month surfaces, not blog posts.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/things-to-do/madrid/july-2026" rel="noopener noreferrer"&gt;Madrid events in July 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/things-to-do/stockholm/july-2026" rel="noopener noreferrer"&gt;Stockholm events in July 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/things-to-do/mexico-city/november-2026" rel="noopener noreferrer"&gt;Mexico City events in November 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The implementation point is simple. Travel search has a date dimension that generic itinerary pages often ignore.&lt;/p&gt;

&lt;p&gt;A traveler does not only ask for “things to do in Madrid.” They ask what is happening while they are there.&lt;/p&gt;

&lt;p&gt;That changes the content shape.&lt;/p&gt;

&lt;p&gt;A useful travel page needs the destination, the month, the event window, and the itinerary context. It also needs a reason to exist beyond a list.&lt;/p&gt;

&lt;p&gt;ValidaTrip already checks trip plans against opening hours, closures, holidays, bookings, neighborhoods, and maps.&lt;/p&gt;

&lt;p&gt;The city-month pages connect that checker to demand that already has dates baked in.&lt;/p&gt;

&lt;p&gt;The product flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A traveler searches for events in a city and month.&lt;/li&gt;
&lt;li&gt;They land on a date-scoped city page.&lt;/li&gt;
&lt;li&gt;They see relevant events, attractions, and trip timing context.&lt;/li&gt;
&lt;li&gt;They paste their own plan into &lt;a href="https://www.validatrip.com/validate-trip-hours" rel="noopener noreferrer"&gt;the trip-hours validator&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;They catch closed places, booking-sensitive stops, and missed event windows before the trip.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same surface supports AI travel checks.&lt;/p&gt;

&lt;p&gt;ChatGPT can write a plausible travel plan without knowing live hours. It can also miss a festival that overlaps the exact trip.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;ChatGPT itinerary checker&lt;/a&gt; handles the post-AI reality check.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.validatrip.com/guides/missed-events-and-festivals" rel="noopener noreferrer"&gt;missed events and festivals guide&lt;/a&gt; explains the event problem directly.&lt;/p&gt;

&lt;p&gt;The public AI citation layer mirrors that answer in &lt;a href="https://www.validatrip.com/llms-full.txt" rel="noopener noreferrer"&gt;llms-full.txt&lt;/a&gt;. That file gives AI search systems canonical wording and canonical URLs.&lt;/p&gt;

&lt;p&gt;The ranking lesson is not “publish more pages.”&lt;/p&gt;

&lt;p&gt;It is “publish pages where a specific traveler intent already includes a city, date, and action.”&lt;/p&gt;

&lt;p&gt;For ValidaTrip, that intent is not abstract.&lt;/p&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What events are in this city during my trip?&lt;/li&gt;
&lt;li&gt;Is this place open when I arrive?&lt;/li&gt;
&lt;li&gt;Does this AI itinerary work on real dates?&lt;/li&gt;
&lt;li&gt;Can I turn these notes into a checked map?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions map to pages with a clear job.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/destinations" rel="noopener noreferrer"&gt;Find the city-month event page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/validate-trip-hours" rel="noopener noreferrer"&gt;Check the trip hours&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;Validate a ChatGPT itinerary&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/guides/missed-events-and-festivals" rel="noopener noreferrer"&gt;Read the event-overlap guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The traffic is early. The mechanism is visible now.&lt;/p&gt;

&lt;p&gt;Search Console showed unbranded clicks after the site exposed city-month event pages with clean URLs and crawlable content.&lt;/p&gt;

&lt;p&gt;That is the surface I am doubling down on.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>buildinpublic</category>
      <category>marketing</category>
      <category>startup</category>
    </item>
    <item>
      <title>I Built a Free Bingo Caller Board With 331 Audio Clips and No Backend</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 02 Jun 2026 12:28:40 +0000</pubDate>
      <link>https://dev.to/forrestmiller/i-built-a-free-bingo-caller-board-with-331-audio-clips-and-no-backend-3dad</link>
      <guid>https://dev.to/forrestmiller/i-built-a-free-bingo-caller-board-with-331-audio-clips-and-no-backend-3dad</guid>
      <description>&lt;p&gt;A bingo caller board has four jobs: draw a number without repeats, say it out loud, mark it on a flashboard, and leave enough history on screen to check a winner. I built that as a browser-only feature for &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, because the room already has one host and one screen. There is no shared state worth sending to a server.&lt;/p&gt;

&lt;p&gt;The live version is here: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;. It supports 75-ball, &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;90-ball&lt;/a&gt;, and &lt;a href="https://bingwow.com/caller/30-ball" rel="noopener noreferrer"&gt;30-ball speed bingo&lt;/a&gt;, with voice calls and a fullscreen board for a TV or projector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caller is local state
&lt;/h2&gt;

&lt;p&gt;A multiplayer bingo game needs a server because every player has an independent board and every tap needs authority. A standalone caller does not. One host runs it. The output is public in the room. Refreshing starts a new game, which is normal for a caller.&lt;/p&gt;

&lt;p&gt;The main state is a reducer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CallerState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BallMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// '30' | '75' | '90'&lt;/span&gt;
  &lt;span class="nl"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;          &lt;span class="c1"&gt;// shuffled, pop() to draw&lt;/span&gt;
  &lt;span class="nl"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BingoBall&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;calledSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// O(1) caller-board lookup&lt;/span&gt;
  &lt;span class="nl"&gt;isAutoMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;roundNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DRAW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;makeBall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller sends no request when a number is drawn. The deck is shuffled in the tab, the flashboard reads the called set, and the auto-call timer advances only after the current animation has completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I skipped the Web Speech API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;speechSynthesis.speak()&lt;/code&gt; looked like the cheap answer. It failed the product test.&lt;/p&gt;

&lt;p&gt;The available voices differ by OS and browser. A calm voice on a Mac became a different voice on a Chromebook. Rapid calls also queued badly during auto-call, and traditional 90-ball nicknames sounded flat when a browser voice read them.&lt;/p&gt;

&lt;p&gt;The shipped caller uses 331 prerecorded MP3s instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;number calls for 30, 75, and 90-ball games&lt;/li&gt;
&lt;li&gt;every traditional 90-ball nickname, from "Legs eleven" to "Two fat ladies"&lt;/li&gt;
&lt;li&gt;welcome, pause, progress, and round-transition clips&lt;/li&gt;
&lt;li&gt;short clips that fit between visual calls at faster speeds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes the &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;90-ball bingo caller&lt;/a&gt; sound the same in a classroom, a senior center, a church hall, or a browser tab on a TV.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flashboard is the product
&lt;/h2&gt;

&lt;p&gt;Most users describe the same surface as a bingo caller board, bingo calling board, or flashboard. The code treats it as a pure display component: it does not own the game; it only renders the current mode and called numbers.&lt;/p&gt;

&lt;p&gt;75-ball uses B-I-N-G-O columns. 90-ball and 30-ball use number ranges. The shared caller component seeds the mode from the route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// /caller defaults to 75-ball&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CallerClient&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// /caller/90-ball&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CallerClient&lt;/span&gt; &lt;span class="na"&gt;initialMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"90"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// /caller/30-ball&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CallerClient&lt;/span&gt; &lt;span class="na"&gt;initialMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"30"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave each mode its own indexable page while keeping one caller implementation. The 90-ball page also includes an interactive list of all 90 traditional calls, built from the same audio manifest used by the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio fires from the animation timeline
&lt;/h2&gt;

&lt;p&gt;The drawn ball flies into its cell on the flashboard. The number needs to be spoken at impact, not when React finishes rendering. The first implementation keyed audio from a state effect, and it drifted during auto-call.&lt;/p&gt;

&lt;p&gt;The fix was to fire audio from the animation callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;runFlyingBallToCell&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;ball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;targetCell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;onAbsorbed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCellRevealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;voiceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;playBallImpact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lingoEnabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeline owns timing, the reducer owns state, and the flashboard stays a pure read model. That split kept manual draw, auto-call, voice mute, and fullscreen mode from fighting each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Printing completes the offline game
&lt;/h2&gt;

&lt;p&gt;A free caller alone is only half a bingo night. Players still need cards. The 75-ball caller has a Print Cards flow that generates up to 500 unique cards with validation codes. The support guide is here: &lt;a href="https://bingwow.com/blog/free-online-bingo-caller" rel="noopener noreferrer"&gt;Free online bingo caller&lt;/a&gt;, and the card printer is here: &lt;a href="https://bingwow.com/print" rel="noopener noreferrer"&gt;bingwow.com/print&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The practical setup is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;free caller board&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Print cards or hand out existing tickets.&lt;/li&gt;
&lt;li&gt;Use fullscreen on a TV or projector.&lt;/li&gt;
&lt;li&gt;Start manual draw or auto-call.&lt;/li&gt;
&lt;li&gt;Check the winner against the call history.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try the caller
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;75-ball caller board: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;90-ball UK caller: &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;bingwow.com/caller/90-ball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;30-ball speed caller: &lt;a href="https://bingwow.com/caller/30-ball" rel="noopener noreferrer"&gt;bingwow.com/caller/30-ball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;No-equipment host guide: &lt;a href="https://bingwow.com/blog/free-online-bingo-caller" rel="noopener noreferrer"&gt;free online bingo caller&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bingo night guide: &lt;a href="https://bingwow.com/blog/how-to-host-bingo-night" rel="noopener noreferrer"&gt;how to host bingo night&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Six layers to canonicalize 'FiDi', 'Wall Street area', and 'Lower Manhattan' as one neighborhood</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:10:33 +0000</pubDate>
      <link>https://dev.to/forrestmiller/six-layers-to-canonicalize-fidi-wall-street-area-and-lower-manhattan-as-one-neighborhood-5cb1</link>
      <guid>https://dev.to/forrestmiller/six-layers-to-canonicalize-fidi-wall-street-area-and-lower-manhattan-as-one-neighborhood-5cb1</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A user pastes their travel list into &lt;a href="https://www.validatrip.com/" rel="noopener noreferrer"&gt;our travel-paste validator&lt;/a&gt;. They got recs from a friend, a blog, an AI itinerary, and a Reddit thread. The list mentions Lower Manhattan four ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"FiDi"&lt;/li&gt;
&lt;li&gt;"Wall Street area"&lt;/li&gt;
&lt;li&gt;"Financial District NYC"&lt;/li&gt;
&lt;li&gt;"Lower Manhattan financial district"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our Neighborhoods tab groups by area so users can &lt;a href="https://www.validatrip.com/guides/plan-trip-days-by-neighborhood" rel="noopener noreferrer"&gt;plan a day at a time&lt;/a&gt;. Without canonicalization, those four labels become four area cards with one place each. The user can't tell that they're looking at one walkable district. The product looks broken.&lt;/p&gt;

&lt;p&gt;This is a small example. The full surface across 145 cities is thousands of variants per city — formal vs informal, English vs local, Wikipedia title vs administrative subdivision, polygon-level granularity vs guidebook vocabulary. Solving it in one pass with a single API or a single LLM call is the obvious wrong answer.&lt;/p&gt;

&lt;p&gt;We solved it in six layers. Each layer covers a class of variants the next layer can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: spatial reverse-geocode
&lt;/h2&gt;

&lt;p&gt;When a validated item has &lt;code&gt;(lat, lng)&lt;/code&gt;, the polygon-based name overrides whatever text label the upstream Google Places match returned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/providers/places/spatial-reverse.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;fetchAddressDescriptors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GOOGLE_GEOCODING_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;areas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;addressDescriptors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;areas&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="c1"&gt;// areas are returned smallest-to-largest with WITHIN / NEAR / OUTSKIRTS&lt;/span&gt;
&lt;span class="c1"&gt;// take the smallest WITHIN — the most specific containing area&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;areas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&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="o"&gt;=&amp;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;containment&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WITHIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Google's call fails, we fall through to Mapbox v6 reverse with &lt;code&gt;types=neighborhood,locality,place&lt;/code&gt;. Result is cached per rounded-coord (4 decimal places, ~10 m precision) so adjacent items in the same neighborhood share a single billable call.&lt;/p&gt;

&lt;p&gt;This collapses the trivial split: two items that physically sit inside Tribeca will both come back labeled "Tribeca", even if one was pasted as "near the Holland Tunnel exit" and the other as "by the Mysterious Bookshop."&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: bulk alias mining via Gemini
&lt;/h2&gt;

&lt;p&gt;The spatial pass handles items with coordinates. About 2.5% of real-world pastes have no coordinates because the title alone failed to match any place ("our friend's apartment in BoCoCa", "the Marais restaurant Maya recommended"). For those, we lean on a per-city alias dictionary.&lt;/p&gt;

&lt;p&gt;We mined the dictionary once with a single Gemini 2.5 Flash call per city:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/places/mine-aliases-gemini.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`For each canonical neighborhood in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, list the
informal abbreviations, prefix-stripped forms, article variations,
multilingual transliterations, and common misspellings real travelers use.

Return strict JSON: { "&amp;lt;canonical&amp;gt;": ["alias1", "alias2", ...] }

Canonical list:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;canonicalEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We tried this against the Wikidata Action API first. The anon &lt;code&gt;wbsearchentities&lt;/code&gt; + &lt;code&gt;wbgetentities&lt;/code&gt; flow rate-limits at roughly 1 request per second with 11-second &lt;code&gt;Retry-After&lt;/code&gt; headers under load. At ~75K canonical entries with ~4 fetches each, the wall-clock cost is days.&lt;/p&gt;

&lt;p&gt;The Gemini call returns aliases for an entire city in one shot with hallucination guards: a returned alias is dropped if it matches another canonical entry in the same city (those are distinct neighborhoods), and aliases containing "X and Y" are dropped when the canonical doesn't (catches "Camden Town and Regent's Park" pseudo-combinations).&lt;/p&gt;

&lt;p&gt;Result: FiDi → Financial District. DUMBO → Down Under the Manhattan Bridge Overpass. Le Marais → Marais. La Roma → Roma. 渋谷 → Shibuya. The 4-spelling NYC FiDi example collapses at this layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Overture Maps polygon point-in-polygon
&lt;/h2&gt;

&lt;p&gt;Spatial reverse-geocode handles coords. The alias dictionary handles labels. Neither handles the case where a Google Places result returns the right polygon but at the wrong level of administrative granularity.&lt;/p&gt;

&lt;p&gt;NYC is the canonical example. Overture's NYC neighborhood polygons are at Community Board granularity. CB 5 covers Midtown East, Midtown West, AND Murray Hill — three guidebook neighborhoods folded into one admin zone. If we wrote the polygon's raw name to the item, the user would see a "Manhattan Community Board 5" area card instead of the three distinct neighborhoods they care about.&lt;/p&gt;

&lt;p&gt;We pre-bake the polygons once per release with DuckDB against Overture's S3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;duckdb &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
  COPY (
    SELECT id, names.primary AS name, subtype, bbox, geometry
    FROM read_parquet('s3://overturemaps-us-west-2/release/2026-05-20.0/theme=divisions/type=division_area/*')
    WHERE subtype IN ('neighborhood','microhood','macrohood','borough','localadmin')
      AND ST_Intersects(geometry, ST_GeomFromText('&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cityBbox&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'))
  ) TO 'polygons-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;slug&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.geo.json' (FORMAT 'GeoJSON');
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At validate time, we lazy-load the city's &lt;code&gt;.geo.json&lt;/code&gt;, build a &lt;code&gt;flatbush&lt;/code&gt; R-tree from pre-computed bboxes, run &lt;code&gt;@turf/boolean-point-in-polygon&lt;/code&gt; to find every enclosing polygon, and pick the most specific one by subtype rank:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUBTYPE_RANK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;microhood&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;neighborhood&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;macrohood&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;borough&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;localadmin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-city "too-coarse" skip patterns prevent the granularity downgrade. NYC skips Community Board names so the LLM resolver (Layer 5) keeps its more specific guess. London skips borough names like "City of Westminster" because that single borough covers Mayfair / Soho / Marylebone / Covent Garden / Westminster / St James's — six guidebook neighborhoods. Mexico City skips the 13 alcaldía names.&lt;/p&gt;

&lt;p&gt;The skip list is per-name, not per-subtype, because Overture occasionally labels admin areas at the &lt;code&gt;neighborhood&lt;/code&gt; subtype.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: POI gazetteer (Wikidata-derived)
&lt;/h2&gt;

&lt;p&gt;Some titles are famous enough that the title itself tells you the neighborhood. "Whitney Museum of American Art" implies Meatpacking District. "Tate Modern" implies South Bank. We mined a per-city &lt;code&gt;(title → neighborhood)&lt;/code&gt; gazetteer from Wikidata SPARQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sparql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?poiLabel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhoodLabel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&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="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?landmarkType&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="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q33506&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q1244442&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q12876&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q35112&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q860861&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="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P31&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P279&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?landmarkType&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="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhood&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="nv"&gt;?neighborhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;cityQid&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="k"&gt;UNION&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="nv"&gt;?neighborhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?parent&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="nv"&gt;?parent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;cityQid&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="k"&gt;FILTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;EXISTS&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="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P576&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?dissolved&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="k"&gt;SERVICE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;label&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="nn"&gt;bd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;serviceParam&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;language&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&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;p&gt;That gives us roughly 8,600 entries across 15 cities. Lookup is foldKey-exact only (we tried substring matching once — 14 of 23 hits on a live Paris audit were false positives, so exact-only is the conservative choice). The gazetteer file is module-cached after first load.&lt;/p&gt;

&lt;p&gt;This catches the long tail Layer 3 polygons can't catch: a paste that says "Whitney" with no coordinates and no neighborhood label resolves to Meatpacking District because the gazetteer recognizes the abbreviation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: per-trip LLM canonical resolver
&lt;/h2&gt;

&lt;p&gt;After the four deterministic layers, some labels still don't match. "Historic center of Mexico City", "Colonia Centro", "Centro Histórico", and "Centro" all describe the same neighborhood but the alias dictionary only had three of them.&lt;/p&gt;

&lt;p&gt;The fifth layer runs ONE &lt;code&gt;gpt-4o-mini&lt;/code&gt; call per trip that maps every distinct raw label to the canonical guidebook name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/validation/resolve-canonical-neighborhoods.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Map each raw label to the closest canonical neighborhood in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.

If a label doesn't clearly match any canonical entry, return it unchanged.
Don't guess across cities. Don't invent new canonical names.

Canonical: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;canonicalNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
Raw labels in this trip: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rawNeighborhoods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

Return JSON: { "&amp;lt;raw&amp;gt;": "&amp;lt;canonical or raw&amp;gt;", ... }`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two defensive filters at the boundary: the response is filtered against &lt;code&gt;Set(rawNeighborhoods)&lt;/code&gt; AND &lt;code&gt;Set(canonicalNames)&lt;/code&gt;. Any hallucinated key that isn't in the input set is dropped silently. Any value that isn't in the canonical set is dropped silently. The LLM can never hand the database a name we didn't ask about.&lt;/p&gt;

&lt;p&gt;One call per trip, not per item. A 30-item trip costs the same as a 3-item trip. Latency is amortized into the existing validate step, which already takes 8 seconds.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; is missing or the call fails, the layer is a no-op. The four deterministic layers above are sufficient for the common cases; the LLM is a long-tail backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 6: per-city consolidation maps
&lt;/h2&gt;

&lt;p&gt;After all five layers, polygon-real names sometimes still aren't the guidebook canonical. Overture subdivides Polanco into "Polanco 3ª Sección", "Polanco 4ª Sección", "Polanco 5ª Sección". Paris arrondissements arrive in 50+ surface forms ("14th arrondissement of Paris", "Paris 14e Arrondissement", "14th Arrondissement").&lt;/p&gt;

&lt;p&gt;A small per-city map collapses each polygon-real subdivision into the guidebook canonical via foldKey lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CANONICAL_CONSOLIDATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mexico city|MX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polanco 3a seccion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Polanco&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polanco 4a seccion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Polanco&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polanco 5a seccion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Polanco&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;morelos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Centro Histórico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;guerrero&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Centro Histórico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;buenavista&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Centro Histórico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// 30+ more&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paris|FR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14th arrondissement of paris&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14th Arrondissement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paris 14e arrondissement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14th Arrondissement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// 48+ more arrondissement variants&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a new polygon raw name appears in &lt;code&gt;verify-pass-d&lt;/code&gt; that should fold to an existing canonical, the fix is to add it here, not to teach the LLM to handle it stochastically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why six layers, and not one LLM call
&lt;/h2&gt;

&lt;p&gt;A single LLM call would work for any individual case. It would also cost more per validate, take longer, hallucinate occasionally, and degrade silently when the model drifts between releases.&lt;/p&gt;

&lt;p&gt;Each deterministic layer handles a class of variants the cheaper and more stable way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Strength&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Spatial reverse-geocode&lt;/td&gt;
&lt;td&gt;Exact answer when coords exist&lt;/td&gt;
&lt;td&gt;~1 Geocoding API call per unique coord&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Gemini alias mining&lt;/td&gt;
&lt;td&gt;Handles label-only no-coord cases&lt;/td&gt;
&lt;td&gt;Mined ONCE per city, $0 per validate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Overture polygon PIP&lt;/td&gt;
&lt;td&gt;Handles weird Google Places labels&lt;/td&gt;
&lt;td&gt;Lazy-load + R-tree, no API call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. POI gazetteer&lt;/td&gt;
&lt;td&gt;Famous landmarks by title alone&lt;/td&gt;
&lt;td&gt;foldKey-exact lookup, no API call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. LLM canonical resolver&lt;/td&gt;
&lt;td&gt;Long-tail rephrasings&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;gpt-4o-mini&lt;/code&gt; call per trip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. Consolidation map&lt;/td&gt;
&lt;td&gt;Polygon-real → guidebook canonical&lt;/td&gt;
&lt;td&gt;Static map lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The LLM is layer five, not layer one, because by the time we reach it, 95% of the labels have been resolved deterministically. The remaining 5% are exactly the cases where its strength — pattern-matching across spellings — matters most.&lt;/p&gt;

&lt;p&gt;That same logic applies to most "the data is messy" problems in travel software. Real pastes are heterogeneous because real users are heterogeneous. &lt;a href="https://www.validatrip.com/things-to-do/london/june-2026" rel="noopener noreferrer"&gt;A London trip&lt;/a&gt; has Tube-stop names, Wikipedia titles, blog rephrasings, and Reddit short-hand all in the same paste. The cheapest path to a clean Neighborhoods tab is to peel the layers in order: geometry first, dictionaries second, structured catalogs third, LLM last.&lt;/p&gt;

&lt;p&gt;If you want to see the result, paste anything messy into &lt;a href="https://www.validatrip.com/trips/new" rel="noopener noreferrer"&gt;a new trip&lt;/a&gt; and open the Neighborhoods tab. The canonicalization runs on every validate. The full canonical guidebook lives at &lt;a href="https://www.validatrip.com/destinations" rel="noopener noreferrer"&gt;our destinations page&lt;/a&gt; if you want to see what we're matching against, or &lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;check a ChatGPT itinerary&lt;/a&gt; if you want to see the same pipeline applied to LLM-generated travel plans.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>1,071 of our 1,190 moderation-queue cards were test pollution. Here's the 5-layer fix.</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:10:12 +0000</pubDate>
      <link>https://dev.to/forrestmiller/1071-of-our-1190-moderation-queue-cards-were-test-pollution-heres-the-5-layer-fix-1n9c</link>
      <guid>https://dev.to/forrestmiller/1071-of-our-1190-moderation-queue-cards-were-test-pollution-heres-the-5-layer-fix-1n9c</guid>
      <description>&lt;h2&gt;
  
  
  The bug we shipped twice
&lt;/h2&gt;

&lt;p&gt;Our test fixtures leaked into production. Twice.&lt;/p&gt;

&lt;p&gt;The first leak was visible. 117 QA cards reached &lt;code&gt;/cards/education/science&lt;/code&gt; because their titles looked enough like real cards to slip past the title-pattern heuristic our public surfaces used. We cleaned them up in May.&lt;/p&gt;

&lt;p&gt;The second leak was invisible until I audited the moderation queue last week. &lt;strong&gt;1,071 of 1,190 cards waiting for human review were automation pollution&lt;/strong&gt; — Playwright fixtures, a &lt;code&gt;QA 65678&lt;/code&gt; test account, timestamped runs named &lt;code&gt;PW Test&lt;/code&gt;, &lt;code&gt;Security Test&lt;/code&gt;, &lt;code&gt;Mobile PW&lt;/code&gt;, plus high-volume anon seeds like &lt;code&gt;Road Trip Bingo&lt;/code&gt; (×217) and &lt;code&gt;Test Merged Create&lt;/code&gt; (×128). They had been queued up for the AI moderator as if a human had submitted them.&lt;/p&gt;

&lt;p&gt;Neither leak was a bug in any single code path. The bug was that "is this a test row?" was a guess every consumer made on its own. So I wrote down the rule once and enforced it five ways.&lt;/p&gt;

&lt;p&gt;This is the playbook, with the actual Postgres + TypeScript that runs in &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "is this a test row?" is hard
&lt;/h2&gt;

&lt;p&gt;Test fixtures look exactly like real records. They use the same INSERT path. They carry the same shape. The only difference is intent: a human typed one, a Playwright spec typed the other.&lt;/p&gt;

&lt;p&gt;Pre-2026, our public surfaces filtered tests with title heuristics — strings like &lt;code&gt;QA test card&lt;/code&gt; or &lt;code&gt;[playwright]&lt;/code&gt; were dropped at query time. That worked until a Shiplight test forgot to include the magic substring. Then the row was indistinguishable.&lt;/p&gt;

&lt;p&gt;The fix Stripe shipped is the canonical reference: every record carries a &lt;code&gt;livemode&lt;/code&gt; boolean set at INSERT time by whichever code path created it. Public surfaces filter on it; analytics segment by it; cleanup automates on it.&lt;/p&gt;

&lt;p&gt;We needed the same shape, expanded to an enum-of-strings because we have more origins than test/prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: the origin column (NOT NULL + CHECK)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;card_templates&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'legacy_unknown'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;card_templates&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;card_templates_origin_check&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'test'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four values. &lt;code&gt;ai&lt;/code&gt; is the cron pipeline that invents brand-new cards from trending topics. &lt;code&gt;user&lt;/code&gt; is everything a human submits at &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;our create page&lt;/a&gt;. &lt;code&gt;admin&lt;/code&gt; is hand-curated. &lt;code&gt;test&lt;/code&gt; is every automation row.&lt;/p&gt;

&lt;p&gt;Why text + CHECK instead of a native ENUM: &lt;code&gt;ALTER TYPE … ADD VALUE&lt;/code&gt; acquires &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; on every table using the type, which blocks writes during the migration. text + CHECK uses &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;. Concurrent writes still work, the validation is the same.&lt;/p&gt;

&lt;p&gt;After backfill, the column is the discriminator the codebase had been faking. &lt;code&gt;creator_id IS NULL&lt;/code&gt; previously conflated AI cards with anonymous human cards (anonymous users have null &lt;code&gt;creator_id&lt;/code&gt; too — that conflation broke moderation for months before we caught it).&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: the Postgres CHECK that makes the bad state impossible
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;card_templates&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;card_templates_no_published_test_origin&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'test'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the load-bearing line.&lt;/p&gt;

&lt;p&gt;Any INSERT or UPDATE that would set &lt;code&gt;origin='test' AND status='published'&lt;/code&gt; returns &lt;code&gt;23514 check_violation&lt;/code&gt;. Postgres refuses the write. The application code doesn't get a chance to do the wrong thing.&lt;/p&gt;

&lt;p&gt;Application-level guards are necessary but not sufficient. A direct service-role write from a test fixture bypasses every TypeScript type-check. A future contributor who copy-pastes a working INSERT block from another file inherits whatever assumptions that file made. The database constraint is the only thing every write path passes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: &lt;code&gt;isTestTraffic()&lt;/code&gt; at every INSERT site
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isTestTraffic&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;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;is_automated&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;is_automated&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isInternalRequest&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;userId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// THE rule. Real user iff bingwow_anon_id cookie present.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^|;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;bingwow_anon_id=/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three card-creation routes call this on every POST: &lt;a href="https://bingwow.com/blog/how-to-make-bingo-cards-online" rel="noopener noreferrer"&gt;card creation&lt;/a&gt;, fork-and-start, and clone. The origin is computed at the row's birth — &lt;code&gt;isTestTraffic(request, body, userId) ? 'test' : 'user'&lt;/code&gt; — and written into the same INSERT.&lt;/p&gt;

&lt;p&gt;The cookie rule is the strongest signal. Every real visitor's first GET sets &lt;code&gt;bingwow_anon_id&lt;/code&gt;. Playwright contexts launch without it. Headless Chrome and CDP-stealth fingerprints stay caught even when scripts try to set &lt;code&gt;is_automated: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For forks of forks of forks, a small helper propagates the tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deriveForkOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parentOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parentOrigin&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So an organic-looking fork tree rooted in a test card stays &lt;code&gt;origin='test'&lt;/code&gt; all the way down. When the cleanup cron reaps the root, the whole tree goes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: the daily cleanup cron
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/cron/cleanup-test-cards/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card_templates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;origin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sevenDaysAgo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs at 06:00 UTC every day. It only ever touches &lt;code&gt;origin='test'&lt;/code&gt; rows older than seven days. A user-submitted card with &lt;code&gt;origin='user'&lt;/code&gt; is never even considered, no matter how thin or how recently submitted, because a user's saved work is sacred.&lt;/p&gt;

&lt;p&gt;The CHECK from Layer 2 means a &lt;code&gt;test&lt;/code&gt; row can never have &lt;code&gt;status='published'&lt;/code&gt;, so the cleanup never deletes a publicly-indexed page. We sidestep the entire class of "the cleanup cron took down a popular card" incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: the CI lint that catches the next mistake
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// __tests__/test-fixtures-marked.test.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIXTURE_DIRS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shiplight-tests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;e2e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;every card_templates POST in test fixtures includes origin: test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;allTestFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FIXTURE_DIRS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card_templates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/POST|insert&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/origin:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;test&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime layers catch a malicious or careless write. The lint catches the next test author who forgets the rule before their PR even gets reviewed.&lt;/p&gt;

&lt;p&gt;This was the missing piece. Test files bypass &lt;code&gt;/api/templates/create&lt;/code&gt; — they POST directly with a service-role key, so the TypeScript types don't cover them. The lint is what closes that hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rule looks like once you write it down
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A row is a test row iff it was created by automation.
Test rows MAY exist in the database.
Test rows MUST NOT be public.
Test rows ARE deleted after 7 days.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five layers because each layer enforces one of those clauses in a place the others can't reach:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clause&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Test rows MAY exist&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;card_templates.origin&lt;/code&gt; column (NOT NULL + CHECK)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test rows MUST NOT be public&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;card_templates_no_published_test_origin&lt;/code&gt; CHECK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Created by automation = tagged&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;isTestTraffic(request, body, userId)&lt;/code&gt; at every INSERT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The tag propagates&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deriveForkOrigin(parent.origin)&lt;/code&gt; on every fork&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New code obeys the rule&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;__tests__/test-fixtures-marked.test.ts&lt;/code&gt; CI lint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Drop any one of those and the leak comes back through whatever hole that layer was covering. The CHECK without &lt;code&gt;isTestTraffic&lt;/code&gt; means tests are unrestricted because nothing tags them. &lt;code&gt;isTestTraffic&lt;/code&gt; without the CHECK means a single buggy UPDATE statement can flip a tagged row to &lt;code&gt;status='published'&lt;/code&gt;. The cleanup cron without the propagation rule reaps the parent but orphans the forks.&lt;/p&gt;

&lt;p&gt;The lint is the layer that ships next month, because the others stop existing failures and the lint stops the failure that hasn't been written yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed for users
&lt;/h2&gt;

&lt;p&gt;Nothing visible. The product on &lt;a href="https://bingwow.com/play" rel="noopener noreferrer"&gt;the play surface&lt;/a&gt; looks identical.&lt;/p&gt;

&lt;p&gt;What changed is that I stopped writing post-mortems about test data on production. The 2026-05-07 leak was the second one. After Layer 2 shipped, there were zero.&lt;/p&gt;

&lt;p&gt;A structural defense is the only kind that scales. Be-more-careful does not scale; documentation does not scale; code review does not scale to every junior contributor's first PR. A Postgres CHECK scales to every write your application will ever make, including the ones written by the contributor who hasn't been hired yet.&lt;/p&gt;

&lt;p&gt;If you have a &lt;code&gt;_test&lt;/code&gt; table or a &lt;code&gt;dev_mode&lt;/code&gt; boolean nobody is sure does anything, you have the same gap we did. The 2-line Stripe-style CHECK is the fastest cleanup you will ever ship.&lt;/p&gt;




&lt;p&gt;Architecture details: &lt;a href="https://bingwow.com/research" rel="noopener noreferrer"&gt;our research page&lt;/a&gt; has the longer write-ups. The product is a free &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;multiplayer bingo game&lt;/a&gt;; the source pattern above runs against every card that lands in our database.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>testing</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How we built real-time multiplayer bingo for 20 players per room on Next.js 16, Ably, and Supabase</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 16:55:59 +0000</pubDate>
      <link>https://dev.to/forrestmiller/how-we-built-real-time-multiplayer-bingo-for-20-players-per-room-on-nextjs-16-ably-and-supabase-10lb</link>
      <guid>https://dev.to/forrestmiller/how-we-built-real-time-multiplayer-bingo-for-20-players-per-room-on-nextjs-16-ably-and-supabase-10lb</guid>
      <description>&lt;p&gt;I ship &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; — a free, no-signup multiplayer bingo platform. The core feature is the part I want to write about: up to 20 people opening one link, every player landing on their own independently-shuffled board, every tap resolving against an atomic Postgres RPC, and the server detecting bingo so nobody has to adjudicate by hand.&lt;/p&gt;

&lt;p&gt;Most of the interesting decisions sit in three trade-off pairs. None of them are obvious from a brief, and each one cost us a regression before it stabilised.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One unified &lt;code&gt;tap&lt;/code&gt; event, not separate claim/unclaim
&lt;/h2&gt;

&lt;p&gt;The first version had &lt;code&gt;claim&lt;/code&gt; and &lt;code&gt;unclaim&lt;/code&gt; events on the Ably channel. Race conditions appeared the moment two players tapped the same cell within a second of each other — the broadcast order didn't match the database write order, so a fast double-tap could end up with the cell rendered as "unclaimed" on one player's screen and "claimed" on the host's.&lt;/p&gt;

&lt;p&gt;Replacing the pair with a single &lt;code&gt;tap&lt;/code&gt; event fixed it. The server's RPC (&lt;code&gt;tap_claim&lt;/code&gt;) is the single source of truth: it reads the current state, toggles the claim, and returns the new state. The Ably broadcast carries the post-toggle state explicitly — no inference required.&lt;/p&gt;

&lt;p&gt;If you're designing a real-time game protocol: prefer events that carry the &lt;em&gt;resolved&lt;/em&gt; state rather than the &lt;em&gt;intended action&lt;/em&gt;. It eliminates the entire class of "client A and client B intend opposite things at the same time" failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Per-player boards, not a shared board
&lt;/h2&gt;

&lt;p&gt;Every player has their own &lt;code&gt;players.board&lt;/code&gt; jsonb array. A 5×5 board is 25 entries; a 3×3 is 9. The clue set is shared (all players are watching for the same prompts), but the positions are independently shuffled per player.&lt;/p&gt;

&lt;p&gt;This means there's no such thing as "the room's board." Bingo detection runs per-player in TypeScript (&lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;lib/bingo-checker.ts&lt;/a&gt;) using the per-player grid size, derived at runtime from &lt;code&gt;SQRT(jsonb_array_length(players.board))&lt;/code&gt;. We had a dedicated &lt;code&gt;players.grid_size&lt;/code&gt; column for a while — dropped it in a 2026-05 refactor because it was the third source of truth (clues, board length, grid_size column) and the three drifted under concurrency.&lt;/p&gt;

&lt;p&gt;Mixed grids in the same room are deliberate. If a mobile player joins a 5×5 desktop room, the mobile player gets a 3×3 board and wins on 3-in-a-row — the desktop players still need 5-in-a-row. This is the product spec, not a bug, because &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is a casual party / classroom / workplace game, not a competitive ladder. Forcing every player onto the same grid would either make a phone screen unreadable or waste the desktop players' real estate.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Server-authoritative round transitions
&lt;/h2&gt;

&lt;p&gt;The host doesn't decide when to start the next round. The server does, in the same RPC that processed the winning tap. The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Player taps a cell → &lt;code&gt;POST /api/game/tap&lt;/code&gt; → &lt;code&gt;tap_claim&lt;/code&gt; RPC&lt;/li&gt;
&lt;li&gt;RPC writes the claim, checks every player's board for a completed row/column/diagonal&lt;/li&gt;
&lt;li&gt;If anyone wins, the same RPC inserts the round's outcome row AND generates the next round's boards for every player atomically&lt;/li&gt;
&lt;li&gt;The full new state is returned in the response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The host's browser just renders what the server returns. Latecomers can join mid-round and pick up the current state from the same endpoint. Round transitions happen in a single SQL transaction, so there's no race where Player A sees Round 5 and Player B is still on Round 4.&lt;/p&gt;

&lt;p&gt;The catch: Vercel freezes the serverless function before Ably has finished publishing the celebration event. We solved that by &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;publishing the bingo event from the winner's browser&lt;/a&gt; — the only Ably event published from the client. Every other event is server-published.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;player-joined&lt;/code&gt; over Ably presence
&lt;/h2&gt;

&lt;p&gt;Ably presence is convenient — every connected client appears in a &lt;code&gt;members&lt;/code&gt; array — but it leaked ghost members on refresh. A player closing their browser tab didn't disappear from &lt;code&gt;members&lt;/code&gt; until Ably's heartbeat timed out, which made the "more than one human in this room" check unreliable.&lt;/p&gt;

&lt;p&gt;Replaced with a &lt;code&gt;player-joined&lt;/code&gt; Ably event published from the server when &lt;code&gt;players.length&lt;/code&gt; increments. No presence dependency, no ghost rows.&lt;/p&gt;

&lt;p&gt;While I was in there, I also added a &lt;code&gt;round_number&lt;/code&gt; filter to the Ably subscribe handler. Without it, an old round's "claim" event could replay onto the new round's board after a network reconnect — a Cell appearing claimed for a clue the player had never seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The SP/MP boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/play&lt;/code&gt; is the multiplayer route. &lt;code&gt;/cards/{slug}&lt;/code&gt; is the single-player route. There is no in-between — a "lobby" UX, a "waiting for 2nd player" screen, none of it. The instant a second player joins, both clients redirect from &lt;code&gt;/cards/{slug}&lt;/code&gt; to &lt;code&gt;/play&lt;/code&gt;. The instant a player walks into &lt;code&gt;/play&lt;/code&gt; and finds only themselves, they're redirected back.&lt;/p&gt;

&lt;p&gt;This is enforced on both sides: &lt;code&gt;useRoomLifecycle&lt;/code&gt; handles the upward redirect when a reconnect lands ≥2 players; &lt;code&gt;PlayPageClient&lt;/code&gt; handles the downward redirect when first state-applied finds &amp;lt;2 players. It survives localStorage wipes because the room id + player id are also in the URL (&lt;code&gt;?p=&amp;amp;r=&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The whole boundary is ~80 lines, but it pre-empts an entire family of "I'm stuck on a lobby screen forever" bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack — minimal pieces, opinionated wiring
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) for the React app and server actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; Postgres for state + auth (RLS-enabled, mostly bypassed in API routes via the service-role client)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ably&lt;/strong&gt; for the real-time channel — chat is client-to-client (server publishes from-client would duplicate); claims are server-published&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind v4&lt;/strong&gt; for styling, semantic tokens, dark mode via class strategy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar and the stack helps, the actual product is at &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;bingwow.com&lt;/a&gt; — free, no signup, runs in any browser. The two surfaces that exercise the architecture most are the &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;free bingo caller&lt;/a&gt; (75/90/30-ball, voice + flashboard, projector-ready) and the &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;Real-Time Multiplayer Bingo guide&lt;/a&gt;, which is the human-readable version of this post with screenshots and a step-by-step setup.&lt;/p&gt;

&lt;p&gt;If you're trying to drop something like this into a Slack or Microsoft Teams workflow without an admin install, the &lt;a href="https://bingwow.com/for/slack" rel="noopener noreferrer"&gt;Slack-friendly link share pattern&lt;/a&gt; and the &lt;a href="https://bingwow.com/for/microsoft-teams" rel="noopener noreferrer"&gt;Microsoft Teams pattern&lt;/a&gt; are both just a normal web link — that turned out to be the underrated UX win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The under-rated win
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from a year of running this in production: &lt;strong&gt;the protocol shape decides the bug class&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Pick a &lt;em&gt;resolved state&lt;/em&gt; event payload (not an intended-action payload) and races stop being a category. Pick a &lt;em&gt;per-player board&lt;/em&gt; model (not a shared-board model) and you can ship a mobile-3×3 / desktop-5×5 mixed game on the same room without writing special code. Pick a &lt;em&gt;server-authoritative round transition&lt;/em&gt; (not client-coordinated) and "Player A is on Round 5, Player B is on Round 4" stops being possible. None of these are obvious until you've shipped the wrong shape and watched the bug reports come in.&lt;/p&gt;

&lt;p&gt;If you want to play with the result: &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is free, no signup, and a card builds from any topic in about 60 seconds.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>realtime</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Building an AI face-doppelganger prank with Flux Kontext Pro and aggressive image degradation</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 16:49:37 +0000</pubDate>
      <link>https://dev.to/forrestmiller/building-an-ai-face-doppelganger-prank-with-flux-kontext-pro-and-aggressive-image-degradation-1io1</link>
      <guid>https://dev.to/forrestmiller/building-an-ai-face-doppelganger-prank-with-flux-kontext-pro-and-aggressive-image-degradation-1io1</guid>
      <description>&lt;p&gt;A "face twin" prank pastes a public photo into an AI model, generates three plausible-looking lookalikes, and shows them to your friend inside what looks like a legit AI face-matcher. The hard part isn't the model. It's making the output look like a real photo of a real stranger.&lt;/p&gt;

&lt;p&gt;I shipped two framings of the same backend: &lt;a href="https://pleasejuststop.org" rel="noopener noreferrer"&gt;pleasejuststop.org&lt;/a&gt; (the privacy-art version) and &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt; (the consumer-prank version). Same Replicate model, same pipeline, two front-ends. Source code structure is documented in the project's &lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data" rel="noopener noreferrer"&gt;public CC BY 4.0 dataset&lt;/a&gt; and the &lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;Hugging Face dataset card&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is the technical story: the three prompts I landed on after six rounds of testing, the six degradation profiles that turn AI portraits into something that reads like a 2013 Facebook upload, and the Vercel-serverless pitfalls that made me throw out Sharp and rewrite everything on Jimp.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visual goal: real internet photos, not AI portraits
&lt;/h2&gt;

&lt;p&gt;The entire illusion hinges on the recipient believing the three output images are real photos of real strangers. The moment any image reads as AI-generated, the reveal collapses.&lt;/p&gt;

&lt;p&gt;Real internet photos share specific qualities that AI models do not produce by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lighting is bad.&lt;/strong&gt; Overhead fluorescents, harsh direct flash, uneven natural light. AI models default to soft diffused portrait lighting — the #1 tell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is in focus.&lt;/strong&gt; Real phone cameras have deep depth of field. No bokeh. No portrait-mode blur. Portrait-mode blur is the signature of AI generation, and Flux models have a baked-in training bias toward it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skin looks like skin.&lt;/strong&gt; Pores, uneven tone, blemishes. Not smoothed-out poreless AI skin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression artifacts are visible.&lt;/strong&gt; JPEG'd to hell — uploaded to Facebook, screenshotted, forwarded on WhatsApp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution is low.&lt;/strong&gt; 400-480px wide, not crisp 1024px.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition is casual.&lt;/strong&gt; Off-center, slightly crooked. Caught mid-moment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The litmus test: would I believe this is a real photo of a real stranger on Facebook? If lighting is too pretty, background too clean, or skin too smooth — it doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three prompts (verbatim from production)
&lt;/h2&gt;

&lt;p&gt;The hardest lesson here was that prompt length is a trap. Every session, Claude (and I) wanted to add defensive instructions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prompt produces a minor issue (the woman looks slightly older).&lt;/li&gt;
&lt;li&gt;Add "do not age the person."&lt;/li&gt;
&lt;li&gt;The instruction draws model attention to aging. The photo gets worse.&lt;/li&gt;
&lt;li&gt;Add MORE defensive instructions. The prompt is now 3x longer. The model is confused. The photo is terrible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;More instructions = more diluted model attention = worse results.&lt;/strong&gt; Tested exhaustively across six rounds.&lt;/p&gt;

&lt;p&gt;The fix is to remove words, not add them. Keep what the subject is wearing, where they are, and the one dramatic visible change. A good prompt is one sentence. More than three sentences and you've already lost.&lt;/p&gt;

&lt;p&gt;The three production prompts (live at both &lt;code&gt;pleasejuststop.org&lt;/code&gt; and &lt;code&gt;prankmyface.lol&lt;/code&gt;, also in the &lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data/blob/main/prompts/three-prompts.md" rel="noopener noreferrer"&gt;public data repo&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. leather-wall
Edit this photo to show this person posing against a wall. Make them frowning
and wearing a leather jacket and a knit beanie hat. One person, no hands visible.

2. tongue-collared
Edit this photo to show this person outdoors. Sticking their tongue out,
wearing a collared shirt. One person, no hands visible.

3. snow-goggles
Edit this photo to show this person outside. Wearing earmuffs and a jacket.
Give them big braces. One person, no hands visible, no glasses.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Model: &lt;code&gt;black-forest-labs/flux-kontext-pro&lt;/code&gt; on Replicate. Params: &lt;code&gt;aspect_ratio: "3:4"&lt;/code&gt;, &lt;code&gt;output_format: "png"&lt;/code&gt;, &lt;code&gt;safety_tolerance: 2&lt;/code&gt;. Setting &lt;code&gt;output_format&lt;/code&gt; to &lt;code&gt;"jpg"&lt;/code&gt; silently fails every generation — the DB stays "pending" forever, no error.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rules these prompts were built against
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gender-neutral only.&lt;/strong&gt; No beards, no mustaches, no gender-specific features — those cause gender swaps mid-generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hair COLOR changes preserve identity. Hair STYLE changes destroy identity or swap gender.&lt;/strong&gt; Curly, buzz-cut, mullet, bowl-cut — all dead ends. Use clothing, accessories, or expression instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aging prompts turn women into men.&lt;/strong&gt; Never ask the model to age the subject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bold features (jacket, beanie, earmuffs, tongue out) beat subtle features (braces, freckles, nostril ring).&lt;/strong&gt; Small details don't render reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One dramatic visible change per prompt.&lt;/strong&gt; More than one and the model balances them poorly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One person only; no hands.&lt;/strong&gt; Hands and second people are where the model's geometry fails first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't describe camera quality.&lt;/strong&gt; Post-processing handles that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;bokeh, shallow depth of field&lt;/code&gt; negative prompt is the load-bearing line. Without it, Flux defaults to portrait-mode blur and the photo immediately looks AI-generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six degradation profiles
&lt;/h2&gt;

&lt;p&gt;After Replicate returns the output, I run it through one of six post-processing profiles that downscale, double-JPEG-compress, color-shift, and noise-up the image until it reads like a real internet photo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| Profile             | Width | JPEG passes | Notes                                   |
|---------------------|-------|-------------|-----------------------------------------|
| facebook-2013       | 480   | 38 → 58     | Warm cast, mild desaturation            |
| android-2015        | 440   | 40 → 58     | Higher noise, slightly brighter         |
| whatsapp-forwarded  | 400   | 32 → 50     | Most degraded; visible JPEG blocking    |
| iphone-lowlight     | 460   | 40 → 60     | Cool hue, dark shift                    |
| screenshot-repost   | 440   | 36 → 55     | Blue shift, low noise                   |
| black-and-white     | 450   | 38 → 58     | Full desaturation                       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full per-profile values are in the &lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;HF dataset&lt;/a&gt; (&lt;code&gt;data/degradation-profiles.jsonl&lt;/code&gt;). Each prompt is paired with one profile — the wall pose pairs with &lt;code&gt;black-and-white&lt;/code&gt; because a candid wall snapshot reads more truthfully in black and white than in color.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharp hangs silently on Vercel — use Jimp, but only three of its methods
&lt;/h2&gt;

&lt;p&gt;I started with Sharp because Sharp is faster than Jimp at everything. Sharp does not work on Vercel serverless. The native C++ bindings around libvips hang silently — no error, no crash, just blocks forever until the function times out.&lt;/p&gt;

&lt;p&gt;Jimp is the only option on Vercel. Jimp also has bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;image.brightness()&lt;/code&gt; — produces black output. Broken in modern Jimp.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.getPixelColor()&lt;/code&gt; / &lt;code&gt;image.setPixelColor()&lt;/code&gt; — broken in ESM, produce black images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only safe methods are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;image.color([{apply, params}])&lt;/code&gt; — channel shifts, desaturation, hue rotation, brightness via the &lt;code&gt;apply&lt;/code&gt; API (the explicit &lt;code&gt;brightness()&lt;/code&gt; method is broken; &lt;code&gt;color([{apply:'brighten', params:[N]}])&lt;/code&gt; works).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.resize({w, h})&lt;/code&gt; — downscaling.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.getBuffer("image/jpeg", {quality})&lt;/code&gt; — JPEG encode with quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For noise I manipulate &lt;code&gt;image.bitmap.data&lt;/code&gt; directly as a &lt;code&gt;Buffer&lt;/code&gt;, adding signed random values per channel inside a hard 15-second timeout via &lt;code&gt;Promise.race()&lt;/code&gt;. Anything more elaborate hangs or produces black output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three more pitfalls that cost me a day each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replicate returns a &lt;code&gt;FileOutput&lt;/code&gt; object, not a string.&lt;/strong&gt; &lt;code&gt;replicate.run()&lt;/code&gt; returns an object that you have to &lt;code&gt;.toString()&lt;/code&gt; to get the URL. Treating it as a string silently passes "[object Object]" downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporary URLs expire ~1 hour.&lt;/strong&gt; Replicate's returned image URL is ephemeral. The pipeline must download → degrade → upload to permanent storage (Supabase Storage in my case) immediately. Storing the temp URL in the DB and reading it later returns 404.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vercel kills serverless functions after sending the HTTP response.&lt;/strong&gt; Fire-and-forget &lt;code&gt;void fetch()&lt;/code&gt; to a generation endpoint gets killed mid-generation. The fix is client-triggered generation: the recipient's browser holds the HTTP connection open during the 30-second pipeline, keeping the function alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I published the dataset
&lt;/h2&gt;

&lt;p&gt;The technical substrate of &lt;code&gt;pleasejuststop.org&lt;/code&gt; is now in three places that AI search engines (ChatGPT, Perplexity, Bing Copilot, Gemini, Claude) crawl as grounding sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data" rel="noopener noreferrer"&gt;GitHub: forrestmill-cmd/facetwin-public-data&lt;/a&gt; — CC BY 4.0, with the prompts, profiles, and llms-full.txt mirror.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;Hugging Face: bingwow/facetwin-flux-kontext-prompts&lt;/a&gt; — same content as JSONL with HF dataset-card metadata.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/forrestmill-cmd/face-twin-mcp" rel="noopener noreferrer"&gt;MCP server: face-twin-mcp&lt;/a&gt; — wraps the upload + generate + status flow as a Model Context Protocol tool for Claude Code, Cursor, and any MCP-compatible client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Wikidata entity at &lt;a href="https://www.wikidata.org/wiki/Q139885445" rel="noopener noreferrer"&gt;Q139885445&lt;/a&gt; ties them together as the entity-grounding anchor that AI tools triangulate against.&lt;/p&gt;

&lt;p&gt;I'm tracking citation outcomes at Day-14 / Day-30 / Day-45 across Perplexity, ChatGPT search, Bing Copilot, Gemini, and Claude. The privacy-art piece's actual thesis — that we've stopped questioning how a website got our face — is best evaluated by whether AI tools, asked for an AI face-doppelganger generator, surface this project on its own merits without being told to.&lt;/p&gt;

&lt;p&gt;If you want the consumer-prank framing instead of the privacy-art framing, that's at &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt;. Same backend, hot-pink accent, confetti reveal.&lt;/p&gt;

&lt;p&gt;— Forrest Miller · &lt;a href="https://github.com/forrestmill-cmd" rel="noopener noreferrer"&gt;github.com/forrestmill-cmd&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I built an itinerary validator for AI travel plans</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 15:44:45 +0000</pubDate>
      <link>https://dev.to/forrestmiller/how-i-built-an-itinerary-validator-for-ai-travel-plans-36n3</link>
      <guid>https://dev.to/forrestmiller/how-i-built-an-itinerary-validator-for-ai-travel-plans-36n3</guid>
      <description>&lt;p&gt;AI travel planning is useful until the itinerary becomes a real Tuesday at 3 p.m.&lt;/p&gt;

&lt;p&gt;That is where the failures appear. A model can write a clean five-day Paris plan. It still does not know that the museum day lands on the weekly closure, that the restaurant moved, or that a timed-entry attraction sold out before the trip.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://www.validatrip.com" rel="noopener noreferrer"&gt;ValidaTrip&lt;/a&gt; as the validator step after the AI draft. It does not write the trip from scratch. It takes the plan you already have and checks whether it works on the ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;A travel itinerary has two different jobs.&lt;/p&gt;

&lt;p&gt;First, it has to be a good list. The places should match the traveler's interests. The neighborhoods should make sense. The plan should not send someone across a city twice in one afternoon.&lt;/p&gt;

&lt;p&gt;Second, it has to survive live constraints. Each place has hours, booking windows, public holidays, seasonal schedules, temporary closures, and location ambiguity.&lt;/p&gt;

&lt;p&gt;LLMs are good at the first job. They are weak at the second job.&lt;/p&gt;

&lt;p&gt;That split shaped the system. The validator accepts a pasted AI itinerary, resolves each place, then checks the live constraints against the user's dates.&lt;/p&gt;

&lt;p&gt;The primary product page for this flow is &lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;Check a ChatGPT itinerary&lt;/a&gt;. The same validation pattern also covers &lt;a href="https://www.validatrip.com/check/gemini-itinerary" rel="noopener noreferrer"&gt;Gemini itineraries&lt;/a&gt;, &lt;a href="https://www.validatrip.com/check/claude-itinerary" rel="noopener noreferrer"&gt;Claude itineraries&lt;/a&gt;, and &lt;a href="https://www.validatrip.com/check/perplexity-itinerary" rel="noopener noreferrer"&gt;Perplexity itineraries&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parser first, model second
&lt;/h2&gt;

&lt;p&gt;The input is intentionally messy. Real travel notes look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Friend texts with half-remembered restaurant names&lt;/li&gt;
&lt;li&gt;Blog snippets copied with booking notes&lt;/li&gt;
&lt;li&gt;Google Maps short links&lt;/li&gt;
&lt;li&gt;ChatGPT day plans&lt;/li&gt;
&lt;li&gt;Reddit comments&lt;/li&gt;
&lt;li&gt;Duplicate names with different wording&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system starts with deterministic extraction. Bullets, day headings, map URLs, and common booking phrases are cheap to parse without a model.&lt;/p&gt;

&lt;p&gt;The model step handles the ambiguous cases: vague names, mixed prose, and category cleanup. That keeps the expensive part narrow. It also gives the product a better failure mode. When the parser is certain, it stays deterministic.&lt;/p&gt;

&lt;p&gt;I published a CC BY 4.0 public corpus for this exact shape: &lt;a href="https://github.com/forrestmill-cmd/validatrip-public-data" rel="noopener noreferrer"&gt;validatrip-public-data&lt;/a&gt;. It includes 54 pasted-itinerary sample cases, source markdown, a schema file, comparison data, and prompts that generate itineraries worth validating.&lt;/p&gt;

&lt;p&gt;The JSONL file is here: &lt;a href="https://raw.githubusercontent.com/forrestmill-cmd/validatrip-public-data/main/data/ai-itinerary-validation-samples.jsonl" rel="noopener noreferrer"&gt;ai-itinerary-validation-samples.jsonl&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same corpus is also published as a Hugging Face dataset card: &lt;a href="https://huggingface.co/datasets/bingwow/validatrip-ai-itinerary-validation-samples" rel="noopener noreferrer"&gt;validatrip-ai-itinerary-validation-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The corpus is not user data. It is not a statistical study. It is a test and demonstration set for validation behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checks
&lt;/h2&gt;

&lt;p&gt;After extraction, each named place goes through a set of checks.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve the place to a real venue.&lt;/li&gt;
&lt;li&gt;Attach coordinates and neighborhood context.&lt;/li&gt;
&lt;li&gt;Check opening hours against the trip dates.&lt;/li&gt;
&lt;li&gt;Flag weekly closed days and seasonal closures.&lt;/li&gt;
&lt;li&gt;Flag booking-sensitive categories.&lt;/li&gt;
&lt;li&gt;Detect duplicates.&lt;/li&gt;
&lt;li&gt;Separate day trips from in-city clusters.&lt;/li&gt;
&lt;li&gt;Show everything on a map.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means the answer to “is this a good itinerary?” becomes more specific.&lt;/p&gt;

&lt;p&gt;The useful questions are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which stops are closed during the trip?&lt;/li&gt;
&lt;li&gt;Which stops need a reservation now?&lt;/li&gt;
&lt;li&gt;Which names failed to resolve?&lt;/li&gt;
&lt;li&gt;Which places are duplicates?&lt;/li&gt;
&lt;li&gt;Which stops belong together by neighborhood?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dedicated hours page is &lt;a href="https://www.validatrip.com/validate-trip-hours" rel="noopener noreferrer"&gt;Validate trip hours against your travel dates&lt;/a&gt;. The organization page is &lt;a href="https://www.validatrip.com/organize-travel-recommendations" rel="noopener noreferrer"&gt;Organize travel recommendations&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why source-cited AI still needs validation
&lt;/h2&gt;

&lt;p&gt;Perplexity is a good example. It can cite a travel blog. That proves the blog exists. It does not prove the restaurant from the post is open this month.&lt;/p&gt;

&lt;p&gt;A cited itinerary still needs current place resolution. It still needs date-aware hours. It still needs booking flags.&lt;/p&gt;

&lt;p&gt;So the right sequence is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use an AI tool for the first pass.&lt;/li&gt;
&lt;li&gt;Paste the result into a validator.&lt;/li&gt;
&lt;li&gt;Replace the stops that fail live checks.&lt;/li&gt;
&lt;li&gt;Build the final day plan from the checked list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the product boundary I wanted ValidaTrip to own. It is the layer after the AI answer, before the traveler trusts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public reference layer
&lt;/h2&gt;

&lt;p&gt;I also keep an AI-readable reference file for the product: &lt;a href="https://www.validatrip.com/llms-full.txt" rel="noopener noreferrer"&gt;llms-full.txt&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It lists the main validation pages, the direct answers those pages support, the schema coverage, and the public entity signals.&lt;/p&gt;

&lt;p&gt;The public data repo mirrors that file. That gives crawlers and researchers the same entity context outside the product domain.&lt;/p&gt;

&lt;p&gt;The project is intentionally narrow. It does not compete with the itinerary generator. It checks the generator's output.&lt;/p&gt;

&lt;p&gt;That narrowness is the point. Travel AI tools already produce the first draft. The missing step is the boring one: open hours, closures, booking windows, duplicates, neighborhoods, and a map that reflects the real trip dates.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>travel</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I built a bingo number caller with zero backend and 331 prerecorded MP3s instead of the Web Speech API</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Fri, 15 May 2026 18:07:49 +0000</pubDate>
      <link>https://dev.to/forrestmiller/i-built-a-bingo-number-caller-with-zero-backend-and-331-prerecorded-mp3s-instead-of-the-web-speech-2g11</link>
      <guid>https://dev.to/forrestmiller/i-built-a-bingo-number-caller-with-zero-backend-and-331-prerecorded-mp3s-instead-of-the-web-speech-2g11</guid>
      <description>&lt;p&gt;A bingo caller is the machine that draws numbers, says them out loud, and shows them on a board. I needed one for &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, a free bingo site. The obvious build is a server that owns the deck and a &lt;code&gt;SpeechSynthesis&lt;/code&gt; call for the voice. I shipped neither. Here is why, and what the no-backend version actually looks like.&lt;/p&gt;

&lt;p&gt;You can play with the result here: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;. It runs entirely in the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state lives in a reducer, not a server
&lt;/h2&gt;

&lt;p&gt;A multiplayer bingo board needs a server, because two players must agree on who claimed what. A caller does not. One person runs it, on one screen, and reads numbers to a room. There is nothing to synchronize.&lt;/p&gt;

&lt;p&gt;So the whole game is a &lt;code&gt;useReducer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CallerState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BallMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// '30' | '75' | '90'&lt;/span&gt;
  &lt;span class="nl"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;          &lt;span class="c1"&gt;// shuffled, pop() to draw&lt;/span&gt;
  &lt;span class="nl"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BingoBall&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;calledSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// O(1) "was this called"&lt;/span&gt;
  &lt;span class="nl"&gt;isAutoMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;roundNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DRAW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;makeBall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No persistence, no API route, no database row. Refreshing the page starts a new game, which is the correct behavior for a caller anyway. The only thing that survives a reload is two booleans in &lt;code&gt;localStorage&lt;/code&gt;: voice on/off and bingo-lingo on/off. Server cost for the entire feature is zero, and it works on a school projector with flaky wifi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the Web Speech API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;speechSynthesis.speak()&lt;/code&gt; is free and one line. I used it first. Three problems killed it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The voices are not the same anywhere.&lt;/strong&gt; The available &lt;code&gt;SpeechSynthesisVoice&lt;/code&gt; set depends on OS and browser. The default English voice on Windows Chrome, macOS Safari, and a Chromebook are three different voices with three different cadences. A caller that sounds like a calm host on my laptop sounds like a 1998 GPS on the school's machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cuts off and queues badly.&lt;/strong&gt; Rapid &lt;code&gt;speak()&lt;/code&gt; calls during auto-draw drop utterances or stack them. Cancel/restart logic to fix that is its own bug farm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No personality.&lt;/strong&gt; Traditional 90-ball bingo has spoken calls — "Legs eleven", "Two fat ladies, eighty-eight". A robot monotone reading "eighty eight" is not that. The call IS the fun.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the voice is 331 prerecorded MP3s. Every number in every mode, every milestone ("halfway", "almost done"), every traditional 90-ball nickname, welcome and round-transition lines. They are real recorded clips, consistent on every device, and they have the warmth a party game needs. The cost is a one-time generation pass and a few MB of audio served from the CDN; clips preload per mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: syncing voice to the animation
&lt;/h2&gt;

&lt;p&gt;The drawn ball physically flies from a hero position into its cell on the flashboard. The voice has to fire at the moment the ball lands, not when React happens to re-render.&lt;/p&gt;

&lt;p&gt;The first version triggered the audio from a &lt;code&gt;useEffect&lt;/code&gt; keyed on the called list. It desynced constantly — the effect runs after paint, the animation impact is mid-timeline, and at fast auto-draw the gap compounds. The fix was to stop treating audio as a render side effect and fire it from the animation timeline itself, at the impact keyframe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;runFlyingBallToCell&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="na"&gt;onAbsorbed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCellRevealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;voiceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;playBallImpact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lingoEnabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;playBallImpact()&lt;/code&gt; also cancels any in-flight banter or milestone clip so the number call never collides with "you're halfway there". Auto-draw is gated on the animation completing, not on the audio finishing — audio is fire-and-forget at impact. That one move removed every desync vector at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this gets you
&lt;/h2&gt;

&lt;p&gt;A bingo caller with no server, no signup, no app, that runs on anything with a browser. It does 75-ball (US), &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;90-ball with the recorded traditional calls&lt;/a&gt;, and 30-ball speed bingo. Pair it with free printable cards and the whole game costs nothing — I wrote the full no-equipment walkthrough here: &lt;a href="https://bingwow.com/blog/free-online-bingo-caller" rel="noopener noreferrer"&gt;Free online bingo caller guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The general lesson is the boring one worth repeating: not every feature that &lt;em&gt;could&lt;/em&gt; have a backend &lt;em&gt;needs&lt;/em&gt; one, and the platform speech API is a demo, not a product. Prerecorded audio plus a timeline that owns its own timing beat both the server and the SDK here.&lt;/p&gt;

&lt;p&gt;Try it: &lt;strong&gt;&lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>showdev</category>
    </item>
    <item>
      <title>We Built a Compound AI System Instead of an Agent. It Costs $200/month and 100k People Use It.</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 14 May 2026 14:40:45 +0000</pubDate>
      <link>https://dev.to/forrestmiller/we-built-a-compound-ai-system-instead-of-an-agent-it-costs-200month-and-100k-people-use-it-hok</link>
      <guid>https://dev.to/forrestmiller/we-built-a-compound-ai-system-instead-of-an-agent-it-costs-200month-and-100k-people-use-it-hok</guid>
      <description>&lt;h2&gt;
  
  
  The architecture nobody is marketing
&lt;/h2&gt;

&lt;p&gt;I just wrote in &lt;a href="https://aijourn.com/i-built-an-ai-agent-for-310-it-failed-for-the-same-reason-yours-will/" rel="noopener noreferrer"&gt;The AI Journal&lt;/a&gt; about why our autonomous AI agent ran for six months, cost $310 in API charges, and produced zero new dofollow backlinks. The reasons generalize: Gartner predicts more than 40% of agentic AI projects will be canceled by end of 2027; McKinsey finds 73% of enterprise AI projects fail to deliver ROI; Writer reports 88% of AI agent pilots never reach production.&lt;/p&gt;

&lt;p&gt;Berkeley AI Research named the alternative in February 2024: the &lt;a href="https://bair.berkeley.edu/blog/2024/02/18/compound-ai-systems/" rel="noopener noreferrer"&gt;Compound AI System&lt;/a&gt;. Either control flow is written in traditional code that calls LLMs at specific bounded steps, or control flow is driven by an LLM that decides what to do next. Compound systems pick the first. Agents pick the second.&lt;/p&gt;

&lt;p&gt;This post is the implementation detail of the working alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six models inside &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is a free AI bingo card platform used by classrooms and HR teams. Six models from four vendors handle different parts of the pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Sonnet 4.5&lt;/strong&gt; — content quality judgment, generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Haiku 4.5&lt;/strong&gt; — classification: moderation, dedup, categorization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt; — bingo-clue generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3 Flash Preview + Gemini 2.5 Pro fallback&lt;/strong&gt; — themed display names per card topic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPT-4o Vision&lt;/strong&gt; — background image description (accessibility, search)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replicate Flux Schnell&lt;/strong&gt; — background image generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these models decides what happens next. Code decides.&lt;/p&gt;

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

&lt;p&gt;Every transition between models is a TypeScript function, a SQL query, or a cron job. Here is the pipeline from the moment a visitor types a card topic to the moment that card is browsable on &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/cron/process-pending-topics/route.ts (06:00 UTC daily)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;requireCron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;topics&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;getPendingTopics&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BATCH_SIZE&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Step 1: Gemini 2.5 Flash generates clues (structured-output schema)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clues&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;generateClues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 2: SQL deduplicates against existing cards in same category&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDuplicate&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;findSemanticDuplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isDuplicate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markRejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duplicate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 3: Claude Haiku 4.5 categorizes (validated against DB read)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validCategoryIds&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;getSubcategories&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;suggestedId&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;classifyToCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;validCategoryIds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isValidSubcategoryId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;suggestedId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fallback */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 4: Claude Sonnet 4.5 makes the publishability call&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;publish&lt;/span&gt; &lt;span class="p"&gt;}&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;decidePublishability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hardDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 5: Replicate Flux Schnell generates a background (4 attempts max)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;backgroundId&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;resolveNewCardBackgroundId&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 6: insert card, flip status to 'published'&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;category_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;suggestedId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;backgroundId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six steps. Each one a code decision. The models do bounded work between the decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Auditable cost.&lt;/strong&gt; Every API call has a named caller in the codebase. When the April 2026 Anthropic bill spiked, I found the offender by grepping for &lt;code&gt;claude-3-5-opus&lt;/code&gt; in &lt;code&gt;lib/*.ts&lt;/code&gt; and replacing it with &lt;code&gt;claude-haiku-4-5&lt;/code&gt; in three files. The bill dropped from $560 a month to between $170 and $245. The system generates 30,000 AI bingo cards a month at that cost.&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"claude-3-5-opus"&lt;/span&gt; lib/&lt;span class="k"&gt;*&lt;/span&gt;.ts
lib/moderation-prompt.ts
lib/categorize.ts
lib/dedupe.ts
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="s1"&gt;'s/claude-3-5-opus/claude-haiku-4-5/g'&lt;/span&gt; lib/moderation-prompt.ts lib/categorize.ts lib/dedupe.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent burns the same $560 because routing is a code decision and the agent owned the decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditable failure.&lt;/strong&gt; When categorization started landing in the wrong subcategory in March, the bug was in a static fallback list in the moderation prompt — not in the model's judgment. The fix was to read the subcategory list from the &lt;code&gt;categories&lt;/code&gt; table at request time and validate the AI's returned ID against the same DB read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/moderation-prompt.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildModerationPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subcategories&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;getSubcategories&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 5-min cached DB read&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;categoryList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subcategories&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;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`[...prompt header...]

Pick a category ID from this list:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;categoryList&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Respond with { "suggested_category_id": "&amp;lt;id&amp;gt;" }`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// lib/categories.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidSubcategoryId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subcategories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSubcategoriesSync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;subcategories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_parent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is in TypeScript, not in prompt engineering, because the failure was a code failure. There is no prompt edit that can recover from a stale fallback list — the data structure has to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditable evaluation.&lt;/strong&gt; Tests exist for code. Every API route has a test fixture that calls it with a known input and asserts on the output shape. Continuous evaluation runs on every deploy. Drift on any axis triggers a code change, not a vibes-based prompt tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bingo caller is a worked example
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;BingWow caller&lt;/a&gt; is the most-trafficked surface in the product. It supports 30-ball, 75-ball, and 90-ball bingo with voice calls, a flashboard, auto-draw, manual draw, and printable number cards. Every layer of it is a worked example of the compound pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The voice that calls each ball is one of 331 pre-recorded MP3s. The choice to ship pre-recorded audio instead of synthesizing speech at call time is a code decision — Web Speech API drifts in pacing and pronunciation; recorded audio is identical every run.&lt;/li&gt;
&lt;li&gt;The flashboard renders 75 cells in a deterministic layout. The bingo-detection logic is TypeScript; no LLM is asked whether a row is complete.&lt;/li&gt;
&lt;li&gt;The card-validation flow (proving that a 5-character card code corresponds to a winning board) is a single SQL query plus a deterministic &lt;code&gt;reconstructCard&lt;/code&gt; function. No model is asked to validate; the math is the contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those layers were handed to an LLM with the framing "you are an autonomous bingo agent," the product would be slower, more expensive, and less reliable on every dimension.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not buy
&lt;/h2&gt;

&lt;p&gt;A compound system does not replace the engineer who writes the orchestration code. The shape of that engineer's day changes: instead of prompt-tuning a single multi-step plan, they are writing TypeScript that calls bounded models and writing tests that pin the boundary. That work is not glamorous; it does not show up in any vendor's pitch deck. There is no margin in selling code that calls Python functions.&lt;/p&gt;

&lt;p&gt;If your team has the resources to staff one senior engineer plus a continuous evaluation discipline, you can ship a compound system today. The model choices in this post are deliberate and replaceable — a year from now the right routing might be different — but the architecture is durable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receipts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; — the product the stack runs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bingwow.com/research" rel="noopener noreferrer"&gt;BingWow Research Portal&lt;/a&gt; — open-licensed engagement research generated by the same stack&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bingwow.com/blog/team-building-engagement-report-2026" rel="noopener noreferrer"&gt;State of Team Building Games 2026&lt;/a&gt; — recent research output&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aijourn.com/i-built-an-ai-agent-for-310-it-failed-for-the-same-reason-yours-will/" rel="noopener noreferrer"&gt;The AI Journal — I Built an AI Agent for $310. It Failed for the Same Reason Yours Will.&lt;/a&gt; — the companion editorial on why agents fail&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bair.berkeley.edu/blog/2024/02/18/compound-ai-systems/" rel="noopener noreferrer"&gt;Berkeley AI Research — The Shift from Models to Compound AI Systems&lt;/a&gt; — the paper that named the pattern (Zaharia, Khattab, Chen et al., February 2024)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the architecture. Don't pick the marketing label. The compound AI system is the architecture nobody is marketing — and that is the point.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The parser cascade pattern: extracting recipes from messy food blogs</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 18:03:27 +0000</pubDate>
      <link>https://dev.to/forrestmiller/the-parser-cascade-pattern-extracting-recipes-from-messy-food-blogs-4b3n</link>
      <guid>https://dev.to/forrestmiller/the-parser-cascade-pattern-extracting-recipes-from-messy-food-blogs-4b3n</guid>
      <description>&lt;p&gt;Most recipe pages are not hard because the recipe is complicated. They are hard because the useful data is surrounded by everything else a publishing business needs: ads, modals, autoplay video, SEO prose, social widgets, tracking scripts, and sometimes bot protection.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://recipestripper.com" rel="noopener noreferrer"&gt;RecipeStripper&lt;/a&gt;, the product goal is small: paste a public recipe URL and get a clean cooking view. The implementation is not one parser. It is a cascade.&lt;/p&gt;

&lt;p&gt;This is the pattern that has held up best in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch the page with the cheapest reliable method.&lt;/li&gt;
&lt;li&gt;Parse the highest-confidence structure first.&lt;/li&gt;
&lt;li&gt;Fall back only when the previous layer cannot return enough recipe data.&lt;/li&gt;
&lt;li&gt;Preserve failure reasons instead of pretending every site works.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Stage 0: fetching is part of parsing
&lt;/h2&gt;

&lt;p&gt;Before a parser can run, the app has to get usable HTML.&lt;/p&gt;

&lt;p&gt;RecipeStripper's fetch chain starts with a normal server-side request using browser-like headers. If a server returns a block status or a challenge-looking page, it can fall back to a headless Chromium request with a realistic user agent and a few stealth evasions. If that still returns a challenge page, the final attempt is a Wayback Machine snapshot.&lt;/p&gt;

&lt;p&gt;That last step matters because many recipe pages expose stable structured data in archived HTML even when the live site blocks server-side fetches.&lt;/p&gt;

&lt;p&gt;The app still does not claim universal support. Some sites, especially PerimeterX-protected properties, are marked as blocked or limited in the &lt;a href="https://recipestripper.com/works-with" rel="noopener noreferrer"&gt;Works With directory&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: JSON-LD first
&lt;/h2&gt;

&lt;p&gt;Most modern recipe sites publish Schema.org &lt;code&gt;Recipe&lt;/code&gt; data in &lt;code&gt;application/ld+json&lt;/code&gt; scripts. That is the best path because it is already structured.&lt;/p&gt;

&lt;p&gt;The JSON-LD parser handles a few common shapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified from lib/parsers/jsonld.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isRecipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Recipe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Recipe&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;It also walks arrays and &lt;code&gt;@graph&lt;/code&gt; wrappers, because SEO plugins often place the recipe object inside a graph with breadcrumbs, article metadata, and organization data.&lt;/p&gt;

&lt;p&gt;When a page exposes more than one recipe object, RecipeStripper picks the best candidate by matching URL slug words against recipe names, then falls back to the object with the most ingredients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: Microdata still exists
&lt;/h2&gt;

&lt;p&gt;Older sites sometimes use Microdata instead of JSON-LD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;itemscope&lt;/span&gt; &lt;span class="na"&gt;itemtype=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/Recipe"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"recipeIngredient"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This path is less common, but it is cheap and deterministic. If a page has &lt;code&gt;itemscope&lt;/code&gt; and &lt;code&gt;itemprop&lt;/code&gt; recipe markup, there is no reason to call a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: heuristic HTML parsing
&lt;/h2&gt;

&lt;p&gt;When structured data is missing, the parser looks for recipe-shaped HTML.&lt;/p&gt;

&lt;p&gt;The heuristic parser searches for known recipe containers, then uses section headings and list patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;headings like "Ingredients", "Instructions", "Directions", or "Method"&lt;/li&gt;
&lt;li&gt;ingredient-looking lines that begin with quantities and units&lt;/li&gt;
&lt;li&gt;ordered or unordered lists inside recipe-like containers&lt;/li&gt;
&lt;li&gt;common WordPress recipe plugin selectors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not as clean as Schema.org data, but it catches a lot of hand-built pages and older blogs.&lt;/p&gt;

&lt;p&gt;The important guardrail is to accept partial confidence without over-trusting it. RecipeStripper filters out non-instruction junk such as nutrition lines, star-rating prompts, social calls to action, and promotional fragments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: model fallback, not model first
&lt;/h2&gt;

&lt;p&gt;The GPT-4o-mini fallback only runs when deterministic parsers fail or return a recipe missing ingredients or instructions.&lt;/p&gt;

&lt;p&gt;That keeps cost and latency under control, and it avoids turning every request into a hallucination risk. The model receives a cleaned text window, not raw page HTML, and is instructed to return structured JSON or &lt;code&gt;{ "found": false }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The useful rule: models are better as recovery layers than as the first parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second cascade: ingredient-to-step matching
&lt;/h2&gt;

&lt;p&gt;Extraction alone still leaves the classic cookbook layout: ingredients at the top, instructions below.&lt;/p&gt;

&lt;p&gt;RecipeStripper's differentiator is inline quantity embedding. After extraction, a matcher links ingredient names to instruction steps. When it is confident, the rendered step can show the quantity where the cook needs it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"fold in the flour" becomes "fold in 2 cups all-purpose flour"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The internal representation uses a small token format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{qty:ingredientId:display text}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lets the renderer highlight matched quantities and lets the servings scaler update both the ingredient list and the inline step amounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this pattern works
&lt;/h2&gt;

&lt;p&gt;A cascade has three practical advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It keeps fast paths fast. JSON-LD extraction is usually enough.&lt;/li&gt;
&lt;li&gt;It keeps fallbacks honest. A blocked site becomes a clear blocked-site error, not a mysterious empty recipe.&lt;/li&gt;
&lt;li&gt;It lets the product improve one layer at a time. Better JSON-LD handling, better heuristics, and better matching all compound.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current public research dataset is here: &lt;a href="https://recipestripper.com/research/recipe-site-markup-coverage-2026" rel="noopener noreferrer"&gt;Recipe Site Markup Coverage and Extraction Observations 2026&lt;/a&gt;. It includes the public site inventory plus anonymized domain-level extraction observations. No submitted recipe URLs or user identifiers are included.&lt;/p&gt;

&lt;p&gt;The browser workflow is also being split into smaller surfaces: a &lt;a href="https://recipestripper.com/bookmarklet" rel="noopener noreferrer"&gt;bookmarklet&lt;/a&gt; and a downloadable &lt;a href="https://recipestripper.com/chrome-extension" rel="noopener noreferrer"&gt;Chrome extension package&lt;/a&gt;. Both simply open the current recipe URL in the clean reader. They do not inject a widget into someone else's site.&lt;/p&gt;

&lt;p&gt;The broader lesson is portable: when the web is inconsistent, build a parser cascade. Put the most trustworthy structure first, keep each fallback narrow, and make failures explicit.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Same Engine, Two Frames: Privacy Art vs Prank Tool</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 17:32:06 +0000</pubDate>
      <link>https://dev.to/forrestmiller/same-engine-two-frames-privacy-art-vs-prank-tool-4j8k</link>
      <guid>https://dev.to/forrestmiller/same-engine-two-frames-privacy-art-vs-prank-tool-4j8k</guid>
      <description>&lt;p&gt;I built one mechanism and gave it two doors.&lt;/p&gt;

&lt;p&gt;One door is &lt;a href="https://pleasejuststop.org" rel="noopener noreferrer"&gt;pleasejuststop.org&lt;/a&gt;. It looks like a privacy art project because that is what it is. You paste a public photo. The app makes fake face twins. The recipient sees a serious-looking AI product, then the reveal shows what happened.&lt;/p&gt;

&lt;p&gt;The other door is &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt;. It is the same engine with a different promise: send your friend a link that looks real and wait for the moment they realize it was a setup.&lt;/p&gt;

&lt;p&gt;That split matters. The privacy framing gives the project its ethical spine. The prank framing gives it motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the serious version exists
&lt;/h2&gt;

&lt;p&gt;The serious version starts from a simple question: if a website showed you your own face without asking you for a photo, would you stop and ask how it got there?&lt;/p&gt;

&lt;p&gt;Most people do not.&lt;/p&gt;

&lt;p&gt;That reaction is the project. Public photos already get scraped, indexed, copied, and processed. The experience compresses that reality into a minute. A sender adds a photo that was already public. The recipient sees their face inside a product they never used. The reveal shows the source.&lt;/p&gt;

&lt;p&gt;No database of real strangers is involved. No permanent biometric record is created. The point is that the fake product feels plausible because the real world already trained people to accept it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the prank version exists
&lt;/h2&gt;

&lt;p&gt;The prank version works because people share jokes faster than arguments.&lt;/p&gt;

&lt;p&gt;Someone who would never send an essay about facial recognition will absolutely send a link that makes a friend say, "wait, how did this thing find my face?"&lt;/p&gt;

&lt;p&gt;That is not a compromise. It is the delivery system.&lt;/p&gt;

&lt;p&gt;The prank surface does not pretend to the sender. It tells the sender exactly what they are doing. Paste the photo. Make the link. Send it. The deception only happens inside the recipient flow, where the whole experience depends on that short moment of belief before the reveal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product difference is mostly tone
&lt;/h2&gt;

&lt;p&gt;Under the hood, both doors use the same flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Accept a photo from the sender.&lt;/li&gt;
&lt;li&gt;Generate three altered face variants.&lt;/li&gt;
&lt;li&gt;Degrade the outputs so they look like real internet photos, not polished AI portraits.&lt;/li&gt;
&lt;li&gt;Show the recipient a fake face-twin product.&lt;/li&gt;
&lt;li&gt;Burn the link after reveal.&lt;/li&gt;
&lt;li&gt;Ask the recipient if they want to send it to someone else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The copy, color, and reveal tone change around that flow.&lt;/p&gt;

&lt;p&gt;The privacy art version is black, sparse, and confrontational. It wants the recipient to sit with the discomfort.&lt;/p&gt;

&lt;p&gt;The prank version is hotter, faster, and more social. It wants the recipient to laugh, screenshot, and send it onward.&lt;/p&gt;

&lt;p&gt;Same engine. Different social context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed for the viral loop
&lt;/h2&gt;

&lt;p&gt;The reveal is the important screen. It is where the recipient learns what happened, and it is also where the next sender is born.&lt;/p&gt;

&lt;p&gt;So the share path now carries attribution. Links copied from the sender screen and reveal screen include campaign parameters that identify whether the chain came from the original sender, the reveal CTA, a copy action, native share, or a social share.&lt;/p&gt;

&lt;p&gt;That makes the chain measurable without collecting victim PII.&lt;/p&gt;

&lt;p&gt;I also added a public &lt;a href="https://prankmyface.lol/hall-of-fame" rel="noopener noreferrer"&gt;Prank Hall of Fame&lt;/a&gt;. It shows anonymous reveal reactions after basic PII scrubbing. The archive is not there to shame anyone. It is there because the reaction is the product's best ad.&lt;/p&gt;

&lt;p&gt;Someone saying "I knew it was fake but still clicked" explains the experience better than a landing page ever could.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I will not break
&lt;/h2&gt;

&lt;p&gt;The tool only works socially.&lt;/p&gt;

&lt;p&gt;If a random account sends a prank link to a stranger, the experience becomes harassment. It also becomes spam. The correct distribution move is not to prank strangers. It is to put the tool in front of people who will decide, voluntarily, to send it to someone they know.&lt;/p&gt;

&lt;p&gt;That is the boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Public posts about the tool are fine.&lt;/li&gt;
&lt;li&gt;Friends pranking friends are fine.&lt;/li&gt;
&lt;li&gt;Cold-sending generated prank links to strangers is out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prank has to come from a real relationship or the whole thing turns sour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bet
&lt;/h2&gt;

&lt;p&gt;The privacy audience will understand why the project exists. The prank audience will actually spread it.&lt;/p&gt;

&lt;p&gt;Those are not competing goals. The prank version gets the experience into group chats. The privacy version explains why the experience is worth taking seriously after the laugh lands.&lt;/p&gt;

&lt;p&gt;The chain starts at &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt;. The argument lives at &lt;a href="https://pleasejuststop.org" rel="noopener noreferrer"&gt;pleasejuststop.org&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>One Playwright Header Broke Every WebSocket Test</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 17:07:43 +0000</pubDate>
      <link>https://dev.to/forrestmiller/one-playwright-header-broke-every-websocket-test-2m8g</link>
      <guid>https://dev.to/forrestmiller/one-playwright-header-broke-every-websocket-test-2m8g</guid>
      <description>&lt;p&gt;The failing tests looked like an Ably outage.&lt;/p&gt;

&lt;p&gt;Every multiplayer browser test on &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; started timing out at the same gate: "wait until the realtime channel attaches." The app loaded. The room existed. The players joined. Then the WebSocket layer never became ready.&lt;/p&gt;

&lt;p&gt;The root cause was not Ably. It was one Playwright config line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;extraHTTPHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-BingWow-Automated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That header was supposed to mark test traffic as internal. Instead, it leaked to every cross-origin request the browser made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this broke realtime
&lt;/h2&gt;

&lt;p&gt;Playwright's &lt;code&gt;extraHTTPHeaders&lt;/code&gt; is global for the browser context. It does not apply only to requests headed for your app. It applies to third-party calls too.&lt;/p&gt;

&lt;p&gt;In this app, a multiplayer room talks to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bingwow.com&lt;/code&gt; for HTML and API routes&lt;/li&gt;
&lt;li&gt;Ably for realtime channels&lt;/li&gt;
&lt;li&gt;Supabase for auth and storage paths&lt;/li&gt;
&lt;li&gt;analytics endpoints for product events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The custom &lt;code&gt;X-BingWow-Automated&lt;/code&gt; header is not CORS-safelisted. When the browser tried to call Ably with that header, it triggered a preflight request. Ably did not whitelist our private test header. The preflight failed. The actual realtime request never happened.&lt;/p&gt;

&lt;p&gt;From the test's point of view, Ably simply never attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The misleading symptom
&lt;/h2&gt;

&lt;p&gt;The app code was doing the right thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__ably_channel_ready&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wait timed out after two minutes. That made the problem look like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a flaky WebSocket connection&lt;/li&gt;
&lt;li&gt;a race in the join flow&lt;/li&gt;
&lt;li&gt;a broken token endpoint&lt;/li&gt;
&lt;li&gt;a headless browser limitation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of those were plausible. None were true.&lt;/p&gt;

&lt;p&gt;The header never showed up in the app's own logs as an error because the failing request was cross-origin. The damage happened before Ably's actual attach request could complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: use a cookie, not a global header
&lt;/h2&gt;

&lt;p&gt;We still needed to mark test traffic so it would not pollute analytics or product metrics. The replacement was a domain-scoped cookie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;storageState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bingwow_dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.bingwow.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;httpOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lax&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="nx"&gt;origins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That cookie is sent only to BingWow-owned requests. It never goes to Ably, Supabase, or other third-party origins. The app can still identify internal traffic on its own API routes, and the browser no longer poisons external requests with private headers.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://bingwow.com/play" rel="noopener noreferrer"&gt;real-time multiplayer games&lt;/a&gt;, this difference matters. The transport stack has to be boring. A test harness that changes cross-origin network behavior is not observing the product anymore. It is creating a second product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural lint
&lt;/h2&gt;

&lt;p&gt;The fix needed a guardrail because the broken line is easy to reintroduce. We added a Jest test that scans Playwright config files for &lt;code&gt;extraHTTPHeaders&lt;/code&gt; and fails if a non-allowlisted header appears.&lt;/p&gt;

&lt;p&gt;The error message explains why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;extraHTTPHeaders is GLOBAL - it applies to every browser request,
including cross-origin calls to ably.io, posthog, supabase, etc.
Custom non-CORS-safelisted headers trigger preflight failures and
silently break those services.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a style preference. It is a production-shaped invariant for browser tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why headless detection is still useful
&lt;/h2&gt;

&lt;p&gt;The app also skips internal analytics for obvious automation signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;navigator.webdriver&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;HeadlessChrome user agents&lt;/li&gt;
&lt;li&gt;a server-side bot detector&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;bingwow_dev=1&lt;/code&gt; cookie&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those checks are safe because they do not alter third-party request headers. They change only how the app classifies traffic after it receives a request.&lt;/p&gt;

&lt;p&gt;That distinction is the key lesson: mark traffic where you own the request, not by mutating every request the browser makes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I debug this class now
&lt;/h2&gt;

&lt;p&gt;When a browser test involving third-party services fails, I check these first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the Playwright context set global headers?&lt;/li&gt;
&lt;li&gt;Are those headers CORS-safelisted?&lt;/li&gt;
&lt;li&gt;Do failed requests show &lt;code&gt;OPTIONS&lt;/code&gt; preflight before the real call?&lt;/li&gt;
&lt;li&gt;Does the same flow pass with a clean context plus app-domain cookies?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most teams only notice this once they add a service that enforces CORS strictly. WebSockets, analytics, file storage, maps, payments, and auth providers all expose the same trap.&lt;/p&gt;

&lt;p&gt;The visible feature in my case was a simple &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingo card&lt;/a&gt; room. The underlying bug was a general browser automation footgun.&lt;/p&gt;

&lt;p&gt;If you need internal test classification, prefer these patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App-domain cookies through &lt;code&gt;storageState&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;server-side bot detection&lt;/li&gt;
&lt;li&gt;test-only query params on your own origin&lt;/li&gt;
&lt;li&gt;init scripts that set app-local flags without touching network headers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid global headers unless every origin the browser contacts is under your control.&lt;/p&gt;

&lt;p&gt;The same rule now protects the multiplayer flow, the &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;online bingo caller&lt;/a&gt;, and the internal QA suite. Tests should make the product easier to trust. They should not create network conditions real users never hit.&lt;/p&gt;

&lt;p&gt;That one-line Playwright config was doing exactly that, and deleting it made the test suite both greener and more honest.&lt;/p&gt;

&lt;p&gt;For a deeper product-level view of the architecture this affected, I wrote up the broader system in &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;the real-time multiplayer bingo guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>testing</category>
      <category>playwright</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
