<?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: Ted</title>
    <description>The latest articles on DEV Community by Ted (@henry_dan_81513dd35a2f540).</description>
    <link>https://dev.to/henry_dan_81513dd35a2f540</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2256153%2Fd3b27e5a-9e82-4c4d-b481-9b835d4deea3.png</url>
      <title>DEV Community: Ted</title>
      <link>https://dev.to/henry_dan_81513dd35a2f540</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/henry_dan_81513dd35a2f540"/>
    <language>en</language>
    <item>
      <title>The Top Row Had 150 Clicks. It Wasn't a Listing.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Fri, 18 Sep 2026 18:24:51 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-top-row-had-150-clicks-it-wasnt-a-listing-21k9</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-top-row-had-150-clicks-it-wasnt-a-listing-21k9</guid>
      <description>&lt;p&gt;I track every click that leaves one of my sites. When someone taps a booking button, a small function records which listing it was for, which page it happened on and a few other details, and a dashboard adds them up. Its most useful panel is a table called Top Performing Entities: the listings people click most.&lt;/p&gt;

&lt;p&gt;I opened it to see which listings were earning their place. The top row had 150 clicks in 30 days, well ahead of second place. It carried the name of a real listing.&lt;/p&gt;

&lt;p&gt;It wasn't one. It was 29 different listings, stacked on top of each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five kinds of value in one column
&lt;/h2&gt;

&lt;p&gt;Each click row stores an &lt;code&gt;entity_id&lt;/code&gt; — the ID of the listing that was clicked. The dashboard groups by it. That's the whole design: count the rows per ID, sort, show the top ten.&lt;/p&gt;

&lt;p&gt;So I counted what the column actually held. Of the 806 clicks in the last 30 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database UUID        441
"unknown"            157
slug or label        108
list position        100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five different ideas of what an ID is — the slugs and the hand-written labels share a line — in one column. Each had a reasonable origin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;unknown&lt;/code&gt; came from a button.&lt;/strong&gt; On a phone, every detail page shows a sticky bar at the bottom of the screen with the main call to action. The component that draws it takes the listing's ID as an optional prop, and falls back like this:&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="nx"&gt;entityId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&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;None of the four page types that used the bar passed an ID. Most of the site's visitors are on phones, so this was one of the most-clicked buttons on the site, and every click on it was logged as &lt;code&gt;unknown&lt;/code&gt;. All 157 that month came from it; 463 since June.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List positions came from the main listings page.&lt;/strong&gt; The database gives every listing a UUID, but a shared TypeScript type declared &lt;code&gt;id: number&lt;/code&gt;. To satisfy it, the code that turned database rows into cards made one up:&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a position in a list, not a property of a listing. Sort the list differently, filter it, add a listing, and the same number points somewhere else. It did: &lt;code&gt;9037&lt;/code&gt; was logged for two different listings, and so was &lt;code&gt;9010&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slugs and labels came from blog posts&lt;/strong&gt;, whose links were written by hand — sometimes with the database ID, sometimes the URL slug, and sometimes a short label that matched nothing in the database at all.&lt;/p&gt;

&lt;p&gt;None of it was an error. Every insert succeeded, because the column was plain text and accepted all of it.&lt;/p&gt;

&lt;p&gt;It hadn't always been. It started as a UUID column, and months ago I loosened it to text so an events page could log IDs that weren't UUIDs. That was the one thing that could have refused &lt;code&gt;unknown&lt;/code&gt; and &lt;code&gt;9037&lt;/code&gt; — though it would have refused them by dropping the clicks, which is its own kind of wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the dashboard did with it
&lt;/h2&gt;

&lt;p&gt;Grouping by that column did two opposite things at once.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;split&lt;/strong&gt; listings that should have been one row. The most-clicked real listing was spread across five different IDs: its UUID from its own page, a list position, a slug from one blog post, a label from another, and &lt;code&gt;unknown&lt;/code&gt;. Another was spread across seven. In the same 30 days, 76 listing names came back under 102 distinct IDs.&lt;/p&gt;

&lt;p&gt;And it &lt;strong&gt;merged&lt;/strong&gt; listings that should have been separate. Every &lt;code&gt;unknown&lt;/code&gt; click from every listing went into one bucket. The dashboard labelled each row with the name on the first click it happened to read, so 150 clicks from 29 listings wore one listing's name — and sat at the top of the table, because the sum of everyone's missing IDs will outscore any single listing.&lt;/p&gt;

&lt;p&gt;The panel next to it was worse, because it drew a conclusion. A "missed revenue" report flagged listings that got clicks but no affiliate clicks, as candidates for a booking link. It flagged six. Two of them had affiliate clicks all along, logged under a different ID for the same listing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An ID column isn't an identity. It's whatever each piece of code decided to send.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The column promises that one value means one thing, and nothing enforces that promise at the moment of writing. Each caller — a button, a list, a blog post someone wrote by hand — picks what to send, and the table stores it. When they disagree, the database has no way to know. The disagreement only surfaces where someone finally groups by the column and trusts the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolve, don't trust
&lt;/h2&gt;

&lt;p&gt;There were two fixes, and the order mattered.&lt;/p&gt;

&lt;p&gt;The first was to stop believing the label. The dashboard now loads the ID, slug and name of every listing and resolves each click to a real row — by ID first, then by slug, then by name, with names normalised so "The Example Inn" and "Example Inn", or "&amp;amp; Spa" and "and Spa", land on the same listing. Only then does it group: by the resolved row, not the logged value, and under the listing's name from the database rather than whatever the click carried.&lt;/p&gt;

&lt;p&gt;Before shipping it, I ran the same rule in SQL over every listing click ever recorded, to see what it would leave behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;matched by ID      1,219
matched by slug      156
matched by name      811
unmatched            140   (of 2,326)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 140 are almost all listings deleted since — there's no row to resolve them to, but their logged names are consistent, so they still group correctly. The top row went from 150 anonymous clicks to the real most-clicked listing, at 110. The missed-revenue report went from six flags to four.&lt;/p&gt;

&lt;p&gt;The second fix was to stop sending bad IDs. The sticky bar now receives the listing's ID from every page that uses it. The listings page keeps each row's own ID instead of a list position, and the shared type now says &lt;code&gt;id: string&lt;/code&gt;, which is what it was all along. The hand-written blog list tracks the database ID instead of its label. Old rows keep their old values; the resolver handles those.&lt;/p&gt;

&lt;p&gt;Doing it in that order meant the dashboard was right about history the moment it shipped, not just about clicks from then on.&lt;/p&gt;

&lt;p&gt;A few days later the same lesson turned up one layer over. A small script checks every morning that each listing still carries a paid booking link. The site counted links from three booking domains as paid; the checker, written earlier, knew only two. The first listing to use the third domain would have been counted as unpaid on the day it started paying. Two pieces of code, one idea, two definitions — and each was right about its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build passed
&lt;/h2&gt;

&lt;p&gt;One detail from the fix, because it nearly shipped a quieter version of the same bug.&lt;/p&gt;

&lt;p&gt;The resolver builds its lookup with &lt;code&gt;new Map()&lt;/code&gt;. The build succeeded. The typechecker didn't:&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;error&lt;/span&gt; &lt;span class="nx"&gt;TS2350&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Only&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;can&lt;/span&gt; &lt;span class="nx"&gt;be&lt;/span&gt; &lt;span class="nx"&gt;called&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="nx"&gt;TS2558&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Expected&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;but&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard file imports an icon library, and one of its icons is called &lt;code&gt;Map&lt;/code&gt;. In that file, &lt;code&gt;Map&lt;/code&gt; wasn't JavaScript's built-in — it was a React component, and calling &lt;code&gt;new&lt;/code&gt; on it throws. The bundler strips types without checking them, so it compiled without complaint.&lt;/p&gt;

&lt;p&gt;It wouldn't even have crashed. The lookup runs inside a data-fetching hook that catches errors, and I'd written the resolver to fall back to the logged names whenever the lookup wasn't available. So the dashboard would have loaded and looked normal, while quietly grouping by the very column this post is about — the fix switched off by its own safety net. &lt;code&gt;globalThis.Map&lt;/code&gt; put it right.&lt;/p&gt;

&lt;p&gt;I've written before that a &lt;a href="https://tedagentic.com/posts/fallback-chain-error-suppression" rel="noopener noreferrer"&gt;fallback chain is an error-suppression system&lt;/a&gt;, and that &lt;a href="https://tedagentic.com/posts/it-passed-because-it-never-looked" rel="noopener noreferrer"&gt;green is not verification&lt;/a&gt;. This was both, in four lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check first
&lt;/h2&gt;

&lt;p&gt;If a dashboard ranks things by an ID that several parts of your code write, count what's actually in the column before trusting the ranking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;CASE&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="s1"&gt;'^[0-9a-f]{8}-'&lt;/span&gt;          &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'uuid'&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="s1"&gt;'^[0-9]+$'&lt;/span&gt;               &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'numeric'&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;entity_id&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;'unknown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'null'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'placeholder'&lt;/span&gt;
    &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'other'&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&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;FROM&lt;/span&gt; &lt;span class="n"&gt;clicks&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If more than one kind comes back, the top of that table is a claim, not a count.&lt;/p&gt;

&lt;p&gt;And look hard at the fallbacks. &lt;code&gt;|| "unknown"&lt;/code&gt; looks like defensive code. It was a &lt;a href="https://tedagentic.com/posts/legal-data-audit-four-copies" rel="noopener noreferrer"&gt;default that set policy&lt;/a&gt; for 463 clicks, and the policy was: forget which listing this was.&lt;/p&gt;

&lt;p&gt;The top row wasn't my best listing. It was the one place every missing ID agreed to meet.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>database</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Three AI Agents Corrected Each Other. None of Them Were Talking.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Tue, 15 Sep 2026 04:43:01 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/three-ai-agents-corrected-each-other-none-of-them-were-talking-5d2h</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/three-ai-agents-corrected-each-other-none-of-them-were-talking-5d2h</guid>
      <description>&lt;p&gt;CrewAI is an open-source Python framework for running several AI agents as a team. You give each agent a role and a goal, give the team a list of tasks, and the framework passes the work between them. Before building anything real with it, I wanted to answer one question: when people say the agents "talk to each other", what is actually happening?&lt;/p&gt;

&lt;p&gt;So I set up a small crew on the server in my house — a ThinkCentre that already runs my monitoring and automation — and gave it a practice topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crew
&lt;/h2&gt;

&lt;p&gt;Three agents, three tasks, run in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Research Analyst&lt;/strong&gt; — gathers the facts on a topic and marks anything it isn't sure of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Writer&lt;/strong&gt; — turns the research into a 300-word brief. Allowed to ask the Analyst questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor&lt;/strong&gt; — checks the brief against the research, verifies the biggest claim with the Analyst, and sends weak sections back to the Writer. It finishes with a "Team notes" section listing every exchange.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Analyst and the Writer run on Qwen 3 235B through OpenRouter, a service that puts dozens of model hosts behind one API. The Editor runs on Claude Opus 4.8, direct from Anthropic. The cheap model does the volume; the expensive one makes the judgement calls.&lt;/p&gt;

&lt;p&gt;Setup is four commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv tool &lt;span class="nb"&gt;install &lt;/span&gt;crewai
&lt;span class="nv"&gt;CREWAI_DMN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;crewai create crew starter_crew &lt;span class="nt"&gt;--provider&lt;/span&gt; anthropic/claude-haiku-4-5
&lt;span class="nb"&gt;cd &lt;/span&gt;starter_crew &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; crewai &lt;span class="nb"&gt;install
&lt;/span&gt;crewai run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That installed CrewAI 1.15.21, the version everything below was tested on. &lt;code&gt;CREWAI_DMN&lt;/code&gt; isn't in &lt;code&gt;crewai --help&lt;/code&gt;. I found it in the CLI's source: it switches off every interactive prompt, which is what you want when a script rather than a person is driving. It also means the scaffold never writes your &lt;code&gt;.env&lt;/code&gt; — the API keys are yours to add. The &lt;code&gt;--provider&lt;/code&gt; flag only chooses the starting model that gets written into each agent's file; I swapped those afterwards, to Qwen for the Analyst and the Writer and Claude Opus 4.8 for the Editor. The project itself is plain JSON: one file per agent, one &lt;code&gt;crew.jsonc&lt;/code&gt; for the tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The run
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F537ahjn09jh39r8tnjap.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F537ahjn09jh39r8tnjap.png" alt="The finished run: three tasks complete, the Editor's team notes, and three coworker tool calls in the activity log" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It took 118 seconds, 14,609 tokens in and 3,928 out.&lt;/p&gt;

