<?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: Botslave</title>
    <description>The latest articles on DEV Community by Botslave (@botslavehq).</description>
    <link>https://dev.to/botslavehq</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%2F4199846%2F4e526fd3-b86b-4685-b2de-303c7ecb3d97.png</url>
      <title>DEV Community: Botslave</title>
      <link>https://dev.to/botslavehq</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/botslavehq"/>
    <language>en</language>
    <item>
      <title>Your scraper isn't broken: a decision table for recurring data changes</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Tue, 03 Nov 2026 08:29:05 +0000</pubDate>
      <link>https://dev.to/botslavehq/your-scraper-isnt-broken-a-decision-table-for-recurring-data-changes-49d6</link>
      <guid>https://dev.to/botslavehq/your-scraper-isnt-broken-a-decision-table-for-recurring-data-changes-49d6</guid>
      <description>&lt;p&gt;Recurring scraping jobs rarely fail because the selector is wrong. They fail because the pipeline cannot tell the difference between a value that changed, a value that was not observed, and a value that was observed badly.&lt;/p&gt;

&lt;p&gt;Those three cases need different handling. Most implementations collapse them into one: "the new value is different, so overwrite the old one." That is how a temporary timeout becomes a permanent null, and how a layout change quietly corrupts a catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three states
&lt;/h2&gt;

&lt;p&gt;For every field, record the observation before deciding what it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Changed&lt;/strong&gt;: the source returned a complete response, the expected element was present, and the normalized value differs from the last accepted value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unchanged&lt;/strong&gt;: the source returned a complete response and the normalized value matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown&lt;/strong&gt;: the request timed out, returned a partial page, hit an auth wall, or the element was missing. The previous value is still the best available value; it is not evidence that the field is now empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important rule is simple: &lt;strong&gt;an unknown observation must never overwrite a known value.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision table that survives real sites
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Previous value&lt;/th&gt;
&lt;th&gt;Current observation&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Known&lt;/td&gt;
&lt;td&gt;Complete + same&lt;/td&gt;
&lt;td&gt;Skip write; update &lt;code&gt;last_seen_at&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Known&lt;/td&gt;
&lt;td&gt;Complete + different&lt;/td&gt;
&lt;td&gt;Write new value with evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Known&lt;/td&gt;
&lt;td&gt;Timeout / partial / missing element&lt;/td&gt;
&lt;td&gt;Keep previous value; mark &lt;code&gt;unknown&lt;/code&gt;; retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing&lt;/td&gt;
&lt;td&gt;Complete + value&lt;/td&gt;
&lt;td&gt;Write value with evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing&lt;/td&gt;
&lt;td&gt;Complete + absent&lt;/td&gt;
&lt;td&gt;Keep missing; do not invent a default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Parser version changed&lt;/td&gt;
&lt;td&gt;Re-validate a sample before writing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row matters more than it looks. A parser deploy can change normalization without changing the site. If you write every row immediately, a bug in the parser looks exactly like a change in the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store evidence, not just values
&lt;/h2&gt;

&lt;p&gt;A value without provenance is hard to defend later. At minimum, keep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observed_at
source_url
http_status
content_hash
parser_version
previous_value
decision          -- changed | unchanged | unknown
reason            -- timeout | partial | selector_missing | ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With those fields you can answer the two questions that matter during an incident: &lt;em&gt;when did this value change?&lt;/em&gt; and &lt;em&gt;was the source actually observed, or did we simply stop seeing it?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Make unknown visible
&lt;/h2&gt;

&lt;p&gt;A job can return &lt;code&gt;200 OK&lt;/code&gt; and still learn nothing. Track two separate metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Job success rate&lt;/strong&gt;: did the runner finish?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observation coverage&lt;/strong&gt;: for each required field, what percentage of runs produced a complete observation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coverage is the one that catches silent data loss. A scraper with 99% job success and 60% field coverage is not healthy; it is just quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small implementation sketch
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Observation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Evidence&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="na"&gt;state&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;partial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;missing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Evidence&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Observation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&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="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="p"&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="kd"&gt;as const&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unchanged&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;as const&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;changed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;as const&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function is deliberately boring. The useful part is the contract: callers cannot write unless the observation is complete, and every decision carries a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The operational checklist
&lt;/h2&gt;

&lt;p&gt;Before adding another retry or proxy, check these first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does a failed observation preserve the last accepted value?&lt;/li&gt;
&lt;li&gt;Is &lt;code&gt;unknown&lt;/code&gt; a first-class state, or is it stored as &lt;code&gt;null&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Can you trace a disputed value back to its source observation?&lt;/li&gt;
&lt;li&gt;Do you alert on field coverage, not only job status?&lt;/li&gt;
&lt;li&gt;Does a parser change require re-validation before writes?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Recurring collection gets easier when the pipeline stops pretending it always knows the answer. Separating &lt;em&gt;changed&lt;/em&gt;, &lt;em&gt;unchanged&lt;/em&gt;, and &lt;em&gt;unknown&lt;/em&gt; removes a whole class of data corruption before it reaches the warehouse.&lt;/p&gt;

&lt;p&gt;I wrote a longer field guide with the handoff and review steps here: &lt;a href="https://botslave.com/blog/incremental-product-data-change-detection" rel="noopener noreferrer"&gt;Incremental product data collection: detect changes without losing freshness&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Browser History Is a Dataset: Build a Local, Private Bookmark Brain with a Local LLM</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:33:19 +0000</pubDate>
      <link>https://dev.to/botslavehq/your-browser-history-is-a-dataset-build-a-local-private-bookmark-brain-with-a-local-llm-2dmf</link>
      <guid>https://dev.to/botslavehq/your-browser-history-is-a-dataset-build-a-local-private-bookmark-brain-with-a-local-llm-2dmf</guid>
      <description>&lt;p&gt;&lt;strong&gt;A story climbing daily.dev Popular this week — "I gave my local LLM my browser history, and it built a better bookmark system than I ever had" (#6 Popular on 29 October 2026, tags #automation #google-chrome, 18 upvotes, 185.1K impressions) — lands because it names a frustration every heavy browser user knows: bookmarks rot, and browser history is a pile you can only search by remembering the exact words in a title. The interesting part of the story is not the demo. It is the architecture hiding underneath it: your own browsing, indexed locally, retrievable by meaning, with nothing leaving your machine.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We build browser automation for a living, and our rule, stated on our &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; page, is to automate aggressively while keeping the blast radius small. A personal index over your own history is a perfect test of that rule: the data is yours, the value is real, and the failure mode — handing your entire browsing life to a hosted model — is exactly the kind of quiet blast-radius expansion we refuse. This piece is our practitioner design for the system: what browser history actually contains, the pipeline that turns it into a retrievable index, the privacy guardrails most demos skip, and the failure modes that decide whether the thing stays useful past week two.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why bookmarks fail, and why history fails differently
&lt;/h2&gt;

&lt;p&gt;Bookmarks fail by neglect. You save a page with the intention of reading it later, file it in a folder whose name made sense once, and never open the folder again. The collection grows, the retrieval path decays, and after a year the bookmark bar is a museum of intentions. Search inside bookmarks does not save you, because it is substring search over titles you did not write.&lt;/p&gt;

&lt;p&gt;History fails in the opposite direction: it is complete but unorganised. It records everything in chronological order, with no notion of importance, topic, or intent. Finding "that article about connection pool sizing I read three weeks ago" means scrolling a timeline or guessing a keyword that appears in the title. Both tools answer the wrong question. Bookmarks answer "what did I decide to keep?" History answers "what did I open, in order?" Neither answers the question you actually ask: "what did I read about X, and where is it?"&lt;/p&gt;

&lt;p&gt;A semantic index over history answers that third question. You ask in your own words, it returns the pages whose titles, URLs, and (optionally) content are closest in meaning, with links you can click to verify. The local LLM is not the product here. The index is. The model is just the retrieval layer, and keeping it local is what makes the whole thing safe to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What browser history actually contains
&lt;/h2&gt;

&lt;p&gt;Before designing anything, look at the raw material. Chromium-based browsers (Chrome, Edge, Brave, and others) store history in a SQLite database file called &lt;code&gt;History&lt;/code&gt;. On macOS it lives under &lt;code&gt;~/Library/Application Support/Google/Chrome/Default/History&lt;/code&gt;; on Linux under &lt;code&gt;~/.config/google-chrome/Default/History&lt;/code&gt;. Two tables do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;urls&lt;/code&gt;: one row per distinct URL, with the URL itself, the page title, a visit count, and the last visit time.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;visits&lt;/code&gt;: one row per visit, with the visit time, the referring visit, and a transition type (typed, link click, reload, and so on).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two facts about this schema shape the entire design. First, &lt;strong&gt;page content is not stored&lt;/strong&gt;. History knows you visited a URL and what its title was; it does not know what the page said. Any system that claims to search "what the page was about" from history alone is really searching titles and URLs — which is often enough for "that article about X" retrieval, but must not be oversold. If you want content search, you need a separate enrichment step that fetches the page yourself (section 3). Second, &lt;strong&gt;Chrome locks the file while it runs&lt;/strong&gt;. Never query the live database in place. Copy it first, then query the copy. A read-only copy is also your safety net: the pipeline can never corrupt the browser's own data.&lt;/p&gt;

&lt;p&gt;A first look at your own data takes one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Copy first — Chrome locks the live file while it runs.&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; ~/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support/Google/Chrome/Default/History /tmp/history-copy.db

&lt;span class="c"&gt;# Where does your time actually go?&lt;/span&gt;
sqlite3 /tmp/history-copy.db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT substr(url, 1, instr(substr(url, 9), '/') + 8) AS domain,
          COUNT(*) AS visits
   FROM urls GROUP BY domain ORDER BY visits DESC LIMIT 15;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that and you will learn something about your own habits in ten seconds — usually that three domains eat half your browsing. That query is also the shape of the whole pipeline: a small script, a local database, no service, no account, no upload.&lt;/p&gt;

&lt;p&gt;One more schema detail matters for later: Chrome timestamps are microseconds since 1 January 1601, not Unix epoch. Any ingest script must convert (&lt;code&gt;(visit_time / 1000000) - 11644473600&lt;/code&gt; gives Unix seconds) or every "recent" ranking in the system will be silently wrong. This is the kind of boring conversion bug that ships in demos and then poisons retrieval for months. Test it on one known visit before trusting any output.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The pipeline: ingest, clean, enrich, embed, retrieve
&lt;/h2&gt;

&lt;p&gt;The system is five stages, each deliberately boring. Boring is the point: every stage is a script you can read, rerun, and debug, and no stage requires a hosted service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingest (incremental, idempotent).&lt;/strong&gt; A scheduled job — cron on Linux, launchd on macOS — copies the History file and imports new visits. Keep a watermark: the maximum visit time already imported. Each run imports only visits newer than the watermark, and upserts by URL so reruns are harmless. This is the same discipline we described in our &lt;a href="https://botslave.com/blog/event-driven-pipeline-failure-modes" rel="noopener noreferrer"&gt;event-driven pipelines&lt;/a&gt; piece: retries and replays need idempotency and a dead-letter path, or a crashed run duplicates everything downstream. A visit that fails to import three times goes to a small review table, not into an infinite retry loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clean (aggressive, before anything else).&lt;/strong&gt; Raw history is not a dataset; it is a junk drawer. Cleaning runs before embedding, never after, and it does three things. It strips tracking parameters (&lt;code&gt;utm_*&lt;/code&gt;, &lt;code&gt;gclid&lt;/code&gt;, &lt;code&gt;fbclid&lt;/code&gt;, and friends) so the same article shared from three sources collapses to one URL. It drops noise: &lt;code&gt;chrome://&lt;/code&gt; pages, &lt;code&gt;localhost&lt;/code&gt; development servers, new-tab pages, and redirect hops that add rows without adding information. And it applies a denylist (section 4) that removes sensitive categories entirely — not flagged, removed, before any embedding exists. Cleaning is where most hobby versions of this system fail: they embed everything, then try to filter at query time, which means the sensitive data is already sitting in a vector store waiting for a bad query to surface it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enrich (optional, and honestly labelled).&lt;/strong&gt; Title plus URL is enough for a surprising amount of retrieval, because titles usually carry the topic. If you want more, fetch the page text yourself at index time and store a short extracted summary — never the full page, which bloats the index and reintroduces copyrighted text you do not need. Fetching has honest limits: pages behind logins will fail, pages that changed since you visited will differ from what you read, and some sites will block automated fetches. Record the fetch status per URL. A result labelled "title-only, fetch failed" is more trustworthy than a result that silently pretends otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embed (local, always).&lt;/strong&gt; Run an embedding model on your own machine — a local runtime such as Ollama with an embedding model, or any equivalent local setup — and store one vector per URL. The embedding step is the one stage that must never be outsourced: the moment vectors derived from your history go to a hosted API, the privacy story is over, even if the raw URLs never leave. Vector storage can stay in SQLite with a vector extension, or in any local vector store you already run; the choice matters less than the location, which is your disk. Keep the embedding model version in the database alongside the vectors. If you change models later, re-embed everything in one pass rather than mixing vectors from two models in one index — mixed-model indexes retrieve badly in ways that look like random quality loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieve (ranked, with sources).&lt;/strong&gt; A query embeds with the same model, retrieves the nearest neighbours, then reranks with signals history already gives you for free: recency (a page from last week usually beats one from last year for the same query), visit count (you returned to it for a reason), and typed-versus-clicked transitions (a URL you typed is more likely a destination than one you clicked through). The answer format is fixed: a short list of titles with links, each labelled with its source signal (title-only or enriched, last visited date). The LLM may write a one-line summary per result. It may not answer from memory. Every claim in the output must trace to a link you can click — the same rule we apply to automation generally: a machine's summary is a pointer to evidence, not evidence itself.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;What breaks if you skip it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingest&lt;/td&gt;
&lt;td&gt;History copy&lt;/td&gt;
&lt;td&gt;New visits, watermarked&lt;/td&gt;
&lt;td&gt;Full re-imports, duplicates, a runaway job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean&lt;/td&gt;
&lt;td&gt;Raw visits&lt;/td&gt;
&lt;td&gt;Deduplicated, denylisted URLs&lt;/td&gt;
&lt;td&gt;Sensitive pages embedded, redirect spam in results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enrich&lt;/td&gt;
&lt;td&gt;Clean URLs&lt;/td&gt;
&lt;td&gt;Short extracted text or title-only flag&lt;/td&gt;
&lt;td&gt;Title-only retrieval oversold as content search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embed&lt;/td&gt;
&lt;td&gt;Clean text&lt;/td&gt;
&lt;td&gt;Local vectors + model version&lt;/td&gt;
&lt;td&gt;Hosted API sees your history; mixed models degrade ranking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieve&lt;/td&gt;
&lt;td&gt;Query&lt;/td&gt;
&lt;td&gt;Ranked links with source labels&lt;/td&gt;
&lt;td&gt;Summaries without citations, stale pages on top&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  4. The privacy guardrails most demos skip
&lt;/h2&gt;

&lt;p&gt;A local system is private only if its defaults are private. Four rules, all enforced in code rather than in good intentions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Denylist before embedding, not after.&lt;/strong&gt; Banking and payment domains, health portals, webmail, password-reset and one-time-code pages, and any URL containing tokens (&lt;code&gt;token=&lt;/code&gt;, &lt;code&gt;session=&lt;/code&gt;, &lt;code&gt;auth=&lt;/code&gt;) never reach the embedder. Write the denylist as patterns in a config file you can read, and log how many rows each pattern removed per run. If the count is ever zero, something changed in your browsing or your patterns, and you should notice.&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="c1"&gt;# Denylist: applied at clean time, before any embedding exists.
&lt;/span&gt;&lt;span class="n"&gt;DENY_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bank|payment|checkout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# financial flows
&lt;/span&gt;    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mail\.|webmail|inbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;# other people's messages
&lt;/span&gt;    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token=|session=|auth=|otp=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# credentials in URLs
&lt;/span&gt;    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost|127\.0\.0\.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# local dev, often sensitive
&lt;/span&gt;    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chrome://|about:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# browser internals
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DENY_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One machine, one index, unless you decide otherwise.&lt;/strong&gt; History from a work laptop and a personal laptop are different datasets with different sensitivity. Syncing the index across machines through a cloud drive quietly turns a local system into a distributed one. If you want multi-device search, sync the encrypted database file yourself and know that you did — do not let a tool sync it as a side effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A delete path that actually deletes.&lt;/strong&gt; A bookmark brain without deletion becomes a liability the first time someone else uses your machine. Deleting a URL must remove its row, its vector, and its enriched text in one operation, and the operation should be a single command you can run without reading documentation. Test it on one URL and verify the row is gone before trusting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No second audience.&lt;/strong&gt; The index answers to you. Do not point a shared assistant, a team tool, or a "helpful" browser extension at it. The moment a second consumer reads the index, every denylist decision has to be re-audited for that consumer's access, and nobody ever does that audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Failure modes that decide whether it survives week two
&lt;/h2&gt;

&lt;p&gt;Most versions of this system work on day one and decay by day fourteen. The decay has predictable causes, each with a cheap fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doomscroll pollution.&lt;/strong&gt; History records attention, not intention. A week of idle scrolling produces hundreds of visits that will outrank the five pages you actually need, because the index cannot tell a rabbit hole from research. The fix is weighting, not filtering: visits with dwell time under a few seconds, or pages never revisited, rank below pages you returned to or typed directly. If your ingest does not capture dwell time, visit count and typed transitions are usable proxies — use both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate identities.&lt;/strong&gt; The same article arrives as a shared link, a search result, and a newsletter click — three URLs, one page, after tracking parameters are stripped. Dedupe on the cleaned URL, and when two cleaned URLs still point at the same title, keep the earliest and merge visit counts. Without this, your top results for any popular topic will be three copies of the same page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Link rot and drift.&lt;/strong&gt; Pages move, paywalls rise, and content changes after you read it. A monthly dead-link sweep — re-fetch stored URLs, mark the ones that 404 or redirect to a homepage — keeps the index honest. Mark, do not silently delete: a dead link to a page you read is still a useful memory, but it must be labelled dead so you do not click it expecting the article.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Title ambiguity.&lt;/strong&gt; "Dashboard", "Home", and "Untitled" are titles of a thousand different pages. Title-only retrieval fails on exactly these. The fix is the enrichment step from section 3, plus a fallback: when a result's title is generic, show its domain and visit date prominently so you can disambiguate by context. A result you cannot identify is worse than no result, because it costs a click to discover it is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;History gaps.&lt;/strong&gt; Incognito visits are not recorded, other browsers and devices are not in this database, and history auto-expiry deletes old rows. The index is a view of one browser on one machine, not a memory of everything you read. State that limitation in the tool's own help text. Systems that pretend to completeness get trusted for questions they cannot answer; systems that state their coverage get used for the questions they can.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The minimal version worth building
&lt;/h2&gt;

&lt;p&gt;Resist the urge to build a product. The version worth building is two scripts and one database: an ingest script that runs on a schedule and a query script that takes a question and prints ranked links. Everything else — a web UI, a browser extension, a chat interface — is decoration that can come later, and each addition reopens the privacy audit from section 4.&lt;/p&gt;

&lt;p&gt;Concretely, the first weekend version looks like this: a cron job that copies History, imports new visits past the watermark, cleans and denylists, and embeds new titles with a local model; and a query command that embeds your question, retrieves the top ten, reranks by recency and visit count, and prints titles, URLs, and last-visited dates. If you use the query command daily for two weeks and it keeps finding pages faster than scrolling history, the system has earned a UI. If it does not, no UI will save it — the failure is in the data or the ranking, and a nicer interface will only hide that for longer.&lt;/p&gt;

&lt;p&gt;That restraint is also the connection to our earlier writing on browser automation. In our piece on &lt;a href="https://botslave.com/blog/arc-shutdown-browser-automation-lessons" rel="noopener noreferrer"&gt;building automation that survives the browser&lt;/a&gt;, the lesson was to target the engine and the portable session, not the brand on the window. The same lesson applies here: target the History file format and the local index, not any one browser's sync service or any one model's API. Browsers get shut down, sync services change terms, and hosted models get repriced. A SQLite file on your disk and a script you can read survive all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What not to do
&lt;/h2&gt;

&lt;p&gt;Three mistakes show up in nearly every public version of this idea. First, do not export raw history to a hosted LLM, even "just to try it". The export is the breach: once the data leaves, deletion requests and privacy policies are your only recourse, and they are not a design. Second, do not auto-share or auto-publish anything the system surfaces — a retrieved link is a memory aid, not a recommendation, and the difference matters the moment a private page appears in a shared context. Third, do not let the model's summary replace the page. If the summary says a page contains something, click through and check. Models paraphrase confidently from titles alone, and a confident paraphrase of a title you misremember is how a wrong citation enters your work.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this require a powerful machine?&lt;/strong&gt;&lt;br&gt;
No. Embedding a few thousand titles is a small job for a modern laptop, and retrieval over a personal-scale index is fast on SQLite. The expensive version of this system is the one that fetches and embeds full page text for every visit; the title-plus-URL version runs comfortably on hardware you already own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will it work with Firefox or Safari?&lt;/strong&gt;&lt;br&gt;
The pipeline is the same, but the source file differs: Firefox stores history in &lt;code&gt;places.sqlite&lt;/code&gt; with its own schema, and Safari's history storage is less directly accessible. The ingest stage is the only part that changes. If you use two browsers, run one ingest per browser into the same index and tag rows by source — but remember the privacy audit from section 4 applies per source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is title-only retrieval actually useful?&lt;/strong&gt;&lt;br&gt;
For "that article about X" questions, yes — titles usually carry the topic, and recency plus visit-count reranking does the rest. For "what did that page say about X" questions, no, and the system should say so: a title-only result labelled as such is honest, while a summary invented from a title is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when I change embedding models?&lt;/strong&gt;&lt;br&gt;
Re-embed everything in one pass and record the new model version. Mixing vectors from two models in one index degrades retrieval in ways that look like random quality loss, which is the hardest kind of bug to notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I index a shared or work machine?&lt;/strong&gt;&lt;br&gt;
Only with the denylist from section 4 extended for that machine's sensitivity, and only if you are the sole audience for the index. On a machine others use, the delete path matters more than the retrieval quality — build that first.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SQLite command-line shell documentation: &lt;a href="https://sqlite.org/cli.html" rel="noopener noreferrer"&gt;https://sqlite.org/cli.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Ollama, local model runtime and embedding API: &lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;https://ollama.com/&lt;/a&gt; and &lt;a href="https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings" rel="noopener noreferrer"&gt;https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Arc Is Shutting Down: Build Browser Automation That Survives the Browser": &lt;a href="https://botslave.com/blog/arc-shutdown-browser-automation-lessons" rel="noopener noreferrer"&gt;https://botslave.com/blog/arc-shutdown-browser-automation-lessons&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Event-Driven Pipelines Fail in Five Predictable Ways — and How to Design Against Them": &lt;a href="https://botslave.com/blog/event-driven-pipeline-failure-modes" rel="noopener noreferrer"&gt;https://botslave.com/blog/event-driven-pipeline-failure-modes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "How we work": &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;https://botslave.com/how-we-work&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/local-llm-browser-history-bookmark-system" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>When Agents Outgrow Git: What Anthropic's Scion Migration Teaches Small Automation Teams</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Wed, 28 Oct 2026 01:32:00 +0000</pubDate>
      <link>https://dev.to/botslavehq/when-agents-outgrow-git-what-anthropics-scion-migration-teaches-small-automation-teams-3imf</link>
      <guid>https://dev.to/botslavehq/when-agents-outgrow-git-what-anthropics-scion-migration-teaches-small-automation-teams-3imf</guid>
      <description>&lt;p&gt;&lt;strong&gt;Anthropic's engineers did not set out to kill Git. They set out to keep shipping, and Git became the thing that would not keep up — that is why "Why Anthropic ditched Git?" sits at #2 on daily.dev Popular this morning (tags #ai-agents #cicd #git, a 15-minute video), and why it matters to anyone running agents against a real repository.&lt;/strong&gt; In an engineering post published on 13 October 2026, "Redesigning source control at Anthropic", the company describes replacing Git with an in-house system called Scion across its monorepo. The numbers in that post are the reason the story travelled: this is not a complaint about Git being awkward. It is a report about a tool hitting a scaling wall, measured in merges per day.&lt;/p&gt;

&lt;p&gt;We run automation for a living — agents and unattended pipelines that touch other people's systems — and our rule, stated on our &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; page, is to automate aggressively while keeping the blast radius small. Source control is where that rule gets tested first, because every agent you add multiplies checkout, merge, and review work. This piece is our reading of the Scion migration: what actually broke, what Scion changed, what it did not change, and — most usefully for teams that will never have Anthropic's scale — what to do instead of building your own version control system.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The numbers that forced the move
&lt;/h2&gt;

&lt;p&gt;All figures in this section are Anthropic's own, as reported in its engineering post. Treat them as vendor-reported measurements of one extreme workload, not as benchmarks you should expect on your own repository.&lt;/p&gt;

&lt;p&gt;In May 2026, Anthropic reports, its monorepo averaged around 2,500 merged code changes per day. By September 2026, that figure had passed 30,000 per day — roughly a twelvefold increase in four months, or about one merge every three seconds around the clock. The company attributes the jump largely to Claude-written code landing through its agent workflows. Coding, in its framing, stopped being the bottleneck; the developer infrastructure built for human-scale work became the bottleneck instead.&lt;/p&gt;

&lt;p&gt;The repository itself compounded the problem. Anthropic puts the main branch's history alone at almost 100 GB, and a compressed working copy at over 25 GB. Continuous integration alone performed on the order of 25 million checkouts per day. Fetched the naive Git way — a full clone per client — that volume would mean hundreds of petabytes of transfer daily. The team's first response was the standard playbook: shallow clones, sparse checkouts, promisor remotes, snapshots in blob storage, and internal Git mirrors. At peak it ran several hundred mirrors. That bought time, in the company's own words, but the mirror fleet got harder to operate as the merge rate climbed, and mirrors kept falling behind the tip of main.&lt;/p&gt;

&lt;p&gt;The migration that followed took five months and a team of seven engineers, who built Scion plus a new code review tool called Antique and supporting systems. Anthropic reports the result as a 200-fold smaller infrastructure footprint in CPU and memory than the Git mirror fleet it replaced, and a 25 percent CPU capacity win for CI alone. Those are the kinds of numbers that make a story go viral. They are also numbers that should make a small team suspicious of the obvious conclusion, because they describe the far end of a curve, not the curve itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What actually broke: three Git assumptions, not Git itself
&lt;/h2&gt;

&lt;p&gt;It is tempting to read this story as "Git is dead for agents". That reading is wrong, and Anthropic's own account does not support it. What broke was a set of assumptions Git was designed around, each of which inverts when most of your committers are machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumption 1: every client holds the whole repository.&lt;/strong&gt; Git's great strength is that a clone is a complete, offline-capable repository: it can compute diffs, read history, and check out any revision without a server. That strength is priced per client. When your clients are a handful of humans, the price is trivial. When your clients are thousands of agent sandboxes and CI jobs spinning up every minute, you pay the price thousands of times a day — in disk, in transfer, and in time spent waiting for a checkout before any useful work starts. A 25 GB working copy is an annoyance for one developer. Multiplied by 25 million checkouts a day, it is an infrastructure budget line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumption 2: clients are mostly offline, or at least intermittently connected.&lt;/strong&gt; Git's distributed design exists so a developer on a plane can commit, branch, and read history. Anthropic's clients were the opposite: agents and automated jobs that are always online, working against one source of truth, in a company that — like most companies — already used Git as a centralized system in practice, with one canonical remote. Paying the full-clone price to preserve offline capability that nobody used was, for them, pure overhead. This is the key inversion in the story: the feature that made Git win for humans was the feature that cost the most for agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumption 3: merges are rare enough that conflicts are exceptional.&lt;/strong&gt; Human teams merge a few times a day per developer, and a conflict is an event a person resolves by reading a diff. Agent fleets merge continuously, often to the same files, and a conflict that a human resolves in ten minutes is a conflict that blocks an agent that has no judgement to apply and no patience to wait. At 30,000 merges a day, "exceptional" stops being a meaningful category. Merge throughput, not merge correctness in the individual case, becomes the system property you have to design for. Our earlier piece on &lt;a href="https://botslave.com/blog/git-for-ai-agent-teams" rel="noopener noreferrer"&gt;Git for AI agent teams&lt;/a&gt; made the same point from the other side: when most commits are not human, branching, review, and provenance have to be designed as a system, not left as habits.&lt;/p&gt;

&lt;p&gt;Notice what did not break. Git's data model — content-addressed objects, immutable history, cryptographic integrity of the object graph — did not fail. Its distribution model and its client-side cost model failed, at a specific scale, for a specific client mix. That distinction decides everything that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What Scion actually is
&lt;/h2&gt;

&lt;p&gt;Scion is server-centric where Git is client-centric. Commits, rebases, merges, blame, and history queries happen on the server; clients keep a bounded local cache and track local edits. The unit of work is a "changeset" stored in a write-ahead log, with file contents in immutable, content-addressed blob storage. Refs are discouraged except for main and release branches. CI jobs that only need to answer a question about the code — what changed, who touched this line, what does this revision contain — can often do it through an API without a local clone at all, and checkouts are sparse and virtualized, fetching files as they are read rather than up front.&lt;/p&gt;

&lt;p&gt;Two facts about Scion matter more than its architecture, and both are routinely dropped from summaries. First, it is not open source and not available to anyone outside Anthropic. The company says plainly that it is tuned to its own monorepo and workflow and is, in its words, by no means a suitable replacement for Git for general use. If a summary tells you to "switch to Scion", it has not read the post. Second, Anthropic did not only replace version control. It rebuilt the review surface alongside it, with Antique, a code review tool that supports semantic constraints on approvals — treating, for example, comment-only changes differently from sensitive logic changes — so reviewers examine the intent of a change rather than reading every line. Humans still review every change, on the company's account; what changed is what the review is for.&lt;/p&gt;

&lt;p&gt;That second point is the one most worth stealing. The bottleneck at agent scale is rarely just the version control system. It is the whole landing path: checkout, merge, test, review, land. Speeding up checkouts while leaving review as a line-by-line human gate just moves the queue. Anthropic rebuilt the gate too, and any team copying only the version control half of the story will be disappointed by the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The honest translation: what this means at your scale
&lt;/h2&gt;

&lt;p&gt;Here is the part of the story that applies to a team of five, or fifty, or a solo operator running a fleet of agents against a handful of repositories. You almost certainly do not need a new version control system. You need to stop paying Git's full price per client, and you need a landing path that does not require a human to be the merge queue.&lt;/p&gt;

&lt;p&gt;Work through this ladder in order, and stop at the first rung that removes the pain. Each rung is cheaper than the next, and most teams never need to pass rung three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 1: measure the checkout, not the commit.&lt;/strong&gt; Before changing any tooling, measure what your agents actually spend time on. Time a fresh clone, a checkout in CI, and a rebase onto a moving main, and log how many of those happen per day. If a checkout takes 40 seconds and you do 200 a day, that is two hours of wall-clock agent time — annoying, but not a reason to redesign anything. If it takes 10 minutes and you do 2,000 a day, you have a real budget line and a case for rung 2. Teams that skip measurement tend to rebuild their toolchain to fix a problem they have not quantified, which is how a week disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 2: pay for only the files you need.&lt;/strong&gt; Git already ships the tools Anthropic's mirror fleet was approximating, and they are one command each. A partial clone with a blob filter plus sparse checkout is the single highest-value change for agent sandboxes and CI:&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;# Clone history and trees, but fetch file contents on demand.&lt;/span&gt;
git clone &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none &lt;span class="nt"&gt;--no-checkout&lt;/span&gt; https://example.com/org/repo.git
&lt;span class="nb"&gt;cd &lt;/span&gt;repo
git sparse-checkout &lt;span class="nb"&gt;set &lt;/span&gt;src/ pipelines/
git checkout main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the repository metadata local — so history, blame, and diffs still work — while file contents arrive only for the directories the job touches. For agents that work in one service of a monorepo, that is usually a 10x or better reduction in checkout cost, with zero change to the hosting, the protocol, or the review flow. The trade-off is real and worth naming: commands that need a file outside the sparse set will fetch it on demand, so a job that wanders across the whole tree will pay fetch latency repeatedly. Scope the sparse set to the job, not to the repository, and measure again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 3: give each agent its own worktree and a small changeset.&lt;/strong&gt; The other half of agent-scale Git pain is contention: several agents on one checkout, or one agent rebasing a day-old branch onto a main that moved a hundred times. The fix is boring and it works: one worktree per agent task, short-lived branches, small changesets, and a rebase onto main immediately before landing, not hours before. In practice:&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;# One isolated checkout per agent task, sharing one object store.&lt;/span&gt;
git worktree add ../task-1423 &lt;span class="nt"&gt;-b&lt;/span&gt; task/1423 main
&lt;span class="nb"&gt;cd&lt;/span&gt; ../task-1423
&lt;span class="c"&gt;# ... agent works, commits, pushes ...&lt;/span&gt;
git worktree remove &lt;span class="nt"&gt;--force&lt;/span&gt; ../task-1423
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worktrees share the object database, so the tenth agent's checkout costs almost nothing in disk. Combined with a rule that a changeset lands or is rebased within the hour — never left to rot for a day — this removes most of the conflict herd without any new software. This is also where our &lt;a href="https://botslave.com/blog/event-driven-pipeline-failure-modes" rel="noopener noreferrer"&gt;event-driven pipelines&lt;/a&gt; lesson applies: retries and replays need idempotency and a dead-letter path. An agent whose rebase fails three times should land in a queue a human looks at, not loop forever burning CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 4: change the client or the server, not the protocol.&lt;/strong&gt; If rungs 1–3 are not enough — your repository is genuinely huge, or your merge rate is genuinely in the thousands per day — the next step is still not building a VCS. It is swapping one layer while keeping Git in the path. Sapling and Jujutsu replace the client with a model designed for stacked changes and fast operations on large repositories, while still speaking Git underneath. Mononoke replaces the server with a scalable backend, again while speaking Git to clients. Each changes one layer and keeps the ecosystem — hosting, CI, review tooling, deploy pipelines — intact. That layer-by-layer property is exactly what Scion gave up, and it is why Scion only made sense inside one company that could rebuild every layer at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 5: build your own system.&lt;/strong&gt; This rung exists for completeness. It is justified when your merge rate, repository size, and client mix are all simultaneously off the charts, you can staff a dedicated team for years, and you accept that you will also rebuild review, search, access control, and CI integration. Anthropic's five-month, seven-engineer build — with Claude itself doing much of the implementation work — is the optimistic version of this rung. For everyone else, it is a warning, not a template.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom you actually see&lt;/th&gt;
&lt;th&gt;Cheapest fix that usually works&lt;/th&gt;
&lt;th&gt;Escalate when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CI checkout takes minutes, agents idle waiting&lt;/td&gt;
&lt;td&gt;Partial clone + sparse checkout scoped to the job&lt;/td&gt;
&lt;td&gt;Checkout still dominates after scoping, or jobs touch the whole tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agents conflict on the same files daily&lt;/td&gt;
&lt;td&gt;One worktree per task, small changesets, rebase just before landing&lt;/td&gt;
&lt;td&gt;Conflicts persist with small changesets — the contention is in the code layout, not the tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main goes red after agent merges&lt;/td&gt;
&lt;td&gt;Land queue: rebase + test on exact tip before landing, replay on move&lt;/td&gt;
&lt;td&gt;Queue wait exceeds your delivery target — then the fix is smaller changes, not a faster queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review queue is the bottleneck, not merges&lt;/td&gt;
&lt;td&gt;Semantic review rules: auto-approve comment-only and generated-code churn, human review for logic&lt;/td&gt;
&lt;td&gt;Reviewers still read every line — that is a policy problem, not a tooling problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mirror or cache fleet costs more than the CI it serves&lt;/td&gt;
&lt;td&gt;Stop mirroring; sparse checkouts against one canonical remote&lt;/td&gt;
&lt;td&gt;Single remote cannot serve peak checkout rate — measure this before assuming it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  5. The review lesson, restated
&lt;/h2&gt;

&lt;p&gt;If there is one idea from the Scion story worth carrying into a small team's Monday meeting, it is not server-centric version control. It is that review must change when authorship changes. A human author writes a change and a human reviewer reads it; the unit of review is the diff. An agent fleet writes hundreds of changes and a human reviewer cannot read them all, so the unit of review has to become the intent: what was this change for, what did the tests prove, what class of change is it, and what would have to be true for it to be wrong? That is what Antique's semantic constraints are for, and it is a design a small team can approximate today without any new software: label changes by class (generated, mechanical, logic), set approval rules per class, require the test evidence in the change description, and let humans spend their attention where the class says the risk is. The teams that do this find their review queue shrinks. The teams that do not find that their agents simply learn to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What not to do
&lt;/h2&gt;

&lt;p&gt;Three failure modes show up every time a story like this goes viral, and all three are worth naming because they cost real weeks. First, do not announce a migration off Git. You do not have Anthropic's problem, and a migration announcement is a promise to rebuild your review, CI, and deploy tooling for a benefit you have not measured. Second, do not confuse a vendor's benchmark with your bottleneck. A 200-fold footprint reduction on a several-hundred-mirror fleet says nothing about a repository with three mirrors or none. Third, do not let agents pick the tooling. An agent fleet that chooses its own version control will choose whatever minimizes its own checkout time in the moment, which is how you end up with five partial solutions and no canonical history. Tooling choices stay human decisions, for the same reason deploy decisions do: the blast radius is the whole team's history.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should my team switch off Git because Anthropic did?&lt;/strong&gt;&lt;br&gt;
No. Anthropic replaced Git for one monorepo at 30,000 merges a day with a 100 GB history, and says Scion is not a general replacement for Git. If your merge rate and repository size are orders of magnitude smaller — and they almost certainly are — the Git-side fixes in section 4 will remove the pain at a fraction of the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Scion open source? Can I try it?&lt;/strong&gt;&lt;br&gt;
No. It is an internal Anthropic system and has not been released. The usable output of the story is the design: server-side operations, changesets in a write-ahead log, sparse and virtualized checkouts, and semantic review rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the single highest-value change for agent checkouts?&lt;/strong&gt;&lt;br&gt;
Partial clone with &lt;code&gt;--filter=blob:none&lt;/code&gt; plus a sparse checkout scoped to the directories the job touches. It keeps history and blame working while fetching file contents on demand, and it requires no change to hosting or review tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if agents keep conflicting on the same files?&lt;/strong&gt;&lt;br&gt;
That is usually a work-splitting problem, not a version control problem. Give each agent its own worktree, keep changesets small, rebase just before landing, and put repeatedly failing rebases in a human-reviewed queue instead of letting them loop. If conflicts persist with small changesets, the code layout itself needs splitting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does faster version control fix slow review?&lt;/strong&gt;&lt;br&gt;
No — it moves the queue. If humans still read every line of agent-written code, review becomes the bottleneck the moment checkouts get fast. Change the unit of review to intent and change class, with approval rules per class, and require test evidence in the change description.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic Engineering, "Redesigning source control at Anthropic" (13 October 2026): &lt;a href="https://www.anthropic.com/engineering/redesigning-source-control" rel="noopener noreferrer"&gt;https://www.anthropic.com/engineering/redesigning-source-control&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Git documentation, Partial clone and sparse checkout: &lt;a href="https://git-scm.com/docs/partial-clone" rel="noopener noreferrer"&gt;https://git-scm.com/docs/partial-clone&lt;/a&gt; and &lt;a href="https://git-scm.com/docs/git-sparse-checkout" rel="noopener noreferrer"&gt;https://git-scm.com/docs/git-sparse-checkout&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sapling (open-source scalable version control client): &lt;a href="https://sapling-scm.com/" rel="noopener noreferrer"&gt;https://sapling-scm.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jujutsu (open-source version control system with a Git backend): &lt;a href="https://jj-vcs.github.io/jj/" rel="noopener noreferrer"&gt;https://jj-vcs.github.io/jj/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Meta, Mononoke (scalable Git server): &lt;a href="https://github.com/facebookincubator/mononoke" rel="noopener noreferrer"&gt;https://github.com/facebookincubator/mononoke&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Git for AI Agent Teams: Branching, Review and Provenance When Most Commits Aren't Human": &lt;a href="https://botslave.com/blog/git-for-ai-agent-teams" rel="noopener noreferrer"&gt;https://botslave.com/blog/git-for-ai-agent-teams&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Event-Driven Pipelines Fail in Five Predictable Ways — and How to Design Against Them": &lt;a href="https://botslave.com/blog/event-driven-pipeline-failure-modes" rel="noopener noreferrer"&gt;https://botslave.com/blog/event-driven-pipeline-failure-modes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "How we work": &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;https://botslave.com/how-we-work&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/agents-outgrow-git-anthropic-scion-lessons" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>ai</category>
      <category>git</category>
      <category>devops</category>
    </item>
    <item>
      <title>Rate Limiters Are Not Your Enemy: How Token Buckets Work and How Automation Should Behave Under Them</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Tue, 27 Oct 2026 07:57:29 +0000</pubDate>
      <link>https://dev.to/botslavehq/rate-limiters-are-not-your-enemy-how-token-buckets-work-and-how-automation-should-behave-under-them-59gm</link>
      <guid>https://dev.to/botslavehq/rate-limiters-are-not-your-enemy-how-token-buckets-work-and-how-automation-should-behave-under-them-59gm</guid>
      <description>&lt;p&gt;&lt;strong&gt;Rate limiters are having a quiet moment — the topic sits at #12 on daily.dev Popular this morning — and for good reason: it is how most serious automation actually moves.&lt;/strong&gt; Every pipeline we operate eventually meets a 429. A scraper that was happily pulling pages at full speed suddenly gets told to slow down, and the difference between a pipeline that survives the encounter and one that gets its IP banned for a week is almost never the request rate itself. It is whether the client understood the limiter on the other side.&lt;/p&gt;

&lt;p&gt;We build pipelines that move data between systems for a living — lead ingestion, enrichment, scoring, and unattended workflows — and rate limiting is the part of that work nobody demos but everybody pays for. This piece is the mental model we use: how the common limiters actually work, what the response headers are really telling you, and how to design a client that treats a limiter as a protocol instead of an obstacle.&lt;/p&gt;

&lt;p&gt;Our &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; rule applies here too: automate aggressively, but keep the blast radius small. In rate-limit terms, that means a single runaway job should never be able to spend the whole account's budget, and a single 429 should never cascade into a retry storm.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A limiter is a budget, not a wall
&lt;/h2&gt;

&lt;p&gt;The most useful reframe for automation builders is this: a rate limiter does not exist to stop you. It exists to give every client a fair share of a finite resource, and to keep one client's burst from becoming everyone else's outage. When your scraper gets a 429, the server is not saying "go away." It is saying "you have spent your budget for this window; come back when it refills."&lt;/p&gt;

&lt;p&gt;That framing changes the design. A client that treats 429 as an error retries blindly and burns its budget faster. A client that treats 429 as budget information slows down, waits for the refill, and finishes the job — just later.&lt;/p&gt;

&lt;p&gt;HTTP gives you the vocabulary for this. Status 429 (Too Many Requests) is defined in RFC 6585, and the &lt;code&gt;Retry-After&lt;/code&gt; header tells you how long to wait before trying again. Many APIs also send &lt;code&gt;X-RateLimit-Limit&lt;/code&gt;, &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;, and &lt;code&gt;X-RateLimit-Reset&lt;/code&gt; headers, which tell you the size of the budget, how much of it is left, and when it refills. A client that reads those headers is negotiating. A client that ignores them is guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The four limiters you will actually meet
&lt;/h2&gt;

&lt;p&gt;Most limiters you encounter are one of four shapes. Knowing which one you are facing tells you how to behave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed window.&lt;/strong&gt; The server counts requests in a calendar window — say, 100 requests per minute — and resets the counter at the window boundary. This is the simplest limiter to implement and the easiest to game: a client can fire 100 requests in the last second of one window and 100 more in the first second of the next, doubling its effective rate at the boundary. If you see your remaining budget jump back to full at a predictable second, you are probably looking at a fixed window. The correct behaviour is to spread your requests across the window, not to burst at its edges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sliding window.&lt;/strong&gt; The server counts requests in a rolling window — the last 60 seconds, not the current calendar minute. This kills the boundary-burst trick, because there is no boundary to exploit. The cost is memory: the server has to remember timestamps, not just a counter. From the client's side, a sliding window feels like a fixed window that never quite refills all at once; your budget comes back gradually, one expired request at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token bucket.&lt;/strong&gt; This is the one in the daily.dev post, and the most common design for serious APIs. Imagine a bucket that holds a fixed number of tokens. Every request costs one token. Tokens refill at a steady rate — say, 10 per second — up to the bucket's capacity. A client that has been quiet accumulates a full bucket and can burst; a client that has been hammering the API runs dry and has to wait for refills. The bucket gives you two knobs the other designs do not: capacity (how big a burst is allowed) and refill rate (the sustained rate). A bucket with capacity 100 and refill 10/second allows a burst of 100, then a steady 10/second forever. That is why token buckets feel forgiving at first and strict later: the burst budget is real, but it is finite, and once it is gone, the refill rate is the law.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaky bucket.&lt;/strong&gt; The mirror image: requests enter a queue and leak out at a fixed rate. Bursts get queued rather than rejected, which smooths traffic but adds latency — and when the queue fills, requests are dropped. Leaky buckets are more common on the server's processing side than as a client-facing limiter, but the distinction matters when you are designing your own: token bucket limits how many requests you accept; leaky bucket limits how fast you process them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limiter&lt;/th&gt;
&lt;th&gt;Burst allowed?&lt;/th&gt;
&lt;th&gt;Refill behaviour&lt;/th&gt;
&lt;th&gt;What it feels like to a client&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed window&lt;/td&gt;
&lt;td&gt;Yes, at window edges&lt;/td&gt;
&lt;td&gt;All at once, on the boundary&lt;/td&gt;
&lt;td&gt;Budget jumps back to full at a predictable second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding window&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Gradually, as old requests expire&lt;/td&gt;
&lt;td&gt;Budget creeps back, never all at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token bucket&lt;/td&gt;
&lt;td&gt;Yes, up to capacity&lt;/td&gt;
&lt;td&gt;Steady rate, up to capacity&lt;/td&gt;
&lt;td&gt;Generous after idle, strict under sustained load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky bucket&lt;/td&gt;
&lt;td&gt;Queued, not rejected&lt;/td&gt;
&lt;td&gt;Fixed processing rate&lt;/td&gt;
&lt;td&gt;Requests slow down before they fail&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  3. Read the headers before you retry
&lt;/h2&gt;

&lt;p&gt;The single highest-value habit in rate-limit handling is reading the response before deciding what to do next. A well-behaved client checks, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The status code.&lt;/strong&gt; 429 means "slow down." 403 with a rate-limit message (GitHub's secondary rate limits do this) means the same thing with different wording — read the body, not just the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt;.&lt;/strong&gt; If the server tells you to wait 60 seconds, wait 60 seconds. Not 5, not 30. Servers that bother to send &lt;code&gt;Retry-After&lt;/code&gt; usually enforce it, and retrying early is the fastest way to turn a temporary slowdown into a longer ban.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The remaining budget.&lt;/strong&gt; If &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt; is 0 and &lt;code&gt;X-RateLimit-Reset&lt;/code&gt; is 40 seconds away, there is no point sending another request for 40 seconds. Sleep until the reset, then resume. If remaining is high, the 429 you just got was probably a secondary limit — a concurrency limit, a per-endpoint limit, or an abuse heuristic — and the fix is different from waiting for the main budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shape of the failure.&lt;/strong&gt; A 429 on every endpoint means the account budget is gone. A 429 on one endpoint while others work means a per-route limit. A 429 that arrives with a CAPTCHA challenge means the server has decided you look like a bot, and no amount of waiting will fix that — that is a fingerprint and behaviour problem, not a budget problem. Our &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;Shopee security-headers research&lt;/a&gt; covers that side of the problem in depth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code version of this is short, and it belongs in every client you write:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_with_limit_awareness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http_call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;http_call&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;
        &lt;span class="c1"&gt;# Respect the server's own number first.
&lt;/span&gt;        &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# No Retry-After: back off exponentially, with jitter,
&lt;/span&gt;        &lt;span class="c1"&gt;# so a fleet of clients does not retry in lockstep.
&lt;/span&gt;        &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that snippet do most of the work. First, &lt;code&gt;Retry-After&lt;/code&gt; wins over any backoff formula you invented, because the server knows its own limiter and you do not. Second, the jitter matters as much as the backoff: without it, every client that hit the limit at the same second retries at the same second, and the limiter gets a second burst exactly when it is most sensitive. Our &lt;a href="https://botslave.com/blog/event-driven-pipeline-failure-modes" rel="noopener noreferrer"&gt;event-driven pipelines&lt;/a&gt; piece makes the same argument for poison-message retries — jitter is what turns a herd into a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Budget your own tokens, client-side
&lt;/h2&gt;

&lt;p&gt;Waiting for the server to tell you to slow down is reactive. The better design is to never hit the limiter in the first place, by running your own token bucket on the client side, sized conservatively below the server's published limits.&lt;/p&gt;

&lt;p&gt;A minimal client-side bucket in Python:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenBucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refill_per_second&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;refill_per_second&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                              &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refill&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
            &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refill&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules make a client-side bucket actually work in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Size it below the documented limit, not at it.&lt;/strong&gt; If the API documents 100 requests per minute, run your bucket at 80. The margin absorbs clock skew, undocumented secondary limits, and the requests your other jobs are making against the same key that you forgot about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One bucket per key, not per job.&lt;/strong&gt; If three jobs share an API key and each runs its own bucket at 80/minute, you are making 240/minute against a 100/minute limit. The bucket must live where the key lives — a shared service, a Redis key, a single coordinator — not inside each job's memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the bucket as a queue, not a gate.&lt;/strong&gt; When the bucket is empty, the correct behaviour for a scraper is usually to wait, not to fail. Failing on an empty bucket turns a rate limit into a data gap; waiting turns it into a slower run. The exception is interactive work, where a human is waiting — there, fail fast and say why, with the reset time in the message.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  5. Distributed limiters and the limits of client-side politeness
&lt;/h2&gt;

&lt;p&gt;Client-side buckets work until you have more than one machine, more than one key, or a limiter you cannot see. At that point the limiter has to live on the server side of your own system, and the usual tool is a shared store — Redis is the default — holding token counts that every worker checks before calling out. The design questions are the same ones as any distributed system: what happens when the store is briefly unavailable (fail open and risk the ban, or fail closed and stall the pipeline?), and how do you keep the check itself from becoming the bottleneck?&lt;/p&gt;

&lt;p&gt;There is also a category of limiter no client-side discipline can fix: abuse heuristics. A server that decides your traffic pattern looks automated — identical request intervals, no mouse movement, a datacenter IP, a fingerprint that matches a known bot — will block you regardless of your rate. That is not a budget problem; it is a trust problem, and the fix is behavioural (human-like timing, residential egress, real browser fingerprints), not arithmetic. Knowing which kind of block you are facing is the whole diagnostic skill: check the status code, the headers, and the body, in that order, before changing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The pre-flight checklist
&lt;/h2&gt;

&lt;p&gt;Before a new scraper or automation client ships, it should pass five checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Headers:&lt;/strong&gt; does it read &lt;code&gt;Retry-After&lt;/code&gt; and the &lt;code&gt;X-RateLimit-*&lt;/code&gt; headers, and does &lt;code&gt;Retry-After&lt;/code&gt; override its own backoff?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget:&lt;/strong&gt; does it run a client-side bucket sized below the documented limit, shared across every job using the same key?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backoff:&lt;/strong&gt; are retries bounded, exponential, and jittered — never immediate, never infinite?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation:&lt;/strong&gt; can one runaway job spend the whole account's budget, or is each job's share capped?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnosis:&lt;/strong&gt; when a run is blocked, does the log say which limiter fired, what the remaining budget was, and when the reset is — or does it just say "429"?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A client that passes all five will still get rate-limited. That is the point. Rate limits are not failures to avoid; they are the protocol the internet uses to share finite resources. The clients that survive them are not the ones that never see a 429. They are the ones that treat the limiter as information, budget against it, and keep working at the rate the server can actually sustain.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is a token bucket the same as a fixed window?&lt;/strong&gt;&lt;br&gt;
No. A fixed window resets its whole budget at a boundary, which allows boundary bursts. A token bucket refills steadily, so the budget comes back gradually, and a burst is capped by the bucket's capacity rather than by the clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I just retry immediately when I get a 429?&lt;/strong&gt;&lt;br&gt;
No. Immediate retries are the most common way to turn a short slowdown into a long ban. Respect &lt;code&gt;Retry-After&lt;/code&gt; when present; otherwise back off exponentially with jitter, and cap the number of attempts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my client get limited even though it is under the documented rate?&lt;/strong&gt;&lt;br&gt;
Usually one of three reasons: a secondary limit (per-endpoint, per-minute, or concurrency) that is not the headline number; another job sharing the same key; or an abuse heuristic that is not rate-based at all. Check the response headers and body before assuming the headline limit is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a distributed limiter, or is a local bucket enough?&lt;/strong&gt;&lt;br&gt;
A local bucket is enough for one process against one key. As soon as multiple jobs or machines share a key, the bucket has to be shared too, or each job's "safe" rate adds up to an unsafe total.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RFC 6585, Additional HTTP Status Codes (defines 429 Too Many Requests): &lt;a href="https://www.rfc-editor.org/rfc/rfc6585" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc6585&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MDN Web Docs, 429 Too Many Requests and the Retry-After header: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub REST API documentation, Rate limits (primary limits, secondary limits, and Retry-After behaviour): &lt;a href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api" rel="noopener noreferrer"&gt;https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API documentation, Rate limits (idempotency and limit behaviour): &lt;a href="https://docs.stripe.com/api/rate-limits" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/rate-limits&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare documentation, Rate limiting rules: &lt;a href="https://developers.cloudflare.com/waf/rate-limiting-rules/" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/waf/rate-limiting-rules/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Shopee Security Headers: TLS, Tokens &amp;amp; Bot Detection": &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Event-Driven Pipelines Fail in Five Predictable Ways — and How to Design Against Them": &lt;a href="https://botslave.com/blog/event-driven-pipeline-failure-modes" rel="noopener noreferrer"&gt;https://botslave.com/blog/event-driven-pipeline-failure-modes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "How we work": &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;https://botslave.com/how-we-work&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/rate-limiter-token-bucket-automation" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Event-Driven Pipelines Fail in Five Predictable Ways — and How to Design Against Them</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:34:38 +0000</pubDate>
      <link>https://dev.to/botslavehq/event-driven-pipelines-fail-in-five-predictable-ways-and-how-to-design-against-them-1k2a</link>
      <guid>https://dev.to/botslavehq/event-driven-pipelines-fail-in-five-predictable-ways-and-how-to-design-against-them-1k2a</guid>
      <description>&lt;p&gt;&lt;strong&gt;Event-driven architecture is having another moment — the topic sits at #7 on daily.dev Popular this morning — and for good reason: it is how most serious automation actually moves.&lt;/strong&gt; A scraper finishes, an event fires, a scorer picks it up, a writer stores the result, a human reviews the edge cases. Nobody polls anybody. The system reacts. That is also exactly why event-driven systems fail in ways that are hard to see coming: the failure is not a crash, it is a message delivered twice, a message arriving late, a message nobody can replay, and the damage only surfaces hours later in data nobody trusts anymore.&lt;/p&gt;

&lt;p&gt;We build pipelines like this for a living — lead ingestion, enrichment, scoring, and unattended workflows that move data between systems — and the five failure modes below are the ones we design against first, before any feature work. They are not exotic. They are the default behaviour of queues, brokers, and at-least-once delivery, and every one of them has bitten a team that assumed "the queue handles it."&lt;/p&gt;

&lt;p&gt;Our &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; rule applies here in full: automate aggressively, but keep the blast radius small. In event-driven terms, that means every consumer must be safe to run twice, every message must carry enough context to be understood alone, and a human must be able to stop, inspect, and replay the flow. Our &lt;a href="https://botslave.com/blog/dark-software-factories-lights-out" rel="noopener noreferrer"&gt;dark software factories&lt;/a&gt; piece made the same argument for lights-out systems; this piece is the event-driven version of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Duplicates are the default, not an accident
&lt;/h2&gt;

&lt;p&gt;Most messaging systems guarantee at-least-once delivery. That is a polite way of saying: your consumer will see the same message twice, and it is your problem, not the broker's. Retries after a timeout, a consumer crash after processing but before acknowledging, a broker redelivery — all of them produce a second copy of a message your system already acted on. If acting twice means charging twice, emailing twice, or scoring a lead twice and double-counting it, you have a correctness bug, not a performance problem.&lt;/p&gt;

&lt;p&gt;The fix is idempotency, applied at the point where the side effect happens — not in a comment, not in a hope. Every message carries a stable event ID, and the consumer records that ID in the same transaction as the work it does. The second delivery finds the ID already recorded and skips the work.&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="c1"&gt;-- The processed-events table is the whole trick.&lt;/span&gt;
&lt;span class="c1"&gt;-- Record the event ID in the SAME transaction as the side effect.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;consumer&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_lead_scored&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO processed_events (event_id, consumer) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VALUES (%s, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lead-scorer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) ON CONFLICT DO NOTHING RETURNING event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# duplicate delivery: work already done, skip silently
&lt;/span&gt;        &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE leads SET score = %s WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lead_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. First, the dedupe record and the side effect must commit together; recording the ID in a separate step reopens the exact crash window you were closing. Second, the event ID must be stable across redelivery — a UUID generated by the consumer on receipt is useless, because the redelivered copy gets a different one. The producer assigns the ID, and it travels with the event forever.&lt;/p&gt;

&lt;p&gt;The same discipline applies to outbound calls: when a consumer calls a payment API, an email API, or another service, it should pass an idempotency key derived from the event ID, so a retried call is recognised by the downstream service too. Idempotency is not one layer; it is a property you push through every boundary a message crosses.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Order is not guaranteed, so stop designing as if it were
&lt;/h2&gt;

&lt;p&gt;A queue that delivers messages in order under normal conditions will not deliver them in order under failure conditions. A retried message arrives late. A consumer that processes messages in parallel finishes them out of order. A partitioned topic delivers one partition's backlog after another's live stream. If your consumer assumes "update A always arrives before update B," it will eventually apply B, then A, and end up with stale state presented as current.&lt;/p&gt;

&lt;p&gt;The practical response is to make consumers order-tolerant rather than order-dependent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Carry a version with every event.&lt;/strong&gt; A monotonically increasing version per aggregate (or a timestamp from the producer's clock, with its known skew limits) lets the consumer reject stale writes: apply only if &lt;code&gt;event.version &amp;gt; current.version&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model state as a state machine, not a sequence of overwrites.&lt;/strong&gt; A lead that has moved to "contacted" should not move back to "new" because a delayed event arrived. Transitions that are illegal from the current state get logged and dropped, not applied blindly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the event, not just the result.&lt;/strong&gt; When the consumer stores the event log alongside the current state, a later investigation can reconstruct what actually happened, in what order, instead of guessing from the final row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a legitimate exception: some flows genuinely need ordering, such as a sequence of edits to the same document. For those, use a single partition key per aggregate so all of its events land in one ordered stream, and accept the throughput ceiling that comes with it. Ordering is a feature you pay for with parallelism. Do not pay for it everywhere, and do not assume it anywhere you did not pay.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. One poison message can stall everything behind it
&lt;/h2&gt;

&lt;p&gt;Every pipeline eventually receives a message it cannot process: a payload that fails validation, a record that triggers an unhandled edge case, a downstream service that rejects one specific input. What happens next decides whether you have an incident or a footnote.&lt;/p&gt;

&lt;p&gt;The naive design retries forever, immediately. The message fails, goes back to the front of the queue, fails again, and the consumer never reaches the healthy messages behind it. Worse, if the failure is a downstream timeout, the retry loop becomes a self-inflicted denial of service against the very service that is struggling.&lt;/p&gt;

&lt;p&gt;The standard shape, and the one we use, has four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bounded retries with exponential backoff and jitter.&lt;/strong&gt; A handful of attempts, each waiting longer than the last, with random jitter so a fleet of consumers does not retry in lockstep. Backoff without jitter just moves the thundering herd to a later timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dead-letter queue (DLQ).&lt;/strong&gt; After the final attempt, the message moves to a separate queue instead of blocking the main one. The main flow keeps moving; the bad message waits somewhere it can be inspected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An alert on DLQ depth, not on individual failures.&lt;/strong&gt; One poison message is routine. Ten in an hour is a signal — a broken producer deploy, a schema change, a downstream outage. Alert on the rate, and page on the trend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A replay path out of the DLQ.&lt;/strong&gt; A dead letter that can only be deleted is a data-loss decision made under pressure. The DLQ needs a tool that requeues a message after a fix, with the original event ID intact so idempotency still protects the replay.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry_with_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;300.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Exponential backoff + full jitter: spreads retries instead of
&lt;/span&gt;    &lt;span class="c1"&gt;# synchronising a whole fleet onto the same second.
&lt;/span&gt;    &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_seconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design question to ask before shipping any consumer is not "does it handle success?" but "what happens to the tenth consecutive failure, and who finds out?" If the answer is "it retries forever and nobody is paged," the consumer is not finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Schemas change, and every consumer breaks at once
&lt;/h2&gt;

&lt;p&gt;An event schema is a contract with every consumer, and contracts get renegotiated constantly: a new field appears, a field changes type, a field disappears because a producer team decided it was redundant. The failure mode is rarely dramatic. It is a consumer that silently drops a field it no longer recognises, or crashes on a payload shape it never expected, or — worst — interprets a changed field under its old meaning.&lt;/p&gt;

&lt;p&gt;The defences are boring and they work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Additive changes only, by default.&lt;/strong&gt; New fields are optional. Old fields are never renamed or retyped in place; they are deprecated, then removed after every consumer has migrated. A breaking change is a new event version, not an edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version the event envelope, not just the payload.&lt;/strong&gt; Every event carries &lt;code&gt;event_type&lt;/code&gt; and &lt;code&gt;event_version&lt;/code&gt; at the top level, so a consumer can route v1 and v2 to different handlers instead of guessing from the payload shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tolerant readers.&lt;/strong&gt; Consumers read the fields they need and ignore the rest. A consumer that fails on an unknown field has coupled itself to every future producer change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract tests between producer and consumer.&lt;/strong&gt; Before a producer ships a schema change, a test replays a sample of real events through every consumer's parser. This catches the rename that "nobody uses" — which is always used by somebody.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_01J9Q2X..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lead.scored"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-26T01:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lead-scorer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lead_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lead_88412"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;73&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"score_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v3"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what the envelope carries that the payload does not: identity, type, version, time, and origin. A message without those five things cannot be debugged, replayed, or routed by anyone except the team that wrote it. That is not a message; it is a rumour.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. If you cannot replay it, you cannot recover from it
&lt;/h2&gt;

&lt;p&gt;The previous four failure modes all end in the same place: something went wrong, and now you need to reprocess a slice of history. A consumer deployed with a bug scored a thousand leads wrong. A downstream outage lost a day of writes. A schema migration misclassified a batch. In every case, the question is the same: can you take the original events and run them through the fixed consumer again?&lt;/p&gt;

&lt;p&gt;Most systems cannot, because they treated the queue as a transport and threw the events away after delivery. The queue is not storage. Retention expires, DLQs get purged, and the original input is gone. Recovery then means reconstructing data from side effects — the current database rows, the sent emails, the logs — which is guesswork dressed as engineering.&lt;/p&gt;

&lt;p&gt;The fix is to treat the event log as the system of record, or at least as a durable archive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persist events before, or alongside, processing.&lt;/strong&gt; An append-only event store — even a plain table with the envelope and payload — turns "reprocess last Tuesday" from impossible into a query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay is a first-class operation, not a script written during an incident.&lt;/strong&gt; The replay tool takes a time range or a set of event IDs, feeds them through the current consumer, and leans on idempotency so replaying an already-processed event is a no-op. Replaying without idempotency just doubles the damage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlation IDs tie the story together.&lt;/strong&gt; One ID, generated at the entry point, travels through every event a request produces. When a lead's score looks wrong, one query shows every event in its chain instead of a grep across five services' logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same argument our &lt;a href="https://botslave.com/blog/leadops-autopilot-clinic-lead-scoring" rel="noopener noreferrer"&gt;LeadOps build notes&lt;/a&gt; made from the other side: a pipeline you cannot inspect is a pipeline you cannot trust. Observability for event-driven systems is not dashboards of queue depth. It is the ability to answer, for any single record, "what happened to it, in what order, and can I run it again?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-flight checklist
&lt;/h2&gt;

&lt;p&gt;Before a new consumer ships, it should pass five checks — one per failure mode above:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates:&lt;/strong&gt; can this consumer run twice on the same event with no visible difference? (Idempotency key + same-transaction dedupe.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order:&lt;/strong&gt; does it reject stale writes and illegal transitions instead of applying them?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poison:&lt;/strong&gt; after N failures, does the message leave the main queue, page someone on trend, and stay replayable?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema:&lt;/strong&gt; can the producer add a field tomorrow without deploying this consumer?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay:&lt;/strong&gt; can you reprocess last week's events through today's consumer and get the same result?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A consumer that fails any one of these is not "mostly done." It is a future incident with a deployment date. Event-driven architecture rewards the teams that treat these five checks as the definition of done — and it punishes everyone else on exactly the schedule nobody planned for.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is event-driven architecture always the right choice?&lt;/strong&gt;&lt;br&gt;
No. If a flow is a simple request-response with one producer and one consumer and no need for audit history, a direct call is simpler, easier to debug, and easier to reason about. Events earn their complexity when there are multiple consumers, when the history itself has value, or when producers and consumers must evolve independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does exactly-once delivery solve the duplicate problem?&lt;/strong&gt;&lt;br&gt;
No product offers true end-to-end exactly-once processing once side effects leave the system — a consumer can always crash after acting but before acknowledging. Exactly-once within a single broker is a transport guarantee, not a business guarantee. Idempotent consumers are still required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many retries are enough?&lt;/strong&gt;&lt;br&gt;
There is no universal number. Transient failures (timeouts, brief downstream outages) usually clear within a handful of attempts over minutes. Permanent failures (bad payloads, validation errors) never clear, no matter how many retries. The design goal is not a magic count; it is bounded retries plus a DLQ plus an alert, so both kinds of failure end up somewhere visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should every event be stored forever?&lt;/strong&gt;&lt;br&gt;
Not necessarily forever, but for longer than your longest recovery window. If your incident response ever needs "last month's events," retention must cover last month. Storage is cheap; reconstruction from side effects is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; — chapters on message brokers, log-based messaging, and exactly-once semantics (the standard treatment of at-least-once delivery and ordering).&lt;/li&gt;
&lt;li&gt;Chris Richardson, Transactional Outbox pattern: &lt;a href="https://microservices.io/patterns/data/transactional-outbox.html" rel="noopener noreferrer"&gt;https://microservices.io/patterns/data/transactional-outbox.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Apache Kafka documentation, delivery semantics: &lt;a href="https://kafka.apache.org/documentation.html" rel="noopener noreferrer"&gt;https://kafka.apache.org/documentation.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API documentation, idempotency keys: &lt;a href="https://docs.stripe.com/api/idempotency" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/idempotency&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Dark Software Factories: What Runs Lights-Out and What Breaks Without a Human in the Loop": &lt;a href="https://botslave.com/blog/dark-software-factories-lights-out" rel="noopener noreferrer"&gt;https://botslave.com/blog/dark-software-factories-lights-out&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "LeadOps Autopilot: Sourcing and Scoring US Clinic Leads from Public Data": &lt;a href="https://botslave.com/blog/leadops-autopilot-clinic-lead-scoring" rel="noopener noreferrer"&gt;https://botslave.com/blog/leadops-autopilot-clinic-lead-scoring&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "How we work": &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;https://botslave.com/how-we-work&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/event-driven-pipeline-failure-modes" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Arc Is Shutting Down: Build Browser Automation That Survives the Browser</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:33:21 +0000</pubDate>
      <link>https://dev.to/botslavehq/arc-is-shutting-down-build-browser-automation-that-survives-the-browser-40a0</link>
      <guid>https://dev.to/botslavehq/arc-is-shutting-down-build-browser-automation-that-survives-the-browser-40a0</guid>
      <description>&lt;p&gt;&lt;strong&gt;On October 22, 2026, The Browser Company announced that Arc — the browser that made vertical tabs, Spaces and a command-bar workflow fashionable — will stop working on Mac and Windows on December 22, 2026.&lt;/strong&gt; It is currently the #1 story on daily.dev Popular, and it landed hard because Arc was not a neglected side project: it was the product that put the company on the map. But if you build browser automation for a living, the interesting part of this story is not the obituary. It is the pattern. A browser you depend on can go into maintenance mode, then get a sunset date, then stop working — and every script, session and extension workflow built on top of it has to move, on someone else's schedule.&lt;/p&gt;

&lt;p&gt;We run browser automation that touches production systems: scraping pipelines, lead enrichment, unattended workflows that log in, fill forms and move data between systems. Our &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;Shopee security-headers research&lt;/a&gt; taught us how much of the web's behaviour lives in the browser layer — TLS fingerprints, tokens, bot detection — and our &lt;a href="https://botslave.com/blog/dark-software-factories-lights-out" rel="noopener noreferrer"&gt;dark software factories&lt;/a&gt; piece covered what breaks when nobody is watching. Arc's shutdown is the same lesson from a different angle: &lt;strong&gt;the browser is a dependency, and dependencies need a migration plan before they announce one.&lt;/strong&gt; This piece is that plan, written from the automation side.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What actually happened, and why the dates matter
&lt;/h2&gt;

&lt;p&gt;The shutdown is staged, and the staging is the part automation builders should study:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;October 22, 2026:&lt;/strong&gt; the announcement. No new Arc accounts can be created. Existing users keep browsing, and a final update adds a data-export option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;November 23, 2026:&lt;/strong&gt; Arc Search on iOS and Android loses its AI features and sign-in/tab sync. The mobile apps keep working as basic local browsers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;December 22, 2026:&lt;/strong&gt; Arc on Mac and Windows stops working entirely. Accounts are deactivated, and account data is deleted within 30 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The company says users should move to Dia, its AI-first browser, which imports Arc's Spaces, pinned tabs, passwords and extensions. It also says it cannot open-source Arc, because Arc runs on a proprietary internal SDK — the Arc Development Kit — that also powers Dia. Keeping a browser secure, the company wrote, "takes constant, dedicated work that is only increasing in the age of AI," and it will not do that work twice.&lt;/p&gt;

&lt;p&gt;Two details matter more than the headlines. First, &lt;strong&gt;feature development on Arc had already stopped in 2025&lt;/strong&gt;, when the company pivoted to Dia. The sunset date is not the moment Arc died; it is the moment the death became official. If you waited for the announcement to start migrating, you were already a year late. Second, &lt;strong&gt;the kill is staged, not instant&lt;/strong&gt; — sync dies before the browser does, and mobile dies differently from desktop. Any automation that depended on Arc sync, on a signed-in profile, or on a mobile flow had a shorter runway than the December date suggested. Sunset timelines always have a hidden early deadline. Plan for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Target the engine, not the product
&lt;/h2&gt;

&lt;p&gt;Arc was a Chromium browser with a different shell: a sidebar instead of a tab strip, Spaces instead of windows, a command bar instead of an address bar. Underneath, pages rendered with Blink, JavaScript ran on V8, and the network stack was Chromium's. That distinction is the whole game for automation.&lt;/p&gt;

&lt;p&gt;If your scripts drive the &lt;em&gt;product&lt;/em&gt; — clicking Arc's sidebar, relying on a Space layout, scripting the command bar — they die with the product. If they drive the &lt;em&gt;engine&lt;/em&gt; — Playwright or Puppeteer against Chromium, the Chrome DevTools Protocol (CDP), standard DOM and network APIs — they survive, because the engine lives on in Chrome, Edge, Brave and every other Chromium build.&lt;/p&gt;

&lt;p&gt;The practical rule we follow, from our &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; philosophy of automating aggressively but keeping the blast radius small:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never automate a browser brand. Automate a rendering engine plus a version.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Playwright, that means launching by channel or executable path, never by "whatever browser the operator has installed":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pin the engine, not the brand.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chrome&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// or executablePath to a vendored build&lt;/span&gt;
  &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--no-sandbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--disable-dev-shm-usage&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36&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;And in CI, vendor the browser build instead of downloading "latest" on every run:&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;# Pin it in the lockfile era: the browser version is a dependency.&lt;/span&gt;
npx playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium &lt;span class="nt"&gt;--with-deps&lt;/span&gt;
npx playwright &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# record this in the run log, every run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A branded browser is a UI. An engine version is a contract. When the brand dies, the contract transfers to the next Chromium build with, in our experience, only minor breakage — usually a changed CDP method or a new default header, not a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Sessions are the real migration, not the browser
&lt;/h2&gt;

&lt;p&gt;Ask any Arc user what they will miss and they will not say "the rendering engine." They will say their Spaces, their pinned tabs, their logged-in sessions. For automation, the equivalent is the authenticated profile: cookies, localStorage, session tokens, extension state. &lt;strong&gt;Losing the browser is annoying; losing the sessions is expensive&lt;/strong&gt;, because re-authenticating against dozens of sites means CAPTCHAs, 2FA prompts and rate limits — the exact things automation exists to avoid.&lt;/p&gt;

&lt;p&gt;Treat session state as portable data, from day one, not as something that lives inside one browser's profile folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Export: after a successful login, persist the whole context state.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;storageState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sessions/client-a.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Import: on any Chromium build, restore it and verify before doing work.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;storageState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sessions/client-a.json&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example-app.com/dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="user-menu"]&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules make this work in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Export on success, not on schedule.&lt;/strong&gt; The storage-state file should be rewritten every time a login flow completes cleanly, so the newest file is never more than one successful run old.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify after import, before work.&lt;/strong&gt; A restored session that silently expired will produce a login page instead of a dashboard, and a script that does not check will happily scrape the login page and record garbage. The selector check above is the cheapest guard you can write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep sessions out of the browser's own sync.&lt;/strong&gt; Arc's sync dying on November 23 — a month before the browser itself — is the cautionary tale. If your sessions only exist inside a vendor's sync cloud, the vendor's timeline becomes your timeline. Files on disk, encrypted at rest, backed up like any other credential store, are yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One more migration detail Arc users discovered the hard way: &lt;strong&gt;extensions do not always transfer cleanly.&lt;/strong&gt; Extension storage, extension settings and extension-granted permissions are per-browser state. If a workflow depends on an extension (a scraper helper, a session manager, a form filler), test it on the target browser before migration day, not after. Extensions are the most common "it worked in Arc, why is it broken in Chrome?" surprise, because nothing in the export step warns you.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The fingerprint changes when the browser changes
&lt;/h2&gt;

&lt;p&gt;Our Shopee research showed how aggressively the web fingerprints browsers: TLS fingerprints (JA3/JA4), header order, &lt;code&gt;navigator&lt;/code&gt; properties, canvas and font signals. Switching browsers changes your fingerprint, and fingerprint changes trip bot detection, fraud checks and "unusual sign-in" flows.&lt;/p&gt;

&lt;p&gt;A migration checklist for the fingerprint-sensitive parts of a pipeline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What changes on browser switch&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TLS fingerprint (JA3/JA4)&lt;/td&gt;
&lt;td&gt;Chromium version and build change the ClientHello&lt;/td&gt;
&lt;td&gt;Keep the Chromium major version close to the old one; test against the target's bot checks before cutover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User-Agent and &lt;code&gt;navigator&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Brand tokens and version strings change&lt;/td&gt;
&lt;td&gt;Set an explicit, consistent UA per context; never let it default to whatever the new install reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header order&lt;/td&gt;
&lt;td&gt;Minor differences between builds&lt;/td&gt;
&lt;td&gt;Drive through one automation library so headers are generated identically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookies / tokens&lt;/td&gt;
&lt;td&gt;Session state may not transfer&lt;/td&gt;
&lt;td&gt;Export storage state (section 3) and re-verify logins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP / network&lt;/td&gt;
&lt;td&gt;Nothing, if the machine is unchanged&lt;/td&gt;
&lt;td&gt;Keep the egress IP stable during migration week so "new browser + new IP" does not stack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key insight: &lt;strong&gt;migrate one variable at a time.&lt;/strong&gt; New browser on the same machine, same IP, same Chromium major version, same UA string — then change the other variables later, once the new baseline is proven. A migration that changes the browser, the machine and the network on the same day is un-debuggable when something breaks, and something always breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Maintenance mode is the real sunset announcement
&lt;/h2&gt;

&lt;p&gt;Arc's timeline teaches a monitoring lesson. The official sunset came in October 2026, but the &lt;em&gt;actionable&lt;/em&gt; signal came in 2025, when new-feature work stopped. For automation builders, the equivalent signals are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The vendor stops shipping features&lt;/strong&gt; for the browser, the automation library, or the extension you depend on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release cadence slows&lt;/strong&gt; or security patches arrive late.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs stop being updated&lt;/strong&gt; — new APIs documented for the successor, not the product you use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The community migrates&lt;/strong&gt; — Stack Overflow answers, GitHub issues and blog posts start assuming the successor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build a lightweight watch for the browsers and libraries in your stack: a monthly check of release notes and a quarterly "is this still maintained?" review. The cost is an hour a quarter. The cost of missing the signal is a forced migration under a vendor's deadline, which is how you end up re-authenticating forty accounts in a week.&lt;/p&gt;

&lt;p&gt;The same logic applies one level down, to the automation libraries themselves. Playwright, Puppeteer and Selenium all track Chromium's release train; a CDP method deprecated in one Chromium release disappears a few releases later. Pin versions, read the changelog before upgrading, and run your suite against a canary build in a separate job so a breaking change shows up as a canary failure, not a production outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A shutdown-proof automation checklist
&lt;/h2&gt;

&lt;p&gt;Here is the checklist we now apply to every browser-based workflow, and the one we would hand to anyone migrating off Arc this month:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Engine abstraction.&lt;/strong&gt; All flows go through Playwright/Puppeteer APIs against a pinned Chromium version. No product-specific UI scripting, no reliance on a branded browser's chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendored browser builds.&lt;/strong&gt; The browser binary is installed from a pinned version in CI, and its version is logged on every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portable sessions.&lt;/strong&gt; Storage state exported on every successful login, encrypted at rest, restorable on any Chromium build, verified before use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extension inventory.&lt;/strong&gt; Every extension a workflow needs is listed with its store ID and version, and tested on the target browser before cutover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fingerprint baseline.&lt;/strong&gt; UA, headers and TLS behaviour recorded for the old setup; the new setup is diffed against it before it takes production traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matrix testing.&lt;/strong&gt; The suite runs against at least two Chromium builds (stable + previous) so a single build's regression never blocks the pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human gate on migration.&lt;/strong&gt; Moving sessions and credentials between browsers is a privileged operation. A human approves it, the way our dark-factories piece argues for checkpoints anywhere a machine's mistake is expensive. Automation moves the data; a person authorizes the move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EOL watch.&lt;/strong&gt; A quarterly review of every browser, library and extension in the stack, asking one question: is this still maintained, and what is the exit plan if it is not?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It is the boring infrastructure that makes the difference between "Arc died and we migrated in a week" and "Arc died and our pipelines are down." The teams that survive a browser shutdown are not the ones with the cleverest scripts; they are the ones whose scripts never depended on the browser that died.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What to do this week if you are an Arc user
&lt;/h2&gt;

&lt;p&gt;If you personally run Arc, the vendor's own path is straightforward: export your data with the final update, import into Dia (or Chrome, or any Chromium browser), and check that passwords, bookmarks and extensions arrived intact before December 22. Do the export early — sync-dependent data has the November 23 deadline, not the December one.&lt;/p&gt;

&lt;p&gt;If you run automation on Arc — and some teams genuinely did, because Arc's profile model suited multi-client work — do not migrate the browser first. Migrate the &lt;em&gt;sessions and the scripts&lt;/em&gt; first: export storage state, re-point the launcher at a pinned Chromium build, run the suite against the new target, and only then decommission the Arc profile. The browser is the last thing you change, not the first.&lt;/p&gt;

&lt;p&gt;Arc deserved its cult following. It genuinely rethought the browser's interface, and its ideas — vertical tabs, Spaces, a command bar — have already spread to other browsers. But interfaces are not infrastructure. Browsers, like every other dependency, have lifecycles. Build for the lifecycle, and the next shutdown announcement will be a migration ticket, not an emergency.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Arc really shutting down, or just stopping development?&lt;/strong&gt;&lt;br&gt;
It is shutting down. The Browser Company announced on October 22, 2026 that Arc on Mac and Windows stops working on December 22, 2026, with accounts deactivated and data deleted within 30 days. Development had already stopped in 2025; this is the end of the product, not a pause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Dia replace Arc for automation?&lt;/strong&gt;&lt;br&gt;
Dia is the vendor's recommended successor and imports Arc data, but it is a consumer browser with an AI layer, not an automation target. Automate against a pinned Chromium build through Playwright or Puppeteer, not against Dia's UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will my extensions and logins survive a move to Chrome?&lt;/strong&gt;&lt;br&gt;
Extensions install from the Chrome Web Store on any Chromium browser, but their stored settings may not transfer — test them. Logins transfer only if you export session state or re-authenticate; a browser's own sync is not a migration plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the single most important migration step?&lt;/strong&gt;&lt;br&gt;
Export and verify your session state before the sync deadline. Everything else — scripts, fingerprints, extensions — can be fixed after cutover. Lost sessions mean re-authentication, and re-authentication at scale is where migrations actually fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Browser Company, Arc sunset announcement (October 22, 2026), via TechCrunch: &lt;a href="https://techcrunch.com/2026/10/22/arc-browser-sunsets-december-22-as-the-browser-company-doubles-down-on-dia/" rel="noopener noreferrer"&gt;https://techcrunch.com/2026/10/22/arc-browser-sunsets-december-22-as-the-browser-company-doubles-down-on-dia/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The Register, "Arc browser on its way out as makers suggest an alternative" (October 23, 2026): &lt;a href="https://www.theregister.com/software/2026/10/23/arc-browser-on-its-way-out-as-makers-suggest-an-alternative/5305114" rel="noopener noreferrer"&gt;https://www.theregister.com/software/2026/10/23/arc-browser-on-its-way-out-as-makers-suggest-an-alternative/5305114&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Playwright documentation, Browser launch options and storage state: &lt;a href="https://playwright.dev/docs/api/class-browser" rel="noopener noreferrer"&gt;https://playwright.dev/docs/api/class-browser&lt;/a&gt; and &lt;a href="https://playwright.dev/docs/auth" rel="noopener noreferrer"&gt;https://playwright.dev/docs/auth&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Chrome DevTools Protocol documentation: &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;https://chromedevtools.github.io/devtools-protocol/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Shopee Security Headers: TLS, Tokens &amp;amp; Bot Detection": &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "Dark Software Factories: What Runs Lights-Out and What Breaks Without a Human in the Loop": &lt;a href="https://botslave.com/blog/dark-software-factories-lights-out" rel="noopener noreferrer"&gt;https://botslave.com/blog/dark-software-factories-lights-out&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave, "How we work": &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;https://botslave.com/how-we-work&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/arc-shutdown-browser-automation-lessons" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>automation</category>
      <category>tutorial</category>
      <category>devtools</category>
    </item>
    <item>
      <title>CVE Remediation Agents: What Goes in the Context Pack Before a Machine Fixes 47 CVEs</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Sat, 24 Oct 2026 02:03:57 +0000</pubDate>
      <link>https://dev.to/botslavehq/cve-remediation-agents-what-goes-in-the-context-pack-before-a-machine-fixes-47-cves-116k</link>
      <guid>https://dev.to/botslavehq/cve-remediation-agents-what-goes-in-the-context-pack-before-a-machine-fixes-47-cves-116k</guid>
      <description>&lt;p&gt;&lt;strong&gt;The second most-read story on daily.dev right now is a claim that stops most engineers mid-scroll: an AI agent was given the right context and fixed 47 CVEs across PHP, Node.js, Python and Go — 127 upvotes, 1.2M views, and the hottest security-and-automation conversation in the feed.&lt;/strong&gt; It sits above stories about Arc shutting down and the terminal-as-teacher essay, and right next to a PHP2Shell patch guide that is itself still trending. The question the headline provokes is the wrong one. Nobody serious doubts an agent can rewrite a &lt;code&gt;package.json&lt;/code&gt; version pin. The interesting question is what "context" meant in that sentence: what the agent was allowed to see, what it was not allowed to decide, and — critically — what verified that the 47 fixes were real and not 47 new problems.&lt;/p&gt;

&lt;p&gt;We run automation that touches production systems: emergency patch rollouts across fleets, agent-driven workflows, unattended pipelines. Our &lt;a href="https://botslave.com/blog/php2shell-patch-operations-playbook" rel="noopener noreferrer"&gt;PHP2Shell patch-operations playbook&lt;/a&gt; covered the &lt;em&gt;execution&lt;/em&gt; side — how you roll a fix across many machines safely. This piece is about the &lt;em&gt;input&lt;/em&gt; side, which is where agent remediation actually lives or dies: assembling the context pack an agent needs to remediate vulnerabilities without supervision, and the verification loop that makes the fixes trustworthy. It follows the same philosophy as &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; — automate aggressively, and spend human judgment where it changes the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The bottleneck is context, not capability
&lt;/h2&gt;

&lt;p&gt;Give a modern coding agent a CVE number and repository access and it will almost always &lt;em&gt;do something&lt;/em&gt;. That is the problem. Left to its own devices, an agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;upgrades the wrong thing (bumps a transitive dependency's &lt;em&gt;direct&lt;/em&gt; cousin, or "fixes" a devDependency that never ships),&lt;/li&gt;
&lt;li&gt;reads the advisory title but not the &lt;em&gt;affected versions&lt;/em&gt; field, patching a version range that does not include your pinned one,&lt;/li&gt;
&lt;li&gt;applies the upstream fix commit to a codebase that has diverged six months past the tagged release,&lt;/li&gt;
&lt;li&gt;or "fixes" the CVE by deleting the code path — technically eliminating the vulnerability, practically deleting a feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these failures is a context failure, not a capability failure. The model can reason about the fix; what it cannot do is conjure the four facts that determine whether a fix is correct: &lt;strong&gt;what exactly is installed, what is actually reachable, what the fix touches, and how you will know it worked.&lt;/strong&gt; A remediation pipeline that works treats those four facts as a data-assembly problem, not a prompting problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The context pack: what an agent needs before it touches anything
&lt;/h2&gt;

&lt;p&gt;Before any agent session begins, build a single document — the context pack — with these sections. We generate ours from tooling, never by hand, because hand-written context rots within a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.1 Precise inventory.&lt;/strong&gt; Not "we use Django" — the exact resolved dependency tree with versions, drawn from the lockfile (&lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, &lt;code&gt;go.sum&lt;/code&gt;, &lt;code&gt;composer.lock&lt;/code&gt;), plus the runtime inventory: which interpreters and system packages are actually deployed. Lockfile versions and deployed versions disagree more often than anyone admits; the deployed one is what matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.2 The advisory, normalized.&lt;/strong&gt; For each CVE: the affected product, the affected version ranges in machine-readable form, the weakness type (CWE), the attack vector and required privileges. Sources: the &lt;a href="https://nvd.nist.gov/" rel="noopener noreferrer"&gt;NVD&lt;/a&gt;, the &lt;a href="https://github.com/advisories" rel="noopener noreferrer"&gt;GitHub Advisory Database&lt;/a&gt;, and &lt;a href="https://osv.dev/" rel="noopener noreferrer"&gt;osv.dev&lt;/a&gt;, which exposes a clean JSON API for exactly this and is the easiest source to script against. Normalize into one schema; every source formats ranges slightly differently, and an agent reading raw advisory prose will mis-parse version ranges about as often as a tired human.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.3 Reachability analysis.&lt;/strong&gt; This is the section most pipelines skip and the one that matters most. A vulnerable function that is never called, in a module that is never imported, in a container that never runs, is not your Tuesday emergency. Compute: does any code path you ship reach the vulnerable symbol? Tools like &lt;code&gt;npm ls&lt;/code&gt;, &lt;code&gt;pipdeptree&lt;/code&gt;, &lt;code&gt;go mod why&lt;/code&gt;, and call-graph analysis get you most of the way. Mark each finding &lt;strong&gt;reachable / unreachable / unknown&lt;/strong&gt; — and treat "unknown" as reachable until proven otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.4 The fix options and their blast radius.&lt;/strong&gt; For each finding, enumerate the actual options: upgrade to a patched release, backport the upstream patch, apply a configuration mitigation (disable the feature, tighten the firewall rule), or accept the risk with a documented reason. For each option, list what else it touches: breaking changes in the target release, config format changes, migrations. The agent's job is to pick among enumerated options with stated trade-offs, not to invent options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.5 Policy and provenance.&lt;/strong&gt; Who owns this service. Whether it is internet-facing. What data it touches. And a provenance rule borrowed from our agent-git workflow: every agent commit must be attributable and revertible — see our &lt;a href="https://botslave.com/blog/git-for-ai-agent-teams" rel="noopener noreferrer"&gt;Git for AI agent teams&lt;/a&gt; piece for the full discipline. The agent needs to know the blast radius &lt;em&gt;of its own work&lt;/em&gt;, and the humans need to audit it later.&lt;/p&gt;

&lt;p&gt;A context pack is typically 200–600 lines of structured data for a medium service. That sounds like a lot to assemble; it is the entire job. Everything after this is comparatively mechanical.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Triage before fixing: CVSS scores lie, exploitability decides
&lt;/h2&gt;

&lt;p&gt;The original story says "47 CVEs." A raw count of CVEs is the least informative number in vulnerability management. Here is the triage order that actually works, and the order you should encode in the agent's instructions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Known-exploited first.&lt;/strong&gt; If the CVE is in CISA's &lt;a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" rel="noopener noreferrer"&gt;Known Exploited Vulnerabilities catalog&lt;/a&gt;, it jumps the queue — exploitation in the wild is the only signal that beats theory.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reachable + network-exposed second.&lt;/strong&gt; Reachable vulnerable code on an internet-facing service.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reachable + internal third.&lt;/strong&gt; Then unreachable, then dev-only.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two signals sharpen this beyond CVSS, which measures severity in a vacuum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.first.org/epss/" rel="noopener noreferrer"&gt;EPSS&lt;/a&gt;&lt;/strong&gt; (Exploit Prediction Scoring System) estimates the probability of exploitation in the next 30 days. A CVSS 9.8 with EPSS 0.01 is a different animal from a CVSS 7.5 with EPSS 0.6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attack complexity and privileges required&lt;/strong&gt;, from the CVSS vector string itself — an agent can read &lt;code&gt;AV:N/PR:N/UI:N&lt;/code&gt; and correctly conclude "no user interaction, no privileges, network vector: worst case."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule we give agents: &lt;em&gt;fix in exploitability order, verify in the same order, and say out loud which findings you are deferring and why.&lt;/em&gt; An agent that silently fixes 47 low-risk findings while the one KEV-listed RCE waits is worse than an agent that fixed nothing — it manufactured confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The fix loop: reproduce → patch → retest → regress
&lt;/h2&gt;

&lt;p&gt;A remediation without a verification step is a rumor. Every fix the agent makes must pass this loop, and the loop — not the agent's self-assessment — is what declares a CVE fixed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reproduce (or prove unreproducible).&lt;/strong&gt; Where feasible, demonstrate the vulnerable behavior first: a failing security test, a proof-of-concept input, or at minimum the scanner output showing the finding. This is the "before" snapshot.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apply the minimal fix.&lt;/strong&gt; Prefer the vendor's patched release over hand-rolled patches. Prefer the smallest version bump that clears the affected range — jumping three major versions to fix a CVE trades one risk for a regression lottery.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Retest the finding.&lt;/strong&gt; Re-run the scanner or the security test. The finding must be gone &lt;em&gt;for the right reason&lt;/em&gt; — the version is out of the affected range, not because the scanner config changed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Regression-test the neighborhood.&lt;/strong&gt; Run the service's own test suite, at minimum the tests covering the changed dependency's integration points. A fix that breaks checkout is not a fix.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Diff review.&lt;/strong&gt; A human or a strict policy gate reviews the final diff: dependency changes only, no drive-by refactors, lockfile consistent with the manifest.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the human gate, and it stays human. Which brings us to what the agent never decides alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What we never let the agent decide alone
&lt;/h2&gt;

&lt;p&gt;Some decisions carry blast radius that no context pack can fully capture. Our hard rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network-exposed services get human sign-off on the diff.&lt;/strong&gt; The agent can prepare everything; a person approves the merge. The cost of a 30-second review is nothing next to a bad deploy on the front door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data-bearing changes are human decisions.&lt;/strong&gt; Anything touching authentication, encryption, PII handling, or payment code: the agent proposes, a human disposes. This is the same station-gating logic from our &lt;a href="https://botslave.com/blog/dark-software-factories-lights-out" rel="noopener noreferrer"&gt;dark software factories&lt;/a&gt; analysis — the factory runs dark at the safe stations and has lights at exactly the stations where darkness gets expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Risk acceptances are human, written, and expiring.&lt;/strong&gt; "We accept this CVE because the code path is unreachable" is a fine decision — made by a person, recorded with the evidence, with an expiry date. Agents do not get to accept risk; they get to gather the evidence for the acceptance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback must exist before rollout.&lt;/strong&gt; Same rule as the PHP2Shell playbook: no fix ships without a tested path back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice the pattern: the agent does the &lt;em&gt;doing&lt;/em&gt; (triage, patching, testing) and separate mechanisms — scanners, test suites, human gates — do the &lt;em&gt;verifying&lt;/em&gt;. The moment the doer and the verifier are the same mind, you have optimism, not remediation.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Failure modes we have actually seen
&lt;/h2&gt;

&lt;p&gt;A short catalog, because knowing the failure modes is half the defense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context bloat.&lt;/strong&gt; Feeding the agent the entire advisory database "just in case." Past a point, more context degrades decisions — the agent starts pattern-matching to the wrong CVE. Curate: only the findings that affect this service, only the fields in section 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hallucinated versions.&lt;/strong&gt; The agent asserts a patched release exists that does not, or confuses the fixed version across ecosystems ("lodash 4.17.21" is real; the agent's memory of which release fixed what is not). Ground every version claim in a live registry or advisory lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The silent no-fix.&lt;/strong&gt; The scanner goes green because the agent pinned the dependency to a version &lt;em&gt;outside the scanner's database&lt;/em&gt; or excluded the path from scanning. The retest step (section 4.3) must check &lt;em&gt;why&lt;/em&gt; the finding disappeared.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supply-chain whack-a-mole.&lt;/strong&gt; Fixing a direct dependency while the same vulnerable code arrives via a transitive path. The inventory in section 2.1 must be the full resolved tree, and the retest must scan the tree, not the manifest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing the scanner's opinion, not the vulnerability.&lt;/strong&gt; Upgrading past the affected range per the advisory while the actual vulnerable code was vendored/copied into the repo years ago. Reachability analysis on vendored code is manual work — flag it as &lt;code&gt;unknown&lt;/code&gt; and let a human look.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. A minimal pipeline you can build this week
&lt;/h2&gt;

&lt;p&gt;You do not need a platform. The smallest useful version is two scripts and a policy file:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Script 1 — context pack builder.&lt;/strong&gt; Walks the lockfile, queries the OSV API for each pinned version, joins the results with a simple reachability check (import graph for Python, &lt;code&gt;npm ls&lt;/code&gt; for Node), and emits the context pack as JSON. The OSV API is public, unauthenticated, and takes batch queries — this is an afternoon's work, and it is the highest-leverage afternoon in the whole pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Script 2 — verification runner.&lt;/strong&gt; Takes a list of finding IDs, re-runs the scanner, runs the affected service's test subset, and reports pass/fail per finding with the reason. This script is the verifier; it must be deterministic and boring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policy file — the agent's standing orders.&lt;/strong&gt; Triage order (KEV → reachable/exposed → reachable/internal → rest), the minimal-fix preference, the mandatory retest loop, and the list of decisions that require a human. This file is short and changes rarely; it is the constitution the agent operates under.&lt;/p&gt;

&lt;p&gt;Run it on one service first. Measure two numbers: median time from advisory to verified fix, and the false-fix rate (fixes that had to be reverted or re-done). If the second number is not near zero, your context pack is incomplete — go back to section 2, not to a bigger model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does the agent need the full CVE database in context?&lt;/strong&gt;&lt;br&gt;
No. It needs the findings that affect its service, normalized, with reachability attached. The full database is a lookup tool the agent queries, not context it carries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CVSS, EPSS, KEV — which one do I sort by?&lt;/strong&gt;&lt;br&gt;
KEV membership first (exploited in the wild beats everything), then EPSS-weighted reachability for the rest. CVSS alone is severity without likelihood; it is the weakest of the three for prioritization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can an agent really do the reachability analysis?&lt;/strong&gt;&lt;br&gt;
Partially. Import graphs and dependency trees are automatable and reliable. Full call-graph reachability across dynamic languages has false negatives — mark those &lt;code&gt;unknown&lt;/code&gt;, treat as reachable, and let the human gate handle them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the single most common mistake?&lt;/strong&gt;&lt;br&gt;
Skipping the "why did the finding disappear" check. A green scanner is not a fixed vulnerability; it is an absence of a signal, and absences have causes. Verify the cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is this different from just running Dependabot?&lt;/strong&gt;&lt;br&gt;
Dependabot opens version-bump PRs — valuable, but it does not do reachability triage, does not weigh EPSS, does not verify beyond CI, and does not explain deferrals. The context-pack pipeline is the judgment layer around the same mechanical upgrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;OSV — distributed vulnerability database and API: &lt;a href="https://osv.dev/" rel="noopener noreferrer"&gt;https://osv.dev/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CISA Known Exploited Vulnerabilities Catalog: &lt;a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" rel="noopener noreferrer"&gt;https://www.cisa.gov/known-exploited-vulnerabilities-catalog&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FIRST EPSS (Exploit Prediction Scoring System): &lt;a href="https://www.first.org/epss/" rel="noopener noreferrer"&gt;https://www.first.org/epss/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NVD — National Vulnerability Database: &lt;a href="https://nvd.nist.gov/" rel="noopener noreferrer"&gt;https://nvd.nist.gov/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Advisory Database: &lt;a href="https://github.com/advisories" rel="noopener noreferrer"&gt;https://github.com/advisories&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;daily.dev Popular feed (trend signal for this piece): &lt;a href="https://daily.dev/posts?sortBy=popular" rel="noopener noreferrer"&gt;https://daily.dev/posts?sortBy=popular&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/cve-remediation-agent-context-pack" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>security</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>LeadOps Autopilot: Sourcing and Scoring US Clinic Leads from Public Data</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:53:03 +0000</pubDate>
      <link>https://dev.to/botslavehq/leadops-autopilot-sourcing-and-scoring-us-clinic-leads-from-public-data-1481</link>
      <guid>https://dev.to/botslavehq/leadops-autopilot-sourcing-and-scoring-us-clinic-leads-from-public-data-1481</guid>
      <description>&lt;p&gt;&lt;strong&gt;Most lead lists are bought, stale, and sprayed. Ours is none of the three — and the interesting part is not the scraping. It is everything the pipeline refuses to do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LeadOps Autopilot is the outbound system we built at Botslave to find US clinics that plausibly need lead-intake and follow-up automation — dental practices, med spas, physical therapy, mental health, urgent care — and to prepare outreach that a human approves before anything is sent. This note documents how it actually works: where the data comes from, how a lead earns its score, and why the pipeline deliberately stops one step short of sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with clinic lead data
&lt;/h2&gt;

&lt;p&gt;Clinics are a hard segment to source well. Directory data is fragmented across official registries, map platforms and review sites, and it decays fast: practices move, rebrand, change booking systems. A purchased list tells you a clinic &lt;em&gt;exists&lt;/em&gt;. It tells you nothing about whether that clinic has the operational shape your offer fits — a working website, an online booking path, signs of inbound volume, a contact channel a human actually reads.&lt;/p&gt;

&lt;p&gt;So instead of buying conclusions, the pipeline collects raw public evidence and scores it. The score is a hypothesis with receipts: every lead carries up to eight recorded reasons, so a reviewer can see &lt;em&gt;why&lt;/em&gt; the machine thinks this clinic fits before approving any contact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: Collect from sources that are allowed to be boring
&lt;/h2&gt;

&lt;p&gt;Two collectors feed the pipeline, chosen for provenance rather than volume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CMS/NPPES.&lt;/strong&gt; The US National Plan and Provider Enumeration System is the official registry of healthcare providers. &lt;code&gt;collect_nppes.py&lt;/code&gt; pulls clinic organizations by state, city and taxonomy — slow, structured, and authoritative about existence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenStreetMap via the Overpass API.&lt;/strong&gt; &lt;code&gt;collect_osm_overpass.py&lt;/code&gt; discovers clinics geographically at zero data cost. Map data is uneven — excellent in some metros, thin in others — which is exactly why it is a discovery layer, not a source of truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An enrichment pass (&lt;code&gt;enrich_websites_google_places.py&lt;/code&gt;) then resolves or confirms each clinic's website, because the website is where the scoring evidence lives. A clinic without a findable website is out of scope for this offer by definition — the README's avoid-list also excludes hospitals and large health systems, whose procurement cycles make them a poor fit, and anything that would require collecting patient health information. That last line is architectural, not decorative: the pipeline handles business contact data only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: Read the homepage, not the listing
&lt;/h2&gt;

&lt;p&gt;The scorer (&lt;code&gt;leadops.py&lt;/code&gt;) fetches each clinic's homepage and scores what it finds there. The model is intentionally simple enough to argue with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every lead starts from a &lt;strong&gt;base score of 35&lt;/strong&gt; — existence is worth something, not much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fit signals found on the site add points&lt;/strong&gt;: evidence that the clinic takes its inbound flow seriously (or visibly struggles with it) moves the score up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative signals subtract&lt;/strong&gt; (a flat −8 penalty in the current rules), and a homepage that cannot be fetched at all costs the lead &lt;strong&gt;35 points&lt;/strong&gt; — if we cannot see your front door, we do not get to pitch you a better one.&lt;/li&gt;
&lt;li&gt;Scores are clamped to 0–100 and mapped to &lt;strong&gt;fit tiers at 75, 58 and 45&lt;/strong&gt;, so review batches sort themselves: work the top tier, sample the middle, ignore the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alongside the score, the pipeline generates a &lt;em&gt;pain hypothesis&lt;/em&gt; from the recorded reasons — a one-line theory of what this clinic's intake problem probably is — and drafts outreach copy from it. The hypothesis is labeled as a hypothesis everywhere it appears. That discipline matters more than the model: a scorer that shows its reasons can be corrected; a black-box score just gets believed.&lt;/p&gt;

&lt;p&gt;Segmentation runs in parallel (&lt;code&gt;segments.py&lt;/code&gt;): dental, physical therapy and the other verticals each carry their own keyword rules, so batches, scoring context and copy stay separated by vertical instead of dissolving into one generic blast list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: The gate — where the pipeline stops on purpose
&lt;/h2&gt;

&lt;p&gt;Here is the design decision people question most: &lt;strong&gt;the MVP stops before sending.&lt;/strong&gt; Its output is a reviewed CSV — leads, scores, reasons, segments, draft messages — and a human approves the high-fit rows before any contact happens. Later stages exist in the codebase (CRM preparation, a sender pool, reply processing), but they sit &lt;em&gt;behind&lt;/em&gt; that approval, not instead of it.&lt;/p&gt;

&lt;p&gt;This is the same principle as &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; on client systems: automate the routine aggressively, and spend human judgment exactly where it changes the outcome. In outbound, the judgment step is "should this business hear from us at all?" Getting that wrong at scale is how domains get burned and studios get a reputation. The 60-second promise LeadOps makes to clinics — &lt;em&gt;every inbound inquiry becomes a qualified record, an assigned owner and a follow-up task within 60 seconds&lt;/em&gt; — only works as a pitch if our own outbound shows the same discipline we are selling.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provenance per lead.&lt;/strong&gt; Source, score, reasons and segment travel with the record into the CRM, so six months later you can still answer "why did we contact them?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap iteration.&lt;/strong&gt; Scoring rules are plain keyword-and-signal logic in one file. When a vertical underperforms, you read the reasons on the rejected leads and adjust the rule — no model retraining, no vendor ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance by construction.&lt;/strong&gt; Public business data only, no patient information anywhere in the pipeline, human approval before contact. The constraints were chosen at design time, when they are cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure is visible.&lt;/strong&gt; An unscored or unreachable lead is a row with a low score and a reason, not a silent gap in a purchased spreadsheet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it goes next
&lt;/h2&gt;

&lt;p&gt;The honest limits: OpenStreetMap coverage varies by city, homepage signals are a proxy for operational pain (a beautiful site can hide a chaotic front desk), and a human gate caps throughput by design. We accept all three. The next build-notes entries in this series go deeper on the scoring model itself and on what the approval gate has caught that automation would have sent.&lt;/p&gt;

&lt;p&gt;If you run a clinic group — or any business whose inbound inquiries leak between the website and the follow-up — this is the class of system we build. See &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt;, or read the companion piece on &lt;a href="https://botslave.com/blog/email-validation-mx-catch-all-smtp-probing" rel="noopener noreferrer"&gt;validating email addresses without sending mail&lt;/a&gt;, which covers the verification layer that contact data passes through before it is trusted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why not just buy a clinic list?&lt;/strong&gt;&lt;br&gt;
Because the list answers the wrong question. Existence is cheap; fit is the scarce signal, and fit lives on the clinic's own website, not in the directory row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is OpenStreetMap data reliable enough for this?&lt;/strong&gt;&lt;br&gt;
As a discovery layer, yes — with an authoritative registry (NPPES) alongside it and a scoring stage that penalizes what it cannot verify. No single source is trusted alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the system send the outreach itself?&lt;/strong&gt;&lt;br&gt;
The pipeline prepares scored leads and draft messages; a human approves before contact. Sending mechanics exist behind that gate, by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What data does it collect?&lt;/strong&gt;&lt;br&gt;
Public business information: practice names, websites, published contact points, and the evidence used for scoring. No patient health information is collected or processed.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NPPES (National Plan and Provider Enumeration System): &lt;a href="https://nppes.cms.hhs.gov" rel="noopener noreferrer"&gt;https://nppes.cms.hhs.gov&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenStreetMap Overpass API: &lt;a href="https://overpass-api.de" rel="noopener noreferrer"&gt;https://overpass-api.de&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Places API: &lt;a href="https://developers.google.com/maps/documentation/places/web-service" rel="noopener noreferrer"&gt;https://developers.google.com/maps/documentation/places/web-service&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave — How we work: &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;https://botslave.com/how-we-work&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave — Services: &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;https://botslave.com/services&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/leadops-autopilot-clinic-lead-scoring" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Dark Software Factories: What Runs Lights-Out and What Breaks Without a Human in the Loop</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:32:37 +0000</pubDate>
      <link>https://dev.to/botslavehq/dark-software-factories-what-runs-lights-out-and-what-breaks-without-a-human-in-the-loop-c6n</link>
      <guid>https://dev.to/botslavehq/dark-software-factories-what-runs-lights-out-and-what-breaks-without-a-human-in-the-loop-c6n</guid>
      <description>&lt;p&gt;&lt;strong&gt;The tenth most-read story on daily.dev today is a question about the industry's newest industrial metaphor: are "dark software factories" the next big thing?&lt;/strong&gt; The piece frames a future where AI agents write, test, deploy and monitor software with nobody watching — software's version of a lights-out factory. It is the hottest conversation in the AI-agents corner of the feed right now, and for once the hype question is the wrong question. The useful question is not "can agents run a factory" — it is &lt;em&gt;which stations on the line can actually run dark, and which ones need a human standing at the gate&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;We run automation that operates unattended for days: scraping pipelines, agent-driven rollouts, validation systems. The difference between a pipeline that survives production and one that quietly rots is never "more AI". It is the placement of the humans. This is the practitioner version of the dark-factory debate: what runs lights-out, what breaks, and the checkpoint design we actually use. It follows the same philosophy as &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt; — automate the routine aggressively, and spend human judgment exactly where it changes the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The borrowed term, and what the borrowing gets wrong
&lt;/h2&gt;

&lt;p&gt;A "dark factory" is a manufacturing concept: a plant that runs without human presence, lights out, because the work is standardized enough for machines. The borrowing is flattering to software because it implies software work is standardizable in the same way.&lt;/p&gt;

&lt;p&gt;It is not. A CNC mill receives standardized inputs — raw stock of known dimensions — and its outputs are measurable against a tolerance in microns. Failure modes are bounded and enumerated in advance. Software work has none of those properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inputs are adversarial and non-standard.&lt;/strong&gt; Requirements arrive as prose. Dependencies update themselves. Third-party targets change their headers, rotate their fingerprints, and deploy new bot detection on a Tuesday (we have the scars: our &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;Shopee security research&lt;/a&gt; documented a full anti-bot stack that shifts under you).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outputs are hard to verify.&lt;/strong&gt; A mill measures the part; an agent ships a diff that "passes CI" while subtly changing behavior. Verification in software is usually another program with its own blind spots — not a caliper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The factory floor fights back.&lt;/strong&gt; Manufacturing robots do not face an adversary. Scraping, deliverability, fraud detection and security do. An unattended pipeline in an adversarial environment does not degrade gracefully; it degrades silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means lights-out is a fantasy. It means "dark" is not a property of the factory — it is a property of each &lt;em&gt;station&lt;/em&gt;. Some stations are dark-ready. Others break specifically because nobody is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What actually runs lights-out today (and why)
&lt;/h2&gt;

&lt;p&gt;Look at the automation that genuinely runs unattended in production, and a pattern emerges. A station can go dark when it satisfies three properties at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bounded inputs.&lt;/strong&gt; The agent's decision space is finite and enumerable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap, independent verification.&lt;/strong&gt; You can check the result without the same judgment that produced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversible failure.&lt;/strong&gt; When it goes wrong, you can roll back and the world is as it was.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Concrete stations that satisfy all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic transforms.&lt;/strong&gt; Formatting, linting, code generation from schemas (OpenAPI → clients, protobuf → stubs). The input is a spec; the verifier is a compiler or a diff against the previous generation. This is dark today and has been for years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency-bump proposals.&lt;/strong&gt; An agent opens the pull request bumping a library; the gate is the existing test suite plus a policy check on the version range. The agent never merges — the gate decides. Reversible by revert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metric-gated rollouts.&lt;/strong&gt; The deployment pipeline from our &lt;a href="https://botslave.com/blog/php2shell-patch-operations-playbook" rel="noopener noreferrer"&gt;PHP2Shell patch playbook&lt;/a&gt;: canary, health signals, automatic rollback. The rollout &lt;em&gt;executes&lt;/em&gt; unattended; the &lt;em&gt;decision to continue&lt;/em&gt; is a verification function with thresholds, not a person. When the threshold trips, a human is paged — the factory has lights at the exact station where darkness becomes expensive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice the shape: in every genuinely dark station, an agent does the &lt;em&gt;doing&lt;/em&gt; and a separate, dumber mechanism does the &lt;em&gt;verifying&lt;/em&gt;. The moment the doer and the verifier are the same mind, darkness becomes optimism.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The failure taxonomy: where lights-out breaks
&lt;/h2&gt;

&lt;p&gt;Three failure modes recur in unattended automation, and each one has a specific human-shaped hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grounding gaps: the proxy is not the thing.&lt;/strong&gt; Agents optimize whatever signal you give them. Give them "all tests pass" and you get green tests, not correct behavior. This is not a theoretical alignment problem — it is a Tuesday problem. In our email-validation work, an SMTP server that answers "yes" to a recipient probe is not ground truth: catch-all servers accept everything, so the probe's "valid" verdict is a lie told politely (&lt;a href="https://botslave.com/blog/email-validation-mx-catch-all-smtp-probing" rel="noopener noreferrer"&gt;we documented the whole taxonomy&lt;/a&gt;). Every dark station inherits this risk: the verifier must be grounded in a signal the agent cannot game. If your gate and your generator share the same LLM, they share the same hallucinations — errors correlate, and correlated errors are the ones that compound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adversarial drift: the floor moves.&lt;/strong&gt; An unattended pipeline in a contested environment rots. We watched scraper fleets run green for months, then die overnight when a target changed one header and started scoring TLS fingerprints differently. The automation was correct; the world moved. Lights-out works where the world is stationary. Where the world is adversarial — bot detection, fraud, deliverability — the pipeline needs a feedback station that measures &lt;em&gt;outcome&lt;/em&gt; (did the data arrive correct? did the mail land?), not just &lt;em&gt;execution&lt;/em&gt; (did the job run?). Drift detection is the station that keeps the factory's lights on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Irreversibility: blast radius is the real risk metric.&lt;/strong&gt; The question at every station is not "can the agent do this" but "what happens if it is wrong". Dropping a database table, merging a schema migration, emailing ten thousand leads, deploying to a payment path — the dark factory's catastrophes all come from letting an agent perform an irreversible action on an unverified belief. Reversibility, not capability, should decide what goes dark.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The checkpoint design: gates keyed to blast radius
&lt;/h2&gt;

&lt;p&gt;The practical answer is not "human reviews everything" (that is the pre-factory era) and not "agents do everything" (that is the accident). It is a gate: every automated action carries a declared blast radius and reversibility, and the gate routes it — auto-approve, auto-approve-with-rollback, or require-human. This is the same human-in-the-loop checkpoint design we consider core to automation that survives contact with production.&lt;/p&gt;

&lt;p&gt;A minimal version, in the shape we actually implement for pipeline actions:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BlastRadius&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;LOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# reversible, single-record, retry-safe
&lt;/span&gt;    &lt;span class="n"&gt;MEDIUM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# bulk but reversible within 24h
&lt;/span&gt;    &lt;span class="n"&gt;HIGH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="c1"&gt;# irreversible or customer-facing
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;blast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BlastRadius&lt;/span&gt;
    &lt;span class="n"&gt;reversible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;undo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;callable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;   &lt;span class="c1"&gt;# must exist if reversible
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Route an automated action. Returns &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auto&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;span class="s"&gt;auto+watch&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;human&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;span class="c1"&gt;# 1. Claim before work: idempotency first — a gate that can't tell
&lt;/span&gt;    &lt;span class="c1"&gt;# "already done" from "do it again" is not a gate, it's a gun.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;already_done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Irreversible or high blast radius: a human signs, no exceptions.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blast&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;BlastRadius&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HIGH&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reversible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;human&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Medium: run, but watch. Verification is a separate, dumber
&lt;/span&gt;    &lt;span class="c1"&gt;# mechanism than the agent that proposed the action — outcome
&lt;/span&gt;    &lt;span class="c1"&gt;# signals, not "it says it worked".
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blast&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;BlastRadius&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MEDIUM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto+watch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Low + reversible + idempotent: genuinely dark.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;skipped&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;reason&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;idempotent claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;human&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;queued_for_human&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;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# Auto-rollback on outcome failure, not on self-report.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outcome_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reversible&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;undo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;undo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;rolled_back&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;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto+watch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;24h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;done&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;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four properties make this more than pseudocode optimism:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency is the first gate&lt;/strong&gt; (&lt;code&gt;already_done&lt;/code&gt;). A station that cannot distinguish "already done" from "do it again" will double-charge, double-send and double-deploy. Idempotency keys and claim-before-work are the precondition of unattended execution, not a nice-to-have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The verifier is dumber than the agent on purpose.&lt;/strong&gt; &lt;code&gt;outcome_ok&lt;/code&gt; checks &lt;em&gt;outcomes&lt;/em&gt; — the data arrived, the record matches, the canary metrics hold — using signals the proposing agent cannot produce. If the same model both proposes and judges, you have theater with two acts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;auto+watch&lt;/code&gt; is the middle that most designs skip.&lt;/strong&gt; Medium-blast actions run but register a 24-hour outcome watch. Drift detection lives here: did the mail actually land, did the scrape return the same schema, did the fingerprint still verify? This is the station that catches adversarial drift while the factory runs dark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The human queue is an asset, not a failure.&lt;/strong&gt; &lt;code&gt;queued_for_human&lt;/code&gt; is a designed output. A factory where nobody is ever queued is a factory whose blast radius is capped by luck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This pattern scales precisely because the human is a &lt;em&gt;routed exception&lt;/em&gt;, not a &lt;em&gt;bottleneck&lt;/em&gt;: in a healthy week, the queue is near-empty, and every item in it is there because an irreversible or adversarial decision needed judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The lights-out scorecard
&lt;/h2&gt;

&lt;p&gt;Apply the taxonomy station by station and you get a working map. "Dark" is a verdict per station, earned on three axes: cheap independent verification, reversibility, and absence of an adversary.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline station&lt;/th&gt;
&lt;th&gt;Independent verification cheap?&lt;/th&gt;
&lt;th&gt;Reversible?&lt;/th&gt;
&lt;th&gt;Adversary present?&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code formatting / linting&lt;/td&gt;
&lt;td&gt;Yes (deterministic)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema → client generation&lt;/td&gt;
&lt;td&gt;Yes (compile + diff)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency-bump PRs&lt;/td&gt;
&lt;td&gt;Yes (existing test suite)&lt;/td&gt;
&lt;td&gt;Yes (revert)&lt;/td&gt;
&lt;td&gt;Supply-chain risk at merge&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark until merge; human merges&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent-written feature code&lt;/td&gt;
&lt;td&gt;Partially (tests are written by the same mind)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Auto + human review&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test-plan-free "self-review" by the generating agent&lt;/td&gt;
&lt;td&gt;No (same mind judges)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not a gate — do not ship on this&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scraping at scale&lt;/td&gt;
&lt;td&gt;Only via outcome signals (schema, volume, spot-checks)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt; (bot detection)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark + drift watch, never dark forever&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email/deliverability pipelines&lt;/td&gt;
&lt;td&gt;Only via outcome signals (inbox placement, bounces)&lt;/td&gt;
&lt;td&gt;Sends are not&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt; (filters, catch-alls lie)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Human on sends; dark on prep&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canary rollouts with auto-rollback&lt;/td&gt;
&lt;td&gt;Yes (health metrics)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark; page a human on rollback&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema migrations / destructive DB ops&lt;/td&gt;
&lt;td&gt;Hard (correctness is the app)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Human signs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk customer-facing comms&lt;/td&gt;
&lt;td&gt;Hard (tone, accuracy, timing)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Reputation risk&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Human signs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emergency fleet patching&lt;/td&gt;
&lt;td&gt;Yes (version probes, health checks)&lt;/td&gt;
&lt;td&gt;Mostly&lt;/td&gt;
&lt;td&gt;Attackers racing you&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark execution, human owns the plan&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is worth pausing on, because it is the closest thing to a real dark factory most teams will operate this year: when an emergency lands, automation finds every runtime, bumps every version, restarts every worker and verifies every host — the doing is dark, the plan is human, and verification is outcome-based. That is the shape that works, and it generalizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Where the debate should go next
&lt;/h2&gt;

&lt;p&gt;The dark-factory conversation will mature the way every automation conversation matures: from "will agents replace X" to "what is the gate budget". A gate budget is the honest accounting of a pipeline: how many human judgments per hundred automated actions, and at which stations. Teams that run real unattended automation already keep this ledger informally — the on-call rotation is a gate budget with a pager.&lt;/p&gt;

&lt;p&gt;Three predictions from the floor, not the balcony:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The winning unit is the gated agent, not the autonomous one.&lt;/strong&gt; Frameworks will compete on how well they declare blast radius, enforce idempotency, and route to humans — the boring infrastructure of the &lt;code&gt;gate()&lt;/code&gt; function above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift detection becomes a product category.&lt;/strong&gt; In adversarial environments, "did the world move" is a first-class signal, and pipelines that cannot answer it are not dark — they are blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The human queue gets a UI.&lt;/strong&gt; Right now it is Slack pings and dread. Mature dark factories will treat the human-review queue like a work queue: prioritized, SLAs, audit trail — the station with the lights on, designed as carefully as any dark one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The factory can be as dark as its gates are good. Build the gates first, earn the darkness station by station, and keep one light on — exactly where the blast radius says it belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is a "dark software factory" just hype for "CI/CD with agents"?&lt;/strong&gt;&lt;br&gt;
Mostly, today. The new part is agents proposing changes across the whole lifecycle, not just running a fixed pipeline. The discipline that makes it work — gates, idempotency, outcome verification — is the same discipline CI/CD taught us; the blast radius is larger, so the gates must be better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can tests be the verifier for agent-written code?&lt;/strong&gt;&lt;br&gt;
Only if the tests are not written by the same mind that wrote the code. An agent that writes code and its tests, then reports "all green", has optimized a proxy. Independent verification means a signal the generator cannot produce: existing golden tests, property-based checks against a spec, production shadow traffic, or a human.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the single cheapest gate to add to an existing pipeline?&lt;/strong&gt;&lt;br&gt;
Idempotency claims before every side effect. Most "the agent did it twice" incidents die at this gate, and it costs a key and a lookup per action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should small teams bother with this, or is it enterprise theater?&lt;/strong&gt;&lt;br&gt;
Small teams feel the failure modes first, because one person is the entire human queue. The gate pattern above scales &lt;em&gt;down&lt;/em&gt; well: the point is routing the three irreversible decisions a week to yourself instead of reviewing everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does adversarial drift mean scraping can never go dark?&lt;/strong&gt;&lt;br&gt;
No — it means scraping goes dark with a drift watch, not dark forever. Outcome signals (schema stability, volume bands, spot-check verification) are the lights that stay on while the station runs unattended.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;daily.dev Popular feed — trend signal for this piece (top-10 post on AI-agent "dark software factories", 2026-10-23): &lt;a href="https://daily.dev/posts?sortBy=popular" rel="noopener noreferrer"&gt;https://daily.dev/posts?sortBy=popular&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave — PHP2Shell patch-operations playbook (dark execution, human-owned plan): &lt;a href="https://botslave.com/blog/php2shell-patch-operations-playbook" rel="noopener noreferrer"&gt;https://botslave.com/blog/php2shell-patch-operations-playbook&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave — Git workflows for AI agent teams (reviewable, revertable rollouts): &lt;a href="https://botslave.com/blog/git-for-ai-agent-teams" rel="noopener noreferrer"&gt;https://botslave.com/blog/git-for-ai-agent-teams&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave — Email validation and why SMTP probing lies (grounding gaps): &lt;a href="https://botslave.com/blog/email-validation-mx-catch-all-smtp-probing" rel="noopener noreferrer"&gt;https://botslave.com/blog/email-validation-mx-catch-all-smtp-probing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Botslave — Shopee security headers and bot detection (adversarial drift): &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/dark-software-factories-lights-out" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Shopee Security Headers: TLS, Tokens &amp; Bot Detection</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:42:14 +0000</pubDate>
      <link>https://dev.to/botslavehq/shopee-security-headers-tls-tokens-bot-detection-554k</link>
      <guid>https://dev.to/botslavehq/shopee-security-headers-tls-tokens-bot-detection-554k</guid>
      <description>&lt;p&gt;&lt;strong&gt;Shopee security headers do not operate in isolation.&lt;/strong&gt; To understand why a request succeeds, triggers verification or is rejected, separate at least four layers: the HTTP message, session state, the TLS connection and the application/device context. Matching a URL and a handful of headers does not establish that two requests are equivalent.&lt;/p&gt;

&lt;p&gt;This research note moves from fields such as &lt;code&gt;af-ac-enc-dat&lt;/code&gt;, &lt;code&gt;af-ac-enc-sz-token&lt;/code&gt; and &lt;code&gt;x-sap-access-*&lt;/code&gt; to TLS fingerprints, device integrity and bot-detection models. Its purpose is to build a testable method of analysis, rather than attribute every failure to a "missing token" or "emulator detection."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Research scope:&lt;/strong&gt; selected Shopee Singapore Android test sessions from 15–16 October 2026, reviewed and edited on 21 October 2026. Findings apply to the samples observed, not to Shopee's internal specification. Data examples are illustrative, with substituted values; they are not usable credentials.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. What are Shopee security headers?
&lt;/h2&gt;

&lt;p&gt;HTTP headers describe aspects of a request or response. Some belong to established HTTP standards; others are defined by an application. "Security headers" is often used loosely for fields associated with authentication, session state, integrity checks or contextual information.&lt;/p&gt;

&lt;p&gt;Distinguish a &lt;strong&gt;documented purpose&lt;/strong&gt; from an &lt;strong&gt;inferred purpose&lt;/strong&gt;. For standard headers, consult the relevant specification. For Shopee-specific names, a prefix such as &lt;code&gt;enc&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt; or &lt;code&gt;access&lt;/code&gt;, or a value resembling a timestamp, provides a starting hypothesis rather than an implementation description.&lt;/p&gt;

&lt;p&gt;A value that changes after every action could be a request identifier, random data, a signature output, session data or a structure containing several components. Length and frequency of change help frame questions; they do not identify an algorithm by themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four useful levels of confidence
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Observation:&lt;/strong&gt; a field is present in a sample, has a particular length or changes at a particular time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standards-based interpretation:&lt;/strong&gt; a public specification explains a field or structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hypothesis:&lt;/strong&gt; an explanation fits the data but has not been isolated through testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finding within a defined scope:&lt;/strong&gt; controlled comparisons and repeatable results support a conclusion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Good research preserves the boundaries between these levels. "The value changed between two requests" is more defensible than "This is an HMAC signature that changes every second" when the latter lacks independent support.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Classifying headers: session state, context and custom fields
&lt;/h2&gt;

&lt;p&gt;Do not put every header into a single "anti-bot" category. Different groups may serve different functions, and a request can involve several mechanisms at once.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Examples to identify&lt;/th&gt;
&lt;th&gt;A useful research question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content information&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Accept&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;What does the client send, and which response format does it expect?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Authorization&lt;/code&gt;, where the protocol uses it&lt;/td&gt;
&lt;td&gt;Which authentication scheme is involved?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session state&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Cookie&lt;/code&gt; and application-specific session fields&lt;/td&gt;
&lt;td&gt;When was the session created, and which actions changed it?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser context&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Origin&lt;/code&gt;, &lt;code&gt;Referer&lt;/code&gt;, &lt;code&gt;Sec-Fetch-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;In which context did the request originate?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application-specific fields&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;af-ac-enc-dat&lt;/code&gt;, &lt;code&gt;af-ac-enc-sz-token&lt;/code&gt;, &lt;code&gt;x-sap-access-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Where do these occur, and how do they relate to sessions and actions?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response evidence&lt;/td&gt;
&lt;td&gt;HTTP status, response headers and error codes in the body&lt;/td&gt;
&lt;td&gt;Is the failure at the transport, HTTP or application layer?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Authorization" rel="noopener noreferrer"&gt;MDN's Authorization documentation&lt;/a&gt; explains, the header carries credentials according to an authentication scheme. There is no single token format for all systems. A request without a Bearer token is therefore not necessarily a request without authentication.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cookie" rel="noopener noreferrer"&gt;Cookie header&lt;/a&gt; sends relevant cookies from the client to the server. When comparing samples, record session state and the process that established it. Comparing only the final cookie strings can lose important context.&lt;/p&gt;

&lt;p&gt;In browsers, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site" rel="noopener noreferrer"&gt;Sec-Fetch-Site&lt;/a&gt; describes the relationship between the initiating origin and the destination. This is web-context information, not a universal requirement for native Android requests. Chrome's header set should not be the sole benchmark for an Android app.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should Shopee-specific fields be interpreted?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field or group&lt;/th&gt;
&lt;th&gt;What a sample can establish&lt;/th&gt;
&lt;th&gt;What requires additional evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;af-ac-enc-dat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Presence, size, character representation and changes between samples&lt;/td&gt;
&lt;td&gt;Specific hardware fields inside it, encryption layers or key location&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;af-ac-enc-sz-token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Presence or absence, and when it appears relative to session establishment&lt;/td&gt;
&lt;td&gt;Which component issues it, and its expiration or revocation rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;x-sap-access-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which members occur in a sample and whether they change together&lt;/td&gt;
&lt;td&gt;The exact signing algorithm and which request components are bound to it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Versions, platforms and endpoints do not necessarily use the same fields. Documentation should always identify the version and sample source. This table is a guide to interpreting evidence, not a decoder for private headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tokens, signatures, encryption and Base64 are different concepts
&lt;/h2&gt;

&lt;p&gt;A common mistake is to see a long string, decode it as Base64 and call the result "decrypted security data." Separate four concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encoding:&lt;/strong&gt; representing data in another form for transmission or storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption:&lt;/strong&gt; protecting confidentiality through a cryptographic mechanism and keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MACs and signatures:&lt;/strong&gt; supporting authenticity or integrity checks, depending on the mechanism.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokens:&lt;/strong&gt; a role-based label; a token may be an opaque reference, structured data or cryptographically protected data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/info/rfc4648/" rel="noopener noreferrer"&gt;Base64 in RFC 4648&lt;/a&gt; is a data representation. Recovering bytes from a Base64 string does not establish their meaning or remove an encryption layer inside them. Hexadecimal strings can also represent many different kinds of data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/info/rfc2104/" rel="noopener noreferrer"&gt;HMAC in RFC 2104&lt;/a&gt; combines a secret key with a hash function for message authentication. Knowing what HMAC is does not establish that a particular Shopee header uses it. A fixed-length string can fit several different constructions.&lt;/p&gt;

&lt;h3&gt;
  
  
  An illustrative example: one change, several explanations
&lt;/h3&gt;

&lt;p&gt;Consider a synthetic dataset used to practice analysis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sample A: request_id changes; field X changes; session_label stays the same
Sample B: request_id changes; field X changes; session_label stays the same
Sample C: a new session is created; X changes; session_label changes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This supports saying that X varies within a session and after a new session is created. It does not establish that X depends only on time, expires after three minutes or is signed using a particular algorithm. Those claims require additional observations or independent confirmation.&lt;/p&gt;

&lt;p&gt;Claims such as "the token always contains an IMEI," "the key must be inside a TEE," or "the header expires after three to five minutes" remain &lt;strong&gt;unsupported by sufficient evidence&lt;/strong&gt; in this research. Recording what is unknown improves the report; it does not reduce its value.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. TLS, certificate authorities and certificate pinning
&lt;/h2&gt;

&lt;p&gt;A useful conceptual breakdown is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connection establishment and transport protection
                     ↓
Readable HTTP messages
                     ↓
Authentication / session / operation validity
                     ↓
Server-side application decision and response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an investigative model, not a diagram of Shopee's internal architecture. Success at one layer does not guarantee success at the next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capturing a PCAP does not mean reading HTTP
&lt;/h3&gt;

&lt;p&gt;A PCAP records observed network packets. If application data travels inside TLS, collecting the bytes does not automatically provide the session secrets needed to decrypt it. With ephemeral key exchange, a PCAP is not simply a disguised JSON document that can be revealed through ordinary decoding. &lt;a href="https://datatracker.ietf.org/doc/html/rfc8446" rel="noopener noreferrer"&gt;RFC 8446&lt;/a&gt; describes TLS 1.3's key establishment and data protection.&lt;/p&gt;

&lt;h3&gt;
  
  
  CA trust and pinning are separate checks
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.android.com/privacy-and-security/security-config" rel="noopener noreferrer"&gt;Android Network Security Configuration&lt;/a&gt; describes how applications can select trust anchors and constrain accepted certificates or keys. Installing a CA on a device does not guarantee that every application trusts it; accepting a trust chain does not establish that a separate pinning constraint has been satisfied.&lt;/p&gt;

&lt;p&gt;When a proxy-related failure occurs, classify it: was a connection established, did TLS complete, and did the application receive a response? An error icon alone does not identify a particular native library or the location of a certificate check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Readable HTTP can still be rejected
&lt;/h3&gt;

&lt;p&gt;The research notes include a session in which request and response data were readable while a login step still returned an error. This distinction matters: &lt;strong&gt;observability and permission to perform an operation are independent questions.&lt;/strong&gt; Resolving visibility does not automatically resolve account state, session validity or server policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What do JA3 and JA4 TLS fingerprints tell us?
&lt;/h2&gt;

&lt;p&gt;A TLS fingerprint summarizes aspects of how a client establishes a connection. &lt;a href="https://developers.cloudflare.com/bots/additional-configurations/ja3-ja4-fingerprint/" rel="noopener noreferrer"&gt;Cloudflare's JA3/JA4 documentation&lt;/a&gt; explains their use in identifying and grouping clients by handshake characteristics, including JA4's normalization choices.&lt;/p&gt;

&lt;p&gt;A fingerprint is not a unique identifier for an individual person or phone. Clients with similar configurations can share one. A client's connection characteristics can also change after software or networking changes.&lt;/p&gt;

&lt;p&gt;The investigative implication is that "the browser and script have different fingerprints" does not establish "that difference alone caused the rejection." An appropriate control must isolate the factor. Cloudflare's documentation establishes that this technique exists in the industry; it does not establish that Shopee uses Cloudflare or a particular JA implementation in the observed case.&lt;/p&gt;

&lt;p&gt;A careful finding would be: "The two environments produced different results and had different connection stacks; the effects of TLS, session state, network and application context have not yet been separated." This still guides further investigation without turning correlation into causation.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Bot detection: rules, behavior and risk scoring
&lt;/h2&gt;

&lt;p&gt;An abuse-prevention system need not rely on one algorithm. &lt;a href="https://developers.cloudflare.com/bots/concepts/bot-detection-engines/" rel="noopener noreferrer"&gt;Cloudflare's bot-detection engine documentation&lt;/a&gt; describes a combination of heuristics, client-side signals and machine learning, using information about requests, sessions and browsers.&lt;/p&gt;

&lt;p&gt;Three conceptual models can help organize evidence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;How to think about it&lt;/th&gt;
&lt;th&gt;What the evidence needs to answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rules and heuristics&lt;/td&gt;
&lt;td&gt;A condition or combination of conditions leads to an action&lt;/td&gt;
&lt;td&gt;Which conditions differ between accepted and rejected samples?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequence or behavioral analysis&lt;/td&gt;
&lt;td&gt;Context includes several actions over time&lt;/td&gt;
&lt;td&gt;Was the sequence and session history recorded completely?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk scoring&lt;/td&gt;
&lt;td&gt;Several signals contribute to an assessment&lt;/td&gt;
&lt;td&gt;Do server logs or other authoritative evidence confirm the score and decision?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table is a framework for analysis. It is &lt;strong&gt;not a reverse-engineered description of Shopee's detection algorithm&lt;/strong&gt;. Without server-side data, we often observe only the outcome. Different mechanisms can produce the same verification screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why might a "correct token" be insufficient?
&lt;/h3&gt;

&lt;p&gt;In a hypothetical design, a token might establish only one property: that a session was created, a message was issued or a check occurred. Permission to perform the current operation is a separate question. Similar-looking values do not establish that a token can be transferred between contexts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not infer detection thresholds from a few attempts?
&lt;/h3&gt;

&lt;p&gt;Two successful attempts followed by a failure do not establish a three-request threshold. Timing, caching, session state, verification status and unobserved variables may also have changed. A report should state its sample size and observation window rather than turn a small count into a universal rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Device fingerprints and Play Integrity are not interchangeable
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;device fingerprint&lt;/strong&gt; combines signals that describe an environment. &lt;strong&gt;Device integrity&lt;/strong&gt; is an assessment under a particular mechanism and set of conditions. The concepts are related but not interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.android.com/google/play/integrity/overview" rel="noopener noreferrer"&gt;Google Play Integrity&lt;/a&gt; provides verdicts about application integrity, device environments and certain risky interactions. Its &lt;a href="https://developer.android.com/google/play/integrity/verdicts" rel="noopener noreferrer"&gt;verdict documentation&lt;/a&gt; explains fields such as &lt;code&gt;appIntegrity&lt;/code&gt;, &lt;code&gt;deviceIntegrity&lt;/code&gt; and different device labels, whose requirements depend on the label and Android version.&lt;/p&gt;

&lt;p&gt;A missing expected label does not automatically explain every application failure. An application may also use signals outside this service. The samples in this research do not include sufficient server-side verdict evidence to establish Play Integrity as the cause of the observed login rejection.&lt;/p&gt;

&lt;h3&gt;
  
  
  A phone succeeds and an emulator fails: what follows?
&lt;/h3&gt;

&lt;p&gt;The defensible conclusion is that &lt;strong&gt;two test configurations produced different results&lt;/strong&gt;. Hardware alone cannot be isolated if the network, app installation source, session state, operating system and account history also differ.&lt;/p&gt;

&lt;p&gt;To strengthen a hypothesis, describe the similarities and differences, keep a group of conditions stable and record what cannot be controlled. Changing a displayed device model name does not establish equivalent integrity, networking or application behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Shopee SG case study: what was actually observed?
&lt;/h2&gt;

&lt;p&gt;The research notes from 15–16 October 2026 contain several concrete observations. Only a summary without credentials is published here; raw HAR files and tokens are not provided.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation in the research notes&lt;/th&gt;
&lt;th&gt;Supported finding&lt;/th&gt;
&lt;th&gt;What it does not establish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Some test requests and responses could be read&lt;/td&gt;
&lt;td&gt;A portion of the traffic was observable at an interpretable application level&lt;/td&gt;
&lt;td&gt;Every protocol and endpoint had been analyzed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An account-check step returned HTTP 418 and code &lt;code&gt;90309999&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;That operation did not complete normally in that sample&lt;/td&gt;
&lt;td&gt;The code always means root, a bad IP address or an emulator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Another response returned HTTP 200 with an error in its body&lt;/td&gt;
&lt;td&gt;HTTP status and application outcome must be read separately&lt;/td&gt;
&lt;td&gt;HTTP 200 means the user successfully logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A review of an older HAR found 19 recommended-product cards&lt;/td&gt;
&lt;td&gt;The older sample contained some product data&lt;/td&gt;
&lt;td&gt;A complete PDP was captured for a product opened in a different test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A newer PCAP retained connection data, but the target product JSON was not readable&lt;/td&gt;
&lt;td&gt;Candidate network streams remained available for further analysis&lt;/td&gt;
&lt;td&gt;The product-detail request and response had been confirmed from ciphertext alone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A particularly useful lesson is that &lt;strong&gt;reviewing evidence can change the scope of a conclusion&lt;/strong&gt;. Before the older HAR was re-examined, saying "no product data has been obtained" was too broad. After finding recommendation cards, the better conclusion became: "Recommended-product card data exists in an older sample; the search or PDP flow under investigation remains unverified."&lt;/p&gt;

&lt;p&gt;That correction is more useful than a general success claim. Recommendation, search and product-detail data may come through different flows. Finding a field in one response does not establish that other fields were obtained or that they share the same source.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not assign error-code meanings by guesswork
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/403" rel="noopener noreferrer"&gt;HTTP 403&lt;/a&gt; indicates that a server refuses to fulfill a request. The status alone does not identify a specific cause. Platform-specific application codes require documentation or suitable controlled evidence before they can be mapped to causes.&lt;/p&gt;

&lt;p&gt;In this case, the evidence does not establish a direct mapping between the UI label F13 and &lt;code&gt;90309999&lt;/code&gt;, or make either one conclusive proof of an IP or integrity problem. This remains an open question, not an identified algorithm.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. A test matrix for isolating causes
&lt;/h2&gt;

&lt;p&gt;Write the question before changing configurations. For example: "Does enabling the observation layer change the outcome of the same operation in the current configuration?" A small question makes the evidence requirements clearer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation axis&lt;/th&gt;
&lt;th&gt;Minimum useful record&lt;/th&gt;
&lt;th&gt;A design mistake to avoid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;Version, installation source, original or modified build&lt;/td&gt;
&lt;td&gt;Changing the app build and network simultaneously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device&lt;/td&gt;
&lt;td&gt;OS, environment type and relevant configuration&lt;/td&gt;
&lt;td&gt;Recording only a model name and omitting other changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;Connection type, timing and connection changes&lt;/td&gt;
&lt;td&gt;Attributing differences to IP when the session also changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;New or continued session; verification state&lt;/td&gt;
&lt;td&gt;Comparing a fresh session with a long-established one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observation&lt;/td&gt;
&lt;td&gt;Capture enabled or disabled; which layers are readable&lt;/td&gt;
&lt;td&gt;Assuming every connection has the same decryption status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome&lt;/td&gt;
&lt;td&gt;UI result, HTTP status, application code and timestamp&lt;/td&gt;
&lt;td&gt;Retaining only a screenshot or only an HTTP status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A practical investigative sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Establish a baseline:&lt;/strong&gt; record the unchanged configuration's result for the specific operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State the hypothesis:&lt;/strong&gt; describe what should change if it is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the variable:&lt;/strong&gt; change one factor where possible, documenting exceptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Align the timeline:&lt;/strong&gt; associate the UI action with the correct timestamp and request/response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare layers:&lt;/strong&gt; separate transport, HTTP and application differences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repeat within bounds:&lt;/strong&gt; record both failures and successes rather than selecting favorable samples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the finding:&lt;/strong&gt; state what is supported, what has been ruled out and what evidence is missing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These steps apply to environments and operations within an authorized testing scope. A useful report does not require high-volume traffic; the quality of controls often matters more than the number of requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  An example test record
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Test ID: LAB-EXAMPLE-01
Question: distinguish observation failures from application failures
Time: record timestamp and time zone
Application / OS: record actual versions
Session: new or continued; do not record raw tokens
Single change: describe explicitly
UI outcome: success / verification / error
HTTP and application code: record separately, if readable
Finding: limited to this configuration
Uncontrolled variables: list them rather than filling gaps with assumptions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an illustrative template, not a log from a real account or device.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Common mistakes in security-header analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Treating web and mobile clients as one protocol
&lt;/h3&gt;

&lt;p&gt;Clients may follow different paths. The absence of a browser header in an app is not proof that the app is missing a required field. An app's behavior also does not establish that all web APIs behave identically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guessing internal structure from ciphertext
&lt;/h3&gt;

&lt;p&gt;A long string does not reveal a schema. Start with representation, size and changes. If the serialization or encryption layer is unverified, label it "unknown" instead of assigning an algorithm name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mixing evidence from different sessions
&lt;/h3&gt;

&lt;p&gt;A UI screenshot from session A, a HAR from session B and a product screenshot from session C can form a convincing but incorrect story. Each finding must reference the correct sample, timestamp and action.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treating proxy visibility as the application's full view
&lt;/h3&gt;

&lt;p&gt;Data may not pass through the observed component, may remain encrypted or may use a structure the tool cannot parse. A limitation of the observation tool is not a conclusion about the entire application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treating one success as a durable workflow
&lt;/h3&gt;

&lt;p&gt;Describe a successful result under its actual conditions. A single sample does not establish long-term reliability, applicability to another account or suitability for a different purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What exactly does &lt;code&gt;af-ac-enc-dat&lt;/code&gt; contain?
&lt;/h3&gt;

&lt;p&gt;This research does not have a verified schema to publish. Characteristics can be catalogued from authorized samples, but the field name alone does not establish that it contains a specific hardware identifier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does copying cookies guarantee that a request will be accepted?
&lt;/h3&gt;

&lt;p&gt;The samples in this article provide no such guarantee. Cookies form part of the session context; analysis should also consider how the session was established and which other conditions changed, rather than treating cookies as the client's entire state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why can HTTPS be readable while login still fails?
&lt;/h3&gt;

&lt;p&gt;Readable data establishes visibility only for the observed flow. A server can still reject an operation because of session state, verification, permissions or application policy. Identifying the specific reason requires additional evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Shopee definitely use JA4 or Play Integrity?
&lt;/h3&gt;

&lt;p&gt;This article does not establish either claim for the tested sessions. Those technologies explain industry mechanisms and help formulate hypotheses; their public documentation is not evidence of Shopee's internal configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does error &lt;code&gt;90309999&lt;/code&gt; mean the device has been blocked?
&lt;/h3&gt;

&lt;p&gt;That conclusion cannot be drawn from the status or application code alone. The research notes do not provide an official mapping or controls sufficient to establish a device block, its duration or a single cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a complete PCAP immediately reveal JSON?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. Distinguish complete capture at the observation level from possession of session secrets, successful decryption and understanding the payload format. These are separate conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Has this research reconstructed Shopee's anti-bot algorithm?
&lt;/h3&gt;

&lt;p&gt;No. It provides a classification of security layers, bounded observations and a method for testing hypotheses. Knowing a few headers or error codes does not reconstruct the server's decision-making model.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Conclusions and references
&lt;/h2&gt;

&lt;p&gt;Shopee security headers should be studied as part of a stateful system, not as a list of strings to copy. The lasting value is in locating the failure layer, maintaining evidence provenance and controlling variables in each comparison.&lt;/p&gt;

&lt;p&gt;This approach also reflects &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;Botslave's perspective on building systems&lt;/a&gt;: establish clarity before adding complexity, retain human ownership of important decisions and progress through steps that can be verified. Explore other notes in the &lt;a href="https://botslave.com/blog/en" rel="noopener noreferrer"&gt;Botslave Journal&lt;/a&gt;, or &lt;a href="https://botslave.com/#access" rel="noopener noreferrer"&gt;start a conversation about a research question&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Primary technical references
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Authorization" rel="noopener noreferrer"&gt;MDN — Authorization&lt;/a&gt;: authentication schemes in HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cookie" rel="noopener noreferrer"&gt;MDN — Cookie&lt;/a&gt;: cookies in requests.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site" rel="noopener noreferrer"&gt;MDN — Sec-Fetch-Site&lt;/a&gt;: origin context for web requests.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/info/rfc4648/" rel="noopener noreferrer"&gt;RFC 4648&lt;/a&gt;: Base16, Base32 and Base64.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/info/rfc2104/" rel="noopener noreferrer"&gt;RFC 2104&lt;/a&gt;: the HMAC construction.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datatracker.ietf.org/doc/html/rfc8446" rel="noopener noreferrer"&gt;RFC 8446&lt;/a&gt;: TLS 1.3's design.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/privacy-and-security/security-config" rel="noopener noreferrer"&gt;Android — Network Security Configuration&lt;/a&gt;: trust anchors and certificate pinning.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/bots/additional-configurations/ja3-ja4-fingerprint/" rel="noopener noreferrer"&gt;Cloudflare — JA3/JA4&lt;/a&gt;: fingerprints at the TLS layer.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/bots/concepts/bot-detection-engines/" rel="noopener noreferrer"&gt;Cloudflare — Bot detection engines&lt;/a&gt;: a public example of layered bot detection.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/google/play/integrity/overview" rel="noopener noreferrer"&gt;Google — Play Integrity overview&lt;/a&gt; and &lt;a href="https://developer.android.com/google/play/integrity/verdicts" rel="noopener noreferrer"&gt;Integrity verdicts&lt;/a&gt;: application and device assessments.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/403" rel="noopener noreferrer"&gt;MDN — 403 Forbidden&lt;/a&gt;: HTTP semantics, not a Shopee application-error catalogue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Methodology and publication limits
&lt;/h3&gt;

&lt;p&gt;Shopee-specific observations were checked against research notes from 15–16 October 2026. Published summaries exclude account information, cookies, tokens and device identifiers. Public references explain foundational mechanisms; they do not confirm Shopee's private algorithms. Server-side decision logs were not available to establish signal weights or detection thresholds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revision history — 21 October 2026:&lt;/strong&gt; expanded the header classification, token and cryptography distinctions, detection layers, empirical evidence table, controlled-test matrix and FAQs. This English edition preserves the Vietnamese article's scope and uncertainty. Future evidence that changes a finding will be recorded as a revision rather than silently upgrading an earlier hypothesis into fact.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>api</category>
    </item>
    <item>
      <title>PHP2Shell and the Patch-Operations Playbook: Rolling an Emergency Fix Across a Real Fleet</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:35:33 +0000</pubDate>
      <link>https://dev.to/botslavehq/php2shell-and-the-patch-operations-playbook-rolling-an-emergency-fix-across-a-real-fleet-397n</link>
      <guid>https://dev.to/botslavehq/php2shell-and-the-patch-operations-playbook-rolling-an-emergency-fix-across-a-real-fleet-397n</guid>
      <description>&lt;p&gt;&lt;strong&gt;The most-read story on daily.dev today is not a new framework or a benchmark — it is an emergency: on October 21, 2026, the PHP project shipped fixes for two critical remote code execution flaws, CVE-2026-109618 and CVE-2026-109619, nicknamed PHP2Shell.&lt;/strong&gt; Both score 9.2 on CVSS v4.0, and an unauthenticated attacker can run code on a vulnerable server. The news cycle covered the bug itself. This article is the practitioner's version — the problem we actually solve for clients: the fix exists, but does it &lt;em&gt;run&lt;/em&gt; everywhere it needs to? The distance between "released" and "deployed" is where incidents happen, and closing that distance is pure automation work. It is the same philosophy as &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;: automate the routine aggressively so human judgment is spent exactly where it changes the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Released is not deployed: the uncomfortable math
&lt;/h2&gt;

&lt;p&gt;The patched releases are PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 (released October 21, 2026, at 14:00 UTC — pulled forward a day after the CVE IDs leaked). The PHP team also published minimal source backports for 7.4, 8.0 and 8.1, which receive no official release.&lt;/p&gt;

&lt;p&gt;Now the uncomfortable part: Bitsight's internet-wide scans found that only about a third of the PHP instances where the version could be identified run a branch covered by the security update. The rest sit on older branches, and the most common branch by far is PHP 7.4, with more than two million observations. So for a large share of the real world, "the fix is out" and "we are fixed" are two different sentences. Your job on a day like this is to turn the second one into truth, systematically, before someone else turns it into a breach.&lt;/p&gt;

&lt;p&gt;The two flaws themselves sit in the engine's memory management and error handling — code nearly every request touches. CVE-2026-109618 is OOM corruption: after a memory allocation fails, PHP leaves invalid data behind for its cleanup routine to process. CVE-2026-109619 is stale global state: request-owned state survives after its memory is freed, and a later request on the same worker can read or write through the dangling pointers. Every setup serving web requests is exposed — PHP-FPM, mod_php, php-cgi, FrankenPHP, LiteSpeed — with mod_php rated highest risk because the exploit fires before any application code runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Inventory: find every PHP runtime you own
&lt;/h2&gt;

&lt;p&gt;You cannot patch what you do not know runs PHP. On emergency-patch day, the inventory step is the one most teams skip, and it is the one that decides whether the patch actually covers the fleet. A real inventory pulls from several sources of truth, because PHP hides in all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container images:&lt;/strong&gt; &lt;code&gt;Dockerfile&lt;/code&gt; and compose files that pin a PHP image tag. This is the fastest place to scan first, because it is text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosts:&lt;/strong&gt; packages installed via apt/dpkg or rpm/yum, plus anything compiled from source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running processes:&lt;/strong&gt; what the package manager says and what actually serves traffic can disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless and PaaS:&lt;/strong&gt; runtimes managed by someone else — some vendors auto-patched (Google Cloud patched supported serverless runtimes; Laravel Cloud auto-patches), but custom container images and App Engine flexible deployments need a manual redeploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosts you do not control:&lt;/strong&gt; shared hosting, where the fix depends on the provider. Updating WordPress, plugins or themes does not change your PHP version, because PHP lives on the host — check &lt;code&gt;php -v&lt;/code&gt; on every server, and ask your host when it will patch if you do not control it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A first-pass scan can be embarrassingly simple and still catch most of the fleet:&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;# 1. Every container image pinning a PHP base image&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"FROM php:"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Dockerfile*'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'docker-compose*'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# 2. Every host with PHP installed (Debian/Ubuntu fleet via your config management)&lt;/span&gt;
dpkg &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^ii\s+php[0-9]'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2, $3}'&lt;/span&gt;

&lt;span class="c"&gt;# 3. What is actually running right now (catches source builds and stray SAPIs)&lt;/span&gt;
ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'php-fpm|apache2|httpd|litespeed'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt;

&lt;span class="c"&gt;# 4. What version answers the web (spot-check the externally visible truth)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;host &lt;span class="k"&gt;in &lt;/span&gt;web1 web2 web3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'php -v | head -1'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For codebases at scale, this is exactly what Sourcegraph published search queries and batch-change specs for during this event: finding Dockerfiles, CI pipelines and Helm charts that pin old PHP versions, then proposing the bump as a batch change. We have written about this machinery before — our &lt;a href="https://botslave.com/blog/git-for-ai-agent-teams" rel="noopener noreferrer"&gt;guide to Git workflows for agent teams&lt;/a&gt; covers batch changes and reviewable, revertable rollouts, which is the same shape of work: many small, verified, reviewable changes instead of one heroic push.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The restart problem: upgrading the package is not the fix
&lt;/h2&gt;

&lt;p&gt;This is the detail that eats patches in production. On Ubuntu, fixes exist for all supported releases — but running &lt;code&gt;apt upgrade&lt;/code&gt; does not finish the job, because unattended upgrades do not restart running applications. The old, vulnerable PHP-FPM workers keep serving traffic until you restart them. The same is true for Apache with mod_php. A fleet that is "fully patched" in the package database and still vulnerable in memory is the classic failure mode of emergency patching.&lt;/p&gt;

&lt;p&gt;The restart semantics differ by platform, and on patch day you need the real list, not assumptions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform / SAPI&lt;/th&gt;
&lt;th&gt;What actually applies the fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu/Debian PHP-FPM&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apt upgrade&lt;/code&gt; + restart &lt;code&gt;php*-fpm&lt;/code&gt; and the web server; unattended-upgrades will not restart running apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mod_php (Apache)&lt;/td&gt;
&lt;td&gt;Restart Apache; highest risk SAPI, since the exploit fires before application code runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud&lt;/td&gt;
&lt;td&gt;Auto-patches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Vapor (PHP 8)&lt;/td&gt;
&lt;td&gt;Apps redeployed automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Vapor (PHP 7)&lt;/td&gt;
&lt;td&gt;No patch at all — the app must be upgraded off the branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Forge&lt;/td&gt;
&lt;td&gt;Manual update under Runtime → Versions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Cloud serverless&lt;/td&gt;
&lt;td&gt;Supported runtimes auto-patched; custom images need a manual redeploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudways&lt;/td&gt;
&lt;td&gt;WordPress instances auto-patched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean Droplets / DOKS / App Platform&lt;/td&gt;
&lt;td&gt;Manual action required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the rollout script has two halves — the version bump and the restart — and the second half is where verification must happen:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# bump-and-restart.sh — the boring half that actually matters&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt; php8.4-fpm
&lt;span class="c"&gt;# Restart the runtime AND the web server; the old workers stay in memory otherwise&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart php8.4-fpm
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart apache2 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart nginx
&lt;span class="c"&gt;# Verify the fix is the thing actually running, not just installed&lt;/span&gt;
php &lt;span class="nt"&gt;-v&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principle generalizes beyond PHP: a patch is only real when the vulnerable code is no longer in memory. Anything in your rollout that checks the package database but not the running process is checking the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rollout mechanics: canary, stages, re-runnable automation
&lt;/h2&gt;

&lt;p&gt;Emergency does not mean reckless. A staged rollout with a canary still beats a fleet-wide push on a CVE day, because the patch itself is a change to every request path in your application, and engine-level patches have historically broken extensions and edge-case behavior. The mechanics that work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Patch one canary host or one non-critical app first&lt;/strong&gt;, watch error rates and latency for a full traffic cycle, then proceed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the rollout re-runnable (idempotent).&lt;/strong&gt; The same script run twice must converge to the patched state, not fail or double-apply. On a day when you are patching at 2 a.m. with tired operators, re-runnable beats clever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it reversible.&lt;/strong&gt; Engine patches occasionally break third-party extensions; know how to roll back to the previous package version before you start, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch the config changes.&lt;/strong&gt; Dockerfile pins, CI pipeline images and Helm chart tags should be bumped as reviewable changes with CI green — not hand-edited on servers. This is where the batch-change approach from our &lt;a href="https://botslave.com/blog/git-for-ai-agent-teams" rel="noopener noreferrer"&gt;agent Git workflows post&lt;/a&gt; earns its keep: dozens of small, verified, revertable changes instead of snowflake servers.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  5. The EOL fleet: what to do with 7.4, 8.0 and 8.1
&lt;/h2&gt;

&lt;p&gt;A large share of the internet runs PHP branches that get no official release — 7.4 alone had more than two million observations in Bitsight's scans, and W3Techs data puts about 48.5% of PHP sites on end-of-life branches. The PHP project published minimal source patches for 7.4, 8.0 and 8.1 to help with backports, but that is not a supported release; your vendor's security advisory is the authority on whether a patched package exists for your platform.&lt;/p&gt;

&lt;p&gt;Be honest about the decision tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vendor package exists:&lt;/strong&gt; take it, then restart (section 3 still applies).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No package, and you can upgrade the branch:&lt;/strong&gt; schedule it — this event is the forcing function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No package and no upgrade path right now:&lt;/strong&gt; this is where CERT-EU, the NSA and CISA landed — isolate those systems or take them offline. Several governments did exactly that: Dutch government services, Finland's state IT provider, German and French federal IT all pulled sites down rather than run exposed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Awaiting the vendor" is not a plan. A written decision with a date — patched, isolated, or offline — is.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Defense in depth while the rollout runs
&lt;/h2&gt;

&lt;p&gt;Patching a fleet takes time: inventory, maintenance windows, application testing against the new builds. During that window, boundary controls reduce exposure to the confirmed attack paths. Cloudflare turned on default WAF rules for its customers about nine hours before disclosure, after attackers began probing, and AWS published a guide with AWS WAF rules for the confirmed PHP2Shell attack paths. A reverse proxy or WAF in front of PHP can block hostile requests before they reach the vulnerable parser.&lt;/p&gt;

&lt;p&gt;Two caveats, both worth stating plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A WAF does not fix PHP.&lt;/strong&gt; It covers known attack patterns; the vulnerable code is still underneath. Patching is the only complete fix, and it stays priority one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect payload mutation.&lt;/strong&gt; Cloudflare reported attackers already adapting payloads to evade its rules. Static rules buy time; they do not end the race.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Detection matters as much as blocking. Datadog's analysis suggests looking for large form submissions, memory-exhaustion errors during request initialization, and unexpected shell processes spawned by the web server user — and notes that any PHP script exposes the vulnerable path, even one that only prints a response, because the request never reaches application code. Practical log checks:&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;# Requests hitting the session upload-progress path (the reported trigger surface)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'PHP_SESSION_UPLOAD_PROGRESS'&lt;/span&gt; /var/log/apache2/access.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;

&lt;span class="c"&gt;# The literal calling card some scanners used&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'php2shell'&lt;/span&gt; /var/log/nginx/access.log | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;

&lt;span class="c"&gt;# Shell processes that should never be children of the web server user&lt;/span&gt;
ps &lt;span class="nt"&gt;-ef&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$1=="www-data" &amp;amp;&amp;amp; /sh|bash|wget|curl/ {print}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arctic Wolf Labs reported scanning and attempts pulling ELF and Golang malware binaries via &lt;code&gt;wget&lt;/code&gt; — so unexpected outbound fetch-and-execute behavior from web-server-owned processes is exactly the signal to alert on. (On the bot-detection side of boundary defense, we have a full breakdown of &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;TLS fingerprints and token-based bot detection&lt;/a&gt; — the same layer where these WAF rules live.)&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Verify the patch actually landed
&lt;/h2&gt;

&lt;p&gt;The rollout is not done when the script exits green. It is done when every runtime in the inventory answers with a fixed version. Close the loop:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# verify-php-fleet.sh — the inventory from section 2, re-run as an assertion&lt;/span&gt;
&lt;span class="nv"&gt;FIXED&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"8.5.12"&lt;/span&gt; &lt;span class="s2"&gt;"8.4.27"&lt;/span&gt; &lt;span class="s2"&gt;"8.3.36"&lt;/span&gt; &lt;span class="s2"&gt;"8.2.35"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;host &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;fleet-hosts.txt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;ver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'php -r "echo PHP_VERSION;"'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"no"&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FIXED&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ver&lt;/span&gt;&lt;span class="s2"&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;$f&lt;/span&gt;&lt;span class="s2"&gt;"&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="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
  &lt;span class="c"&gt;# Also confirm the running workers are the new build, not stale memory&lt;/span&gt;
  &lt;span class="nb"&gt;echo&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; -&amp;gt; php &lt;/span&gt;&lt;span class="nv"&gt;$ver&lt;/span&gt;&lt;span class="s2"&gt; | patched: &lt;/span&gt;&lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;tee &lt;/span&gt;patch-verification-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep that log. When the next CVE drops — and Taylor Otwell's warning during this event was that this probably will not be the last time, nor PHP the only language to go through it — the difference between a two-hour response and a two-day scramble is whether your inventory already exists. The fleet list, the restart runbook and the verification script are assets. Build them once, on a calm day, and CVE day becomes execution instead of archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does updating WordPress, my plugins or my theme fix PHP2Shell?&lt;/strong&gt;&lt;br&gt;
No. The vulnerability is in the PHP runtime itself, which lives on the host — below your application. Update the runtime (and restart it), regardless of what your CMS or framework reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does putting a WAF in front fix it?&lt;/strong&gt;&lt;br&gt;
No. WAF rules from Cloudflare, AWS and others reduce exposure to confirmed attack paths while you patch, but they do not remove the vulnerability. Patching is the only complete fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I run PHP 7.4. Am I covered?&lt;/strong&gt;&lt;br&gt;
Not by an official release. The PHP project published minimal source patches for 7.4, 8.0 and 8.1 to aid backports; check your OS vendor's security advisory for patched packages. If neither exists, the official guidance from CERT-EU, NSA and CISA is to isolate the system or take it offline — or upgrade the branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if someone already exploited it against me?&lt;/strong&gt;&lt;br&gt;
Look for the signals in section 6: large form submissions, memory-exhaustion errors during request initialization, unexpected shell processes or &lt;code&gt;wget&lt;/code&gt;/&lt;code&gt;curl&lt;/code&gt; activity owned by the web-server user, and &lt;code&gt;php2shell&lt;/code&gt; strings in access logs. Absence of these signals is reassuring, not proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is this suddenly everywhere?&lt;/strong&gt;&lt;br&gt;
Both flaws were found with frontier AI assistance — Calif's team with OpenAI's models, and Wiz independently with its Atlas tool — and one researcher demonstrated that a working proof of concept was roughly "one prompt away" once hints leaked. The finding side of security is accelerating; the patching side has to keep up, which is exactly what the playbook above is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tenable Research — PHP2Shell FAQ (disclosure timeline, CVE details, affected SAPIs): &lt;a href="https://www.tenable.com/blog/php2shell-cve-2026-109618-cve-2026-109619-frequently-asked-questions-about-critical-php-remote" rel="noopener noreferrer"&gt;https://www.tenable.com/blog/php2shell-cve-2026-109618-cve-2026-109619-frequently-asked-questions-about-critical-php-remote&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS Security Blog — using AWS WAF to mitigate PHP2Shell while patching: &lt;a href="https://aws.amazon.com/blogs/security/using-aws-waf-to-apply-mitigations-for-php2shell-rce-vulnerabilities-cve-2026-109618-and-cve-2026-109619/" rel="noopener noreferrer"&gt;https://aws.amazon.com/blogs/security/using-aws-waf-to-apply-mitigations-for-php2shell-rce-vulnerabilities-cve-2026-109618-and-cve-2026-109619/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Datadog Security Labs — detection, analysis and remediation: &lt;a href="https://securitylabs.datadoghq.com/articles/php2shell-critical-vulnerabilities-in-php/" rel="noopener noreferrer"&gt;https://securitylabs.datadoghq.com/articles/php2shell-critical-vulnerabilities-in-php/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bitsight — vulnerability details and internet-wide version exposure data: &lt;a href="https://www.bitsight.com/blog/critical-vulnerability-alert-cve-2026-109618-and-cve-2026-109619-php2shell" rel="noopener noreferrer"&gt;https://www.bitsight.com/blog/critical-vulnerability-alert-cve-2026-109618-and-cve-2026-109619-php2shell&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Brandefense — attack timeline and official alerts roundup: &lt;a href="https://brandefense.io/blog/php2shell-php-rce-cve-2026-109618-109619/" rel="noopener noreferrer"&gt;https://brandefense.io/blog/php2shell-php-rce-cve-2026-109618-109619/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SOCRadar — FAQ separating confirmed facts from leaked/unverified claims: &lt;a href="https://socradar.io/blog/php2shell-cve-2026-109618-109619/" rel="noopener noreferrer"&gt;https://socradar.io/blog/php2shell-cve-2026-109618-109619/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/php2shell-patch-operations-playbook" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Validate Email Addresses Without Sending Mail: MX Records, Catch-Alls, SMTP Probing</title>
      <dc:creator>Botslave</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:33:41 +0000</pubDate>
      <link>https://dev.to/botslavehq/how-to-validate-email-addresses-without-sending-mail-mx-records-catch-alls-smtp-probing-2j71</link>
      <guid>https://dev.to/botslavehq/how-to-validate-email-addresses-without-sending-mail-mx-records-catch-alls-smtp-probing-2j71</guid>
      <description>&lt;p&gt;&lt;strong&gt;Every lead pipeline eventually asks the same question: "is this email address real?"&lt;/strong&gt; The honest answer is that "real" is four different questions wearing one coat — and each layer of validation only answers one of them. Confusing the layers is how teams end up trusting a green checkmark that proves almost nothing, or throwing away good leads because one weak signal said no.&lt;/p&gt;

&lt;p&gt;This is the validation model we use in our own lead operations work: what each check actually proves, where SMTP probing helps, where it quietly lies to you, and how to design a pipeline that records evidence instead of pretending to know the truth. The thinking behind this staged approach — automate the routine, keep judgment where it matters — is the same one behind &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Four questions, not one
&lt;/h2&gt;

&lt;p&gt;When someone says an address is "valid", they might mean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is it well-formed?&lt;/strong&gt; Does it parse as an email address at all?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can the domain receive mail?&lt;/strong&gt; Is there a mail server willing to accept messages for this domain?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does this mailbox exist?&lt;/strong&gt; Is there an actual account behind the local part?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it safe and worthwhile to send to?&lt;/strong&gt; Will sending hurt sender reputation, or reach a human who matters?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Syntax checks answer question 1. MX lookups answer question 2. Question 3 can only be &lt;em&gt;approximated&lt;/em&gt; without sending mail — and question 4 is a policy decision, not a technical one. Most "email verifier" scores collapse all four into a single number. That is convenient and wrong: a catch-all corporate domain and a personal Gmail account can both score "valid" while meaning completely different things for your pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Layer one: syntax, and why it is not trivial
&lt;/h2&gt;

&lt;p&gt;The grammar of an email address is defined by RFC 5322, and it is more permissive than almost every hand-rolled regex assumes. Quoted local parts (&lt;code&gt;"john..doe"@example.com&lt;/code&gt;) are legal. So are comments in obsolete syntax that no sane system should accept but some parsers do. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use a real parser from your platform's ecosystem, not a regex copied from a forum. Then apply a &lt;em&gt;stricter business rule&lt;/em&gt; on top: most pipelines reject quoted strings, consecutive dots and addresses longer than 254 characters, not because the RFC forbids all of them, but because real-world mail systems handle them inconsistently.&lt;/li&gt;
&lt;li&gt;Normalize before you validate. Trim whitespace, lowercase the &lt;strong&gt;domain&lt;/strong&gt; (domains are case-insensitive; local parts technically are not, though nearly every provider treats them case-insensitively), and convert internationalized domains to their ASCII form with IDNA/punycode — you must validate and resolve the form DNS actually uses.&lt;/li&gt;
&lt;li&gt;Do not "fix" addresses silently. Plus-addressing (&lt;code&gt;name+tag@gmail.com&lt;/code&gt;) and Gmail's dot-insensitivity are provider semantics: the same mailbox, but stripping tags or dots to deduplicate is a &lt;em&gt;dedupe decision&lt;/em&gt;, not a syntax correction. Record the original; derive a canonical form separately if your pipeline needs one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Syntax validation is cheap and deterministic. It is also the layer that proves the least. Everything interesting happens at the domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Layer two: MX records — the domain's front door
&lt;/h2&gt;

&lt;p&gt;To find out where mail for a domain goes, you ask DNS for its MX (mail exchanger) records. Each record has a preference value (lower wins) and a host name. Two lookups cover most of the world:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node.js — no dependencies, built-in dns module&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:dns/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mxFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolveMx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ENODATA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ENOTFOUND&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python — with dnspython (pip install dnspython)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dns.resolver&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mx_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NXDOMAIN&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MX records exist&lt;/strong&gt; → the domain publishes where its mail goes. You have also learned something else for free: the MX hosts usually reveal the provider (Google Workspace, Microsoft 365, a regional hoster), which tells you which behavioral rules to expect later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single MX record pointing at "." (null MX, RFC 7505)&lt;/strong&gt; → the domain &lt;em&gt;explicitly declares it accepts no mail&lt;/em&gt;. This is a hard, standards-based "invalid" — one of the few you will ever get. Treat any address at such a domain as undeliverable, permanently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No MX records at all&lt;/strong&gt; → strictly, RFC 5321 §5.1 defines an "implicit MX": fall back to the domain's A/AAAA records. In practice, domains with A records but no MX are a grey zone — personal sites, parked domains, misconfigurations. Most lead pipelines mark them low-confidence rather than valid, and they are right to: a domain that wanted mail would publish MX.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What MX tells you is strictly question 2: a front door exists. It says nothing about whether your specific mailbox is behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Layer three: SMTP probing — useful, and a compulsive liar
&lt;/h2&gt;

&lt;p&gt;The classic trick for question 3 is to start an SMTP conversation with the receiving server and stop just before sending anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S: 220 mx.example.net ESMTP ready
C: EHLO validator.example.com
S: 250 Hello
C: MAIL FROM:&amp;lt;check@example.com&amp;gt;
S: 250 OK
C: RCPT TO:&amp;lt;person@example.com&amp;gt;
S: 250 OK            &amp;lt;- mailbox accepted?
C: RSET / QUIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Illustrative session, not a captured transcript.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;550&lt;/code&gt; here often means "no such user" and a &lt;code&gt;250&lt;/code&gt; is taken as "mailbox exists". Both readings are unreliable, for structural reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accept-all edges.&lt;/strong&gt; Many servers — especially corporate gateways and Microsoft/Google tenants fronted by security appliances — accept &lt;code&gt;RCPT TO&lt;/code&gt; for &lt;em&gt;any&lt;/em&gt; local part and decide what to do with the message later. Your probe gets a 250 for mailboxes that do not exist, and the bounce only happens after a real send, if it happens at all (some systems silently discard).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-enumeration defenses.&lt;/strong&gt; Large providers deliberately stopped answering honestly years ago, precisely because probing was used to harvest valid addresses. A 250 from them is close to meaningless; a 550 may be policy, not absence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greylisting and tempfails.&lt;/strong&gt; A &lt;code&gt;450&lt;/code&gt;/&lt;code&gt;451&lt;/code&gt; can mean "come back later" (greylisting), rate limiting of &lt;em&gt;your&lt;/em&gt; probing IP, or a tarpit deliberately slowing you down. It is not a verdict in either direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your reputation, not theirs.&lt;/strong&gt; Probing is done from your IP. Datacenter IPs are frequently refused outright, and sustained probing patterns get IPs listed. The probe measures your standing with their filters as much as the mailbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The professional conclusion: an SMTP probe is a &lt;em&gt;weak signal&lt;/em&gt;, strongest when it returns an explicit &lt;code&gt;550 user unknown&lt;/code&gt; from a server you have already established is &lt;strong&gt;not&lt;/strong&gt; catch-all, and weakest everywhere else. Which raises the real question — how do you know a domain is catch-all?&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Catch-all domains: where mailbox validation ends
&lt;/h2&gt;

&lt;p&gt;A catch-all (accept-all) domain accepts mail for every local part, real or invented. The standard detection is simple: probe the server with a local part that cannot exist — a long random string:&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;RCPT TO:&amp;lt;xq7zv2k9plm4rf8d@example.com&amp;gt;
250 OK   -&amp;gt; this domain accepts anything: it is catch-all (at this edge, today)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the random address is accepted, mailbox-level validation for that domain is over. Every address there is "unverifiable" by construction — not valid, not invalid. In B2B lead data this is not an edge case: small and mid-size companies land on catch-all configurations all the time, through default hoster settings, migration leftovers, or a deliberate "never miss a message" choice by their admin.&lt;/p&gt;

&lt;p&gt;How a pipeline should treat catch-all addresses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never merge them into "valid".&lt;/strong&gt; Keep a separate bucket with its own sending policy (for outreach: send later, send fewer, watch the first bounces closely).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never auto-delete them either.&lt;/strong&gt; A catch-all domain contains real people; the CEO's address is in there next to a thousand invented strings. Deleting the bucket deletes your best leads along with the noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache the verdict per domain&lt;/strong&gt;, not per address, and re-check periodically — catch-all status is a server configuration, and configurations change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same discipline we apply to any layered signal system: in our &lt;a href="https://botslave.com/blog/shopee-security-headers-tls-tokens-bot-detection" rel="noopener noreferrer"&gt;research into Shopee's security headers&lt;/a&gt;, the recurring lesson was that one signal rarely means what it appears to mean until you know which layer produced it. Email is no different.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Supporting signals that are actually worth collecting
&lt;/h2&gt;

&lt;p&gt;Around the three main layers, a few cheap checks add real value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disposable/temporary domains.&lt;/strong&gt; Maintain or license a list of throwaway-mail domains. The address may be perfectly valid &lt;em&gt;and&lt;/em&gt; worthless in ten minutes — that is a question-4 decision, and for lead pipelines the answer is usually reject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role accounts.&lt;/strong&gt; &lt;code&gt;postmaster@&lt;/code&gt;, &lt;code&gt;admin@&lt;/code&gt;, &lt;code&gt;sales@&lt;/code&gt;, &lt;code&gt;contato@&lt;/code&gt; are deliverable but shared inboxes. Tag them; do not drop them — in small companies the role inbox &lt;em&gt;is&lt;/em&gt; the decision-maker's inbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider fingerprint from MX.&lt;/strong&gt; Knowing a domain sits on Google Workspace vs. a small regional hoster changes how you interpret probes, bounces and rate limits downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain typos.&lt;/strong&gt; &lt;code&gt;gmial.com&lt;/code&gt;, &lt;code&gt;hotmial.com&lt;/code&gt; and friends are data-entry errors, not mailboxes. Suggest the correction to a human or a form; never silently rewrite an address and send to a stranger's mailbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free vs. corporate domain.&lt;/strong&gt; Not a validity signal at all — but valuable routing metadata for enrichment and segmentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Designing the pipeline: verdicts with evidence, not scores
&lt;/h2&gt;

&lt;p&gt;A robust validation stage stores, per address: the syntax result, the MX set and provider, the null-MX flag, the catch-all verdict (and when it was measured), the probe outcome if you probe at all, and timestamps for everything. The output is a &lt;strong&gt;label with a reason&lt;/strong&gt;, for example:&lt;/p&gt;

&lt;p&gt;| Evidence | Label | Pipeline action |&lt;br&gt;
|---|---|&lt;br&gt;
| Bad syntax / NXDOMAIN | &lt;code&gt;invalid&lt;/code&gt; | Drop, log reason |&lt;br&gt;
| Null MX (RFC 7505) | &lt;code&gt;invalid&lt;/code&gt; | Drop, do not retry |&lt;br&gt;
| No MX, A record only | &lt;code&gt;low-confidence&lt;/code&gt; | Hold for review or enrich later |&lt;br&gt;
| Catch-all domain | &lt;code&gt;unverifiable&lt;/code&gt; | Separate bucket, cautious policy |&lt;br&gt;
| MX ok, probe 550 user-unknown, not catch-all | &lt;code&gt;likely-invalid&lt;/code&gt; | Drop, keep evidence |&lt;br&gt;
| MX ok, everything else neutral | &lt;code&gt;deliverable&lt;/code&gt; | Proceed |&lt;/p&gt;

&lt;p&gt;Two design rules make this work over time. First, &lt;strong&gt;never overwrite the raw input&lt;/strong&gt; — store the pipeline's conclusions next to the original address, so a better future check can re-derive them. Second, &lt;strong&gt;revalidate on a schedule&lt;/strong&gt;: MX records move, domains lapse, catch-all gets switched off. A validation result is a measurement with a date, not a property of the address. (It is the same reason our automations are built to be safely re-runnable — &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;idempotent by design&lt;/a&gt;, so re-checking never corrupts state.)&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does a 250 response to RCPT TO guarantee the mailbox exists?&lt;/strong&gt;&lt;br&gt;
No. On catch-all and accept-all systems it only proves the server accepts the address at the edge. Delivery — or a later bounce — is decided afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is SMTP probing illegal or abusive?&lt;/strong&gt;&lt;br&gt;
Probing is a normal protocol conversation, but at scale it looks exactly like address harvesting, which is what the defenses are built against. Expect blocks, respect them, and prefer MX-level checks plus evidence-based scoring for bulk hygiene. Sending one real, wanted email remains the only ground truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I skip probing entirely?&lt;/strong&gt;&lt;br&gt;
For list hygiene, mostly yes: syntax + MX + null MX + catch-all detection removes the addresses that damage sender reputation (hard-invalid domains), which is where most of the value is. Probing adds a marginal signal at a real operational cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should a lead list be revalidated?&lt;/strong&gt;&lt;br&gt;
Before any major send, and periodically for standing lists — domains and mail configurations change constantly. A verdict older than a few months should be treated as stale until re-measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RFC 5321 §5.1 — address resolution and the implicit MX rule: &lt;a href="https://www.rfc-editor.org/info/rfc5321/" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/info/rfc5321/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RFC 7505 — Null MX, "a domain that accepts no mail": &lt;a href="https://www.rfc-editor.org/info/rfc7505/" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/info/rfc7505/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RFC 5322 — Internet Message Format (address grammar): &lt;a href="https://www.rfc-editor.org/info/rfc5322/" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/info/rfc5322/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Node.js DNS module documentation: &lt;a href="https://nodejs.org/api/dns.html" rel="noopener noreferrer"&gt;https://nodejs.org/api/dns.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;dnspython documentation: &lt;a href="https://dnspython.readthedocs.io/" rel="noopener noreferrer"&gt;https://dnspython.readthedocs.io/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://botslave.com/blog/email-validation-mx-catch-all-smtp-probing" rel="noopener noreferrer"&gt;botslave.com&lt;/a&gt;. Botslave is an automation studio — see &lt;a href="https://botslave.com/services" rel="noopener noreferrer"&gt;what we build&lt;/a&gt; and &lt;a href="https://botslave.com/how-we-work" rel="noopener noreferrer"&gt;how we work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>tutorial</category>
      <category>webdev</category>
      <category>security</category>
    </item>
  </channel>
</rss>