&lt;p&gt;The Writer asked the Analyst a question before drafting. Then the Editor read the draft and stopped on a price: a capable home AI server for about $1,600. It asked the Analyst whether that held up. The answer came back that $1,600 is the graphics card alone — a complete machine runs $2,500–3,500 — and that a single 24 GB card can't run a 70-billion-parameter model at a usable speed anyway. The Editor sent the paragraph back to the Writer with the correction, took the revised version, and recorded both exchanges in its Team notes.&lt;/p&gt;

&lt;p&gt;Three agents, one caught error, one fix. It looked exactly like a team conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  It wasn't a conversation
&lt;/h2&gt;

&lt;p&gt;The activity log at the bottom of the run screen says what actually happened. Three entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ ask_question_to_coworker     15.3s
✓ ask_question_to_coworker     20.4s
✓ delegate_work_to_coworker     8.9s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are tools. When an agent is allowed to delegate, CrewAI hands it two extra functions, the same way you'd hand an agent a web search or a file reader. Calling &lt;code&gt;ask_question_to_coworker&lt;/code&gt; with a coworker's name, a question and some context starts a fresh model call as that coworker, and whatever it returns comes back to the caller as the tool's result. The Editor never spoke to the Analyst. It called a function whose implementation happens to be another agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A conversation between agents isn't a conversation. It's a function call with a job title.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That changes how you build with it. The quality of the "discussion" is the quality of the arguments one model passes into a tool. The Editor only knew about the $1,600 because it was in the draft it was handed; the Analyst could only answer what the question contained. Nothing is shared between agents except what one call gives the next — and the handoffs between tasks work the same way, each task's output pasted into the next task's prompt.&lt;/p&gt;

&lt;p&gt;It also exposes the limit of what I watched. Nobody in that exchange looked anything up. These agents had no web access, so the Analyst "confirmed" the correction from the same kind of training data that produced the original number. The new figures — the $2,500–3,500 and the claim about the 24 GB card — are plausible. Neither is verified. The Analyst had no way to say "I don't know", and one of the operating rules I keep for this blog covers exactly that: &lt;a href="https://tedagentic.com/rules" rel="noopener noreferrer"&gt;a system with no way to say "I don't know" will answer anyway&lt;/a&gt;. A second model agreeing is a second opinion, not a second source. Giving agents tools can change that. Giving them coworkers doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things that broke first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The key was a placeholder.&lt;/strong&gt; My &lt;code&gt;.env&lt;/code&gt; held template text, not a key, and Anthropic answered &lt;code&gt;401 invalid x-api-key&lt;/code&gt;. One direct request to the API would have told me that before any framework was involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The provider wasn't installed.&lt;/strong&gt; The scaffold depends only on &lt;code&gt;crewai[tools]&lt;/code&gt;. Anthropic support is an optional extra, so the first run died with &lt;code&gt;Anthropic native provider not available&lt;/code&gt;. The fix is &lt;code&gt;uv add "crewai[anthropic]"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory wanted a key I never gave it.&lt;/strong&gt; The scaffold switches crew memory on, and memory's default embedder is OpenAI's. With no OpenAI key, every memory lookup errored. &lt;code&gt;"memory": false&lt;/code&gt; until you configure a different embedder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The newest Claude doesn't fit yet.&lt;/strong&gt; I wanted Opus 5 as the Editor, so I read CrewAI's Anthropic code first. Its thinking setting accepts only "enabled" or "disabled"; Opus 5 thinks by default and rejects the old "enabled with a budget" form; and CrewAI drops the thinking blocks when it rebuilds a tool-call turn. Agents calling each other through tools is exactly the path that would hit it. I didn't spend money finding out — Opus 4.8 costs the same per token and only thinks when asked.&lt;/p&gt;

&lt;p&gt;The fifth one is the one worth a section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit I never set
&lt;/h2&gt;

&lt;p&gt;The first full three-agent run failed halfway. The Analyst finished its task. The Writer failed on every attempt — ten in a row — with this, passed back from the model host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Requested token count exceeds the model's maximum context length of 131072 tokens.
You requested a total of 132086 tokens: 1014 tokens from the input messages
and 131072 tokens for the completion.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Writer's prompt was about a thousand tokens. The other 131,072 were the reply — under a reply limit I had never set.&lt;/p&gt;

&lt;p&gt;That was the whole problem. CrewAI only sends a reply limit (&lt;code&gt;max_tokens&lt;/code&gt;) when you configure one; I checked the code. With none sent, the reply was treated as allowed to fill the model's entire context window, my thousand tokens of input went on top, and the total was rejected for being over the window. The request didn't fail because it was big. It failed because it didn't say how big.&lt;/p&gt;

&lt;p&gt;I reproduced it without CrewAI: one request to the same host, a 12-token prompt, no &lt;code&gt;max_tokens&lt;/code&gt;. Same error — 12 plus 131,072 is 131,084, over by twelve. Why the Analyst survived, I can only guess: OpenRouter spreads requests across hosts, and my best explanation is that its requests landed on one that handled a missing limit differently. I didn't capture which host served them.&lt;/p&gt;

&lt;p&gt;Here is what I can show, and what I can't. The limit being enforced is 131,072 — the error comes back from that host's backend — while OpenRouter's listing for the same host advertises 262,144 tokens of context and 235,929 of output. What I can't show from outside is which layer wrote 131,072 into the reply field, OpenRouter or the host. That it matches the host's real window, not the advertised one, points at the host. Treat that as a hypothesis.&lt;/p&gt;

&lt;p&gt;The fix is one field. Every agent's model now carries &lt;code&gt;"max_tokens": 4096&lt;/code&gt; — 16,000 for the Claude editor. It's another case of a rule from the same list, &lt;a href="https://tedagentic.com/rules" rel="noopener noreferrer"&gt;defaults are policy&lt;/a&gt;: an unset value isn't an absence. Something downstream always chooses one, and here it chose the largest number it had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An unset limit isn't no limit. It's the maximum.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;With only the Qwen models, a full three-agent run cost under half a cent. With Claude as Editor, the OpenRouter share stayed around $0.003. Anthropic doesn't report per-run cost to an API key, but even if every one of that run's 18,537 tokens had gone through Claude at Opus 4.8's list price — $5 per million input tokens, $25 per million output — it would come to about 17 cents.&lt;/p&gt;

&lt;h2&gt;
  
  
  I had a crew review this post
&lt;/h2&gt;

&lt;p&gt;Before publishing, I built a second crew and gave it this draft. A Cold Reader plays a stranger arriving from Google. A Reframe Critic looks for the one sentence where the idea flips. A Claude Editor checks every claim against the text and gives a verdict. Before any of them ran, plain code handled the mechanical checks — no private site names, a valid category, the image present, the links resolving — because no model should be trusted with those.&lt;/p&gt;

&lt;p&gt;The verdict was "fix first", and the two most useful catches were mine to own. I had stated two guesses as facts: why the Analyst's requests survived, and which layer filled in the 131,072. Both are labelled as guesses now. It also noticed that the setup command names one Claude model while the crew runs another, with nothing to reconcile them.&lt;/p&gt;

&lt;p&gt;One reviewer was simply wrong. The Cold Reader said a stranger "cannot access" the rules page these posts link to. It's a public page. It said so with exactly the same confidence as everything else it said.&lt;/p&gt;

&lt;p&gt;That review had the same shape as the run above. The reviewers didn't discuss the post. Each one was a function call handed the draft, and the Editor was handed their outputs. The good catches came from the Editor checking claims against the text in front of it. The false one came from an agent with no way to check anything, answering anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you set one up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;max_tokens&lt;/code&gt; on every agent's model. Don't let something downstream pick it.&lt;/li&gt;
&lt;li&gt;Turn memory off, or configure an embedder, before the first run.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;CREWAI_DMN=true crewai run&lt;/code&gt; in scripts — plain output that exits when it's done. Use plain &lt;code&gt;crewai run&lt;/code&gt; when you want to watch.&lt;/li&gt;
&lt;li&gt;Read the activity log, not the final report. The log is the actual conversation — and it's a list of function calls.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>crewai</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>The Error Was Real. The Page Was Fine.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sat, 12 Sep 2026 03:10:00 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-error-was-real-the-page-was-fine-4g9o</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-error-was-real-the-page-was-fine-4g9o</guid>
      <description>&lt;p&gt;The report arrived from the hosting platform's AI monitoring, and it was admirably specific.&lt;/p&gt;

&lt;p&gt;A listings page on one of my sites, it said, &lt;em&gt;always&lt;/em&gt; falls back to a degraded list. The main database query fails every time, because it asks for a field that doesn't exist. The page silently drops to a simpler query, so the listings load without their city, state and country — which can break the grouping, the filters and the labels visitors see. It had errored 28 times in 24 hours.&lt;/p&gt;

&lt;p&gt;That is a good report. It names the symptom, the mechanism, the consequence and a count. I nearly went straight to the consequence and started looking for broken labels.&lt;/p&gt;

&lt;p&gt;The first half turned out to be exactly right. The second half never happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query had never worked
&lt;/h2&gt;

&lt;p&gt;The page asks the database for its listings along with a nested relationship: each listing's city, that city's state, and that state's country. In Supabase that is one request, written as a nested select:&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;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;hotels&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    *,
    cities:city_id (
      name, slug,
      states:state_id (
        name, code,
        countries:country_id ( name, code )
      )
    )
  `&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran that exact request against the live database and got this back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 400
{"code":"42703","message":"column countries_3.code does not exist"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither the &lt;code&gt;states&lt;/code&gt; table nor the &lt;code&gt;countries&lt;/code&gt; table has a &lt;code&gt;code&lt;/code&gt; column. The database reports only the first missing column it trips over, so the error names countries, but both references were wrong. The request was malformed as written, so it could not succeed on any load, for any visitor, ever.&lt;/p&gt;

&lt;p&gt;Wrapped around it was a pattern you have probably written yourself:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&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="nf"&gt;tryWithRelationships&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relationship query failed, falling back to simple query:&lt;/span&gt;&lt;span class="dl"&gt;'&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="k"&gt;return&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;hotels&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try the rich version first. If it fails, degrade gracefully. It reads like defensive engineering, and it is. It is also, in this case, a description of a code path that had executed on every page load since the day it shipped. The "rich version" was not the primary path with a safety net beneath it. It was a request that failed, followed by the actual implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I checked what visitors saw
&lt;/h2&gt;

&lt;p&gt;The report's consequence — listings without their location — is a reasonable inference from that error. The failing request is the one that fetches location. If it never returns, location should be missing.&lt;/p&gt;

&lt;p&gt;Except the listings table stores its own copy. Each row carries plain &lt;code&gt;city&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt; and &lt;code&gt;country&lt;/code&gt; columns, and those columns were populated on all 58 rows. The fallback's &lt;code&gt;select('*')&lt;/code&gt; returns them. And the code that formats each listing already reached for them:&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;city&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cities&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cities&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;states&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown&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 uses the relationship when it exists and the row's own column when it doesn't. That had been written deliberately, with a comment explaining why: only 8 of the 58 rows had a &lt;code&gt;city_id&lt;/code&gt; set at all. So for 50 listings the relationship would have come back empty even on a day the query worked. Whoever wrote the formatter had already stopped trusting the relationship and routed around it.&lt;/p&gt;

&lt;p&gt;So every visitor got the fallback, and the fallback carried everything the page needed. The grouping worked. The filters worked. The labels were right. The error fired on every single load, and nobody who loaded the page ever saw it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An error rate counts what your code tried and failed. It does not count what anyone saw. When a fallback is doing its job, those two numbers stop having anything to do with each other.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The mirror image of the usual problem
&lt;/h2&gt;

&lt;p&gt;I have written before about a fallback chain that did the opposite. Four layers of image lookup, where a dead link in layer three was simply covered by layer four, and forty-nine broken URLs sat behind a default photo without a single error being raised. That was silence hiding damage.&lt;/p&gt;

&lt;p&gt;This was noise hiding the absence of damage. Same architecture, the same defensible habit of degrading instead of crashing, and the signal failed in the other direction. A fallback can make a real failure invisible. It can just as easily make a harmless failure look catastrophic, because the thing that fails is loud, and the thing that catches it is quiet.&lt;/p&gt;

&lt;p&gt;That's what the report got wrong, and it was a good-faith mistake. It read the error, which is about a query, and inferred the impact, which is about a page. The error message was completely accurate about the query. It simply contained no information about the page, because the page's outcome was decided one step later, by code the error never passed through.&lt;/p&gt;

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

&lt;p&gt;Not nothing, which is worth saying plainly, since "the page was fine" is not the same as "there was no bug."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A failed request on every load.&lt;/strong&gt; 28 in a day is 28 wasted round-trips and 28 console errors, each one burying whatever real error might arrive next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A code path that misrepresented itself.&lt;/strong&gt; Anyone reading the component would believe the page had a richer mode it used when it could. It never had one. Code that describes behaviour it doesn't have is a trap for the next person who reads it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One field genuinely degraded.&lt;/strong&gt; Country had no column fallback. It read &lt;code&gt;row.cities?.states?.countries?.name || 'USA'&lt;/code&gt;, so every listing was labelled with a hardcoded default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix for the error was deleting two field names from the select. The request now returns 200 and resolves the relationship for the 8 rows that have one.&lt;/p&gt;

&lt;p&gt;The country field was the part that didn't go the way I expected. With the query fixed, I checked what came back for those 8 rows, expecting real country names at last. Every one of them was null. The &lt;code&gt;states&lt;/code&gt; table has a &lt;code&gt;country_id&lt;/code&gt; column, and it is empty on every row. So the country relationship cannot resolve for anything, and the label still falls back to &lt;code&gt;'USA'&lt;/code&gt;. It happens to be correct today, because every listing is in the US. It will quietly be wrong for the first one that isn't.&lt;/p&gt;

&lt;p&gt;That is the richer mode, working exactly as written, with nothing underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful version of the report
&lt;/h2&gt;

&lt;p&gt;I don't think the lesson is "distrust automated monitoring." The monitor found a real defect that had been live for as long as the page existed, and I would never have looked otherwise.&lt;/p&gt;

&lt;p&gt;The lesson is that an error with a 100% failure rate and zero visible impact is not a contradiction to be explained away. It is a finding in its own right, and a sharper one than the report made. &lt;strong&gt;If something fails every time and nobody notices, then nobody depends on it.&lt;/strong&gt; That path is dead code wearing the costume of the main path.&lt;/p&gt;

&lt;p&gt;So when an alert tells you something fails on every request, ask the second question before reaching for the consequence: &lt;em&gt;what did the user get instead?&lt;/em&gt; If the answer is "exactly what they needed," then the error isn't telling you the page is broken. It's telling you the part you thought was the system has never actually run — and that the part you thought was the backup is the system.&lt;/p&gt;

&lt;p&gt;The number was true. The story attached to it was a guess.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>database</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>The API Said 44 Updated. Twenty Didn't Change.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sat, 12 Sep 2026 03:08:39 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-api-said-44-updated-twenty-didnt-change-2eom</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-api-said-44-updated-twenty-didnt-change-2eom</guid>
      <description>&lt;p&gt;I keep a copy of every post from this blog on dev.to, the developer blogging platform. Each copy points back here with a canonical link, so search engines treat this site as the original. Last month I rewrote the descriptions on all of them — the short summary that shows on share cards and in dev.to's own search.&lt;/p&gt;

&lt;p&gt;Forty-five articles. A job for a script, not a morning of clicking.&lt;/p&gt;

&lt;p&gt;I did it the careful way. I updated one article first, and before and after I fingerprinted everything the update could plausibly disturb: the length of the body, the tags, the canonical URL, the title, the published state. Only the description had changed. Everything else was byte-identical. Then I ran the other forty-four.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;updated 44, failed 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request came back &lt;code&gt;200 OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Out of habit rather than suspicion, I ran the checker again — the one that compares what's live against what I intended. Twenty articles still had their old description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not a cache
&lt;/h2&gt;

&lt;p&gt;My first thought was lag. The endpoint that lists all your articles at once is exactly the kind of thing that serves a slightly stale snapshot. So I skipped it and fetched each of the twenty individually, from the endpoint that returns a single article fresh.&lt;/p&gt;

&lt;p&gt;Old descriptions, all twenty. The writes hadn't been delayed. They hadn't happened.&lt;/p&gt;

&lt;p&gt;So forty-four requests had gone out, all forty-four had been answered with success, and twenty of them had changed nothing. Nothing in the responses separated the twenty from the twenty-four. Same status, same shape, same body echoing back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twenty had something in common
&lt;/h2&gt;

&lt;p&gt;Laid side by side, the difference was in the article bodies. Every one of the twenty began like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Zero&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Measurement"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;old&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;summary,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;still&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sitting&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;here."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debugging, monitoring&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://tedagentic.com/posts/zero-is-not-a-measurement&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

The article itself starts here...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That block between the two &lt;code&gt;---&lt;/code&gt; lines is front matter: metadata written into the top of a markdown file, the same convention static site generators use. dev.to supports it. You can publish an article by sending the whole thing, metadata block included, and dev.to reads its settings from there.&lt;/p&gt;

&lt;p&gt;The twenty-five articles that updated correctly had no such block. Their bodies were just the article.&lt;/p&gt;

&lt;p&gt;Same author, same months, interleaved through the calendar. I had two kinds of article and had never known it, because from the outside — on the page, in the list, in the API — they looked identical.&lt;/p&gt;

&lt;p&gt;And the one I had tested first, so carefully, with all those fingerprints? It was one of the twenty-five. My probe had been perfectly representative of the articles where the method works, and had told me nothing at all about the ones where it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was writing to the output of a function
&lt;/h2&gt;

&lt;p&gt;Here is what happened on each of those twenty saves.&lt;/p&gt;

&lt;p&gt;The request arrived with a new &lt;code&gt;description&lt;/code&gt;. It was well-formed, the field was a legitimate part of the article, and dev.to accepted it. Then, as part of the same save, dev.to read the front matter out of the body, found a &lt;code&gt;description:&lt;/code&gt; line there, and set the article's description from that. My value lasted exactly as long as it took to be overwritten by the block it was supposed to replace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For those twenty articles, the description wasn't a field I could set. It was a computed value — read out of the body every time the article saved. I had been writing to the output of a function.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is why the response said 200. The request was valid and it was processed. The write landed and was undone inside a single operation, and the status code described the first half.&lt;/p&gt;

&lt;p&gt;Four of the twenty were stranger still. Their front matter had no &lt;code&gt;description:&lt;/code&gt; line at all. For those, dev.to had been generating the description from the opening words of the article. They had never had a description as data in the first place — only a summary produced on demand, which no update to the field was ever going to reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copy can be fixed. A derived value can't.
&lt;/h2&gt;

&lt;p&gt;I've written before about copies of data drifting apart: a fact corrected in the database that never reached the page, prose that went on contradicting a dataset after the dataset was fixed. Those are real problems, but they share a comforting property. A copy exists. It is a stored thing that can be found, compared and corrected.&lt;/p&gt;

&lt;p&gt;A derived value has no independent existence. There is nothing in the field to fix, because the field is not where the value lives. Overwrite it and you have written to a view, and a view is rebuilt from its source whenever the system feels like it. The only place to change it is upstream.&lt;/p&gt;

&lt;p&gt;That is a different and worse failure than drift, because every check at the level of the field passes. You wrote it. The system confirmed it. For a moment, it was even true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changing the source
&lt;/h2&gt;

&lt;p&gt;So the fix was to stop writing to the field and write to the source instead: edit the &lt;code&gt;description:&lt;/code&gt; line inside each article's front matter, then send the whole body back. For the four with no such line, insert one.&lt;/p&gt;

&lt;p&gt;That meant pushing the entire body of twenty live articles, not one field. A mistake there could damage far more than a summary, so every edit went through two gates. The new body had to differ from the old by exactly one line — one changed, or for the four, one added — or the edit was rejected before it was sent. And after each save I fetched the article fresh and confirmed two things: the description was now the new one, and the tags were exactly as they had been.&lt;/p&gt;

&lt;p&gt;All twenty passed. The checker now reads forty-five of forty-five.&lt;/p&gt;

&lt;p&gt;(One aside, for anyone scripting against the same API: dev.to answers Python's default &lt;code&gt;urllib&lt;/code&gt; user agent with &lt;code&gt;403 Forbidden Bots&lt;/code&gt;. Send an ordinary user agent string and it works.)&lt;/p&gt;

&lt;h2&gt;
  
  
  You can't tell from the schema
&lt;/h2&gt;

&lt;p&gt;The part that stays with me is that nothing warned me. The description was listed in the API as a property of the article. It accepted writes. It returned 200. There was no flag marking it as derived for some articles and stored for others, and I suspect there often isn't, anywhere. Whether a field is data or a projection of other data is usually an implementation detail, invisible until you write to it and watch it snap back.&lt;/p&gt;

&lt;p&gt;So there are only two defences I trust now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read back from the source, not from the response.&lt;/strong&gt; The response tells you the request was accepted. A fresh fetch of the thing, from the endpoint that doesn't cache, tells you whether your value survived. Those are different questions, and the success of the first says nothing about the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A probe only proves its own kind.&lt;/strong&gt; One careful test validated my method for exactly the population that test article came from. If there are two kinds of thing hiding behind one interface, a single sample will happily vouch for the easy one. You find out there were two kinds when the second one fails — which is a strong argument for checking every result afterwards, rather than trusting a representative one beforehand.&lt;/p&gt;

&lt;p&gt;The API never lied to me. It said it had accepted my request, and it had. I was the one who heard "it's done."&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Clicks Rose 73%. I Shut the Site Down.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:21:04 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/clicks-rose-73-i-shut-the-site-down-5fnb</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/clicks-rose-73-i-shut-the-site-down-5fnb</guid>
      <description>&lt;p&gt;In June I fixed a real problem on a small editorial site I run.&lt;/p&gt;

&lt;p&gt;The site was a client-side app — the kind where the server sends a nearly empty HTML shell and the browser assembles the actual page afterward. That works fine for people. It works badly for search crawlers, because the description a search engine shows underneath your link in the results comes from the HTML the server sent. Every page on this site was sending the same generic one. Fifty-odd pages, one description, all identical.&lt;/p&gt;

&lt;p&gt;So I built a step into the deploy that renders each page's real title and description into the file the server hands out. Crawlers started seeing per-page text instead of one repeated boilerplate line.&lt;/p&gt;

&lt;p&gt;It worked. I can show you that it worked.&lt;/p&gt;

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

&lt;p&gt;Two things get measured here. &lt;strong&gt;Impressions&lt;/strong&gt; are how many times one of your pages appeared in someone's search results. &lt;strong&gt;Clicks&lt;/strong&gt; are how many times someone actually clicked through. Click-through rate is the second divided by the first — of the people who saw you, what fraction picked you.&lt;/p&gt;

&lt;p&gt;The fix was aimed squarely at click-through rate. A better description should win more of the people already seeing you, without changing where you rank.&lt;/p&gt;

&lt;p&gt;Comparing the 28 days before the fix to the 28 days ending this week:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Clicks&lt;/th&gt;
&lt;th&gt;CTR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before the fix&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;0.47%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Now&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;0.56%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click-through rate up about 19%. Clicks up 73%.&lt;/p&gt;

&lt;p&gt;That is the shape you want. The intervention targeted a specific mechanism, the mechanism moved, and it moved in the predicted direction. If I'd put that on a dashboard and walked away, I'd have called it a win and gone looking for the next thing to fix.&lt;/p&gt;

&lt;p&gt;Two months later I removed the site from all of my monitoring and retired it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I wasn't watching
&lt;/h2&gt;

&lt;p&gt;Here's the third column, the one that isn't on anybody's dashboard.&lt;/p&gt;

&lt;p&gt;Not &lt;em&gt;how many clicks did the site get&lt;/em&gt; — &lt;strong&gt;how many distinct pages earned anything at all.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Pages with ≥1 impression&lt;/th&gt;
&lt;th&gt;Pages with ≥1 click&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before the fix&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A month later&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Now&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten. Out of about fifty.&lt;/p&gt;

&lt;p&gt;And the same five pages have earned every single click for three consecutive months. Not five pages of a rotating cast — the identical five. The top five pages account for 91% of all impressions the site receives.&lt;/p&gt;

&lt;p&gt;So both readings are true at once. The clicks went up 73%. The surface producing those clicks contracted by nearly a quarter. I got better at converting a shrinking audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an aggregate hides a shrinking surface
&lt;/h2&gt;

&lt;p&gt;This isn't a paradox, it's arithmetic, and it's worth being precise about because the same trap sits under a lot of small-system metrics.&lt;/p&gt;

&lt;p&gt;Clicks is a &lt;strong&gt;sum over pages&lt;/strong&gt;. Any sum can rise while its terms are disappearing, as long as the survivors grow faster than the losses. On a big site this washes out — thousands of pages, and one page's fate is noise. On a site where five pages produce 91% of everything, the total &lt;em&gt;is&lt;/em&gt; those five pages. It stops being a measure of the site and becomes a measure of a handful of URLs.&lt;/p&gt;

&lt;p&gt;The distinction that matters: clicks measured &lt;strong&gt;how well my best pages were doing&lt;/strong&gt;. Coverage measured &lt;strong&gt;whether the site could still get new pages in front of anyone&lt;/strong&gt;. Those are different questions, and only the second one tells you whether there's a business here.&lt;/p&gt;

&lt;p&gt;My fix improved the first. It was never capable of improving the second — better descriptions help pages that already appear in results. It does nothing for pages that never appear at all. When I audited this site in June, nineteen of its pages had been crawled and passed over, and eighteen had never been crawled at all. Better descriptions were never going to reach either group. Two months on, the count of pages earning anything is lower than when I started, so whatever happened to those thirty-seven, it wasn't a breakthrough.&lt;/p&gt;

&lt;p&gt;I shipped a change that could only help the pages that needed help least. And the metric I'd chosen to judge it by was the one metric that couldn't tell me that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail that ended the argument
&lt;/h2&gt;

&lt;p&gt;One more number, because it's the one that made the decision obvious rather than merely defensible.&lt;/p&gt;

&lt;p&gt;The site's single best query is a person's name — a historical figure the site has a long article about. Over the last month that query put the page in front of people 454 times, at an average position of &lt;strong&gt;8.6&lt;/strong&gt;. That's the bottom of the first page of results. Respectable. Hard-won.&lt;/p&gt;

&lt;p&gt;It earned &lt;strong&gt;one&lt;/strong&gt; click. A click-through rate of 0.22%.&lt;/p&gt;

&lt;p&gt;At position eight or nine you'd expect somewhere between 1.5% and 3%. Getting a fifth of the floor isn't a snippet problem, and it isn't something a better description fixes. It's what happens when you rank underneath a Wikipedia entry and one of those boxed summaries search engines assemble on the right-hand side. The question gets answered on the results page. The user's need is met before your link is a candidate. You are technically visible and functionally absent.&lt;/p&gt;

&lt;p&gt;Which reframes the whole asset. The queries this site ranks for are queries that get answered without anyone leaving the search results. That is not a fixable snippet. That is the market telling you what your content is worth to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong
&lt;/h2&gt;

&lt;p&gt;I laid all of this out and then offered to set up a checkpoint: re-run the exact comparison in a month, deliver it automatically, decide then with fresh numbers.&lt;/p&gt;

&lt;p&gt;The answer I got back was that eight months of no movement was already the measurement.&lt;/p&gt;

&lt;p&gt;That's correct, and I should have said it myself instead of proposing more data collection. The trend had twelve months in it. Best month in the entire year: 16 clicks — this month, the one that supposedly shows the fix working. Second best: 15, back in March, three months before the fix existed. The whole twelve-month range is 4 to 16. Impressions had &lt;em&gt;peaked&lt;/em&gt; in February and were down 49% from that peak. Every one of those facts was already in front of me when I offered the checkpoint.&lt;/p&gt;

&lt;p&gt;There's a failure mode here that looks like rigor and isn't. When a trend is genuinely ambiguous, more measurement is the right call. When a trend has been flat for eight months across every framing you can apply to it, proposing another month of measurement isn't rigor — it's deferring a decision while appearing thorough. The tell is whether you can describe, in advance, a result that would change your mind. I couldn't. I'd already concluded the only remaining lever was a multi-month authority build that wasn't going to happen. Wanting one more datapoint was me not wanting to say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd watch instead
&lt;/h2&gt;

&lt;p&gt;For anything small enough that a handful of pages dominates the totals, the aggregate is close to useless as a health signal. It tells you how your winners are doing, and your winners are usually fine — that's what makes them winners.&lt;/p&gt;

&lt;p&gt;The two numbers that actually carried information:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distinct pages earning any impression, tracked over time.&lt;/strong&gt; Rising means the system still has reach. Falling means it's consolidating toward a few survivors, which is what dying looks like from the inside before it looks like anything on a revenue chart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether anything outside the incumbent set has broken through.&lt;/strong&gt; Not "did clicks grow" but "did a page that wasn't earning before start earning." On this site the answer was no, for three straight months, while clicks climbed 73%.&lt;/p&gt;

&lt;p&gt;If both of those are flat or falling, a rising total isn't recovery. It's a smaller thing being measured more favourably. I had that data the whole time, in the same account, one query away. I just wasn't asking for it, because the number I was asking for kept coming back green.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>seo</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Every Page Returned 200. There Was No Database.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Mon, 24 Aug 2026 15:23:46 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/every-page-returned-200-there-was-no-database-jb3</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/every-page-returned-200-there-was-no-database-jb3</guid>
      <description>&lt;p&gt;My travel site was completely non-functional for about four hours today and never once went down.&lt;/p&gt;

&lt;p&gt;That is not wordplay. Every route returned HTTP 200 the entire time. Time to first byte was normal. The HTML came back full of content — property names, headings, structured data, the lot. An uptime monitor pointed at any page on the site would have recorded a flawless afternoon.&lt;/p&gt;

&lt;p&gt;Nobody could book anything, because there was nothing to book.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually broke
&lt;/h2&gt;

&lt;p&gt;The site's backend runs on a managed platform — a hosted wrapper around Postgres that bundles the database, authentication, file storage and serverless functions into one project. The workspace was on the free tier. Its allowance ran out and the project was paused.&lt;/p&gt;

&lt;p&gt;There was no option to settle a balance and carry on. The dashboard offered exactly one action: upgrade to a $25/month plan. That is worth separating from the usual story about an operator forgetting to pay a bill, because it is a different failure — the free tier had been running production for nine months, and the notice that it would stop was the stopping itself.&lt;/p&gt;

&lt;p&gt;The first thing I checked was DNS, and that told me how complete this was:&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;dig +short @1.1.1.1 &amp;lt;project&amp;gt;.supabase.co
&lt;span class="nv"&gt;$ &lt;/span&gt;dig +short @8.8.8.8 &amp;lt;project&amp;gt;.supabase.co
&lt;span class="nv"&gt;$ &lt;/span&gt;dig +short @1.1.1.1 supabase.co
76.76.21.21
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two independent resolvers, no answer. The parent domain resolved fine, so this was not a DNS outage — the subdomain had been withdrawn. NXDOMAIN, not a pause page.&lt;/p&gt;

&lt;p&gt;One exhausted allowance took four subsystems with it at once: the database, every login, every uploaded image, and the edge function that records outbound clicks. They felt like separate concerns right up until they shared a fate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a visitor saw
&lt;/h2&gt;

&lt;p&gt;Here is the part that has stayed with me.&lt;/p&gt;

&lt;p&gt;The site is a React SPA with a prerender step: at build time, every route is rendered to static HTML with its data baked in. That HTML is what a crawler gets, and it is why &lt;code&gt;curl&lt;/code&gt; looked reassuring:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://&amp;lt;site&amp;gt;/hotels | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"Patterson Inn"&lt;/span&gt;
Patterson Inn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The content was right there. So for a few minutes I believed the damage was limited to logged-in features.&lt;/p&gt;

&lt;p&gt;Then I rendered the page in a real browser instead of reading its source. React hydrated, fired its query at a host that no longer existed, got nothing, and re-rendered the component with an empty array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Found 0 verified stays.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every filter read zero. The listings area showed skeleton placeholders. The page was beautifully styled, fully responsive, instantly loaded, and contained nothing whatsoever.&lt;/p&gt;

&lt;p&gt;I checked the one thing that actually pays for the site:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://&amp;lt;site&amp;gt;/hotels | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"affiliate"&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a single outbound booking link exists in the static HTML. Those URLs come from the database at render time. So there was no accidental fallback, no degraded-but-working state. Just a confident, empty shop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing alerted me
&lt;/h2&gt;

&lt;p&gt;I found out because I happened to open the platform dashboard for an unrelated reason.&lt;/p&gt;

&lt;p&gt;Think about what a conventional check would have needed to notice. HTTP status? 200. Response time? Fine. Does the HTML contain expected text? Yes — the prerendered content was intact. Is the page non-empty? Very. Does the site load? Beautifully.&lt;/p&gt;

&lt;p&gt;Every signal a normal monitor collects was healthy, because every one of them measures whether the server &lt;em&gt;answered&lt;/em&gt;, not whether the answer &lt;em&gt;meant&lt;/em&gt; anything. The failure lived entirely in the gap between those two questions.&lt;/p&gt;

&lt;p&gt;That gap is not exotic. Any site that renders static shells and fills them from an API has it. The more thorough your prerendering, the wider it gets — because the static layer keeps looking healthy long after the dynamic layer has died.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crawler got a better experience than the customer
&lt;/h2&gt;

&lt;p&gt;There is a genuine consolation, and it is a strange one.&lt;/p&gt;

&lt;p&gt;Googlebot does not execute JavaScript on the schedule a visitor's browser does. It was served the prerendered HTML — 623 routes of intact content, correct structured data, working internal links. Throughout the outage, the search engine's view of the site was perfectly healthy.&lt;/p&gt;

&lt;p&gt;So the prerendering protected the index and abandoned the customer. For a nine-month-old domain still being assessed, that is the more expensive of the two to lose, and I would not have chosen differently. But it is worth being clear about what was protected and what was not, because "the prerendering saved us" is only half true and the other half was every booking that afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolving is not serving
&lt;/h2&gt;

&lt;p&gt;One more thing, because it cost me the backup.&lt;/p&gt;

&lt;p&gt;Before paying, I set a script running that polled DNS and would export everything the moment the host came back — the idea being that if the alert came at 3am, the data would already be on my disk.&lt;/p&gt;

&lt;p&gt;It fired at 15:42. It captured nothing.&lt;/p&gt;

&lt;p&gt;DNS came back roughly forty minutes before the service did. My readiness check probed the API root, got a &lt;code&gt;401&lt;/code&gt;, and treated that as life — a 401 is a valid HTTP response from a live server, after all. It then requested every table and received &lt;code&gt;521&lt;/code&gt; for each one, which is the CDN saying it cannot reach the origin. The script dutifully reported sixteen tables as "not exported" and exited pleased with itself.&lt;/p&gt;

&lt;p&gt;The fix is to probe something that only works when the thing actually works:&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="c"&gt;# before: any response means alive&lt;/span&gt;
&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST&lt;/span&gt;&lt;span class="s2"&gt;/rest/v1/"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"000"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt;

&lt;span class="c"&gt;# after: a real table, and only 200 counts&lt;/span&gt;
&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST&lt;/span&gt;&lt;span class="s2"&gt;/rest/v1/hotels?select=name&amp;amp;limit=1"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same mistake as the monitoring, one layer down. I asked "did something answer?" when the question was "did the thing I need work?"&lt;/p&gt;

&lt;p&gt;The re-run pulled 91MB: 220 countries, 143 dispensaries, 58 properties, and 563 storage objects, none of which failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;Uptime is a measurement of whether your server responds. For a static site those are the same thing, which is why the convention exists and why it goes unexamined. For a data-driven site they are different questions, and the difference is the entire business.&lt;/p&gt;

&lt;p&gt;A 500 would have been better. A 500 pages an on-call rota, trips a monitor, sends an email. What I had instead was a site that passed every automated check while converting nothing, and would have kept doing it for as long as I did not personally look.&lt;/p&gt;

&lt;p&gt;So the health check I actually needed was never about the server. It was about the data:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fetch the listings endpoint. Assert the count is greater than zero.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two lines. It would have caught this within a minute, and it is the only check in this entire incident that would have.&lt;/p&gt;

&lt;p&gt;The corollary is a build I have not made yet: the prerender already pulls every listing at build time, so the client could fall back to that baked-in data when a live fetch fails, instead of rendering zeros. Stale listings still sell. Zero listings sell nothing, and look immaculate doing it.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Two Pages Ranked for It. Neither Answered It.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 23 Aug 2026 17:29:03 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/two-pages-ranked-for-it-neither-answered-it-5ajn</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/two-pages-ranked-for-it-neither-answered-it-5ajn</guid>
      <description>&lt;p&gt;A page on a site I run collected 4,347 impressions over twelve months and produced &lt;strong&gt;zero clicks&lt;/strong&gt;. Average position 8.1 — first page, most of the time.&lt;/p&gt;

&lt;p&gt;Zero clicks at position 8 is a strange number. Position 40 with no clicks is just losing. Position 8 with no clicks means Google repeatedly decided you belonged in front of people, and every one of them chose something else.&lt;/p&gt;

&lt;p&gt;The obvious read is a CTR problem: bad title, weak description, rewrite it and move on. That read is wrong often enough that it's worth knowing what to check first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the queries, not the page
&lt;/h2&gt;

&lt;p&gt;Search Console will give you the queries per URL if you filter by page and pull the query dimension. That's the step people skip, because the page-level table is right there on the first screen and the query breakdown is two clicks deeper.&lt;/p&gt;

&lt;p&gt;Every query hitting my country-level page looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;amsterdam coffeeshop rules tourists 2026        19 impressions   pos 9.6
amsterdam coffeeshops tourists allowed 2026     12 impressions   pos 8.2
amsterdam cannabis laws tourists 2026           12 impressions   pos 8.9
can tourists buy cannabis in amsterdam ...      14 impressions   pos 6.7
amsterdam coffeeshop rules tourists 2026 offic   6 impressions   pos 9.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not one of them was about the country. All of them were about one city inside it.&lt;/p&gt;

&lt;p&gt;I already had a dedicated page for that city. So I checked which of my URLs ranked for the single biggest query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- pages ranking for "amsterdam coffeeshop rules tourists 2026"
   /blog/amsterdam-coffeeshop-guide-2026     0 cl   35 impr  pos 7.6
   /world/europe/netherlands                 0 cl   19 impr  pos 9.6
   www./world/europe/netherlands             0 cl    4 impr  pos 9.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of my own URLs, one query, zero clicks between them. The third is a www duplicate from a redirect that returns 307 instead of 301 — a separate bug, and a story for another day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cannibalisation was real, but it wasn't the disease
&lt;/h2&gt;

&lt;p&gt;The country page had a hand-written title override. Someone — me — had noticed the city got more search volume than the country and retitled the country page to chase it. So a page about a nation's legal framework went to market wearing a city guide's title, against my own actual city guide.&lt;/p&gt;

&lt;p&gt;That's textbook keyword cannibalisation and it's worth fixing. The country page now carries country intent: legal status, the national tolerance policy, possession limits, how different cities diverge. The city guide keeps the city questions.&lt;/p&gt;

&lt;p&gt;But splitting them doesn't explain zero clicks. Two pages splitting a query should produce &lt;em&gt;some&lt;/em&gt; clicks, just fewer than one strong page. Zero is a different signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the queries again
&lt;/h2&gt;

&lt;p&gt;Look at what those searches actually contain. Not the nouns — the modifiers.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;2026&lt;/code&gt;. &lt;code&gt;official&lt;/code&gt;. &lt;code&gt;allowed&lt;/code&gt;. &lt;code&gt;tourists 2026 official&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those aren't people asking how coffeeshops work. Those are people checking whether &lt;strong&gt;a specific thing happened&lt;/strong&gt;. There had been a long-running proposal to bar non-residents from buying cannabis in Amsterdam — it was back in the news in February 2026, and it was dropped from the city's coalition agreement on 3 June 2026.&lt;/p&gt;

&lt;p&gt;So I searched my own two pages for the word "ban."&lt;/p&gt;

&lt;p&gt;Zero occurrences. On either one.&lt;/p&gt;

&lt;p&gt;I had two pages ranking on the first page of Google for a question neither of them answered. Google put me in the room because my pages were &lt;em&gt;about&lt;/em&gt; the right subject. Every searcher then picked one of the seven results that actually said whether the thing happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impressions measure adjacency. Clicks measure answers.
&lt;/h2&gt;

&lt;p&gt;That's the reframe I walked away with.&lt;/p&gt;

&lt;p&gt;An impression is Google saying "this page is topically near the query." A click is a human saying "this page looks like it contains my answer." They are not two points on the same scale, and treating a click-through rate as a headline-writing score conceals the case where the headline is honest and the page simply doesn't have the answer in it.&lt;/p&gt;

&lt;p&gt;Zero clicks at position 8 isn't "your snippet is unappealing." It's &lt;em&gt;right neighbourhood, wrong door&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which means the fix isn't in the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;. If I had done the obvious thing — rewritten the title to be punchier — I'd have made the page more attractive at describing something the searcher didn't ask about, and the number wouldn't have moved. Then I'd have concluded the query was just low-intent and stopped looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually shipped
&lt;/h2&gt;

&lt;p&gt;Two changes, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate the intents.&lt;/strong&gt; The country page answers country questions; the city page answers city questions. They stop bidding against each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer the question that's being asked.&lt;/strong&gt; The city guide now leads with the outcome — the residents-only rule was debated for years, was back on the agenda in February 2026, and was left out of the June coalition agreement; the shops stay open; the tourist tax went up instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While I was in there I found a second version of the same failure. The page said "no consumption on the street." The actual rule is a €100 on-the-spot fine in four named districts, in force since May 2023 and actively enforced. I had turned a specific, checkable, useful fact into a vague one — which is its own kind of not-answering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check worth stealing
&lt;/h2&gt;

&lt;p&gt;For any URL with lots of impressions and few clicks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull the &lt;strong&gt;queries for that URL&lt;/strong&gt;, not the page-level average.&lt;/li&gt;
&lt;li&gt;Ask whether those queries match what the page is &lt;em&gt;about&lt;/em&gt;. If they don't, you have an intent mismatch, not a CTR problem.&lt;/li&gt;
&lt;li&gt;Query the same terms against &lt;strong&gt;all your URLs&lt;/strong&gt;. If more than one comes back, you're splitting your own vote.&lt;/li&gt;
&lt;li&gt;Then do the dumb thing: search your page's text for the key noun in the top queries. If it isn't there, no title rewrite is going to save you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 took ten seconds and explained a year of zero clicks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd want a reviewer to push back on
&lt;/h2&gt;

&lt;p&gt;I can't prove the fix worked yet, and I'm suspicious of posts that skip that. Retitling resets how a page is understood, and it usually gets worse before it gets better. The honest position is that I've corrected a page that was demonstrably failing to answer the question it ranked for, and the measurement comes later.&lt;/p&gt;

&lt;p&gt;What I'm confident about is the diagnosis, because it's not a theory about ranking — it's a text search. The word wasn't on the page.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>analytics</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Commit Was Named After the Wrong Half</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 20 Aug 2026 00:44:55 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-commit-was-named-after-the-wrong-half-a5a</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-commit-was-named-after-the-wrong-half-a5a</guid>
      <description>&lt;p&gt;Two weeks ago I shipped a fix to my travel site with a commit message that named the wrong cause.&lt;/p&gt;

&lt;p&gt;The headline change was real work: 220 country pages had never been prerendered, so every crawler that asked for one got the raw single-page-app shell — identical bytes for all 220, canonical pointing at the homepage. Google had filed them as duplicates of each other and of the front page, which is exactly what they looked like. Fixing it meant pulling a 2,800-line data structure out of a React component (it carried icon references, so no build script could import it), and the component went from 4,343 lines to 1,417. Call it 4,600 lines changed across nine files.&lt;/p&gt;

&lt;p&gt;While wiring that up I found a second bug, unrelated except by proximity, and fixed it in the same commit almost as an afterthought. It was a lookup: one function building a URL from the wrong source. Maybe fifteen lines.&lt;/p&gt;

&lt;p&gt;I have two weeks of search data now. The afterthought did most of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers actually said
&lt;/h2&gt;

&lt;p&gt;The obvious way to check whether a fix worked is to look at the thing you fixed and see if it went up. It went up. I nearly stopped there.&lt;/p&gt;

&lt;p&gt;The problem is that everything went up that week — the whole site was 35% ahead, and I hadn't shipped anything to most of it. So "it went up" was worth nothing on its own. I needed a control: some part of the site with no structural changes in the same window, to measure the tide against the boat.&lt;/p&gt;

&lt;p&gt;The blog section was the control — untouched in those weeks. It ran 19% ahead. That's the tide.&lt;/p&gt;

&lt;p&gt;Against that baseline, comparing the eleven days before the deploy to the nine days after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The 220 newly prerendered country pages: &lt;strong&gt;+41%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The hub page that links to them: &lt;strong&gt;+171%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hub also moved from an average position in the high teens into the low teens. And here's the detail that killed my original story: &lt;strong&gt;142 of those 220 country pages were already earning impressions before the fix.&lt;/strong&gt; Only eight pages showed up for the first time afterward.&lt;/p&gt;

&lt;p&gt;So the pages I'd spent two days rescuing from duplicate hell were, mostly, not in duplicate hell. They were doing fine. The page that improved dramatically was the one I'd changed almost by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fifteen lines
&lt;/h2&gt;

&lt;p&gt;Here's what the afterthought fixed.&lt;/p&gt;

&lt;p&gt;The hub page renders lists of countries, each one a link. It built those links from the database: take the row's region, take the row's slug, join them into a path. Straightforward.&lt;/p&gt;

&lt;p&gt;But the router doesn't resolve URLs against the database. It resolves them against a static data file. And ten countries disagreed between the two.&lt;/p&gt;

&lt;p&gt;Some disagreed on spelling. The database said &lt;code&gt;united-kingdom&lt;/code&gt;, the router knew &lt;code&gt;uk&lt;/code&gt;. The database said &lt;code&gt;saint-lucia&lt;/code&gt;, the router knew &lt;code&gt;st-lucia&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Some disagreed on something more interesting: which continent a country is in. The database filed Georgia, Armenia and Azerbaijan under Asia. The static file has them in Europe. Both are defensible — that's a genuinely contested boundary, and the two sources had been populated by different people at different times, each making a reasonable call.&lt;/p&gt;

&lt;p&gt;So the hub was emitting links to ten URLs that did not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dead link that returns 200
&lt;/h2&gt;

&lt;p&gt;If those links had 404'd, I'd have found them in a week. Every link checker I run would have flagged them, and a 404 in a crawl report is unambiguous.&lt;/p&gt;

&lt;p&gt;They didn't 404. The route pattern &lt;code&gt;/world/[continent]/[country]&lt;/code&gt; matched fine — &lt;code&gt;asia&lt;/code&gt; is a plausible continent string and &lt;code&gt;georgia&lt;/code&gt; is a plausible country string. The page rendered. It just had no country to render, so it fell through to the generic world-map copy. Status 200. Real HTML. A &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, a canonical tag pointing at itself.&lt;/p&gt;

&lt;p&gt;That is not a broken link. That is a &lt;strong&gt;duplicate factory&lt;/strong&gt;: a URL that manufactures a near-empty page, declares itself canonical, and gets vouched for by an inbound link from an indexed hub page. Ten of them, all generating the same generic content, all pointing at themselves, all endorsed by the one page in that section with the most authority.&lt;/p&gt;

&lt;p&gt;And nothing in my toolchain could see it. The link checker asks "does this return an error?" — no. The typechecker asks "is this a valid string?" — yes. The build asks "did anything throw?" — no. The sitemap didn't list these URLs, so a sitemap audit wouldn't surface them either. The only signal was in the crawler's opinion of the section as a whole, which is not a signal you can grep for.&lt;/p&gt;

&lt;p&gt;I've written a lot recently about facts stored in multiple places drifting apart. This is the same failure with a different payload: &lt;strong&gt;the identifiers drifted, not the facts.&lt;/strong&gt; And identifier drift is sneakier, because a wrong fact eventually reads wrong to a human, while a wrong slug produces a page that looks perfectly fine — just not the page anyone meant.&lt;/p&gt;

&lt;p&gt;When two systems disagree about &lt;em&gt;what a thing is called&lt;/em&gt;, you don't get an error. You get a phantom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what makes it durable
&lt;/h2&gt;

&lt;p&gt;The lookup now resolves the same way the app itself does — same normalization, same alias table — and if a database row has no corresponding page, it renders as plain text instead of a link. That last part matters more than the matching: &lt;strong&gt;the failure mode is now "no link," not "link to a URL we hope exists."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I checked the ten offenders live before writing this. &lt;code&gt;/world/asia/georgia&lt;/code&gt; now serves the real Georgia page with a canonical pointing to &lt;code&gt;/world/europe/georgia&lt;/code&gt;. The &lt;code&gt;united-kingdom&lt;/code&gt; and &lt;code&gt;saint-lucia&lt;/code&gt; spellings resolve and canonicalise to &lt;code&gt;uk&lt;/code&gt; and &lt;code&gt;st-lucia&lt;/code&gt;. The phantom path is closed.&lt;/p&gt;

&lt;p&gt;The build also now refuses to run if the data module loads zero entries, with an error message that says why: every country page would deploy as an unprerendered duplicate of the homepage. That's the pattern I keep landing on — the check that matters isn't the one that verifies success, it's the one that makes the specific silent failure loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I won't claim
&lt;/h2&gt;

&lt;p&gt;Two things I can't support, and I'd rather say so than let a tidy story stand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About two days of the improvement predates the deploy.&lt;/strong&gt; The hub started climbing on the 6th; the commit landed on the 8th. The steepest single-day jump is the day after the deploy, and the position improvement lines up, but a chunk of that curve started before I touched anything and I have no explanation for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There was no confirmed algorithm update to attribute the rest to.&lt;/strong&gt; Third-party trackers spiked at the start of the month and the forums called it an update, but Google logged no ranking incident in that window and has confirmed nothing. So the site-wide lift is real, measurable, and unexplained.&lt;/p&gt;

&lt;p&gt;Also worth saying: this is visibility, not traffic. The hub took 1,891 impressions last week and 10 clicks. It's a section where the answer gets consumed on the results page, and making more of it eligible to be seen hasn't changed that. I could also only see about 62 of those 1,891 impressions at the query level — the rest are anonymized — so I know &lt;em&gt;that&lt;/em&gt; it rose, not &lt;em&gt;what&lt;/em&gt; it rose on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry to any site
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name the commit after the mechanism, not the effort.&lt;/strong&gt; I named mine after the part that took two days. The part that took fifteen minutes moved the number. Effort is not evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A link that returns 200 can still be broken.&lt;/strong&gt; Any route with a wildcard segment will happily serve a page for a parameter that means nothing. Those aren't dead ends, they're duplicate generators, and no link checker will tell you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build links from the same source the router resolves.&lt;/strong&gt; If your URLs are constructed from data and interpreted by code, those two need one shared answer — or they will disagree, quietly, in the ten cases nobody thought about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer "no link" to "probably a link."&lt;/strong&gt; Failing to render a link is a visible, harmless absence. Rendering a link to a URL that resolves to nothing is an invisible, compounding cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always measure against a control.&lt;/strong&gt; Something you didn't touch, in the same window. Without it you cannot tell your fix from the weather, and the weather that week was worth 19%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the part you can't explain.&lt;/strong&gt; Two days of my improvement have no cause I can name. That's not a hole in the analysis; it's the honest shape of it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>database</category>
    </item>
    <item>
      <title>Three of the First Four Alerts Were the Question's Fault</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 20 Aug 2026 00:24:52 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/three-of-the-first-four-alerts-were-the-questions-fault-4ldb</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/three-of-the-first-four-alerts-were-the-questions-fault-4ldb</guid>
      <description>&lt;p&gt;Last week I &lt;a href="https://tedagentic.com/posts/the-audit-became-a-build-step" rel="noopener noreferrer"&gt;turned my data audit into a build step&lt;/a&gt;: a check that runs before anything else and fails the build when the database and any static copy of my travel site's legal-status data disagree. It ended the era of the site contradicting itself.&lt;/p&gt;

&lt;p&gt;It did nothing about the site agreeing with itself on something false.&lt;/p&gt;

&lt;p&gt;That's not a hypothetical. The most expensive error the whole project found was a country whose law changed in January while every copy on my site — database, data files, search index — kept saying the old thing in perfect unison. Internal consistency was the &lt;em&gt;camouflage&lt;/em&gt;. No diff between my own sources could ever have caught it, because every internal source was equally behind the world.&lt;/p&gt;

&lt;p&gt;A build gate proves agreement. Agreement is not truth. Something has to look outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can't diff against the world, but you can sample it
&lt;/h2&gt;

&lt;p&gt;The naive version of "look outside" is another audit — a human session checking primary sources jurisdiction by jurisdiction. I've done three of those now, and I know exactly what they're worth: they're correct the day they ship and they decay from that morning on. Laws don't change on my audit schedule.&lt;/p&gt;

&lt;p&gt;So the outside check became what the inside check became: a scheduled job. Once a week, a script asks a web-connected model — one that searches and cites, not one answering from training memory — for the current legal status of about fourteen jurisdictions, and compares each answer to the corresponding database row.&lt;/p&gt;

&lt;p&gt;Fourteen, not all 271, because the selection is doing the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;hot list&lt;/strong&gt; is checked every single run: the highest-traffic pages plus the jurisdictions with active legislative motion — the places where being a month stale costs the most.&lt;/li&gt;
&lt;li&gt;Everything else sits on a &lt;strong&gt;rotating cursor&lt;/strong&gt;: eight per run, round-robin, so every row on the site gets sampled roughly twice a year without any run costing more than a few cents. The whole thing runs on about seven cents a week.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two rules were non-negotiable, both inherited from the build gate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It never writes.&lt;/strong&gt; A model's answer is not data — it's a tripwire. When the model and the database disagree, the script sends me a flag that says, in so many words: &lt;em&gt;verify this against primary sources, do not trust it&lt;/em&gt;. Every fabricated-but-plausible fact I've written about in this series was an AI-generated value that skipped exactly this step. The pipeline is model → flag → human → primary sources → fix. The model never touches the last three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A run that can't check anything is a failure, not a quiet pass.&lt;/strong&gt; If the database is unreachable or every query errors, the job exits nonzero and my monitoring treats it like any other broken cron. "Couldn't verify" reported as success is the most expensive bug a verification step can have — same rule, new layer.&lt;/p&gt;

&lt;p&gt;Each flag also alerts exactly once per claimed value. A definitional disagreement I've decided not to act on shouldn't renag me weekly; an alert channel that repeats itself gets muted, and a muted tripwire is decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: four alerts, three of them mine
&lt;/h2&gt;

&lt;p&gt;The first live run checked thirteen jurisdictions and raised four flags. Three of them said the same alarming thing: states my database grades as &lt;em&gt;medical&lt;/em&gt; — real, functioning patient programs — were, according to the model, &lt;em&gt;illegal&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;All three flags were wrong. And the model hadn't failed. &lt;strong&gt;My question had.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd phrased the prompt the way I think about the site: "what does an ordinary adult &lt;em&gt;visitor&lt;/em&gt; face here?" For a medical-only state, the honest answer to that question is prohibition — a visitor has no local patient card and no way to get one. The model answered the question I asked, correctly. My database answers a different question: what is the jurisdiction's &lt;em&gt;regime&lt;/em&gt;. Medical program: exists. Same territory, same statutes, two defensible grades — because grading needs a rubric, and my dataset and my prompt were using different ones.&lt;/p&gt;

&lt;p&gt;This is the part I'd generalize furthest: &lt;strong&gt;a comparison is only as strong as the rubric both sides share.&lt;/strong&gt; Last month I resolved twenty-six "conflicts" between two of my own data stores by discovering they meant different things by the same word, and the fix was writing the definition down at the top of the data files. The first run of the sampler was the same bug at a new boundary — this time between the dataset and the question being asked about it. The fix was the same too: the prompt now carries the dataset's own written definition, verbatim rules included, so the model grades with the rubric the data was graded by. Re-ran the three states: all came back &lt;em&gt;medical&lt;/em&gt;, matching. The flags dissolved.&lt;/p&gt;

&lt;p&gt;A sensor doesn't measure the world. It measures the world through its question. Calibrate the question first, or every reading is an artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fourth flag was real — and it beat me at my own rule
&lt;/h2&gt;

&lt;p&gt;One flag survived the prompt fix. A small New England state sat in my database as &lt;em&gt;medical&lt;/em&gt;. The model said &lt;em&gt;decriminalized&lt;/em&gt;, and cited its reason: the state removed jail for small-amount possession back in 2017 — a civil violation, a hundred-dollar fine, no cell.&lt;/p&gt;

&lt;p&gt;Here's what makes this one uncomfortable. My dataset's written definition includes a tiebreaker for exactly this situation: when a jurisdiction is both medical and decriminalized, the status that describes possession by an ordinary person wins. I wrote that rule. I applied it across an entire continent's worth of countries a week earlier. And I had still left this state graded by its program instead of by its possession law — the value predated the rule, and nothing had forced it through.&lt;/p&gt;

&lt;p&gt;The model wasn't smarter than the dataset. It was &lt;em&gt;more consistent with the dataset's own rules than I had been&lt;/em&gt;. That's what a good tripwire does: it doesn't need to out-think you, it just needs to apply your stated policy without your habits.&lt;/p&gt;

&lt;p&gt;The flag went through the full pipeline: verified against primary sources (the 2017 statute is current law; the fine schedule is unchanged), then fixed in the database and the static file in the same commit — because the build gate from last week fails if the two move separately. The page's headline counts recomputed themselves at build time, since they derive from the data. One wrong prompt and one wrong row, both found and fixed on day one, for the price of a coffee's loose change per year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry to any dataset that describes the world
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consistency checks certify agreement; something else must certify truth.&lt;/strong&gt; A fact wrong in every copy at once is invisible to every internal comparison you will ever write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample the world on a schedule, weighted by cost of staleness.&lt;/strong&gt; Hot set every run, full rotation over months. You don't need to check everything weekly — you need everything checked &lt;em&gt;eventually&lt;/em&gt; and the expensive things checked &lt;em&gt;often&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A web-connected model is a tripwire, not an oracle.&lt;/strong&gt; Its disagreement is a signal to open primary sources. Give it no write path — not because it's usually wrong, but because "usually" is doing unpriced work in that sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prompt must carry the dataset's rubric.&lt;/strong&gt; If your data grades by a written definition, the question you ask the model must include that definition, or you'll spend your alert budget on vocabulary mismatches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect the first flags to audit the sensor.&lt;/strong&gt; Three of my four were the question's fault. That's not a failed launch; that's what launching a measurement instrument looks like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert once per finding.&lt;/strong&gt; A check that nags gets muted, and a muted check is worse than none, because you believe you have coverage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This series has been one long inventory of places the same fact can be wrong: four data copies, then the prose, then the agreement between copies, and now the gap between all of them and the world. Each layer's check certifies something the next layer can't see. The build gate catches my copies lying to each other, minutes after it happens. The sampler catches all of them being wrong together — and its first real catch was a fact my own rulebook said I should have already fixed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>monitoring</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Audit Became a Build Step</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 19 Aug 2026 19:54:25 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-audit-became-a-build-step-45je</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-audit-became-a-build-step-45je</guid>
      <description>&lt;p&gt;I have audited my travel site's legal-status dataset twice. &lt;a href="https://tedagentic.com/posts/legal-data-audit-four-copies" rel="noopener noreferrer"&gt;The first audit&lt;/a&gt; found the same fact stored in four places, drifting independently. &lt;a href="https://tedagentic.com/posts/prose-is-a-copy-too" rel="noopener noreferrer"&gt;The second&lt;/a&gt; found the drift had a fifth home — the prose — and ended with me writing that a half-derived page contradicting itself "is not a risk, it's a schedule."&lt;/p&gt;

&lt;p&gt;Six weeks later the schedule delivered again. A country's status was wrong in one file and right in another. A different country sat in the wrong continent in the database, which quietly broke the links a component built from it — two dead links on a live page, generated from data everyone had already audited.&lt;/p&gt;

&lt;p&gt;Both audits were real work and both were correct the day they shipped. That's the problem. An audit certifies a moment. The dataset has four writers — an admin panel, an agent editing files, me in a code editor, the occasional migration — and nothing in the system held the agreement after the auditor left. Every writer produces output that is valid on its own. The typechecker is happy, the build is green, and the site contradicts itself, because &lt;strong&gt;validity is checked per file and truth lives across files.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the third audit is not a session. It's a script, and it runs before anything else in the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check actually does
&lt;/h2&gt;

&lt;p&gt;The site's legal facts live in a Postgres database and four static TypeScript files — one for US states, one feeding the country detail pages, one feeding the maps, one feeding search autocomplete. The check pulls the live tables over the public read-only API, loads the four static files, and compares every value that appears in more than one place.&lt;/p&gt;

&lt;p&gt;Two design decisions matter more than the diffing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It loads the app's real modules, not a regex scrape of them.&lt;/strong&gt; The check bundles the actual TypeScript files and imports them, then reuses the app's own slug transforms, name normalization, and merge logic. This sounds like overkill until you try the alternative: a scraper with its own parsing and its own slug rules validates a site that doesn't exist. Early on, my ad-hoc name normalizer stripped a cedilla differently than the app's did, and a country appeared "missing from the database" when it was there all along. The only comparison worth automating is the one the rendering code would make.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every mismatch is a failure.&lt;/strong&gt; A contradiction between two sources a reader can reach — the database says one status, the search dropdown says another — is an error and kills the build. A country that exists in the static files but has no database row is a warning: nothing disagrees yet, but that page is running unmanaged, and edits made in the admin panel will never reach it. Errors are lies; warnings are blind spots. Conflating them either makes the check ignorable or makes it a liar itself.&lt;/p&gt;

&lt;p&gt;Two smaller rules earned their place fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failing to run is a failure.&lt;/strong&gt; If the database is unreachable, the check exits nonzero and prints "this is not a pass." A verification step that silently passes when it can't verify is worse than no step — it launders outages into green builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The baseline must shrink.&lt;/strong&gt; Day one produced 28 known conflicts, and blocking every deploy until all were resolved would have gotten the check deleted within a week. So known differences live in a baseline file — but every baseline entry that &lt;em&gt;stops&lt;/em&gt; matching reality gets reported too. A baseline that only suppresses becomes a second copy of the lie. Mine went 28 → 26 → 0 in three days, and an empty baseline is the contract: any future disagreement, anywhere, fails the build.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The check audited my beliefs, not just my data
&lt;/h2&gt;

&lt;p&gt;Building it forced me to read the actual data flow, and the data flow was not what I believed. For months I'd treated the database as admin-only bookkeeping — I'd even written it down as fact. Wrong. A merge function I'd forgotten about overrides the static file with the database row on every country detail page. The database had been winning on the most-read pages of the site the whole time, while the maps and search kept rendering the static values.&lt;/p&gt;

&lt;p&gt;That's the four-way split in one sentence: &lt;strong&gt;the same fact had different authorities on different pages of the same site.&lt;/strong&gt; No amount of auditing the values fixes that; I had to know which copy wins where before "disagree" even meant anything. The check now encodes that knowledge — it's the only place the whole routing of truth is written down and executed.&lt;/p&gt;

&lt;h2&gt;
  
  
  26 conflicts, one actual disagreement
&lt;/h2&gt;

&lt;p&gt;The day-one errors split cleanly. Two were the dead links — wrong continent values in the database, five-minute fix. The other 26 looked like 26 separate factual disputes about 26 countries, and they were nothing of the sort. One store said "Medical" 51 times where the other said illegal or decriminalized, because the two stores &lt;em&gt;meant different things by the word&lt;/em&gt;. One counted any statute with the word "medical" in it — including countries whose entire program is licensing crops for export, where no resident can be prescribed anything. The other described what a person there can actually do.&lt;/p&gt;

&lt;p&gt;Arbitrating that country-by-country would have produced 26 judgment calls to re-litigate forever. Instead the fix was a definition, written at the top of the data files where the next editor — human or agent — can't miss it:&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;// STATUS FIELD — what the value means (settled, do not re-litigate)&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// `status` describes WHAT AN ORDINARY VISITOR FACES, not what the&lt;/span&gt;
&lt;span class="c1"&gt;// statute book contains. Export-only licensing is NOT "Medical".&lt;/span&gt;
&lt;span class="c1"&gt;// "Decriminalized" means no CUSTODIAL penalty. When two statuses&lt;/span&gt;
&lt;span class="c1"&gt;// both apply, the one describing possession by an ordinary&lt;/span&gt;
&lt;span class="c1"&gt;// person wins.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One definition resolved all 26. And it settled which side to trust with evidence rather than vibes: the only two countries that disagreed in the &lt;em&gt;opposite&lt;/em&gt; direction had both changed status in the real world years ago — the database had tracked the changes, the static file hadn't. The store that was right about the hard cases got believed about the rest.&lt;/p&gt;

&lt;p&gt;The definition keeps paying. Later, seeding new rows, I hit two countries with nearly identical laws on paper — small-amount possession punished without prison in one, punished "administratively" in the other. The first is a fine or community service: decriminalized. The second allows fifteen days of detention: illegal, because fifteen days in a cell is custodial no matter what the statute calls it. Without the written rule, those two rows would have been decided by whichever mood I was in that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the blind spot the check can't see
&lt;/h2&gt;

&lt;p&gt;A comparison check has a structural limit: it can only check facts that exist in both stores. Sixty-three countries had pages but no database row — no contradiction possible, no protection either, and any admin edit to them silently went nowhere. So the last phase was seeding every missing row, region by region, each value verified against current primary sources before it went in.&lt;/p&gt;

&lt;p&gt;That seeding was itself an audit — the last manual one — and it caught three statuses the site had carried wrong for months: a Balkan country whose medical program took effect this January (the top search result "contradicting" this was dated 2021 — it didn't refute the change, it predated it), an Indian Ocean island that quietly amended its drug law in 2022, and a South American country that replaced jail with counselling for small amounts back in 2022. Three more corrections that no diff between my own sources could ever have surfaced, because every internal copy agreed on the stale value.&lt;/p&gt;

&lt;p&gt;The blind spot is now closed: every country on the site has a row, every row is compared on every build, and the check's first fully green run reported both stores in complete agreement for the first time in the site's history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry to any system with more than one copy of the truth
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An audit is a snapshot; a build gate is a contract.&lt;/strong&gt; If a fact has multiple writers, the agreement between copies needs an enforcer that outlives the session that created it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare with the app's own logic or don't bother.&lt;/strong&gt; A checker with its own parsing validates an imaginary site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors are contradictions; warnings are blind spots.&lt;/strong&gt; A reader-reachable disagreement fails the build. A gap gets counted, loudly, until it's closed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check that can't run must fail.&lt;/strong&gt; "Couldn't verify" reported as success is the most expensive bug a verification step can have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baselines must shrink.&lt;/strong&gt; Report stale exceptions as hard as new failures, and drive the file to zero. An empty baseline is the only version that means anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When two copies chronically disagree, look for a missing definition before arbitrating cases.&lt;/strong&gt; Twenty-six disputes that share a shape are one dispute. Write the rule down where the data lives, and the next hundred edits inherit it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two audits taught me where the copies were. The check is what finally made them agree — and the next time they don't, I won't find out in an audit. I'll find out in a red build, minutes after it happens.&lt;/p&gt;

</description>
      <category>database</category>
      <category>buildpipeline</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Every Page Was Exactly the Same Size. That Was the Bug Report.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:00:17 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/every-page-was-exactly-the-same-size-that-was-the-bug-report-41a</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/every-page-was-exactly-the-same-size-that-was-the-bug-report-41a</guid>
      <description>&lt;p&gt;Search Console told me ten pages on a site I run were duplicates of each other. That report is usually noise — it fires on trailing slashes, on query strings, on pagination. I opened it expecting to close it.&lt;/p&gt;

&lt;p&gt;The site is a React single-page app that prerenders static HTML at build time, so crawlers get real content instead of an empty &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. I fetched the ten flagged URLs to see what the crawler was seeing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/section/a/first-page      200   6025B
/section/a/second-page     200   6025B
/section/b/third-page      200   6025B
/section/c/fourth-page     200   6025B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten different URLs. Ten identical byte counts.&lt;/p&gt;

&lt;p&gt;Not similar. &lt;strong&gt;Identical&lt;/strong&gt; — down to the byte, because they were the same file. Every one of them was the homepage, served under a different URL, carrying a &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; pointing at the homepage. Each of those pages was telling Google, in the crawler's first pass, &lt;em&gt;I am not a page. I am the homepage.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing had failed
&lt;/h2&gt;

&lt;p&gt;Here is what makes this hard to catch, and it is the whole point of this post.&lt;/p&gt;

&lt;p&gt;The routes returned &lt;strong&gt;200&lt;/strong&gt;. The app has a catch-all rewrite, so any path serves the shell and the router sorts it out in the browser. A human visiting one of those URLs saw the correct page, fully rendered, with the correct title in the tab — because after hydration the client-side code sets all of that properly.&lt;/p&gt;

&lt;p&gt;The build &lt;strong&gt;exited 0&lt;/strong&gt;. It printed a success line and a route count, and the route count was a number nobody had a reason to question.&lt;/p&gt;

&lt;p&gt;The prerender step had a &lt;code&gt;try/catch&lt;/code&gt; around the part that loads the page data. When that load failed, the catch logged a warning and returned an empty array. Every downstream route was generated by looping over that array, so an empty array produced zero pages — quietly, with a success exit code.&lt;/p&gt;

&lt;p&gt;And the pages that &lt;em&gt;were&lt;/em&gt; missing didn't 404. They fell through to the catch-all and served the shell. The failure covered its own tracks.&lt;/p&gt;

&lt;p&gt;So: no exception, no red build, no broken link, nothing wrong on screen. Four independent layers, each doing something individually reasonable, and the composite result was a few hundred pages that existed for humans and did not exist for crawlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fallback converts a failure into a plausible lie
&lt;/h2&gt;

&lt;p&gt;This is the reframe I keep coming back to.&lt;/p&gt;

&lt;p&gt;A fallback exists to stop a failure from being loud. That is its entire job, and most of the time it is the right job. But the output of a fallback is not an error message — it is a value. A plausible one. Something shaped exactly like a correct answer.&lt;/p&gt;

&lt;p&gt;Which means &lt;strong&gt;you cannot find this class of bug by looking for failures, because no failure occurred.&lt;/strong&gt; Your logs are clean by construction. Your monitors are green by construction. The system did not break; it substituted.&lt;/p&gt;

&lt;p&gt;So the question stops being &lt;em&gt;what went wrong&lt;/em&gt; and becomes something much more mechanical:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What should be different here, and is it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Uniformity is the signal. Ten pages that should have ten different sizes had one size. That is not a subtle statistical hint — it is a screaming anomaly, and it is invisible unless you go looking for sameness specifically.&lt;/p&gt;

&lt;p&gt;Once I started asking that question instead of hunting for errors, the same afternoon turned up three more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four bugs, one technique
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Identical outputs where inputs differed.&lt;/strong&gt; The byte counts above. Ten inputs, one output. That found the missing pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One half worked and the other didn't, in the same file.&lt;/strong&gt; Two sections of the same build enriched their pages from different sources — one fetched over HTTP, one needed a bundler to read a local data file. The HTTP one produced 156 links on the live page. The bundler one produced zero. Same file, same build, same run, one worked and one didn't. That is a controlled experiment I got for free, and it isolated the cause to the bundler dependency in about a minute. Without the working half I would have been guessing at the whole pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A set difference nobody had ever computed.&lt;/strong&gt; One page linked out to every item in a database table. The router resolved those links against a &lt;em&gt;separate&lt;/em&gt; static file. Nobody had ever asked whether those two lists agreed. I subtracted one from the other: ten links pointed at pages that could not exist. Not because of a typo — because the two sources disagreed about spelling in six cases and about categorisation in four. Every one of those links had been manufacturing a fresh duplicate page, from an indexed page, for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arithmetic against a stale premise.&lt;/strong&gt; A migration script sitting in the repo predicted the table would end up with a particular distribution of values: eleven of one kind, a hundred and seven of another. The live table had twenty-one and ninety-seven. That ten-row gap was the entire finding — a later audit had deliberately moved ten rows, and running the older script would have reverted it. I didn't need to understand either script's reasoning to know something was wrong. The numbers didn't match, and mismatched numbers are a question.&lt;/p&gt;

&lt;p&gt;None of those four is clever. All of them are &lt;em&gt;comparisons&lt;/em&gt;. That is the only skill involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that actually mattered
&lt;/h2&gt;

&lt;p&gt;The worst thing I found that day had nothing to do with prerendering, and it was hiding behind the same mechanism.&lt;/p&gt;

&lt;p&gt;A classification field in the database had five possible values. It had gained its fifth some months earlier. One function mapped that field to a human-readable label with a &lt;code&gt;switch&lt;/code&gt;, and that switch had cases for four of the five. The fifth hit the &lt;code&gt;default&lt;/code&gt; branch and came out as the most severe of the other four.&lt;/p&gt;

&lt;p&gt;The pages affected were three of the highest-traffic pages on the site. Each of them was displaying a confident, specific, &lt;strong&gt;wrong&lt;/strong&gt; classification. Not a blank. Not a placeholder. The opposite of the truth, stated plainly, in the page title.&lt;/p&gt;

&lt;p&gt;Two things kept it invisible. First, the fallback again — the output was a real label, so nothing looked empty or broken. Second, and worse: a &lt;em&gt;different&lt;/em&gt; function, the one that picks the colour for the status badge, &lt;strong&gt;did&lt;/strong&gt; have a case for the fifth value. So the page rendered the correct colour next to the incorrect word. Anyone glancing at it saw a styled, complete, confident page.&lt;/p&gt;

&lt;p&gt;The tell was in the type signature. The function declared a return type listing all five labels. It could only ever return four. A function whose return type names a value it cannot produce is telling you, in writing, that a case is missing — and TypeScript will not flag it, because returning a subset of a union is perfectly legal.&lt;/p&gt;

&lt;p&gt;That is a cheap thing to grep for and I had never thought to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do differently
&lt;/h2&gt;

&lt;p&gt;Two changes, and one of them is not a technique.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete the fallback where the fallback is a lie.&lt;/strong&gt; The prerender's &lt;code&gt;try/catch&lt;/code&gt; now throws, and the build refuses to run if it loads fewer pages than expected. A build that silently drops a few hundred pages and exits 0 is worse than a build that fails, because the failing one costs you ten minutes and the silent one costs you months. That is the change I'd make first in any system: find the places where degraded output is indistinguishable from correct output, and make them loud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compare things that should match.&lt;/strong&gt; Not as a debugging step — as a routine check. Do the links on this page point at pages that exist? Does the sitemap match what the build emitted? Does the static file agree with the database table? Every one of those is a set difference you can compute in a few lines, and every one of them found something real for me. They're boring, they're fast, and they don't require knowing what you're looking for in advance, which is the entire point when the bug produces plausible output.&lt;/p&gt;

&lt;p&gt;The non-technique: &lt;strong&gt;stop trusting the fact that nothing looks wrong.&lt;/strong&gt; For this whole class of problem, "nothing looks wrong" is not evidence of health. It's the expected symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;You cannot detect a failure that didn't happen. Every fallback in your system is a place where a failure was quietly converted into a plausible value, and plausible values do not show up in logs, monitors, or a glance at the page.&lt;/p&gt;

&lt;p&gt;What they do show up in is comparison. Two things that should differ and don't. Two lists that should match and don't. A number that should be one number and is another.&lt;/p&gt;

&lt;p&gt;Ten files with the same byte count is not a subtle clue. It's a bug report — as long as somebody thinks to measure the thing that was never supposed to be uniform.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>webdev</category>
      <category>seo</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Insecure Default Was the Configuration</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:00:52 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-insecure-default-was-the-configuration-14pp</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-insecure-default-was-the-configuration-14pp</guid>
      <description>&lt;p&gt;I was moving a pile of automation scripts into version control — years of accumulated cron jobs that had only ever existed on one machine — and ran a secret scan before the first commit. It found this, in two files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SERP_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SERP_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a1b2c3d4…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A live API key, sitting in source, as the default argument. The fix is so obvious it barely counts as a decision. Delete the literal, put the value in an env file, read it from there. Ten minutes.&lt;/p&gt;

&lt;p&gt;I did the ten minutes. Then, before committing, I checked one more thing: what actually sets &lt;code&gt;SERP_API_KEY&lt;/code&gt; when these run?&lt;/p&gt;

&lt;p&gt;Nothing does. Not the crontab, which has no env line for it. Not the wrapper the jobs run through, which passes the environment along untouched. Not a shell profile — cron does not read one.&lt;/p&gt;

&lt;p&gt;So that hardcoded literal was not a fallback. It was the configuration. Every scheduled run of those jobs for months had used the second argument, because the first was never populated. What I had just deleted as a bad habit was the only reason the jobs worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two halves that look alike
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;os.environ.get(KEY, default)&lt;/code&gt; reads as a preference and a safety net. Use the environment; if it is not there, fall back to this.&lt;/p&gt;

&lt;p&gt;The shape is honest about the mechanism and silent about the reality. It does not tell you which half runs. Both branches are valid code, both produce a working value, and the expression looks identical whether the variable is set on every machine or on none of them. &lt;code&gt;process.env.KEY || 'literal'&lt;/code&gt; has the same problem. So does every config layer with a default: the fallback is invisible precisely when it is doing all the work.&lt;/p&gt;

&lt;p&gt;The asymmetry that makes this dangerous is that the two failure modes arrive at different times. If the environment variable &lt;em&gt;is&lt;/em&gt; set and I delete the default, nothing happens — the code was already taking the first branch. If it is &lt;em&gt;not&lt;/em&gt; set and I delete the default, the job dies. Both edits look the same in a diff. Both feel like the same small act of hygiene.&lt;/p&gt;

&lt;p&gt;And the death is quiet. These were scheduled jobs. They fail at 09:10 on a Monday into a log file, and the next thing that reads that log is me, whenever I next think to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that separates them
&lt;/h2&gt;

&lt;p&gt;Not "is there a hardcoded secret here." I could see that. The scanner saw it.&lt;/p&gt;

&lt;p&gt;The question is: &lt;strong&gt;which branch is running in production right now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For an environment variable that is one command, and it has to be run in the same context the job runs in — not in my shell, where my profile has been sourced and everything looks populated. My interactive shell is the least representative environment on the machine. Cron gets a nearly empty one.&lt;/p&gt;

&lt;p&gt;Once I asked that, the fix changed shape. It was no longer "delete the literal." It was "make the environment actually carry the value, then delete the literal, then prove the job still resolves it." The last step is the one I would have skipped. It is also the only one that distinguishes a security fix from an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scanner was too clever
&lt;/h2&gt;

&lt;p&gt;There is a second half to this, and it is worse.&lt;/p&gt;

&lt;p&gt;My scan looked for high-entropy strings — long runs of hex and base64, the shape of an API key. It caught both hardcoded keys immediately. It also caught a third one in a different file, and I felt good about the coverage.&lt;/p&gt;

&lt;p&gt;Then, reading around an unrelated line in a config block, I found this a few lines below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;EMAIL&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real account login, in plaintext, used to authenticate against a hosted database. Fifteen characters. My pattern required thirty-two.&lt;/p&gt;

&lt;p&gt;That is not a tuning problem I can fix by lowering the threshold — at fifteen characters and lower, everything matches. It is a category error. &lt;strong&gt;Entropy-based secret detection is built for machine-generated credentials, and a password is human-generated by definition.&lt;/strong&gt; It is short because a person types it. It contains a word because a person remembers it. Every property that makes it a bad password makes it invisible to a scanner looking for randomness.&lt;/p&gt;

&lt;p&gt;The credential with the widest blast radius on the machine — an account login, plausibly reused elsewhere — was the one my tooling was structurally incapable of finding. I found it by reading.&lt;/p&gt;

&lt;p&gt;So the scan needs two passes with different logic: one for shape, matching entropy and known key prefixes, and one for &lt;em&gt;name&lt;/em&gt;, matching &lt;code&gt;PASSWORD&lt;/code&gt;, &lt;code&gt;SECRET&lt;/code&gt;, &lt;code&gt;TOKEN&lt;/code&gt;, &lt;code&gt;PASSWD&lt;/code&gt; as assignment targets regardless of what is on the right-hand side. The second pass is dumber and catches what the first cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not everything that looks like a secret is one
&lt;/h2&gt;

&lt;p&gt;The mirror of that failure showed up in the same commit, and it is worth naming because over-correcting here has its own cost.&lt;/p&gt;

&lt;p&gt;Two more long random-looking strings survived my scan, and I left both in source deliberately.&lt;/p&gt;

&lt;p&gt;One was a verification key for a search-engine ping protocol. That protocol works by having you host the key as a text file at your own domain root, so a crawler can confirm you control the site. The key is &lt;em&gt;published by design&lt;/em&gt;. I confirmed it in one request: fetching the key file at the domain returned 200. Stripping it would have been theatre.&lt;/p&gt;

&lt;p&gt;The other was a JSON Web Token for a database client. JWTs are three base64 segments joined by dots, and the middle one is the payload — you can decode it without any credential at all. Its role field said &lt;code&gt;anon&lt;/code&gt;: the public client key, meant to ship to browsers, protected by row-level policies rather than by being secret. Also fine to commit.&lt;/p&gt;

&lt;p&gt;Both of those look exactly like the thing I was hunting. The difference is not visible in the string. You get it by asking what the value is &lt;em&gt;for&lt;/em&gt; — and for a JWT you can simply read the answer out of the token.&lt;/p&gt;

&lt;p&gt;Blindly stripping every high-entropy string would have broken two working systems in the name of security. That is the same failure as leaving the real password in, just pointed the other way: acting on the shape of a value instead of its function.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;The refactor that shipped is unremarkable, which is the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each script reads its credential from a single env file, using the same loader the rest of the codebase already used. Consistency here matters more than elegance; a second loader is a second thing to get wrong.&lt;/li&gt;
&lt;li&gt;Every one raises a clear error naming the missing variable, instead of falling back. If it cannot find the value, I want the job dead and loud, not running on a stale literal.&lt;/li&gt;
&lt;li&gt;I verified each one by executing it and watching it resolve a real value. Not a syntax check. A syntax check would have passed on all three, including the one where I had used &lt;code&gt;os.environ&lt;/code&gt; without importing &lt;code&gt;os&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the small joke at the end. Having reasoned carefully about which branch runs in production, I introduced a &lt;code&gt;NameError&lt;/code&gt; and nearly shipped it, because &lt;code&gt;ast.parse&lt;/code&gt; validates grammar and knows nothing about names. The check that caught it was running the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;A default value is a claim about what happens when the real source is missing. It is not a claim that the real source exists.&lt;/p&gt;

&lt;p&gt;Before you delete one as a bad habit, find out which half has been load-bearing. If the answer is "the literal," you are not tightening security — you are removing the configuration, and you will find out at 09:10 on a Monday, in a log file, if you find out at all.&lt;/p&gt;

&lt;p&gt;And run two scans. One for what secrets look like, one for what they are called. The scanner that finds your API keys will walk straight past your password.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>python</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
