<?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: Hirohisa Tanaka</title>
    <description>The latest articles on DEV Community by Hirohisa Tanaka (@tanahiro2010).</description>
    <link>https://dev.to/tanahiro2010</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%2F4009689%2F61f754c4-028a-4437-a87a-1c780cc7aecc.JPG</url>
      <title>DEV Community: Hirohisa Tanaka</title>
      <link>https://dev.to/tanahiro2010</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tanahiro2010"/>
    <language>en</language>
    <item>
      <title>I Want to Be Graceful</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Tue, 03 Nov 2026 10:34:39 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/i-want-to-be-graceful-eok</link>
      <guid>https://dev.to/tanahiro2010/i-want-to-be-graceful-eok</guid>
      <description>&lt;h1&gt;
  
  
  I Want to Be Graceful
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;I don’t want to be someone who never breaks. I want to be someone who still has room to maneuver after things break.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;I thought about the wish to “be graceful” alongside Graceful Degradation in software design. Graceful Degradation is the idea of preserving essential functionality even when some parts become unavailable, so users can still achieve their goals.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grace isn’t defined by perfect manners, but by the options you still have after the unexpected happens&lt;/li&gt;
&lt;li&gt;Decide in advance what matters and what can be sacrificed&lt;/li&gt;
&lt;li&gt;Don’t just recover on your own—let the people around you know what’s happening and what to do next&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;“Implementing this in life” is a metaphor. Humans can’t be run on TypeScript. But the perspective of designing how things fail can also be useful when thinking about how people behave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grace is designed room to maneuver.&lt;/strong&gt; It’s not just the ability to keep things on schedule. It’s the ability to protect what matters when things go off course and give the people around you enough information to make their own decisions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article expands on the content of my five-minute lightning talk, “I Want to Be Graceful,” with a definition of Graceful Degradation and some additional design context. It’s written to be accessible to both engineers and people unfamiliar with technical terminology.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. I wanted to be graceful
&lt;/h2&gt;

&lt;p&gt;Lately, I’ve been listening to classical music. When you listen to music every day, it can start to seem as though not only the sound, but also the posture and gestures of the people listening, are becoming more refined.&lt;/p&gt;

&lt;p&gt;That made me think, “I want to be graceful too.” Someone who doesn’t flinch when they spill tea, responds calmly to an unexpected message, and doesn’t let anything that happens disrupt the mood of the room.&lt;/p&gt;

&lt;p&gt;I’m not trying to claim that classical music makes people graceful. It’s just where this talk began. When I thought about my ideal person, the first image that came to mind was “someone who doesn’t fall apart, no matter what happens.”&lt;/p&gt;

&lt;h2&gt;
  
  
  2. You can’t measure grace on the happy path
&lt;/h2&gt;

&lt;p&gt;It’s not that hard to behave thoughtfully when things are calm. You have time, things are going according to plan, and the other person is relaxed too.&lt;/p&gt;

&lt;p&gt;The hard part is when unexpected things pile up. Plans change. The connection drops. Your presentation slides break right before you’re due to present. If you raise your voice, stop making decisions, and spread anxiety to everyone around you, your usual thoughtfulness can disappear in an instant.&lt;/p&gt;

&lt;p&gt;What you need then isn’t the performance of keeping a straight face. It’s enough room to choose your next move.&lt;/p&gt;

&lt;p&gt;You can break grace down into three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Composure: keeping one more option open&lt;/li&gt;
&lt;li&gt;Judgment: deciding what to protect and what to let go&lt;/li&gt;
&lt;li&gt;Consideration: letting the people around you know what’s happening and what comes next&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. What is Graceful Degradation?
&lt;/h2&gt;

&lt;p&gt;MDN describes Graceful Degradation as a design philosophy that creates the best experience in modern browsers while still providing essential content and functionality in older browsers.&lt;/p&gt;

&lt;p&gt;For example, instead of leaving the entire screen blank when a new API isn’t available, you can preserve the main operation using another method. The experience may be less polished, but users can still achieve their goal. That’s graceful degradation.&lt;/p&gt;

&lt;p&gt;Microsoft’s reliability guidance also describes the idea of providing a degraded mode that preserves critical operations when a component is unavailable. Detect the failure, switch to an alternative path, and let users know what has changed. That’s all part of the design.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[想定外の障害] --&amp;gt; B{重要な経路か}
    B --&amp;gt;|いいえ| C[非本質的な機能を縮退]
    B --&amp;gt;|はい| D[代替経路へ切り替え]
    C --&amp;gt; E[本質的な機能を継続]
    D --&amp;gt; E
    E --&amp;gt; F[状況と次の手を伝える]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The important thing is not to try to preserve everything. Decide what to cut. Decide what to keep. Azure’s Throttling Pattern, for example, describes stopping or degrading nonessential features to preserve resources for critical ones. It’s a design that gives up a complete response in favor of availability.&lt;/p&gt;

&lt;p&gt;If you distinguish between primary operations, tasks that can be retried later, and displays that don’t directly affect users when they fail during the design stage, you won’t have to panic during an outage and choose between “stop everything” and “let everything through.”&lt;/p&gt;

&lt;p&gt;For example, an ordering service might keep product browsing and saving items to a cart available, defer inventory recalculation and product recommendations, and hold off on payment until it can be verified safely. The priority of each operation is decided based on its purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Don’t leave exceptions as exceptions
&lt;/h2&gt;

&lt;p&gt;When writing code, it’s tempting to build the happy path first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unexpected Event → Panic → Crash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this flow, the system’s job is over the moment something unexpected happens. For a person, it’s like ending a conversation as soon as plans change.&lt;/p&gt;

&lt;p&gt;Graceful Degradation leaves a path open even after something unexpected happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unexpected Event → Understand → Adapt → Respond
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, understand what happened. Next, decide what to protect. Decide. Keep. Keep only what’s essential to that goal, and switch to a different response.&lt;/p&gt;

&lt;p&gt;You can’t do everything. Keep. Cut. Communicate.&lt;/p&gt;

&lt;p&gt;This doesn’t mean “accept anything.” Graceful design has conditions for switching paths. For example, if the database goes down, stop writes and switch to read-only mode; if an external API is slow, return cached data.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Let’s write ElegantPerson.ts
&lt;/h2&gt;

&lt;p&gt;In my talk, I represented human behavior in TypeScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ElegantPerson&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;composure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="nx"&gt;consideration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="nx"&gt;adaptability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UnexpectedEvent&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respondElegantly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respondWithDignity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn’t an executable implementation. Since neither &lt;code&gt;UnexpectedEvent&lt;/code&gt; nor &lt;code&gt;respondElegantly&lt;/code&gt; is defined, it won’t build as-is.&lt;/p&gt;

&lt;p&gt;Even so, it makes the design questions visible. Do you have composure? Are you considerate of others? Can you change your response to fit the situation? And if your first response fails, do you have an alternative that preserves your dignity?&lt;/p&gt;

&lt;p&gt;Unfortunately, human code can’t be type-checked before it goes into production. Inputs like lack of sleep, stress, and running out of time can arrive without warning. That’s why it’s worth thinking not about an implementation that never fails, but about what you can preserve when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. You can share grace with others
&lt;/h2&gt;

&lt;p&gt;When a system degrades, notifying users is important. If the functionality changes without an explanation, users can’t tell whether something is broken or they did something wrong.&lt;/p&gt;

&lt;p&gt;It’s the same for people. When you’re running out of room to maneuver, silently trying to handle everything on your own takes options away from the people around you.&lt;/p&gt;

&lt;p&gt;Don’t stop. Don’t hide it.&lt;/p&gt;

&lt;p&gt;“I can’t share the slides right now. I’ll continue verbally.”&lt;/p&gt;

&lt;p&gt;That one sentence explains the situation and offers an alternative path. It’s not perfect. But the event can continue.&lt;/p&gt;

&lt;p&gt;A graceful person isn’t someone who never lets anything happen. They’re someone who doesn’t hide what happened and can offer what’s possible next. They don’t just protect their own composure—they can share a little of it with the people around them, too.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Conclusion
&lt;/h2&gt;

&lt;p&gt;We can’t eliminate the unexpected. Differences between browsers, outages of external services, and oversleeping aren’t going away.&lt;/p&gt;

&lt;p&gt;So design ahead of time. What’s essential? How much can be degraded? Under what conditions should you switch to an alternative path? And what should you tell the other person?&lt;/p&gt;

&lt;p&gt;Graceful Degradation is a technical term for making systems more resilient. If we use it to think about human behavior, we might put it this way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grace isn’t about never breaking. It’s about still having room to maneuver after things break.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I’ll start by learning how to make tea.&lt;/p&gt;

&lt;p&gt;Stay Graceful.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Glossary/Graceful_degradation" rel="noopener noreferrer"&gt;MDN: Graceful degradation&lt;/a&gt; — Definition of a design philosophy that preserves essential functionality&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.w3.org/TR/html-design-principles/" rel="noopener noreferrer"&gt;W3C: HTML Design Principles&lt;/a&gt; — Fallbacks for older or less capable user agents&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/reliability/self-preservation" rel="noopener noreferrer"&gt;Microsoft: Architecture strategies for self-healing and self-preservation&lt;/a&gt; — Degraded modes, alternative paths, and user notifications&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling/" rel="noopener noreferrer"&gt;Microsoft: Throttling Pattern&lt;/a&gt; — The idea of protecting critical features by dropping nonessential ones&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gracefuldegradation</category>
      <category>architecture</category>
      <category>resilience</category>
    </item>
    <item>
      <title>Cloudflare Agents Now Supports Pi 1.4: Testing Agent Interruption, Recovery, and Persistence</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:32:00 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/cloudflare-agentsgapi-14nidui-ying-sitanode-agentnozhong-duan-fu-jiu-yong-sok-hua-wojian-zheng-suru-3kfg</link>
      <guid>https://dev.to/tanahiro2010/cloudflare-agentsgapi-14nidui-ying-sitanode-agentnozhong-duan-fu-jiu-yong-sok-hua-wojian-zheng-suru-3kfg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The interrupted Agent resumed correctly from its last checkpoint. That’s why the side effect happened twice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Article takeaway
&lt;/h2&gt;

&lt;p&gt;Now that the Cloudflare Agents SDK supports Pi 1.4, I stopped an Agent running on a Durable Object and measured how much of its conversation history, tasks, and tool execution could be recovered. The recovery mechanisms worked as expected, but &lt;strong&gt;recovering an Agent and preventing duplicate external side effects are two different things.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I confirmed Hibernation and Parked Tasks, introduced in Pi 1.4, on a real Durable Object&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;replay: "safe"&lt;/code&gt; does not check idempotency. Apply it to a non-idempotent tool, and the same side effect happens twice&lt;/li&gt;
&lt;li&gt;Storage Error Recovery does not save an in-progress call; it reopens Pi starting with the next operation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agents@0.30.0&lt;/code&gt; has a known bug where a task waiting to retry cannot wake while another task is running. Use &lt;code&gt;0.30.1&lt;/code&gt; or later&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I observed&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Conversation history after restart&lt;/td&gt;
&lt;td&gt;Restored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completed tasks&lt;/td&gt;
&lt;td&gt;Not executed twice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotent tool marked &lt;code&gt;safe&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Re-executed, but side effect occurred once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-idempotent tool marked &lt;code&gt;safe&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Side effect occurred twice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;unsafe&lt;/code&gt; tool&lt;/td&gt;
&lt;td&gt;Not automatically re-executed, and did not complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hibernation&lt;/td&gt;
&lt;td&gt;Resumed on a different instance while waiting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test code is here: &lt;a href="https://github.com/tanahiro2010/slides/tree/main/slides/slides/Cloudflare%20Agents%E3%81%8CPi%201.4%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%97%E3%81%9F%E3%81%AE%E3%81%A7%E3%80%81Agent%E3%81%AE%E4%B8%AD%E6%96%AD%E3%83%BB%E5%BE%A9%E6%97%A7%E3%83%BB%E6%B0%B8%E7%B6%9A%E5%8C%96%E3%82%92%E6%A4%9C%E8%A8%BC%E3%81%99%E3%82%8B/sandbox" rel="noopener noreferrer"&gt;https://github.com/tanahiro2010/slides/tree/main/slides/slides/Cloudflare%20Agents%E3%81%8CPi%201.4%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%97%E3%81%9F%E3%81%AE%E3%81%A7%E3%80%81Agent%E3%81%AE%E4%B8%AD%E6%96%AD%E3%83%BB%E5%BE%A9%E6%97%A7%E3%83%BB%E6%B0%B8%E7%B6%9A%E5%8C%96%E3%82%92%E6%A4%9C%E8%A8%BC%E3%81%99%E3%82%8B/sandbox&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here’s the main point I want to make up front.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;replay: "safe"&lt;/code&gt; does not make a tool safe.&lt;/strong&gt; It only tells Pi, “If this tool is interrupted, it’s okay to run it again.” The tool implementation must guarantee that its side effects are truly idempotent, and that an external API will not process the same request twice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article expands on the talk “Cloudflare Agents Now Supports Pi 1.4: Testing Agent Interruption, Recovery, and Persistence” with test procedures, measured results, and failed measurements that I couldn’t cover in the presentation. It’s for people using Cloudflare Agents and Pi Durable to interact with external APIs and databases.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Breaking down the four items in “Pi 1.4 support”
&lt;/h2&gt;

&lt;p&gt;On October 22, 2026, Cloudflare announced Pi 1.4 support in the Agents SDK. The changelog lists four items:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hibernation&lt;/li&gt;
&lt;li&gt;Parked Tasks&lt;/li&gt;
&lt;li&gt;Storage Error Recovery&lt;/li&gt;
&lt;li&gt;Model Stream Retry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At first, I thought all four were new features in Pi 1.4. But tracing the packages and change history revealed that they came from two different layers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Changelog item&lt;/th&gt;
&lt;th&gt;Where the change actually comes from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hibernation&lt;/td&gt;
&lt;td&gt;Agents SDK wiring for Pi 1.4 &lt;code&gt;sleeping&lt;/code&gt; tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parked Tasks&lt;/td&gt;
&lt;td&gt;Agents SDK exposes a Pi 1.4 feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage Error Recovery&lt;/td&gt;
&lt;td&gt;A fix in the Agents SDK, independent of the Pi version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model Stream Retry&lt;/td&gt;
&lt;td&gt;A fix in the Agents SDK, which also works with Pi 1.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In particular, the Model Stream Retry PR says that the behavior was verified with both Pi 1.3.0 and Pi main. So comparing Storage Error Recovery and Model Stream Retry between Pi 1.3 and 1.4 is the wrong axis. The relevant comparison is &lt;code&gt;agents@0.29.0&lt;/code&gt; vs. &lt;code&gt;agents@0.30.x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Vendor changelogs group changes into units that are easy for users to understand. But when investigating an incident, you need to identify which package and layer is responsible.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The roles of Pi, PiHarness, and Lifecycle
&lt;/h2&gt;

&lt;p&gt;This setup has three layers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pi Durable&lt;/td&gt;
&lt;td&gt;Agent execution loop, tasks, checkpoints, and replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PiHarness&lt;/td&gt;
&lt;td&gt;Connects Pi to Durable Object SQLite and startup processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Runs long-running work on a Durable Object and wakes it when needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pi Durable is not a Cloudflare package. It’s available as &lt;code&gt;@earendil-works/pi-durable&lt;/code&gt; and integrated into Durable Objects by the Agents SDK’s &lt;code&gt;PiHarness&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important point is that persistence and resumption are separate features.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Conversation history and tasks are stored in SQLite tables prefixed with &lt;code&gt;pi_&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Resuming sleeping tasks is handled by Lifecycle Wake jobs and Durable Object Alarms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Having data saved is not enough to resume if there’s no mechanism to wake the task. Conversely, even if a task can resume, it won’t know where to continue from without a checkpoint. In this test, I kept these two mechanisms separate.&lt;/p&gt;

&lt;p&gt;The Pi Durable design article puts it this way:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;every step of a run is a task that stores a checkpoint before it moves on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;These checkpoints are the foundation for preventing completed tasks from running again after a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Persist the observations first
&lt;/h2&gt;

&lt;p&gt;When measuring failure recovery, the first thing to build is not the system under test but the recording mechanism. Writes to a Durable Object are not necessarily committed to disk the moment they’re issued.&lt;/p&gt;

&lt;p&gt;The test recorder writes events to SQLite and then usually calls &lt;code&gt;ctx.storage.sync()&lt;/code&gt;. Immediately afterward, it calls &lt;code&gt;ctx.abort()&lt;/code&gt; and compares whether the events remain after restart.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recording method&lt;/th&gt;
&lt;th&gt;Restarted&lt;/th&gt;
&lt;th&gt;Rows remaining after restart&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;With &lt;code&gt;sync()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Without &lt;code&gt;sync()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In both cases, the instance ID generated in the constructor changed. The same instance did not keep running; a newly created instance read from SQLite.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ctx.abort()&lt;/code&gt; is different from a regular &lt;code&gt;throw&lt;/code&gt;. It cannot be caught in application code and immediately resets the Durable Object. If you use a regular exception to inject a failure, you’ll be measuring error handling in the same instance, not Agent recovery.&lt;/p&gt;

&lt;p&gt;After confirming this foundation, I recorded all subsequent events with &lt;code&gt;sync()&lt;/code&gt;. I did not treat observations missing from the logs as evidence of recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Conversation history and tasks survive a restart
&lt;/h2&gt;

&lt;p&gt;I ran an Agent that executes a tool once and called &lt;code&gt;ctx.abort()&lt;/code&gt; after it completed to stop the Durable Object. Then, after the restart, I read the transcript and events.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Before restart&lt;/th&gt;
&lt;th&gt;After restart&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instance identifier&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Changed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transcript entries&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool execution started&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Side effects&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The completed task did not run twice. Submitting twice with the same &lt;code&gt;operationId&lt;/code&gt; returned &lt;code&gt;accepted: true&lt;/code&gt; the first time and &lt;code&gt;accepted: false&lt;/code&gt; the second time. With a different ID, it returned &lt;code&gt;accepted: true&lt;/code&gt;, so submit itself was not broken.&lt;/p&gt;

&lt;p&gt;This shows Pi Durable using checkpoints and submit identifiers to protect completed work.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Agent keeps running after the client disconnects
&lt;/h2&gt;

&lt;p&gt;Next, I extended processing time through model retries and disconnected the WebSocket midway through. I checked whether execution continued after the client was gone.&lt;/p&gt;

&lt;p&gt;The run eventually reached &lt;code&gt;done&lt;/code&gt;. There were three model calls: two failures and one success. The instance ID did not change before or after the disconnect.&lt;/p&gt;

&lt;p&gt;This was not a case of “crashing and recovering.” It showed that &lt;strong&gt;Agent execution did not depend on the client connection&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;After reconnecting, I could reconstruct the current state from the snapshot. However, I could not use a cursor to replay events from the middle of the stream that arrived during the disconnection. The event stream can be used to track state, but it is not a substitute for a history stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Parked Tasks stop crash loops
&lt;/h2&gt;

&lt;p&gt;I repeatedly triggered &lt;code&gt;ctx.abort()&lt;/code&gt; at the same point and tested with &lt;code&gt;restartLimit&lt;/code&gt; reduced to 2.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How it stopped&lt;/th&gt;
&lt;th&gt;Failure triggers&lt;/th&gt;
&lt;th&gt;Instances created&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pending()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Next Alarm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ctx.abort()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;parked&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;None&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regular exception&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Empty&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A regular exception does not reset the Durable Object, so it does not count toward the restart limit. To reach Parked, you need a failure that actually causes a restart.&lt;/p&gt;

&lt;p&gt;A Parked task will not keep waking the Durable Object if left alone. There are two ways to handle it manually.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Return value&lt;/th&gt;
&lt;th&gt;&lt;code&gt;wait()&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.retry()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;done&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.abort()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ok&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;unanswered&lt;/code&gt; / &lt;code&gt;aborted&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;retry()&lt;/code&gt; retries the task; &lt;code&gt;abort()&lt;/code&gt; abandons it. This design does more than stop a crash loop: it lets an operator decide whether to resume or abandon the task.&lt;/p&gt;

&lt;p&gt;Also, even when a tool emitted progress eight times via &lt;code&gt;api.output()&lt;/code&gt;, the restart counter did not reset. Pi considers a task’s transition to its next committed state to be progress, not tool output. If a single unfinished tool keeps emitting output, the crash-loop limit is not reset.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Storage Error Recovery takes effect on the “next operation”
&lt;/h2&gt;

&lt;p&gt;Storage Error Recovery lets Pi be reopened after a storage error. I wrapped Storage in a Proxy and injected two kinds of errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Return an arbitrary error from methods such as &lt;code&gt;commit()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Return a &lt;code&gt;StorageRequestError&lt;/code&gt; from a read&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In both cases, the call already in progress failed. But the next &lt;code&gt;submit()&lt;/code&gt; succeeded, and the run eventually reached &lt;code&gt;done&lt;/code&gt;. For a fatal Storage error, the factory was called twice instead of once, but the Durable Object’s instance ID did not change.&lt;/p&gt;

&lt;p&gt;So this is not Durable Object crash recovery. PiHarness removes the broken Pi instance from its cache and reopens Pi on the next operation or the next step of a Wake job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The call already in progress is not saved.&lt;/strong&gt; The specification also says that an in-progress call fails with &lt;code&gt;SessionFailed&lt;/code&gt;. The factory must be written so it can initialize correctly every time it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. &lt;code&gt;replay: "safe"&lt;/code&gt; does not protect side effects
&lt;/h2&gt;

&lt;p&gt;This is the central finding. I created four types of tools and recorded their execution start, side-effect commit, and completion separately. I stopped the Durable Object immediately after the side effect was committed and compared what happened after restart.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;&lt;code&gt;replay&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Idempotent&lt;/th&gt;
&lt;th&gt;Execution started&lt;/th&gt;
&lt;th&gt;Side effects&lt;/th&gt;
&lt;th&gt;Completed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;write_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;safe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bump_unsafe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unspecified&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bump_safe_unguarded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;safe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bump_safe_guarded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;safe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;write_record&lt;/code&gt; writes the same value, so running it twice does not change the result. &lt;code&gt;bump_safe_unguarded&lt;/code&gt; simply increments a counter, so each execution changes the result. When I marked this tool &lt;code&gt;safe&lt;/code&gt;, Pi trusted that declaration and ran it again, committing the side effect twice.&lt;/p&gt;

&lt;p&gt;On the other hand, &lt;code&gt;bump_unsafe&lt;/code&gt; is not automatically re-executed. In the case where the Durable Object crashed after the side effect was committed, the side effect remained once, but the tool did not complete. The Agent resolves the run by passing the interrupted tool result to the model.&lt;/p&gt;

&lt;p&gt;An interrupted tool does not get a special transcript entry. It uses the regular &lt;code&gt;pi.tool-result&lt;/code&gt; entry, but its content changes to something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;harness&amp;gt;
[error] Tool bump_unsafe was interrupted and may have partially run
&amp;lt;/harness&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is plain text telling the model that the tool may have run partially. Whether the model calls the same tool again is up to the model. In three tests with a real model, it did not call the tool again, but that’s just one model, one prompt, and three trials, so I won’t generalize from it.&lt;/p&gt;

&lt;p&gt;I added an idempotency key to &lt;code&gt;bump_safe_guarded&lt;/code&gt;. It was re-executed, but the second run checked the key and skipped the side effect, keeping the total number of commits to one.&lt;/p&gt;

&lt;p&gt;The takeaway is clear. &lt;strong&gt;Pi will not warn you if you choose &lt;code&gt;safe&lt;/code&gt; incorrectly.&lt;/strong&gt; If your tools interact with external APIs, payment systems, email, databases, and so on, you need to implement idempotency keys or deduplication separately from the &lt;code&gt;safe&lt;/code&gt; setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Model Stream Retry checks error messages
&lt;/h2&gt;

&lt;p&gt;Model Stream Retry treats an interrupted model stream as a temporary error and retries the entire turn. Reading the package revealed that it uses a regular expression to match the error text.&lt;/p&gt;

&lt;p&gt;I made the same number of attempts fail and changed only the error message.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error message&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Model calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;The stream ended before the model finished its answer.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unanswered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;The stream ended without the model finishing its answer.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;done&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Model stream stalled: no data received for 60s.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unanswered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Model stream timed out: no data received for 60s.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;done&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;insufficient_quota: the account has no remaining credit.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unanswered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For messages that trigger a retry, there were three calls: two failures and one success. In other words, a retry starts a new model request; it does not continue a partial stream.&lt;/p&gt;

&lt;p&gt;Even when I set &lt;code&gt;baseDelayMs&lt;/code&gt; to 100 seconds, the wait was capped at about 60 seconds. That’s because the default &lt;code&gt;maxAgentDelayMs&lt;/code&gt; is 60,000 milliseconds. Increasing the setting does not mean it will wait indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Hibernation resumes on a different instance while waiting
&lt;/h2&gt;

&lt;p&gt;For the Hibernation test, I first compared alarm times. But in local workerd, the alarm timings matched whether or not I passed &lt;code&gt;onWake&lt;/code&gt;. I was measuring the wrong thing.&lt;/p&gt;

&lt;p&gt;The essence of Hibernation is not the alarm time, but &lt;strong&gt;whether the Durable Object can be evicted from memory while waiting&lt;/strong&gt;. So I deployed it and recorded the instance ID in the constructor to see which instance ran the retry.&lt;/p&gt;

&lt;p&gt;When I passed &lt;code&gt;onWake&lt;/code&gt;, the sequence was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model_error@old-instance
→ instance_started@new-instance
→ model_request@new-instance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;onWake&lt;/code&gt;, the sequence was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model_error@same-instance
→ model_request@same-instance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;onWake&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Instance that ran the retry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Different instance&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not passed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Same instance&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I did not send any requests while the retry was waiting. Touching the object would wake it and prevent me from observing eviction.&lt;/p&gt;

&lt;p&gt;In-progress work and a sleep while waiting to retry are different states. While work is active, the heartbeat Alarm is rescheduled every 30 seconds. During sleep, the next Alarm is scheduled for the time of the earliest task to wake. Mixing these two up can lead to misreading the Hibernation test results.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Reproducing the &lt;code&gt;agents@0.30.0&lt;/code&gt; bug
&lt;/h2&gt;

&lt;p&gt;I tried to start testing with the officially recommended &lt;code&gt;agents@0.30.0&lt;/code&gt;. But this version has a known bug: a task waiting to retry will not wake while another task is running.&lt;/p&gt;

&lt;p&gt;I ran these two tasks concurrently in one Harness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Session A: run a tool that takes 60 seconds&lt;/li&gt;
&lt;li&gt;Session B: return an error that triggers a retry and enter a 12-second backoff&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;B running alone&lt;/th&gt;
&lt;th&gt;With concurrent work&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agents@0.30.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12 seconds&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12 seconds&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agents@0.30.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12 seconds&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;57 seconds&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With 0.30.0, B’s retry was blocked until A completed. With 0.30.1, B resumed after the expected 12 seconds, even while A was running.&lt;/p&gt;

&lt;p&gt;The cause is also clear from the package diff. Version 0.30.0 checked only whether Pi was active. Version 0.30.1 uses the sleep deadline &lt;code&gt;wake.at&lt;/code&gt; as the next wake-up deadline and calls &lt;code&gt;pi.resume()&lt;/code&gt; when it is reached.&lt;/p&gt;

&lt;p&gt;This isn’t meant to be alarmist. It’s a concrete example of PiHarness being labeled Beta. If you’re testing Pi 1.4 Hibernation, you need to use &lt;code&gt;agents@0.30.1&lt;/code&gt; or later.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Once you upgrade to Pi 1.4, you can’t go back to 1.3
&lt;/h2&gt;

&lt;p&gt;Pi 1.4 updates the SQLite schema for session storage, so you cannot roll back to Pi 1.3.&lt;/p&gt;

&lt;p&gt;After running one turn with Pi 1.4, the schema version was &lt;code&gt;2&lt;/code&gt;. Comparing the migration definitions in the packages shows this difference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Migrations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pi-durable@1.3.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Version 1 only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pi-durable@1.4.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Version 1 and &lt;strong&gt;version 2 &lt;code&gt;sleepingStatus&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Pi 1.3 migration runner returns an error if it encounters a newer schema version it does not support. The added &lt;code&gt;sleepingStatus&lt;/code&gt; is the &lt;code&gt;sleeping&lt;/code&gt; task state that supports Pi 1.4 Hibernation.&lt;/p&gt;

&lt;p&gt;In other words, being able to evict a Durable Object and losing the ability to roll back are not separate changes. They are two sides of the same schema change.&lt;/p&gt;

&lt;p&gt;When comparing Pi 1.4 and 1.3, do not reuse the same Durable Object. Use separate names for experiments and treat migrations as one-way.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. What I learned by rerunning the tests
&lt;/h2&gt;

&lt;p&gt;I did not publish the initial test results as-is. After rebuilding the controls, I revised or held back three claims.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSocket event loss
&lt;/h3&gt;

&lt;p&gt;At first, it looked like a slow event listener was missing events. But the cause was that I closed the WebSocket after eight seconds. When I extended the drain time to 30 seconds, the event counts matched even with the slow listener.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining progress
&lt;/h3&gt;

&lt;p&gt;I had assumed that updating my own counter meant Pi had made progress. Even with a control that called &lt;code&gt;api.output()&lt;/code&gt; eight times, an unfinished tool still became Parked. Progress means that the task committed a state, not that the tool emitted output.&lt;/p&gt;

&lt;h3&gt;
  
  
  A 19-minute model stream
&lt;/h3&gt;

&lt;p&gt;I streamed data for about 19.2 minutes using a faux Provider inside a Worker, and it completed with one model call. However, this did not measure keeping an HTTP connection to an external model API open for 19 minutes. Since it does not disprove the official limitation that requests lasting more than 15 minutes may be disconnected, I’m leaving this conclusion open.&lt;/p&gt;

&lt;p&gt;When testing failure recovery, “I got a result” is not enough. You need controls to rule out possibilities such as the failure never occurring in the first place or the observation side closing the connection too soon.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Conclusion
&lt;/h2&gt;

&lt;p&gt;The Cloudflare Agents and Pi 1.4 recovery mechanisms worked in practice. Conversation history could be restored after a restart, completed tasks were not executed twice, crash loops stopped with Parked Tasks, and an Agent waiting to retry could resume on a different instance through Hibernation.&lt;/p&gt;

&lt;p&gt;But Pi protects the Agent’s execution state. It does not automatically make your external side effects exactly-once.&lt;/p&gt;

&lt;p&gt;When implementing this, keep these points in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add idempotency keys or deduplication for external operations&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;replay: "safe"&lt;/code&gt; only for tools you can prove are safe to execute twice&lt;/li&gt;
&lt;li&gt;Marking a tool &lt;code&gt;unsafe&lt;/code&gt; does not undo a side effect that has already been committed&lt;/li&gt;
&lt;li&gt;Write your factory assuming Storage Error Recovery may call it multiple times&lt;/li&gt;
&lt;li&gt;If you use Hibernation, use &lt;code&gt;agents@0.30.1&lt;/code&gt; or later&lt;/li&gt;
&lt;li&gt;You cannot roll back a Durable Object upgraded to Pi 1.4 to Pi 1.3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I had to distill one lesson from this test, it would be this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Recovered” and “completed without duplicates” are two different claims.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/changelog/post/2026-10-22-pi-harness-pi-1-4/" rel="noopener noreferrer"&gt;Cloudflare Changelog — Agents SDK now supports the latest Pi 1.4 release&lt;/a&gt; — The four items announced as Pi 1.4 support&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/agents/harnesses/pi/" rel="noopener noreferrer"&gt;Cloudflare Docs — Pi&lt;/a&gt; — Official documentation for PiHarness, Recovery, and Hibernation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/agents/harnesses/pi/extensions/" rel="noopener noreferrer"&gt;Cloudflare Docs — Pi Extensions&lt;/a&gt; — Tool &lt;code&gt;replay&lt;/code&gt; settings and behavior during recovery&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/agents/blob/main/packages/agents/CHANGELOG.md#0300" rel="noopener noreferrer"&gt;&lt;code&gt;agents&lt;/code&gt; CHANGELOG&lt;/a&gt; — Changes in &lt;code&gt;0.30.0&lt;/code&gt; / &lt;code&gt;0.30.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/agents/issues/2683" rel="noopener noreferrer"&gt;cloudflare/agents#2683&lt;/a&gt; — Known bug involving &lt;code&gt;onWake&lt;/code&gt; and concurrent tasks&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/earendil-works/pi/blob/v1.4.0/packages/durable/CHANGELOG.md#140---2026-10-21" rel="noopener noreferrer"&gt;&lt;code&gt;pi-durable&lt;/code&gt; CHANGELOG 1.4.0&lt;/a&gt; — Pi 1.4 &lt;code&gt;sleeping&lt;/code&gt; tasks and schema migration&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://earendil.com/posts/pi-durable/" rel="noopener noreferrer"&gt;Pi Durable design article&lt;/a&gt; — Checkpoint and task execution design&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cloudflare</category>
      <category>agents</category>
      <category>durableobjects</category>
      <category>ai</category>
    </item>
    <item>
      <title>MCP Step-Up Authorization vs. OAuth Incremental Authorization: What Actually Changes?</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:58:00 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/mcp-step-up-authorization-vs-oauth-incremental-authorization-what-actually-changes-2a3j</link>
      <guid>https://dev.to/tanahiro2010/mcp-step-up-authorization-vs-oauth-incremental-authorization-what-actually-changes-2a3j</guid>
      <description>&lt;h1&gt;
  
  
  MCP Step-Up Authorization vs. OAuth Incremental Authorization: What Actually Changes?
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;“There are no zones.” There were three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;When Cloudflare announced step-up authorization for its API MCP Server, I wanted to know how it differed from Google OAuth's Incremental Authorization. I read the specs, wrote my own MCP client, and sent 128 requests to the live service. &lt;strong&gt;The additional authorization itself is still ordinary OAuth. The difference is a path for the server to tell the client, “You don't have enough permission.”&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The client remembers previously granted scopes.&lt;/strong&gt; Cloudflare's authorization server does not carry over previously granted scopes outside the baseline set. This is the opposite of Google's &lt;code&gt;include_granted_scopes&lt;/code&gt; behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But Cloudflare's server remembered them too.&lt;/strong&gt; The &lt;code&gt;scope&lt;/code&gt; in a 403 challenge followed the scopes granted to that token. That is the approach MCP removed in the 2026-07-28 specification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The challenge path did not cover every operation.&lt;/strong&gt; On the same server, some insufficiently authorized operations returned a 403 challenge, while others returned &lt;code&gt;200&lt;/code&gt; with an empty list.

&lt;ul&gt;
&lt;li&gt;I reported the third finding to Cloudflare, and it was fixed that same day. The follow-up is in §13.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is what happened when I called the same APIs before and after granting scopes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Before granting scopes&lt;/th&gt;
&lt;th&gt;After granting scopes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;List zones&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 items&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 items&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List Workers scripts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 items&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12 items&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List D1 databases&lt;/td&gt;
&lt;td&gt;403 challenge&lt;/td&gt;
&lt;td&gt;5 items&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test code: &lt;a href="https://github.com/tanahiro2010/slides/tree/main/slides/slides/MCP%E3%81%AEStep-up%20Authorization%E3%81%AF%E3%80%81OAuth%E3%81%AEIncremental%20Authorization%E3%81%A8%E4%BD%95%E3%81%8C%E9%81%95%E3%81%86%E3%81%AE%E3%81%8B%EF%BC%9F/sandbox" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me state the main point upfront.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step-up Authorization does not introduce a new way to authorize. It introduces a way to communicate that a scope is missing.&lt;/strong&gt; In a traditional web app, developers know which feature needs which scope. With MCP, an AI chooses tools, so the client may not know in advance. The server has to notice that permission is missing, and it needs a way to tell the client. When that path works, a client can start with minimal permissions. When it doesn't, the AI simply reports that something “doesn't exist.”&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article expands on my lightning talk, “How Is MCP Step-up Authorization Different from OAuth Incremental Authorization?”, with measurements I couldn't fit into the talk and the details of a conclusion I had to retract once. It is for readers who know the basics of OAuth 2.0 and may build MCP servers.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The third step in a three-part rollout
&lt;/h2&gt;

&lt;p&gt;On October 19, 2026, Cloudflare posted this in its Changelog:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Grant only the required scopes when you connect, then authorize more on demand as your agent performs a task. When a tool call needs a scope you have not granted, the server returns an &lt;code&gt;insufficient_scope&lt;/code&gt; challenge naming it, and your MCP client asks you to approve it. Scopes you already granted are kept.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Start with the minimum, then add permissions when needed. When I read that, I couldn't tell how it differed from &lt;code&gt;include_granted_scopes=true&lt;/code&gt;, which I already use in my own projects. That question started this investigation.&lt;/p&gt;

&lt;p&gt;The announcement was not an isolated change. It was the third step in a rollout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;August 22, 2026&lt;/td&gt;
&lt;td&gt;Wrangler and the API MCP Server added optional scopes and “Edit Permissions” on the consent screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;October 1, 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;workers-oauth-provider&lt;/code&gt; v1 made step-up possible with a one-line &lt;code&gt;insufficientScope()&lt;/code&gt; call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;October 19, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The API MCP Server added step-up authorization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The August Changelog ended with this instruction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a command or tool call needs a scope that you declined, &lt;strong&gt;reauthorize the client&lt;/strong&gt; and grant that scope.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At that point, if you declined a permission, you had to reconnect manually. The ability to grant fewer permissions came with extra friction. The October change removed that manual step.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Two versus 384
&lt;/h2&gt;

&lt;p&gt;Cloudflare's MCP server publishes two different &lt;code&gt;scopes_supported&lt;/code&gt; lists. They have the same name, but different RFCs define them for different purposes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://mcp.cloudflare.com/.well-known/oauth-authorization-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metadata&lt;/th&gt;
&lt;th&gt;RFC&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protected Resource Metadata&lt;/td&gt;
&lt;td&gt;RFC 9728&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user:read&lt;/code&gt;, &lt;code&gt;account:read&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization Server Metadata&lt;/td&gt;
&lt;td&gt;RFC 8414&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;384&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dns.write&lt;/code&gt;, &lt;code&gt;zone.write&lt;/code&gt;, &lt;code&gt;workers-r2.read&lt;/code&gt;, …&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first list says what is minimally needed to access this resource. The second is the full catalog of scopes this authorization server can issue. The MCP specification describes the first as &lt;code&gt;the minimal set of scopes necessary for basic functionality&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without authentication, the server returns a 401 naming those two scopes:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt;
&lt;span class="na"&gt;www-authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer realm="OAuth",&lt;/span&gt;
&lt;span class="s"&gt;  resource_metadata="https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp",&lt;/span&gt;
&lt;span class="s"&gt;  scope="user:read account:read"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The gap between 2 and 384 is why this feature exists.&lt;/strong&gt; Without step-up, the client has to choose from 384 scopes up front. Request too few and things don't work; request too many and you overgrant access. Starting with just two scopes is practical only if you can add more when you discover you need them.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Step-up builds on RFC 6750
&lt;/h2&gt;

&lt;p&gt;In the MCP specification, step-up starts with a &lt;code&gt;403 insufficient_scope&lt;/code&gt; response:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="ne"&gt;Forbidden&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer error="insufficient_scope",&lt;/span&gt;
&lt;span class="s"&gt;                         scope="files:write",&lt;/span&gt;
&lt;span class="s"&gt;                         resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",&lt;/span&gt;
&lt;span class="s"&gt;                         error_description="File write permission required for this operation"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MCP did not invent this error code. It was defined in &lt;a href="https://www.rfc-editor.org/rfc/rfc6750#section-3.1" rel="noopener noreferrer"&gt;RFC 6750 §3.1&lt;/a&gt; in 2012:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;insufficient_scope&lt;/strong&gt; — The request requires higher privileges than provided by the access token.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What MCP adds is the procedure that turns this response into reauthorization:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parse &lt;code&gt;WWW-Authenticate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Determine the required scopes as “the scopes requested last time ∪ the scopes in the challenge.”&lt;/li&gt;
&lt;li&gt;Reauthorize. &lt;strong&gt;This is the normal OAuth authorization-code flow.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Retry the original request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Compared with Google OAuth Incremental Authorization, the differences come down to three questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Google Incremental Authorization&lt;/th&gt;
&lt;th&gt;MCP Step-up Authorization&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who notices that more access is needed?&lt;/td&gt;
&lt;td&gt;The application, before it calls the API&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The server&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How is the missing scope communicated?&lt;/td&gt;
&lt;td&gt;No challenge; the app already knows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WWW-Authenticate: scope="…"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who remembers previously granted scopes?&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;The authorization server&lt;/strong&gt; (&lt;code&gt;include_granted_scopes=true&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The client&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How does reauthorization work?&lt;/td&gt;
&lt;td&gt;OAuth authorization-code flow&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The same flow&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row cannot be resolved by reading the specification alone. The Changelog says “Scopes you already granted are kept,” but leaves out the subject. The client may be taking a union, or the authorization server may be remembering the earlier grant. Both interpretations fit. Determining which one was true in the live service became the center of my test.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Terminology: baseline&lt;/strong&gt;&lt;br&gt;
In this article, &lt;em&gt;baseline&lt;/em&gt; means the minimal scopes named by Protected Resource Metadata (&lt;code&gt;user:read&lt;/code&gt; and &lt;code&gt;account:read&lt;/code&gt;) plus &lt;code&gt;offline_access&lt;/code&gt;. Cloudflare's consent screen presents these three every time, regardless of previous grants.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. It was not a new feature in the 2026-07-28 spec
&lt;/h2&gt;

&lt;p&gt;The Changelog linked to step-up in MCP 2026-07-28, so I assumed it was new. It wasn't. The “Step-Up Authorization Flow” section was already in the &lt;strong&gt;2025-11-25&lt;/strong&gt; specification. It was absent before 2025-06-18.&lt;/p&gt;

&lt;p&gt;What changed in 2026-07-28 was &lt;strong&gt;who creates the union of scopes&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Specification&lt;/th&gt;
&lt;th&gt;Who creates the union?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The server&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The client&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 2025-11-25 version gave servers three choices: Minimum, Recommended, and Extended. All three assumed that the server would include previously granted scopes in the 403 challenge. The 2026-07-28 version removed all three choices.&lt;/p&gt;

&lt;p&gt;The reason is in &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2349" rel="noopener noreferrer"&gt;Issue #2349&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This &lt;strong&gt;contradicts the RFC&lt;/strong&gt;, creates an &lt;strong&gt;impossible requirement for stateless servers&lt;/strong&gt;, and leads to &lt;strong&gt;infinite re-authorization loops&lt;/strong&gt; in practice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/modelcontextprotocol/typescript-sdk/issues/1582" rel="noopener noreferrer"&gt;TypeScript SDK Issue #1582&lt;/a&gt; demonstrates the loop. Imagine a server where &lt;code&gt;initialize&lt;/code&gt; requires &lt;code&gt;init&lt;/code&gt;, while &lt;code&gt;tools/list&lt;/code&gt; requires &lt;code&gt;mcp:tools:read&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as MCP Client
    participant S as MCP Server
    C-&amp;gt;&amp;gt;S: tools/list (token: init)
    S--&amp;gt;&amp;gt;C: 403 scope="mcp:tools:read"
    Note over C: Overwrites _scope; loses init
    C-&amp;gt;&amp;gt;S: tools/list (token: mcp:tools:read)
    S--&amp;gt;&amp;gt;C: 200
    C-&amp;gt;&amp;gt;S: initialize (token: mcp:tools:read)
    S--&amp;gt;&amp;gt;C: 403 scope="init"
    Note over C: Overwrites _scope again; loses mcp:tools:read
    Note over C,S: The loop continues&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;If a 403 faithfully returns only the scope required by that operation, the previous permission disappears as soon as the client overwrites its scope list. A union is not just good manners; it prevents this failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. I wrote a client without the SDK
&lt;/h2&gt;

&lt;p&gt;I did not use &lt;code&gt;@modelcontextprotocol/client&lt;/code&gt; for this test. As soon as that library sees a 403, it calls &lt;code&gt;computeScopeUnion()&lt;/code&gt; and automatically unions the scopes before reauthorization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeScopeUnion&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;scopes&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;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;scopes&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="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;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&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="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a correct implementation. But it means I cannot test reauthorization &lt;strong&gt;without&lt;/strong&gt; a scope union. It would be impossible to isolate who is preserving the previous scopes. So I wrote a minimal client that manually performs OAuth and MCP requests. Being able to choose the requested scopes each time was essential to this test.&lt;/p&gt;

&lt;p&gt;I did not need to pre-register a client. Cloudflare's authorization server supports Dynamic Client Registration, so the script can register itself as a public client. It does not receive a &lt;code&gt;client_secret&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The transport does not throw on HTTP errors. A 400 or 403 is an observation, so the client records and returns the status, headers, and body instead of swallowing them. Every request passes through one hook and is logged as one line of JSON per round trip. Secrets are replaced with fingerprints, not removed entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"access_token"&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;"_redacted_len"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"_sha256_16"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"184d68d85f68caab"&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;Replacing a value with &lt;code&gt;&amp;lt;redacted&amp;gt;&lt;/code&gt; makes it impossible to show whether the same token was sent repeatedly or a new one was issued each time. A fingerprint shows both without disclosing the token. I later used matching fingerprints sent to two resources as evidence that they were the same token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two requested scopes, three granted
&lt;/h3&gt;

&lt;p&gt;The consent process has two screens. First, you choose a preset that includes “Minimal.” Then you see a breakdown of the requested permissions. The Changelog's instruction to “choose &lt;strong&gt;Minimal&lt;/strong&gt; on the consent page” refers to the first screen.&lt;/p&gt;

&lt;p&gt;The second screen has REQUIRED and ADDITIONAL ACCESS sections. Required permissions cannot be removed. Individual optional permissions can be unchecked through Edit Permissions. This is where the optional-scope change from August appears. I show the actual screen in §7.&lt;/p&gt;

&lt;p&gt;I requested two scopes, &lt;code&gt;user:read account:read&lt;/code&gt;, but received three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Requested: user:read account:read
Granted:   user:read offline_access account:read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I briefly wondered why I had an &lt;code&gt;offline_access&lt;/code&gt; scope I never requested. The screen explained it: Background Access was listed as REQUIRED. This made a basic lesson matter from the first test: &lt;strong&gt;record requested scopes and granted scopes separately.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The challenge &lt;code&gt;scope&lt;/code&gt; included the baseline
&lt;/h2&gt;

&lt;p&gt;To trigger 403 responses, I tried read-only endpoints systematically. The OpenAPI specification returned by the &lt;code&gt;search&lt;/code&gt; tool includes &lt;code&gt;x-api-token-group&lt;/code&gt;, whose description says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;API token permissions the endpoint accepts (any one); &lt;strong&gt;OAuth scopes have the same names&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This lets you derive the mapping between scopes and endpoints from the server itself. For example, &lt;code&gt;GET /zones&lt;/code&gt; lists &lt;code&gt;Zone Zone Read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When I called an operation requiring a scope I did not have, I got this challenge:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer realm="OAuth", error="insufficient_scope",&lt;/span&gt;
&lt;span class="s"&gt;  scope="user:read account:read workers-r2.read",&lt;/span&gt;
&lt;span class="s"&gt;  resource_metadata="https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp",&lt;/span&gt;
&lt;span class="s"&gt;  error_description="This operation needs the workers-r2.read scope"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scope&lt;/code&gt; includes the baseline. This is where Cloudflare's implementation and its own documentation diverged from the MCP specification:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What to include in the 403 &lt;code&gt;scope&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MCP 2026-07-28 specification&lt;/td&gt;
&lt;td&gt;Only the scope needed by the operation is sufficient; including previous scopes is not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;workers-oauth-provider&lt;/code&gt; documentation&lt;/td&gt;
&lt;td&gt;Include &lt;strong&gt;all scopes, including the baseline&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live service&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The latter&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;error_description&lt;/code&gt; explicitly names the missing scope, which is also helpful for a human reading the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Who remembers? I got this wrong once
&lt;/h2&gt;

&lt;p&gt;This is the central question. I requested &lt;strong&gt;only&lt;/strong&gt; &lt;code&gt;d1.read&lt;/code&gt;, deliberately dropping the three scopes I already had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Requested: d1.read
Granted:   d1.read user:read offline_access account:read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmad40vh3i03ljwk8mdoo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmad40vh3i03ljwk8mdoo.png" alt="Consent screen after requesting only scope=d1.read. D1 Read and Account Read appear under ADDITIONAL ACCESS, with 4 total permissions." width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three scopes I had not requested were still there. I almost wrote, “The authorization server preserves previous grants.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That was wrong.&lt;/strong&gt; There were three possible explanations, and this observation could not distinguish between them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The authorization server preserved previously granted scopes.&lt;/li&gt;
&lt;li&gt;I had included &lt;code&gt;scope: "user:read account:read"&lt;/code&gt; during DCR registration. That was my own confounding factor.&lt;/li&gt;
&lt;li&gt;The resource simply adds its baseline scopes by default.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had introduced the second confound myself. So I created a control: a brand-new client with no previous grants, registered with only &lt;code&gt;d1.read&lt;/code&gt;, and opened the same consent screen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo1e4igibsvi1djey5a0x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo1e4igibsvi1djey5a0x.png" alt="Consent screen for a client with no previous grants. It is identical to the first screen." width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screens were identical: four permissions, User Read and Background Access under REQUIRED, and D1 Read and Account Read under ADDITIONAL ACCESS. Account Read appeared even though this client had never been authorized before.&lt;/p&gt;

&lt;p&gt;So explanation 3 accounted for it. &lt;strong&gt;I retracted my conclusion.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I redesigned the test: start with a non-baseline scope, &lt;code&gt;d1.read&lt;/code&gt;, then request only &lt;code&gt;workers-r2.read&lt;/code&gt;. The default baseline could no longer explain the result.&lt;/p&gt;

&lt;p&gt;D1 Read was absent from the consent screen, even though it had been granted moments earlier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faccnskxacbvyu6k0c7ey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faccnskxacbvyu6k0c7ey.png" alt="Consent screen after requesting only scope=workers-r2.read. Developer Platform shows only Workers R2 Storage Read; D1 Read is absent." width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After approval, the grant was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Requested: workers-r2.read
Granted:   workers-r2.read user:read offline_access account:read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;d1.read&lt;/code&gt; was gone. I verified the behavior rather than relying on the text alone: the D1 list endpoint returned a 403 challenge again. The capability itself had been removed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cloudflare's authorization server does not carry over previously granted scopes outside the baseline.&lt;br&gt;
The Changelog's statement that “Scopes you already granted are kept” is true because the client creates a union. That is structurally the reverse of Google's &lt;code&gt;include_granted_scopes&lt;/code&gt;, where the authorization server combines the scopes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  8. The server remembered them too—the approach the spec dropped
&lt;/h2&gt;

&lt;p&gt;I changed the scopes in the token and observed the challenge several times:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scopes held&lt;/th&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Challenge &lt;code&gt;scope&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline only&lt;/td&gt;
&lt;td&gt;List R2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user:read account:read workers-r2.read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseline + &lt;code&gt;workers-r2.read&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;List D1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user:read account:read workers-r2.read&lt;/code&gt; &lt;strong&gt;&lt;code&gt;d1.read&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseline + &lt;code&gt;workers-r2.read&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;List KV&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user:read account:read workers-r2.read&lt;/code&gt; &lt;strong&gt;&lt;code&gt;workers-kv-storage.read&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The challenge was not a fixed baseline. &lt;strong&gt;It followed the scopes granted to the token.&lt;/strong&gt; In other words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;challenge.scope = scopes already granted to token ∪ scopes required by operation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the “Recommended approach” from the 2025-11-25 specification—the approach removed in 2026-07-28. Issue #2349 said this was “impossible” for stateless servers because the server needs to know what scopes the token contains to build the union. Cloudflare shares state between its authorization server and resource server, so it does not have that limitation.&lt;/p&gt;

&lt;p&gt;One important consequence is that &lt;strong&gt;older clients keep working&lt;/strong&gt;. &lt;code&gt;@modelcontextprotocol/sdk@1.32.1&lt;/code&gt; overwrites &lt;code&gt;_scope&lt;/code&gt; in its 403 handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insufficient_scope&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Overwrites the value; previous scopes are lost&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is exactly the condition for the loop in §4. It still works against Cloudflare because the server builds the union on the client's behalf. The specification puts the responsibility on the client, but this implementation currently does both.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. “There are no zones”
&lt;/h2&gt;

&lt;p&gt;While testing endpoints systematically, I found operations that did not return a 403:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Required scope&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R2 / KV / D1 / Queues / Images&lt;/td&gt;
&lt;td&gt;Each resource's &lt;code&gt;*.read&lt;/code&gt; scope&lt;/td&gt;
&lt;td&gt;403 + challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List zones&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zone.read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200 + empty list&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List Workers scripts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;workers-scripts.read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200 + empty list&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/user/tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not in the catalog&lt;/td&gt;
&lt;td&gt;403 in the tool result text&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the two middle rows, I could not initially tell whether permissions filtered the results or the account really had zero items. I granted &lt;code&gt;zone.read&lt;/code&gt; and &lt;code&gt;workers-scripts.read&lt;/code&gt;, then repeated the same calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;List zones:             0 items → 3 items
List Workers scripts:  0 items → 12 items
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The resources existed in the account.&lt;/strong&gt; It was the same account and client; I changed only the scopes.&lt;/p&gt;

&lt;p&gt;These operations fall outside the step-up path. Since they do not return a 403, the client does not learn that it lacks permission. It never gets a chance to ask the user for additional authorization. The AI says, “There are no zones,” and the conversation ends there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/user/tokens&lt;/code&gt; fails differently:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cloudflare API error: GET /user/tokens returned HTTP 403: 9109: Unauthorized to access requested resource. &lt;strong&gt;No OAuth scope covers this endpoint, so OAuth connections cannot use it.&lt;/strong&gt; Connect with an API token that has one of these permissions instead: API Tokens Write, API Tokens Read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is no corresponding OAuth scope in the catalog, so there is no way to step up. This is an honest implementation: the reason is returned as text that both the AI and the user can read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Within one server, I found three different ways an authorization failure can be represented.&lt;/strong&gt; This difference matters when you design how to give an agent access. Only operations that return a challenge can recover on their own after starting with minimal permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Refresh cannot add scopes
&lt;/h2&gt;

&lt;p&gt;The reason additional authorization has to return to the browser is in RFC 6749 §6: a refresh token cannot be used to broaden its scope. I tested it directly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;Granted scopes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Omit &lt;code&gt;scope&lt;/code&gt; (control)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Same original seven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broaden (&lt;code&gt;workers-kv-storage.read&lt;/code&gt; added)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Same original seven&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narrow (request baseline only)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Same original seven&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The broader request follows the RFC. The extra scope is silently ignored rather than rejected. I confirmed this behaviorally too: the KV list still returned a 403.&lt;/p&gt;

&lt;p&gt;The narrowing result surprised me. RFC 6749 §6 does not prohibit narrowing, and the &lt;code&gt;workers-oauth-provider&lt;/code&gt; docs say, “Token and refresh requests can only narrow the grant.” In the live service, the request was ignored and &lt;code&gt;d1.read&lt;/code&gt; still worked.&lt;/p&gt;

&lt;p&gt;The library source implements narrowing correctly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tokenScopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downscope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;grantData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;downscope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestedScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowedScopes&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;requestedScope&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;allowedScopes&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;requestedScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scope&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;allowedScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scope&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 docs accurately describe the library. The mismatch is in the hosted service. The challenge header matches &lt;code&gt;insufficientScope()&lt;/code&gt; in &lt;code&gt;workers-oauth-provider&lt;/code&gt;, but refresh-scope handling and token validation are different. &lt;strong&gt;Do not assume that an OSS library and a hosted service with the same name behave identically.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I also found two related details: refreshes are serialized, and overlapping requests return &lt;code&gt;429 temporarily_unavailable&lt;/code&gt;; and the &lt;code&gt;refresh_token&lt;/code&gt; rotates on every refresh.&lt;/p&gt;

&lt;h3&gt;
  
  
  I wrote a conclusion without having measured it
&lt;/h3&gt;

&lt;p&gt;At first, I sent the three refresh cases back to back. The second and later requests failed with 429, so &lt;strong&gt;the requests that were supposed to test the main point never ran&lt;/strong&gt;. Yet the script printed “scope was not broadened.” An empty diff did not mean that was the service's behavior; the request had not reached the relevant code.&lt;/p&gt;

&lt;p&gt;I retracted the result and reran the tests with delays. I changed the script to treat 429 as “not measured yet” and retry with exponential backoff. Do not mistake a conclusion printed by a script for an observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. The 2026-07-28 protocol has no &lt;code&gt;initialize&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This was the first implementation snag. I sent &lt;code&gt;initialize&lt;/code&gt; and got a 400 response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-32020 the request headers and body disagree: an initialize request
       (legacy handshake) was sent with a modern MCP-Protocol-Version header
-32602 the MCP-Protocol-Version header names protocol revision 2026-07-28,
       but the request is missing the required per-request envelope key(s): _meta
-32020 the body names method tools/list but the required Mcp-Method header is absent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 2026-07-28 protocol is stateless and has no &lt;code&gt;initialize&lt;/code&gt; handshake. Instead, every request carries this in &lt;code&gt;params._meta&lt;/code&gt;:&lt;br&gt;
&lt;/p&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;"_meta"&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;"io.modelcontextprotocol/protocolVersion"&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-07-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"io.modelcontextprotocol/clientCapabilities"&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;"io.modelcontextprotocol/clientInfo"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"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;"…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also repeats the request body information in headers: &lt;code&gt;Mcp-Method: tools/call&lt;/code&gt; and &lt;code&gt;Mcp-Name: &amp;lt;tool name&amp;gt;&lt;/code&gt;. That lets an intermediary route or rate-limit requests without opening the body—a useful design for a company that operates a CDN. The Claude Code binary does the same thing.&lt;/p&gt;

&lt;p&gt;The legacy &lt;code&gt;initialize&lt;/code&gt; still works if you omit the &lt;code&gt;MCP-Protocol-Version&lt;/code&gt; header. I confirmed both 2025-11-25 and 2026-07-28 return 200 with the same token.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Aside: a 401/403 mix-up&lt;/strong&gt;&lt;br&gt;
This is not directly related to step-up, so I will keep it short. A randomly generated 43-character string that had never been issued returned &lt;strong&gt;403 &lt;code&gt;insufficient_scope&lt;/code&gt;&lt;/strong&gt; instead of &lt;code&gt;401 invalid_token&lt;/code&gt;. Binary search narrowed the boundary to exactly 40 characters: strings of 40 or more &lt;code&gt;[A-Za-z0-9_-]&lt;/code&gt; characters got a 403; 39 or fewer characters, or strings containing &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, or spaces, got a 401.&lt;/p&gt;

&lt;p&gt;Since 403 &lt;code&gt;insufficient_scope&lt;/code&gt; triggers step-up in MCP, I wondered whether an expired client might be prompted to request more permissions. &lt;strong&gt;It did not.&lt;/strong&gt; I tested three realistic cases, and all returned 401:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State a real client might have&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A real, revoked token&lt;/td&gt;
&lt;td&gt;401 &lt;code&gt;invalid_token&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A valid token for another resource&lt;/td&gt;
&lt;td&gt;401 &lt;code&gt;invalid_token&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A malformed string / 39 characters or fewer&lt;/td&gt;
&lt;td&gt;401 &lt;code&gt;invalid_token&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only a deliberately constructed, never-issued string returned 403. It is an implementation flaw, but it has no practical impact.&lt;/p&gt;

&lt;p&gt;I also tested revocation granularity. Revoking an access token leaves the refresh token alive. Only revoking the refresh token results in &lt;code&gt;invalid_grant "Grant not found"&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  12. So what actually changes?
&lt;/h2&gt;

&lt;p&gt;There are three differences. Everything else is still OAuth:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who notices that permission is missing?&lt;/strong&gt; It moves from the application to the &lt;strong&gt;server&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there a way to communicate it?&lt;/strong&gt; It moves from none (because the app already knows) to &lt;strong&gt;&lt;code&gt;WWW-Authenticate&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who remembers previously granted scopes?&lt;/strong&gt; It moves from the authorization server to the &lt;strong&gt;client&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reauthorization itself is the authorization-code flow in both cases. Step-up does not replace OAuth; it is an entry point back into OAuth.&lt;/p&gt;

&lt;p&gt;Testing also made it clearer why MCP needs this. In a traditional web app, developers know which scopes each feature needs. With MCP, the AI selects a tool based on the user's request, so the client may not know in advance. With a tool like &lt;code&gt;execute()&lt;/code&gt; that can call an arbitrary API internally, the needed permission may not be known until the tool runs. The server is the only component that can notice, so it needs a way to communicate that fact.&lt;/p&gt;

&lt;p&gt;Looking at the implementation, the responsibility that the spec assigns to one side was held by both sides in practice: the client builds a union, and the server returns one too. That is a sensible transition strategy. Older clients do not break.&lt;/p&gt;

&lt;p&gt;The remaining issue is in §9. When an operation does not return a 403, the AI cannot tell that permission is missing. This is less a Cloudflare-specific flaw than a mismatch between &lt;strong&gt;permission-filtered lists and AI agents&lt;/strong&gt;. APIs have returned empty lists for a long time; a human might guess that their permissions hide the results. AI does not infer that. It reports that nothing exists.&lt;/p&gt;

&lt;p&gt;As an MCP server author, I took away one thing: &lt;strong&gt;do not silently return an empty list when permissions are missing.&lt;/strong&gt; Return a 403 challenge, and the client can ask the user. The specification provides the path. Whether an operation uses it is up to the server implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Update: I reported it, and it was fixed that day (2026-10-21)
&lt;/h2&gt;

&lt;p&gt;After publishing the article, I reported the §9 issue to &lt;code&gt;cloudflare/mcp&lt;/code&gt; as &lt;a href="https://github.com/cloudflare/mcp/issues/302" rel="noopener noreferrer"&gt;Issue #302&lt;/a&gt;, titled “A list filtered down to nothing by permissions never raises a scope challenge.”&lt;/p&gt;

&lt;p&gt;Ten minutes later, I opened &lt;a href="https://github.com/cloudflare/mcp/pull/304" rel="noopener noreferrer"&gt;PR #304&lt;/a&gt;. &lt;strong&gt;Two minutes after that&lt;/strong&gt;, Cloudflare opened &lt;a href="https://github.com/cloudflare/mcp/pull/305" rel="noopener noreferrer"&gt;PR #305&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;14:41&lt;/td&gt;
&lt;td&gt;Issue #302&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14:51&lt;/td&gt;
&lt;td&gt;PR #304 (mine)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;14:53&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PR #305 (Cloudflare)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22:11&lt;/td&gt;
&lt;td&gt;#305 merged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This was not a retrospective rewrite: both PRs were opened almost simultaneously. #305 was the one that merged, with this note:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fixes #302, building on &lt;a class="mentioned-user" href="https://dev.to/tanahiro2010"&gt;@tanahiro2010&lt;/a&gt;'s report and branch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The merged commit includes my name in &lt;code&gt;Co-authored-by&lt;/code&gt;. I expected the report might be accepted and fixed, but I did not expect the fix to build on my branch and credit me as a co-author.&lt;/p&gt;

&lt;p&gt;One goal accomplished: becoming a contributor to some Cloudflare repository while still a student. Well, I guess maintainer is next…&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Lists empty because of missing permissions are now treated as refusals.&lt;/strong&gt; &lt;code&gt;GET /zones&lt;/code&gt;, which previously returned &lt;code&gt;200&lt;/code&gt; with an empty list, now returns &lt;code&gt;403&lt;/code&gt; and a &lt;code&gt;WWW-Authenticate&lt;/code&gt; challenge. The same applies to &lt;code&gt;execute&lt;/code&gt;, endpoint tools, and forge tools.&lt;/p&gt;

&lt;p&gt;Of the three failure modes in the article's original table, the second now matches the first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;At publication&lt;/th&gt;
&lt;th&gt;After #305&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R2 / KV / D1&lt;/td&gt;
&lt;td&gt;403 + challenge&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List zones / Workers scripts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200 + empty list&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;403 + challenge&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/user/tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;403 in result text&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A useful implementation detail
&lt;/h3&gt;

&lt;p&gt;One thing I liked in the implementation is that &lt;strong&gt;it does not treat every empty list as suspicious&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;authorize()&lt;/code&gt; in &lt;code&gt;src/api-permissions.ts&lt;/code&gt; returns one of &lt;code&gt;granted&lt;/code&gt;, &lt;code&gt;missing&lt;/code&gt;, &lt;code&gt;unreachable&lt;/code&gt;, &lt;code&gt;direct&lt;/code&gt;, or &lt;code&gt;unlisted&lt;/code&gt;. &lt;code&gt;refusalFor()&lt;/code&gt; in &lt;code&gt;src/api-refusals.ts&lt;/code&gt; converts an empty list into a refusal only when the result is &lt;code&gt;missing&lt;/code&gt;. It does not challenge a list that contains even one item.&lt;/p&gt;

&lt;p&gt;That means accounts that really have zero items are not caught up in this. The challenge applies only when the connection has none of the scopes accepted by that endpoint. If the account really is empty, the user is asked about scopes once and the flow ends.&lt;/p&gt;

&lt;p&gt;The refusal message is also careful: &lt;strong&gt;it does not claim that Cloudflare returned a 403.&lt;/strong&gt; It says that the server returned 200 and an empty list, then adds the challenge without misrepresenting the response.&lt;/p&gt;

&lt;p&gt;The naming mismatch I mentioned in §6 was fixed too. Aliases now map values such as &lt;code&gt;Zone Zone Read&lt;/code&gt; from &lt;code&gt;x-api-token-group&lt;/code&gt; to their catalog scope names. Without that mapping, the refusal could incorrectly tell users that no OAuth scope covers the endpoint. That is what &lt;code&gt;GET /zones&lt;/code&gt; reported before my issue.&lt;/p&gt;

&lt;p&gt;The tests record and classify responses for 549 endpoints. &lt;code&gt;tests/filtered-lists.test.ts&lt;/code&gt; adds 292 lines, and the test fails against &lt;code&gt;main&lt;/code&gt; before the fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this article stands
&lt;/h3&gt;

&lt;p&gt;I am keeping §§9 and 12 as a record of what I observed at publication. &lt;strong&gt;If you call &lt;code&gt;mcp.cloudflare.com&lt;/code&gt; now, the zone list no longer returns an empty array.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The broader conclusion still holds: whether an MCP server silently returns an empty list or a 403 challenge when permission is missing is &lt;strong&gt;the server author's decision&lt;/strong&gt;. The spec provides the path, but the implementation decides whether to connect it. Cloudflare has now connected it.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/changelog/post/2026-10-19-cloudflare-mcp-step-up/" rel="noopener noreferrer"&gt;Cloudflare API MCP server now supports step-up authorization&lt;/a&gt; — The announcement that started this investigation. Add &lt;code&gt;/index.md&lt;/code&gt; to read it as Markdown.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/changelog/post/2026-08-22-wrangler-mcp-optional-oauth-scopes/" rel="noopener noreferrer"&gt;Choose OAuth scopes for Wrangler and the Cloudflare API MCP server&lt;/a&gt; — Optional scopes and Edit Permissions on the consent screen.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;MCP Specification 2026-07-28 — Authorization&lt;/a&gt; — Step-Up Authorization Flow and Scope Challenge Handling.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" rel="noopener noreferrer"&gt;MCP Specification 2025-11-25 — Authorization&lt;/a&gt; — The version with three server-side union strategies.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2349" rel="noopener noreferrer"&gt;Issue #2349&lt;/a&gt; — Why responsibility moved to clients; the issue body explains the three arguments.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/modelcontextprotocol/typescript-sdk/issues/1582" rel="noopener noreferrer"&gt;typescript-sdk#1582&lt;/a&gt; — Reproduction of the infinite reauthorization loop.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/workers-oauth-provider" rel="noopener noreferrer"&gt;cloudflare/workers-oauth-provider&lt;/a&gt; — &lt;code&gt;insufficientScope()&lt;/code&gt; and the two &lt;code&gt;scopes_supported&lt;/code&gt; lists.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.google.com/identity/protocols/oauth2/web-server" rel="noopener noreferrer"&gt;Google — Using OAuth 2.0 for Web Server Applications&lt;/a&gt; — &lt;code&gt;include_granted_scopes&lt;/code&gt; and combined authorization.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc6750#section-3.1" rel="noopener noreferrer"&gt;RFC 6750 §3.1&lt;/a&gt; — Definition of &lt;code&gt;insufficient_scope&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9470" rel="noopener noreferrer"&gt;RFC 9470&lt;/a&gt; — Step-up &lt;strong&gt;Authentication&lt;/strong&gt;, which is a different concept.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/mcp/issues/302" rel="noopener noreferrer"&gt;cloudflare/mcp Issue #302&lt;/a&gt; — The report from §9, filed after publication.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/mcp/pull/305" rel="noopener noreferrer"&gt;cloudflare/mcp PR #305&lt;/a&gt; — The merged fix; &lt;code&gt;authorize()&lt;/code&gt; and &lt;code&gt;refusalFor()&lt;/code&gt; are worth a look.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>oauth</category>
      <category>cloudflare</category>
      <category>mcp</category>
    </item>
    <item>
      <title>I Migrated a Cloudflare Agents Workspace and Measured Everything: What Changes When the Storage Format Changes?</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Thu, 22 Oct 2026 01:07:27 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/i-migrated-a-cloudflare-agents-workspace-and-measured-everything-what-changes-when-the-storage-28lg</link>
      <guid>https://dev.to/tanahiro2010/i-migrated-a-cloudflare-agents-workspace-and-measured-everything-what-changes-when-the-storage-28lg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The difference was not the table names.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;On October 20, 2026, Cloudflare Agents switched its Workspace implementation from &lt;code&gt;@cloudflare/shell&lt;/code&gt; to &lt;code&gt;@cloudflare/computer&lt;/code&gt;. The change is not just a new API name. &lt;strong&gt;It changes how files are stored.&lt;/strong&gt; The old implementation stored one row per path, with the file contents base64-encoded in that same row. The new one separates inodes and chunks, storing content-addressed blobs in 512 KiB pieces.&lt;/p&gt;

&lt;p&gt;I ran the old and new implementations in the same Durable Object and compared the data before and after migration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The tables are different, so old files are invisible after switching without migration.&lt;/strong&gt; They are still there, though.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storing a whole file in one row changes the size limit and performance.&lt;/strong&gt; The old implementation hit a limit at about 6 MiB, and its 1 MB read/write cycle was 13.5 times slower.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The schema is versioned, so it migrates forward automatically but cannot go backward.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Old &lt;code&gt;@cloudflare/shell&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;New &lt;code&gt;@cloudflare/computer&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cf_workspace_&amp;lt;namespace&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vfs_*&lt;/code&gt; / &lt;code&gt;_vfs_*&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Representation of one file&lt;/td&gt;
&lt;td&gt;One row (&lt;code&gt;path TEXT PRIMARY KEY&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inode + 512 KiB chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where content lives&lt;/td&gt;
&lt;td&gt;Base64 in the same row's &lt;code&gt;content&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;Blob addressed by SHA-256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observed file-size limit&lt;/td&gt;
&lt;td&gt;About 6 MiB without R2&lt;/td&gt;
&lt;td&gt;No single-row limit; content is chunked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 MB write + read&lt;/td&gt;
&lt;td&gt;87.25 ms&lt;/td&gt;
&lt;td&gt;6.45 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same content at two paths&lt;/td&gt;
&lt;td&gt;Stored twice&lt;/td&gt;
&lt;td&gt;Both paths share one blob row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reopen with old implementation&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Fails with &lt;code&gt;EIO&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test code: &lt;a href="https://github.com/tanahiro2010/slides/tree/main/slides/slides/Cloudflare%20Agents%20Workspace%E7%A7%BB%E8%A1%8C%E3%83%BB%E6%B0%B8%E7%B6%9A%E5%8C%96%E6%A4%9C%E8%A8%BC/sandbox" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the main point upfront.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The old and new implementations differ in storage format, not API.&lt;/strong&gt; Once you understand that, the reason files disappear after switching without a migration, why the file-size limit changes, why the new implementation is faster, and why you cannot switch back all follow from the same underlying change. The migration guide says to copy files manually because the tables differ. The deeper difference is their structure, not their names.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article expands on my talk about migrating a Cloudflare Agents Workspace with measurements I could not fit into the presentation. It is for people using Cloudflare Agents who are preparing to move to &lt;code&gt;@cloudflare/computer&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The announcement
&lt;/h2&gt;

&lt;p&gt;On October 20, 2026, Cloudflare posted this changelog:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Think and Agent Skills now use the workspace from &lt;code&gt;@cloudflare/computer&lt;/code&gt; as well as the filesystem tools.&lt;br&gt;
&lt;code&gt;@cloudflare/shell&lt;/code&gt; is deprecated, and so is Think's &lt;code&gt;createExecuteTool&lt;/code&gt;. These will no longer be developed nor supported from today onwards.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On the same day, Cloudflare published &lt;code&gt;@cloudflare/think@0.21.0&lt;/code&gt;, &lt;code&gt;@cloudflare/computer@0.6.0&lt;/code&gt;, and &lt;code&gt;agents@0.29.0&lt;/code&gt;. &lt;code&gt;@cloudflare/shell&lt;/code&gt; also received version &lt;code&gt;0.5.0&lt;/code&gt; that day to announce its deprecation. npm's deprecated flag applies retroactively to earlier versions too, so &lt;code&gt;0.4.3&lt;/code&gt; shows the same warning.&lt;/p&gt;

&lt;p&gt;The migration guide itself had landed earlier, in PR #2564 on October 14. It did not arrive at the same time as the announcement.&lt;/p&gt;

&lt;p&gt;The Changelog also says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a breaking change. &lt;code&gt;@cloudflare/computer&lt;/code&gt; stores files in different tables from &lt;code&gt;@cloudflare/shell&lt;/code&gt;, and there is no automatic data migration between workspace formats.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;“Different tables.” That is where I started. Let's see how different they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The old Workspace stored one file per row
&lt;/h2&gt;

&lt;p&gt;A Workspace is where an agent stores its files. A model interacts with them through &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, and &lt;code&gt;ls&lt;/code&gt;; the files themselves live in Durable Object SQLite.&lt;/p&gt;

&lt;p&gt;The old &lt;code&gt;@cloudflare/shell&lt;/code&gt; schema is included in the migration guide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;cf_workspace_default&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;path&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;parent_path&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;name&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="k"&gt;type&lt;/span&gt;             &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'directory'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'symlink'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;mime_type&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'text/plain'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;size&lt;/span&gt;             &lt;span class="nb"&gt;INTEGER&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;storage_backend&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'inline'&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;storage_backend&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'inline'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'r2'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;r2_key&lt;/span&gt;           &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;target&lt;/span&gt;           &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content_encoding&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'utf8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;       &lt;span class="nb"&gt;INTEGER&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unixepoch&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="n"&gt;modified_at&lt;/span&gt;      &lt;span class="nb"&gt;INTEGER&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unixepoch&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is straightforward: the path is the primary key, and the metadata and content live in the same row. The &lt;code&gt;content&lt;/code&gt; column holds file contents, base64-encoded for binary files.&lt;/p&gt;

&lt;p&gt;Large files are handled separately. The default threshold is 1,500,000 bytes. Files above that threshold are moved to R2, leaving only an &lt;code&gt;r2_key&lt;/code&gt; in the row. If no R2 bucket is provided, the implementation logs a warning and keeps the file inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[Workspace] File &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes but no R2 bucket was provided. Storing inline.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This “put everything in one row” design matters later.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The new Workspace separates inodes and chunks
&lt;/h2&gt;

&lt;p&gt;The new &lt;code&gt;@cloudflare/computer&lt;/code&gt; uses several tables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Stores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vfs_dirents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent inode + name → child inode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vfs_nodes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inode metadata (type / mode / mtime / size / link_target)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vfs_chunks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inode + chunk index → blob SHA-256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vfs_blobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SHA-256 → size and last-reference time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vfs_blob_bytes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SHA-256 → actual bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vfs_meta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;schema_version&lt;/code&gt; and revision counter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Path resolution walks &lt;code&gt;vfs_dirents&lt;/code&gt; from the root (inode = 1). File contents are split into 512 KiB chunks, each stored in &lt;code&gt;vfs_blob_bytes&lt;/code&gt; as a blob keyed by SHA-256:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;524288&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tables start with &lt;code&gt;vfs_&lt;/code&gt; or &lt;code&gt;_vfs_&lt;/code&gt;, so they do not collide with &lt;code&gt;cf_workspace_*&lt;/code&gt;. Both formats can coexist during migration.&lt;/p&gt;

&lt;p&gt;From here, I measured what follows from the structural difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Consequence #1: switching does not make old files visible
&lt;/h2&gt;

&lt;p&gt;I put both implementations on the same Durable Object. Splitting them across two DO namespaces would not test migration on the same storage. I switched between them inside one class.&lt;/p&gt;

&lt;p&gt;I wrote 13 items with the old implementation, initialized the new one, and tried reading them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read through old Workspace before switch → {found: true, length: 23}
Initialize new Workspace
Read /workspace/notes.md through new     → ENOENT no such file
Read /notes.md through new               → ENOENT no such file
New-side tree                            → [{path: "/workspace", type: "dir"}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new implementation could not see the files.&lt;/p&gt;

&lt;p&gt;If I stopped here, the article would say “the data is gone.” That would be quick—and wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add two controls
&lt;/h3&gt;

&lt;p&gt;“Not visible through the new implementation” does not tell us whether the original write failed. In the same run, I verified that the old Workspace could still read the file both before and after switching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read through old before switch        → {found: true, length: 23}
Read through old after switch         → {found: true, length: 23}
Rows in cf_workspace_default          → 14 (same as before)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old implementation could still read the file after the switch. The row count had not changed either.&lt;/p&gt;

&lt;p&gt;The data was not gone. The new implementation was looking somewhere else. A table listing showed both formats coexisting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cf_workspace_default
computer_execution_runtime
sqlite_sequence
vfs_blob_bytes   vfs_blobs    vfs_changes   vfs_chunks
vfs_dirents      vfs_manifests  vfs_meta    vfs_nodes
_vfs_fetch_cursor  _vfs_mounts  _vfs_push_cursor
_vfs_sync_operations  _vfs_sync_skips  _vfs_watermark
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also learned that all &lt;code&gt;vfs_*&lt;/code&gt; tables are created as soon as you call &lt;code&gt;new Workspace()&lt;/code&gt;. They do not wait for the first filesystem operation. The &lt;code&gt;schema_version&lt;/code&gt; was &lt;code&gt;10&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Consequence #2: the size limit changes when everything fits in one row
&lt;/h2&gt;

&lt;p&gt;The old implementation base64-encodes file contents and puts them in one cell. A 4 MB file becomes 5,592,408 base64 characters.&lt;/p&gt;

&lt;p&gt;Meanwhile, the Durable Objects Limits page says:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Documented limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Maximum string, &lt;code&gt;BLOB&lt;/code&gt;, or table row size&lt;/td&gt;
&lt;td&gt;2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KV key size&lt;/td&gt;
&lt;td&gt;Key and value combined cannot exceed 2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 4 MB file should not fit in the old Shell. I predicted that while designing the benchmark and prepared files of 1 KB, 100 KB, 1 MB, and 4 MB.&lt;/p&gt;

&lt;p&gt;They all worked.&lt;/p&gt;

&lt;p&gt;My prediction was wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 MB → storage_backend=inline, content_encoding=base64,
       content_len=5,592,408 → read-back SHA-256 matches
8 MB → Error: string or blob too big: SQLITE_TOOBIG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got the same result in local Miniflare and a deployed production Durable Object.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measurement: where is the actual limit?
&lt;/h3&gt;

&lt;p&gt;Stepping through the library would only reveal its implementation constant, not the underlying limit, so I called &lt;code&gt;ctx.storage.sql&lt;/code&gt; and &lt;code&gt;ctx.storage.kv&lt;/code&gt; directly to find the boundary. Each probe used a new Durable Object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Largest accepted  8,388,627 bytes
Smallest rejected 8,388,628 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;8 MiB is 8,388,608 bytes. TEXT, BLOB, and a KV value all failed at the same boundary.&lt;/p&gt;

&lt;p&gt;That is four times the documented 2 MB. I repeated probes around the boundary on a new Durable Object and got the same result. I reported it in &lt;a href="https://github.com/cloudflare/cloudflare-docs/issues/34502" rel="noopener noreferrer"&gt;cloudflare-docs#34502&lt;/a&gt;. Since I could not rule out 2 MB being the guaranteed limit and 8 MiB merely an implementation allowance, I did not open a PR asserting a new limit; I attached a reference branch instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the old implementation stop at 6 MiB?
&lt;/h3&gt;

&lt;p&gt;The old Shell hit its own boundary below the raw SQL limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Largest file accepted by shell.writeFileBytes  6,291,408 bytes (8,388,544 base64 chars)
Smallest rejected                             6,291,409 bytes (8,388,548 base64 chars)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base64 expands the content by about 4/3, so that works out to roughly 6 MiB of file data.&lt;/p&gt;

&lt;p&gt;I could not explain why the Shell fails earlier than raw SQL at 8,388,627 bytes. I reproduced its DDL, indexes, INSERT statement, &lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt; clause, and base64 string, then ran them against the same Durable Object. &lt;strong&gt;Raw SQL accepted the value.&lt;/strong&gt; I also suspected that UPSERT might duplicate the value through &lt;code&gt;excluded&lt;/code&gt;, but an UPSERT against an existing row accepted the same size.&lt;/p&gt;

&lt;p&gt;The stack trace points to the Shell's INSERT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: string or blob too big: SQLITE_TOOBIG
  at Object.run (index.js:367:17)
  at Workspace.writeFileBytes (index.js:652:22)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I stopped there. For practical purposes, remember that “without R2, the old Shell's file-size limit is about 6 MiB.”&lt;/p&gt;

&lt;p&gt;The new Computer splits content into 512 KiB chunks, so this particular wall is gone. Files of 8 MB, 16 MB, and 32 MB all worked. Chunking may look like a performance design, but it also changes the size limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Consequence #3: base64 conversion on every operation is slower
&lt;/h2&gt;

&lt;p&gt;I hit an oddity here: &lt;strong&gt;&lt;code&gt;Date.now()&lt;/code&gt; does not advance inside a Worker.&lt;/strong&gt; In production, even a 4 MB write was recorded as &lt;code&gt;0ms&lt;/code&gt;. It advanced in local Miniflare, so at first I thought I was misreading the production logs.&lt;/p&gt;

&lt;p&gt;I measured from outside instead. I sent one request that repeated the operation 60 times and a control request that did nothing, then divided the difference by 60. The table shows the median of seven runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Old Shell&lt;/th&gt;
&lt;th&gt;New Computer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 KB&lt;/td&gt;
&lt;td&gt;1.58 ms&lt;/td&gt;
&lt;td&gt;-0.75 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 KB&lt;/td&gt;
&lt;td&gt;4.92 ms&lt;/td&gt;
&lt;td&gt;0.80 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87.25 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.45 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I cannot draw conclusions from the 1 KB and 100 KB results. As the negative value shows, the differences are buried in HTTP round-trip variance.&lt;/p&gt;

&lt;p&gt;At 1 MB, the gap was clear: 13.5×. The old Shell base64-encodes a 1 MB file into one row on every write and decodes it on every read, adding overhead. My measurement includes the write and the read together, so I cannot say which one is slower on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Consequence #4: content addressing enables deduplication
&lt;/h2&gt;

&lt;p&gt;The new implementation uses the blob's SHA-256 as its primary key. The same bytes can be shared by files anywhere in the tree.&lt;/p&gt;

&lt;p&gt;I wrote the same 512 KiB of content to two paths and counted rows in &lt;code&gt;vfs_blobs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initial blob count                 → 0
Write identical content to path 1  → 1
Write identical content to path 2  → 1  (no increase)
Control: write different content  → 2  (increases)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the control, I could not distinguish deduplication from an implementation that never added a blob at all. The different byte sequence confirmed that a second row is created when the content differs.&lt;/p&gt;

&lt;p&gt;The old implementation stores &lt;code&gt;content&lt;/code&gt; per path, so putting the same file in two places uses storage twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Consequence #5: there is nowhere to put some metadata
&lt;/h2&gt;

&lt;p&gt;The old &lt;code&gt;FileInfo&lt;/code&gt; has &lt;code&gt;mimeType&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, and &lt;code&gt;updatedAt&lt;/code&gt;. The new &lt;code&gt;WorkspaceStatResult&lt;/code&gt; has only these fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isSymbolicLink&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no API in &lt;code&gt;workspace.fs&lt;/code&gt; to read or write MIME types. There is only one timestamp, &lt;code&gt;mtime&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The migration guide also says this metadata is not retained:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Things the copy does not keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;modified_at&lt;/code&gt;. Computer stamps the time of the copy.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mime_type&lt;/code&gt;. Computer does not store a MIME type per file.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my measurements, every migrated file's &lt;code&gt;mtime&lt;/code&gt; was the time the copy ran. It is not just that the data is “not retained”; &lt;strong&gt;the new schema has nowhere to store it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Consequence #6: you can move forward, but not backward
&lt;/h2&gt;

&lt;p&gt;The new implementation versions its schema. Its initialization code looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onDiskVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT v FROM vfs_meta WHERE k = ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schema_version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onDiskVersion&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nf"&gt;createWorkspaceError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EIO&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Unsupported workspace filesystem schema version &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;onDiskVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onDiskVersion&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;onDiskVersion&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;runMigrations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onDiskVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrations run forward automatically. There are nine registered migrations, from v1 to v10.&lt;/p&gt;

&lt;p&gt;There is no migration backward.&lt;/p&gt;

&lt;p&gt;Rather than deploying an old version, I changed the on-disk &lt;code&gt;schema_version&lt;/code&gt; to 11 to simulate a database newer than the binary, then ran the same initialization path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current schema_version                 → 10
Control: reconstruct with same version → {read: "hello"}
Set schema_version to 11
Open again with older binary            → EIO Unsupported workspace filesystem schema version 11
Set schema_version back to 10
Can it open now?                        → {read: "hello"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I measured is that the binary cannot open a database whose on-disk &lt;code&gt;schema_version&lt;/code&gt; is newer than the version it supports. This means “you cannot roll back” only if a new release raises the schema version. It has already been raised nine times, so it is reasonable to expect that it could happen again.&lt;/p&gt;

&lt;p&gt;Neither the migration guide nor the Changelog mentions this. There is one sentence in the implementation document &lt;code&gt;03_filesystem_schema.md&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Open() refuses to run if the binary is older than the on-disk &lt;code&gt;schema_version&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The old data remains, so if migration fails you can still read from the old tables. The inability to roll back is the less recoverable part.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Running the migration guide's script
&lt;/h2&gt;

&lt;p&gt;Now that I understood the structural difference, I ran the migration.&lt;/p&gt;

&lt;p&gt;The guide provides a copy function named &lt;code&gt;copyShellWorkspace()&lt;/code&gt;. It walks the old Workspace with &lt;code&gt;glob("/**")&lt;/code&gt; and writes the files through the new &lt;code&gt;fs&lt;/code&gt; API. The destination is under &lt;code&gt;/workspace&lt;/code&gt;, which Think also uses as its working directory.&lt;/p&gt;

&lt;p&gt;I ran the script without changing it. My test data included UTF-8 Japanese text, TypeScript in a nested directory, JSON for exact-content comparison, 64 KB of binary data generated with a deterministic pseudorandom sequence, an empty directory, and four symlinks covering relative, absolute, and root-escaping targets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;comparison&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&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="err"&gt;total:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;missing:&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="err"&gt;typeChanged:&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="err"&gt;contentMismatch:&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="err"&gt;extra:&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;All 13 items existed after migration, their types had not changed, and the 64 KB binary's SHA-256 matched. The empty directory remained too. I got the same result in local &lt;code&gt;wrangler dev&lt;/code&gt; and in a deployed production environment.&lt;/p&gt;

&lt;p&gt;I also verified that the hash comparison could actually detect a mismatch. I changed one bit in a byte sequence and confirmed that it produced a different hash: &lt;code&gt;af2ba40f…&lt;/code&gt; versus &lt;code&gt;73734ea1…&lt;/code&gt;. A comparison that cannot detect a mismatch does not prove that two files match.&lt;/p&gt;

&lt;p&gt;I checked one assumption the script relies on as well. A comment says, “glob returns rows ordered by path, so parents come before children.” If that were false, &lt;code&gt;writeFile&lt;/code&gt; could run before &lt;code&gt;mkdir&lt;/code&gt;. In my tests, results were sorted lexicographically, so each parent appeared before its children.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symlinks need one extra check
&lt;/h3&gt;

&lt;p&gt;When I followed each link individually, one of them failed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Link&lt;/th&gt;
&lt;th&gt;Target in Shell&lt;/th&gt;
&lt;th&gt;Target after migration&lt;/th&gt;
&lt;th&gt;Can follow it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/links/rel.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;../notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/workspace/notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/links/abs.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/workspace/notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/links/dotdot.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/src/../notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/workspace/src/../notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/links/esc.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/../notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/workspace/../notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ENOENT&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;/workspace/../notes.md&lt;/code&gt; resolves to &lt;code&gt;/notes.md&lt;/code&gt;, which is outside the migration destination &lt;code&gt;/workspace&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause is in the script's &lt;code&gt;resolveTarget()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Shell resolves a relative target against the link's directory and never&lt;/span&gt;
&lt;span class="c1"&gt;// goes above `/`.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Absolute paths pass through&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="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;part&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;..&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;else&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;part&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&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="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&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 &lt;code&gt;@cloudflare/shell&lt;/code&gt; implementation (&lt;code&gt;resolveSymlink&lt;/code&gt;) also sends absolute paths through &lt;code&gt;normalizePath&lt;/code&gt;. &lt;code&gt;normalizePath&lt;/code&gt; stops &lt;code&gt;..&lt;/code&gt; at the root, so &lt;code&gt;/../notes.md&lt;/code&gt; becomes &lt;code&gt;/notes.md&lt;/code&gt; in Shell. The migration script passes the path through unchanged. When it prefixes &lt;code&gt;/workspace&lt;/code&gt;, the target escapes that directory.&lt;/p&gt;

&lt;p&gt;The conditions are narrow. Relative paths all migrate correctly. Absolute paths containing &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;//&lt;/code&gt;, or &lt;code&gt;..&lt;/code&gt; that does not go above the root also work. The only failure is an &lt;strong&gt;absolute path containing &lt;code&gt;..&lt;/code&gt; that goes above the root&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The script does not report an error. &lt;code&gt;symlink()&lt;/code&gt; saves the target without validating it, and following it stops at the root rather than throwing. This is like a legal dangling link on POSIX. The copy completes successfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A path/type/hash comparison will not catch this.&lt;/strong&gt; The link exists, so it is not missing. Its type is still &lt;code&gt;symlink&lt;/code&gt;, so the type matches. It has no file content, so it is not included in the hash comparison. My own comparison script reported &lt;code&gt;missing: [], contentMismatch: []&lt;/code&gt;. To catch this, you have to follow and read the link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;linkPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ENOENT if the target cannot be followed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second issue is that the migration is not safe to rerun:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First migration  → ok
Second migration → EEXIST path exists: /workspace/links/abs.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mkdir({ recursive: true })&lt;/code&gt; silently succeeds for an existing directory. &lt;code&gt;writeFile&lt;/code&gt; overwrites unless &lt;code&gt;exclusive&lt;/code&gt; is specified. &lt;code&gt;symlink(target, path)&lt;/code&gt; has no equivalent of a &lt;code&gt;force&lt;/code&gt; option; if the path already exists, it always throws &lt;code&gt;EEXIST&lt;/code&gt;. That matches Node's &lt;code&gt;fs.symlink&lt;/code&gt; behavior and is not a library bug.&lt;/p&gt;

&lt;p&gt;The guide's &lt;code&gt;onStart&lt;/code&gt; example sets a flag after copying completes. If the copy fails partway through, the flag is never set and the next startup fails again on the same link. Remove the link before recreating it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;symlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reported these two issues in &lt;a href="https://github.com/cloudflare/agents/issues/2645" rel="noopener noreferrer"&gt;cloudflare/agents#2645&lt;/a&gt;. Before filing, I checked for duplicates and found that both had been raised in earlier reviews. One did not survive a PR replacement; the other had been marked resolved based on the incorrect assumption that absolute paths were already rebased. Checking first meant I did not present either as a new discovery.&lt;/p&gt;

&lt;p&gt;If your Workspace has no symlinks, you will not hit this part.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Persistence survives hibernation
&lt;/h2&gt;

&lt;p&gt;The storage format changed, but the files still live in Durable Object SQLite. I checked whether they survive restarts.&lt;/p&gt;

&lt;p&gt;“Readable from another request” is not enough evidence. The same instance might still be in memory and return the file from there. I generated a &lt;code&gt;crypto.randomUUID()&lt;/code&gt; in the constructor and included it in the response. If that value changes, the Durable Object is a new incarnation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                     bootId                                  Read
1. Write             1cadfbdd-7595-4879-94c1-43abca4688c0   ok
2. Read immediately  1cadfbdd-7595-4879-94c1-43abca4688c0   ok   (same)
3. After 15 seconds  e4f5f2ab-3d7d-4389-8e2d-ee32fdd97b02   ok   (changed)
4. After 150 seconds c602f7f1-c9f6-465e-9b36-0c3259706d64   ok   (changed again)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads 3 and 4 came from different incarnations. The file content included the old &lt;code&gt;bootId&lt;/code&gt; from when it was written, showing it was the same data.&lt;/p&gt;

&lt;p&gt;I decided which fields to record before running the test: a case number for writing back the verdict, &lt;code&gt;bootId&lt;/code&gt; to show whether a restart happened, &lt;code&gt;env&lt;/code&gt; to distinguish local from production, &lt;code&gt;sha256&lt;/code&gt; and &lt;code&gt;size&lt;/code&gt; to identify the file, the operation's elapsed time, and &lt;code&gt;ok&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; fields that hold results instead of throwing exceptions.&lt;/p&gt;

&lt;p&gt;This control proved useful. After redeploying, my first immediate read succeeded, but the &lt;code&gt;bootId&lt;/code&gt; was unchanged. The DO was still in memory and running the old code; it had not restarted. Without the control, I might have concluded that the file persisted across a restart. I waited 15 seconds and tried again, then confirmed that the &lt;code&gt;bootId&lt;/code&gt; changed and the file was still readable.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long until hibernation?
&lt;/h3&gt;

&lt;p&gt;I varied the wait time and checked &lt;code&gt;bootId&lt;/code&gt;. The zero-second wait was the control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wait (s)  Restarted  Read
      0   No         ok   (control)
      3   No         ok
      6   No         ok
      9   No         ok
     11   Yes        ok
     13   Yes        ok
     15   Yes        ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary is between 9 and 11 seconds, matching the documentation's “hibernate after 10 seconds of inactivity.”&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Before you migrate
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count your symlinks.&lt;/strong&gt; Run &lt;code&gt;glob("/**")&lt;/code&gt; and count entries with &lt;code&gt;type === "symlink"&lt;/code&gt;. If there are none, the official script should work as-is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you have links, follow and read them after migration.&lt;/strong&gt; Path/type/hash comparisons will not catch the issue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for files larger than 6 MiB.&lt;/strong&gt; If the old implementation stored them in R2, pass the same &lt;code&gt;r2&lt;/code&gt; bucket and &lt;code&gt;name&lt;/code&gt; during migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy assuming you cannot roll back.&lt;/strong&gt; Once the new schema has been written, an older &lt;code&gt;@cloudflare/computer&lt;/code&gt; cannot open that Durable Object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not delete the old data immediately.&lt;/strong&gt; The old tables remain, so wait to &lt;code&gt;DROP TABLE&lt;/code&gt; until your comparison is done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dependency arrangement changes too. In &lt;code&gt;@cloudflare/think@0.20.2&lt;/code&gt;, &lt;code&gt;@cloudflare/shell&lt;/code&gt; was a direct dependency; you did not install it yourself. In &lt;code&gt;0.21.0&lt;/code&gt;, &lt;code&gt;@cloudflare/computer&lt;/code&gt; is a peer dependency. The mechanism behind “I upgraded and my Workspace is empty” is right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Conclusion
&lt;/h2&gt;

&lt;p&gt;The storage format changed, and the rest followed from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different tables&lt;/strong&gt; → files are invisible after switching without a migration, but the old tables remain and the old Workspace can still read them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One row versus chunks&lt;/strong&gt; → the old implementation hits a limit at about 6 MiB; the new one accepts files of 32 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base64 conversion on every operation&lt;/strong&gt; → a 13.5× difference in the 1 MB read/write test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-addressed blobs&lt;/strong&gt; → the same content at two paths takes one blob row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer metadata columns&lt;/strong&gt; → there is nowhere to store &lt;code&gt;mime_type&lt;/code&gt; or &lt;code&gt;created_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A versioned schema&lt;/strong&gt; → it migrates forward automatically and cannot be opened by an older implementation after the version changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration guide says to copy files manually because the tables differ. The difference is not just the table names; it is the structure. Understanding that structure tells you what to verify before you migrate.&lt;/p&gt;

&lt;p&gt;I also found two mismatches with the documentation. The Durable Object SQLite value limit I tested was 8 MiB, not 2 MB, and the user-facing docs do not mention that an older implementation cannot open a Durable Object after its schema has been upgraded.&lt;/p&gt;

&lt;p&gt;The most surprising result was that a 4 MB file worked. I had predicted the result from the official limit and got it wrong. Because I had trusted the documented number, I no longer trusted my other predictions once it failed. I ended up measuring both the size limit and performance myself. That took more time than the migration itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/changelog/post/2026-10-20-think-computer-workspace/" rel="noopener noreferrer"&gt;Think agents now use a @cloudflare/computer workspace&lt;/a&gt; — The announcement, the lack of automatic migration, and deprecation of &lt;code&gt;createExecuteTool&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/agents/blob/main/docs/shell/migrating-to-computer.md" rel="noopener noreferrer"&gt;Migrating from &lt;code&gt;@cloudflare/shell&lt;/code&gt; to &lt;code&gt;@cloudflare/computer&lt;/code&gt;&lt;/a&gt; — Both schemas and the copy script discussed in §10.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/computer/blob/main/docs/03_filesystem_schema.md" rel="noopener noreferrer"&gt;cloudflare/computer &lt;code&gt;docs/03_filesystem_schema.md&lt;/code&gt;&lt;/a&gt; — All &lt;code&gt;vfs_*&lt;/code&gt; table definitions, chunk size, and schema version behavior.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/durable-objects/platform/limits/" rel="noopener noreferrer"&gt;Durable Objects — Limits&lt;/a&gt; — The documented 2 MB row-size limit tested in §5.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/" rel="noopener noreferrer"&gt;Durable Objects — Lifecycle&lt;/a&gt; — Sources for the 10-second hibernation and 70–140-second eviction behavior.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/computer/issues/217" rel="noopener noreferrer"&gt;cloudflare/computer#217&lt;/a&gt; / &lt;a href="https://github.com/cloudflare/computer/issues/219" rel="noopener noreferrer"&gt;#219&lt;/a&gt; — Acknowledgment that writing to R2 and namespaces are not supported yet.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudflare/agents/issues/2645" rel="noopener noreferrer"&gt;cloudflare/agents#2645&lt;/a&gt; — Report about the symlink migration script in §10.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cloudflare</category>
      <category>typescript</category>
      <category>agents</category>
      <category>database</category>
    </item>
    <item>
      <title>Investigating the Return of the PHP2Shell Nightmare</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Thu, 22 Oct 2026 00:56:06 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/investigating-the-return-of-the-php2shell-nightmare-eee</link>
      <guid>https://dev.to/tanahiro2010/investigating-the-return-of-the-php2shell-nightmare-eee</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The public PoC claiming “RCE via a PHP core UAF” did not work in my lab. After reading the official patches, the actual causes turned out to be state corruption during OOM handling and state left behind across requests—not a zval UAF within a single request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this article concludes
&lt;/h2&gt;

&lt;p&gt;I built a local Docker lab to investigate the PHP vulnerability making headlines as “php2shell,” compared the public PoC with PHP source code, and then read the patch diffs published with the official releases. The result: &lt;strong&gt;the “PHP core UAF” described by the public PoC did not reproduce, and the official root causes were different. However, a chain combining the &lt;code&gt;session.upload_progress&lt;/code&gt; write primitive with an application-level LFI did work.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On October 21, 2026, PHP released 8.5.12, 8.4.27, 8.3.36, and 8.2.35. CVE-2026-109618 is “OOM corruption,” and CVE-2026-109619 is “stale global state across requests.” Both have a CVSS v4.0 score of 9.2, and the official advisories explicitly call them RCE vulnerabilities.&lt;/li&gt;
&lt;li&gt;The public PoCs for 618 and 619 did not reproduce a UAF or RCE in PHP 8.4.4 and 8.5.4 ASAN builds, PHP 5.6.40, with ASLR disabled, or with a poisoned call address.&lt;/li&gt;
&lt;li&gt;The patches do not primarily fix the alleged “zval corruption on the second update” in &lt;code&gt;ext/session&lt;/code&gt;. They redesign the OOM bailout path, initialize session state before RINIT, and reset state left behind by earlier requests. The public PoC identified the wrong root cause.&lt;/li&gt;
&lt;li&gt;A chain using &lt;code&gt;session.upload_progress&lt;/code&gt; and an application-level LFI did reproduce. It worked not only with &lt;code&gt;cleanup=0&lt;/code&gt;, but also in a 3/3 race with the default &lt;code&gt;cleanup=1&lt;/code&gt;; RCE through an uploaded temporary file also remained with &lt;code&gt;enabled=0&lt;/code&gt;. The official advisory also identifies Session Upload Progress as a confirmed consumer for 619.&lt;/li&gt;
&lt;li&gt;So the accurate conclusion is neither “the PHP core gives you universal RCE” nor “PHP2Shell is a hoax.” &lt;strong&gt;The core CVEs are real, and upgrading is the only complete fix for them. But an application-level LFI chain can remain after upgrading.&lt;/strong&gt; These are two separate layers.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public PoCs 618/619 (8.4.4 ASAN)&lt;/td&gt;
&lt;td&gt;Not reproduced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public PoCs 618/619 (8.5.4 ASAN / 5.6.40)&lt;/td&gt;
&lt;td&gt;Not reproduced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LFI with &lt;code&gt;cleanup=0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Race with default &lt;code&gt;cleanup=1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;uid=33(www-data)&lt;/code&gt; in 3/3 attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporary-file include with &lt;code&gt;enabled=0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;use_strict_mode=1&lt;/code&gt; with &lt;code&gt;Set-Cookie&lt;/code&gt; follow-up&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mod_php (Apache)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test code and raw logs are available &lt;a href="https://github.com/tanahiro2010/slides/tree/main/slides/slides/PHP2SHELL%E3%81%A8%E3%81%84%E3%81%86%E6%82%AA%E5%A4%A2%E3%81%AE%E5%86%8D%E6%9D%A5%E3%82%92%E6%A4%9C%E8%A8%BC%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B/sandbox" rel="noopener noreferrer"&gt;in the repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here is the most important point upfront:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“The public PoC does not work” and “the vulnerability does not exist” are different claims.&lt;/strong&gt; The public PoC may help explain source locations, but it does not prove RCE. On the other hand, the &lt;code&gt;session.upload_progress&lt;/code&gt; primitive is real, and combining it with an application-level LFI can reach code execution without authentication. Do not assert the core root cause until you have read the official patch diffs. That is the minimum standard for avoiding being misled by information circulating during an embargo.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article expands my PHP2SHELL investigation with source reading and experiment logs that I could not cover in my talk. It is for people who develop or operate PHP applications and want to distinguish vulnerability claims from observed behavior.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. What happened
&lt;/h2&gt;

&lt;p&gt;On October 20, 2026, PHP’s official website posted a short notice titled “Upcoming Critical PHP Security Release.” It said that fixes for two critical vulnerabilities would be released the following day, October 21, at 14:00 UTC (23:00 JST). Both had a CVSS v4.0 score of 9.2. Details, affected versions, and fixed versions would be published at release time.&lt;/p&gt;

&lt;p&gt;The notice was only about four lines long. But information leaked before the notice was followed by the release. Fragments such as possible CVE numbers, &lt;code&gt;session.upload_progress&lt;/code&gt;, impact on PHP 5.4 and later, and CVSS 10.0 circulated on social media and Hacker News. Researchers and security companies posted that the vulnerabilities were RCE and that they had reproduced them. None of the official PHP notice mentioned a CVE number, “RCE,” or “Session Upload Progress.”&lt;/p&gt;

&lt;p&gt;That abnormal order is the central theme here. &lt;strong&gt;Leaked information and secondhand claims spread before the official facts were confirmed.&lt;/strong&gt; Some of those claims later raised questions about whether the public PoC was reproducible.&lt;/p&gt;

&lt;p&gt;Criticizing the situation would not tell me what was actually happening, so I built a local Docker lab. I compiled PHP with ASAN, ran the public PoC, read the PHP source, and checked which claims were supported by evidence.&lt;/p&gt;

&lt;p&gt;I wrote the first draft based only on the advance notice. The official release was published by 22:45 JST on October 21, and confirmed the CVE numbers, causes, affected versions, and fixed versions. To keep the facts available at announcement time separate from what became known after release, I have kept this section as an account of what was known from the notice and summarized the release results in §10.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What the official notice did—and did not—confirm
&lt;/h2&gt;

&lt;p&gt;At the time of the notice, the official source confirmed five things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A security release was scheduled for October 21, 2026, at 14:00 UTC (23:00 JST).&lt;/li&gt;
&lt;li&gt;There were two critical vulnerabilities.&lt;/li&gt;
&lt;li&gt;Both had a CVSS v4.0 score of 9.2.&lt;/li&gt;
&lt;li&gt;Details, affected versions, and fixed versions would be published with the release.&lt;/li&gt;
&lt;li&gt;The release might be moved earlier if details became public or active exploitation was observed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The official notice did &lt;strong&gt;not&lt;/strong&gt; say:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The CVE numbers were CVE-2026-109618 and CVE-2026-109619.&lt;/li&gt;
&lt;li&gt;The issues were classified as RCE.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;session.upload_progress&lt;/code&gt; was involved.&lt;/li&gt;
&lt;li&gt;Which versions were affected. “PHP 5.4 and later” was a leak claim. After release, the official fixes covered 8.2 and later; source backports were also provided for 7.4, 8.0, and 8.1.&lt;/li&gt;
&lt;li&gt;Which version numbers would contain the fixes. 8.5.12, 8.4.27, 8.3.36, and 8.2.35 were unverified guesses at the time. All four were later confirmed.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Official notice&lt;/th&gt;
&lt;th&gt;Researchers / leaks&lt;/th&gt;
&lt;th&gt;My lab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Two issues / CVSS 9.2 / date&lt;/td&gt;
&lt;td&gt;Confirmed&lt;/td&gt;
&lt;td&gt;Consistent&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE numbers&lt;/td&gt;
&lt;td&gt;Not listed&lt;/td&gt;
&lt;td&gt;Claimed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RCE&lt;/td&gt;
&lt;td&gt;Not listed&lt;/td&gt;
&lt;td&gt;Claimed&lt;/td&gt;
&lt;td&gt;Core UAF not reproduced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session Upload Progress&lt;/td&gt;
&lt;td&gt;Not listed&lt;/td&gt;
&lt;td&gt;Claimed&lt;/td&gt;
&lt;td&gt;Primitive confirmed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP 5.4+&lt;/td&gt;
&lt;td&gt;Not listed&lt;/td&gt;
&lt;td&gt;Leak claim&lt;/td&gt;
&lt;td&gt;Primitive confirmed on 5.6.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed versions&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;Guessed&lt;/td&gt;
&lt;td&gt;Not tested yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The October 21 release filled in every “not listed” and “not published” cell. The CVE numbers were real, the official advisories called the issues RCE, and all four guessed fixed versions were correct. I summarize the difference between the notice and the release in §10.&lt;/p&gt;

&lt;p&gt;The disagreement over CVSS is especially illustrative. PHP’s official score was 9.2. Meanwhile, the CEO of watchTowr told the press, “Both of them are CVSS10.” This is not about deciding which score is right. &lt;strong&gt;It shows how a secondary claim of 10.0 can spread even while the official source says 9.2.&lt;/strong&gt; That was the information environment around this incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What Session Upload Progress does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;session.upload_progress&lt;/code&gt; is a PHP feature that writes file-upload progress into a session. If a &lt;code&gt;multipart/form-data&lt;/code&gt; POST contains a field named &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt;, PHP writes progress data to &lt;code&gt;$_SESSION["upload_progress_&amp;lt;value&amp;gt;"]&lt;/code&gt;. The progress data can include &lt;code&gt;start_time&lt;/code&gt;, &lt;code&gt;content_length&lt;/code&gt;, &lt;code&gt;bytes_processed&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;, and &lt;code&gt;files[]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important detail is that &lt;strong&gt;progress is written before the application script runs&lt;/strong&gt;. PHP writes it while parsing the POST body, before the application’s authentication or input validation. The PHP engine does this even when the application does not use a progress display.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant A as Attacker
    participant W as Web server
    participant P as PHP multipart parser
    participant S as ext/session
    participant App as Application
    A-&amp;gt;&amp;gt;W: POST multipart (PHP_SESSION_UPLOAD_PROGRESS=...)
    W-&amp;gt;&amp;gt;P: Request body
    P-&amp;gt;&amp;gt;S: MULTIPART_EVENT_START / FORMDATA
    S-&amp;gt;&amp;gt;S: progress-&amp;gt;key = "upload_progress_" + value
    P-&amp;gt;&amp;gt;S: FILE_START / FILE_DATA
    S-&amp;gt;&amp;gt;S: Write to session file before app runs
    P-&amp;gt;&amp;gt;App: Run script
    App-&amp;gt;&amp;gt;App: session_start() exposes progress data&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The defaults matter too: &lt;code&gt;session.upload_progress.enabled&lt;/code&gt; is On, &lt;code&gt;session.upload_progress.cleanup&lt;/code&gt; is On (progress is removed at the end of the request), and &lt;code&gt;session.use_strict_mode&lt;/code&gt; is 0 (an attacker can specify a session ID). These defaults lead into why changing a single setting is not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Reading php-src: two layers, a callback and a parser
&lt;/h2&gt;

&lt;p&gt;Reading php-src shows that this feature has two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The multipart parser itself in &lt;code&gt;main/rfc1867.c&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The progress callback in &lt;code&gt;ext/session/session.c&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;main/rfc1867.c&lt;/code&gt; has a global function pointer named &lt;code&gt;php_rfc1867_callback&lt;/code&gt;. The parser calls it for START, FORMDATA, FILE_START, FILE_DATA, FILE_END, and END events. During MINIT, &lt;code&gt;ext/session&lt;/code&gt; registers &lt;code&gt;php_session_rfc1867_callback&lt;/code&gt; by assigning it to &lt;code&gt;php_rfc1867_callback&lt;/code&gt;, thereby hooking into the parser.&lt;/p&gt;

&lt;p&gt;The callback starts with this early return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;PS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rfc1867_enabled&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="n"&gt;retval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So setting &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; stops &lt;strong&gt;only this callback&lt;/strong&gt;. The parser in &lt;code&gt;main/rfc1867.c&lt;/code&gt; still runs for multipart POST requests regardless of the setting.&lt;/p&gt;

&lt;p&gt;The progress key is created during FORMDATA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;smart_str_appendl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rfc1867_prefix&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rfc1867_prefix&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;smart_str_appendl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value_len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;php_session_rfc1867_update()&lt;/code&gt; runs &lt;code&gt;php_session_initialize()&lt;/code&gt; → &lt;code&gt;zend_hash_update()&lt;/code&gt; → &lt;code&gt;php_session_flush()&lt;/code&gt;, writing &lt;code&gt;sess_&amp;lt;PHPSESSID&amp;gt;&lt;/code&gt; before the application script runs. The session ID is read early from the Cookie or GET parameter by &lt;code&gt;php_session_rfc1867_early_find_sid()&lt;/code&gt;. With &lt;code&gt;use_strict_mode=0&lt;/code&gt;, an attacker can choose an arbitrary ID.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[multipart POST] --&amp;gt; B[main/rfc1867.c]
    B --&amp;gt; C{php_rfc1867_callback}
    C --&amp;gt; D[ext/session callback]
    D --&amp;gt; E{enabled?}
    E -- 0 --&amp;gt; F[Early return]
    E -- 1 --&amp;gt; G[Create progress-&amp;gt;key]
    G --&amp;gt; H[Write session file]
    H --&amp;gt; I[Run application]
    B --&amp;gt; J[Parser continues running]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;What we can conclude from this is the structure: &lt;strong&gt;the setting disables only the session callback&lt;/strong&gt;. At the time of the first draft, it was not enough to say “therefore the second vulnerability is in the parser itself.” I give the answer in §10, but the official explanation was not “a UAF in the parser.”&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Testing the public PoC’s “core UAF” claim
&lt;/h2&gt;

&lt;p&gt;The public bundle &lt;code&gt;Zer061/CVE-2026-109618&lt;/code&gt; says that &lt;code&gt;php_session_rfc1867_progress&lt;/code&gt; caches two &lt;code&gt;zval *&lt;/code&gt; pointers, and that a second &lt;code&gt;update()&lt;/code&gt; breaks the tree when the session file is decoded again and the old tree is freed by &lt;code&gt;zval_ptr_dtor&lt;/code&gt;. It then claims that 149 &lt;code&gt;t[]&lt;/code&gt; fields reclaim the freed memory, allowing a forged &lt;code&gt;zend_fcall_info&lt;/code&gt; to call &lt;code&gt;zend_call_function&lt;/code&gt; and reach &lt;code&gt;system()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I built PHP 8.4.4 with ASAN and set &lt;code&gt;USE_ZEND_ALLOC=0&lt;/code&gt; so ASAN would also observe the Zend heap. I sent PoCs for 618 and 619 through PHP-FPM + nginx, and checked for ASAN reports, &lt;code&gt;uid=&lt;/code&gt;, and worker crashes.&lt;/p&gt;

&lt;p&gt;The result: &lt;strong&gt;I could not reproduce either the UAF or RCE.&lt;/strong&gt; The progress-store path was reached: I could see &lt;code&gt;bytes_processed=258&lt;/code&gt; and &lt;code&gt;files[0].bytes_processed=1&lt;/code&gt;. But there was no ASAN report, no &lt;code&gt;uid=&lt;/code&gt;, and no worker crash. I got the same result with an ASAN build of PHP 8.5.4 (the build assumed by the public PoC’s &lt;code&gt;DELTA_CALL&lt;/code&gt;), PHP 5.6.40, ASLR disabled, the actual address of &lt;code&gt;zend_call_function&lt;/code&gt;, and the poisoned address &lt;code&gt;0x4141414141414141&lt;/code&gt;. A poisoned address not causing a crash is evidence that the stale pointer was not being used as a function pointer.&lt;/p&gt;

&lt;p&gt;Instrumenting the source made the reason clearer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;post_bytes_processed&lt;/code&gt; points to a zval inside &lt;code&gt;progress-&amp;gt;data&lt;/code&gt;; &lt;code&gt;progress&lt;/code&gt; itself holds a reference, so it remains alive during the update.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;track_init&lt;/code&gt; / &lt;code&gt;zend_hash_update&lt;/code&gt; free the copy decoded from the session.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;post&lt;/code&gt; read in &lt;code&gt;update()&lt;/code&gt; happens before &lt;code&gt;php_session_initialize()&lt;/code&gt;, while the free happens afterward.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;force=1&lt;/code&gt; update at &lt;code&gt;MULTIPART_EVENT_END&lt;/code&gt; skips the read entirely.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;current_file_bytes_processed&lt;/code&gt; is also retained by the &lt;code&gt;files[]&lt;/code&gt; array, and the &lt;code&gt;FILE_END&lt;/code&gt; update changes an existing key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The public bundle’s own &lt;code&gt;VALIDATION.md&lt;/code&gt; says, “ASAN crash not observed locally.” The image attached to its tweet is a screenshot explaining the alleged mechanism, not execution evidence showing &lt;code&gt;uid=&lt;/code&gt; or an ASAN report. The distributed tarball’s SHA-256 did not match the hash listed on its page (&lt;code&gt;a29d…&lt;/code&gt; versus &lt;code&gt;0c254d…&lt;/code&gt;). The code was identical to the GitHub mirror, but anyone running it should pin a commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This does not prove that the CVE does not exist.&lt;/strong&gt; It only means that the public PoC, at least as provided, did not reproduce RCE. In the first draft, I wrote, “The core root cause cannot be confirmed until the official patch diff is available.” The patch is now public, and §10 provides the answer. In short, the official root cause was different from the PoC’s story.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A chain that did work: multipart + LFI
&lt;/h2&gt;

&lt;p&gt;The core UAF in the public PoC did not reproduce, but a chain combining the &lt;code&gt;session.upload_progress&lt;/code&gt; primitive with an application-level LFI did.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;The attacker chooses a &lt;code&gt;PHPSESSID&lt;/code&gt; (&lt;code&gt;use_strict_mode=0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The attacker puts PHP code in &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Before the application runs, PHP serializes the value into &lt;code&gt;sess_&amp;lt;PHPSESSID&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An application LFI includes &lt;code&gt;sess_&amp;lt;PHPSESSID&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;PHP code embedded in the key string (&lt;code&gt;&amp;lt;?php ... ?&amp;gt;&lt;/code&gt;) executes.
&lt;/li&gt;
&lt;/ol&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant A as Attacker
    participant P as PHP
    participant L as LFI app
    A-&amp;gt;&amp;gt;P: PHPSESSID + PHP_SESSION_UPLOAD_PROGRESS
    P-&amp;gt;&amp;gt;P: Write sess_&amp;lt;id&amp;gt; before app runs
    A-&amp;gt;&amp;gt;L: ?file=/tmp/sess_&amp;lt;id&amp;gt;
    L-&amp;gt;&amp;gt;L: include(sess_&amp;lt;id&amp;gt;)
    L--&amp;gt;&amp;gt;A: uid=33(www-data)&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;For the test, I used a local-only application that intentionally calls &lt;code&gt;include($_GET['file'])&lt;/code&gt;. The result was &lt;code&gt;uid=33(www-data)&lt;/code&gt;. The session file contained the PHP code embedded in the progress key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;upload_progress_&amp;lt;?php /* ... */ ?&amp;gt;|a:5:{s:10:"start_time";...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nginx access log also showed the following sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"POST /index.php HTTP/1.1" 200 5
"GET /index.php?file=%2Ftmp%2Fsess_...&amp;amp;0=id HTTP/1.1" 200 414
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This clarifies what people meant by “a few more vulnerabilities.” The central requirement is an &lt;strong&gt;application-level LFI or another sink that reads the session file&lt;/strong&gt;. Other contributing factors can include SID fixation due to &lt;code&gt;use_strict_mode=0&lt;/code&gt;, a race when &lt;code&gt;cleanup=1&lt;/code&gt;, predictable &lt;code&gt;session.save_path&lt;/code&gt;, and including an upload temporary file that remains available even when &lt;code&gt;enabled=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is not universal RCE from the PHP core alone.&lt;/strong&gt; It requires an application-level sink. Mixing this with the alleged core UAF in the public PoC blurs two separate chains.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The default-setting race, &lt;code&gt;enabled=0&lt;/code&gt;, strict mode, and mod_php
&lt;/h2&gt;

&lt;p&gt;The default &lt;code&gt;cleanup=1&lt;/code&gt; does not make this safe. It removes progress at the end of a request, but progress remains during the upload. I sent the first chunk (6,000 bytes) to satisfy FPM’s &lt;code&gt;FILLUNIT=5120&lt;/code&gt;, stalled for 1.5 seconds, and requested the LFI during that interval. The result was &lt;code&gt;uid=33(www-data)&lt;/code&gt; in all 3 attempts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;enabled=0&lt;/code&gt; stops progress from being written to the session. But multipart uploads continue, and &lt;code&gt;$_FILES['f']['tmp_name']&lt;/code&gt; is still created. If the application includes that file, another RCE path remains. Indeed, an include of the temporary file returned &lt;code&gt;uid=33(www-data)&lt;/code&gt; even with &lt;code&gt;enabled=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;use_strict_mode=1&lt;/code&gt; rejects an unknown SID chosen by an attacker. LFI with the chosen SID failed. However, the upload response’s &lt;code&gt;Set-Cookie&lt;/code&gt; header contained a new SID. If the response can be read, the attacker can follow that SID. With my test’s &lt;code&gt;--follow-cookie&lt;/code&gt; option, the result was &lt;code&gt;uid=33(www-data)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same chain also worked with mod_php (&lt;code&gt;php:8.4.4-apache&lt;/code&gt;). This chain is independent of the SAPI.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cleanup=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default &lt;code&gt;cleanup=1&lt;/code&gt; + race&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;uid=33(www-data)&lt;/code&gt; in 3/3 attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;enabled=0&lt;/code&gt; + temporary-file include&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;use_strict_mode=1&lt;/code&gt; + chosen SID&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;use_strict_mode=1&lt;/code&gt; + follow &lt;code&gt;Set-Cookie&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mod_php&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  8. How to read the shared impact assessment
&lt;/h2&gt;

&lt;p&gt;I was also sent an impact assessment for a web application running PHP 5.6.40. I cannot disclose the application name or names of credential keys, so I have generalized the details.&lt;/p&gt;

&lt;p&gt;The assessment did a good job of listing exposure conditions: PHP 5.6.40 is EOL and outside the scope of the official fixes; the &lt;code&gt;session&lt;/code&gt; extension is installed; &lt;code&gt;upload_progress&lt;/code&gt; is enabled by default; &lt;code&gt;file_uploads&lt;/code&gt; is on; and all requests reach PHP. Together, these establish an attack surface. It was also practical to describe impact conditionally as the “blast radius if RCE is achieved.”&lt;/p&gt;

&lt;p&gt;There are several caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“&lt;code&gt;enabled=Off&lt;/code&gt; means the vulnerable code path does not run” applies &lt;strong&gt;only to the session callback&lt;/strong&gt;. The multipart parser and temporary files remain. My &lt;code&gt;enabled=0&lt;/code&gt; + temporary-file include test is a counterexample.&lt;/li&gt;
&lt;li&gt;“This is RCE” was a researcher/leak claim at the time of the first draft, not yet confirmed by PHP. The October 21 release explicitly confirmed RCE. The assessment itself rated confidence as medium.&lt;/li&gt;
&lt;li&gt;“PHP 5.6 is affected” was a leak claim. Official fixes cover 8.2 and later, with backports for 7.4, 8.0, and 8.1; PHP 5.6 remains outside the official scope and unconfirmed.&lt;/li&gt;
&lt;li&gt;If an assessment does not mention LFI, it does not prove that RCE is achievable. The chain in my lab requires an application-level LFI.&lt;/li&gt;
&lt;li&gt;Do not commit the assessment as-is to a public repository if it contains internal application names or secret-key names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an article, the accurate statement is that “the exposure conditions were present in a PHP 5.6.40 web application.” It would not be accurate to say “RCE was confirmed.” The assessment was about impact &lt;strong&gt;if&lt;/strong&gt; RCE were achieved.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Mitigations and what they do—and do not—stop
&lt;/h2&gt;

&lt;p&gt;Mitigations need to be evaluated by what they stop:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;th&gt;What it stops&lt;/th&gt;
&lt;th&gt;What remains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.upload_progress.enabled=Off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Progress writes to the session&lt;/td&gt;
&lt;td&gt;Multipart uploads / temporary files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.upload_progress.cleanup=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Progress after request completion&lt;/td&gt;
&lt;td&gt;Race during the upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.use_strict_mode=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Attacker-chosen SID&lt;/td&gt;
&lt;td&gt;Following the SID if &lt;code&gt;Set-Cookie&lt;/code&gt; is readable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAF / multipart restrictions&lt;/td&gt;
&lt;td&gt;Some automated attacks&lt;/td&gt;
&lt;td&gt;Legitimate uploads, other SAPIs, other sinks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patch&lt;/td&gt;
&lt;td&gt;The root causes identified by PHP&lt;/td&gt;
&lt;td&gt;Application-level LFI / configuration issues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Least privilege / isolation&lt;/td&gt;
&lt;td&gt;Limits impact after compromise&lt;/td&gt;
&lt;td&gt;RCE itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;enabled=Off&lt;/code&gt; is the strongest mitigation for the session write primitive. However, multipart uploads and temporary files remain. If the application has a sink that includes uploaded files, that must be fixed separately.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cleanup=1&lt;/code&gt; only removes progress after the request ends, so it does not prevent a race during upload. &lt;code&gt;use_strict_mode=1&lt;/code&gt; prevents the attacker from choosing a SID, but progress is still written. If the response’s &lt;code&gt;Set-Cookie&lt;/code&gt; can be read, the SID can be followed.&lt;/p&gt;

&lt;p&gt;That is why mitigations should be separated into three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Patch&lt;/strong&gt; — fix the core root causes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration&lt;/strong&gt; — disable the primitive with settings such as &lt;code&gt;enabled=Off&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove application sinks&lt;/strong&gt; — eliminate LFI, includes of uploaded files, and code that reads session files.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not treat “set one option and you are done” as a complete response.&lt;/p&gt;

&lt;p&gt;The official advisories also list concrete mitigations for the core issues: force &lt;code&gt;memory_limit=-1&lt;/code&gt; in administrator configuration (and prevent applications from overriding it), reduce &lt;code&gt;post_max_size&lt;/code&gt;, use cgroup/container memory limits, set &lt;code&gt;fatal_error_backtraces=0&lt;/code&gt; on PHP 8.5, and set &lt;code&gt;pm.max_requests=1&lt;/code&gt; to prevent workers from being reused. The advisories explicitly say &lt;strong&gt;not&lt;/strong&gt; to use &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt;, because they can cause &lt;code&gt;mmap()&lt;/code&gt; failures and trigger the vulnerable path. These measures reduce attack paths, but PHP says there is no complete mitigation; upgrading is the only safe fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. What the official release and patch diffs showed
&lt;/h2&gt;

&lt;p&gt;The official release was already available at 22:45 JST, before the announced 23:00 JST time. I read it and checked my first-draft predictions one by one.&lt;/p&gt;

&lt;h3&gt;
  
  
  10.1 Facts confirmed by PHP
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed versions&lt;/td&gt;
&lt;td&gt;PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-109618&lt;/td&gt;
&lt;td&gt;RCE via OOM corruption (GHSA-w8hw-w9fx-j79w)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-109619&lt;/td&gt;
&lt;td&gt;RCE via stale global state (GHSA-6wmf-4rv5-p3rp)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Severity&lt;/td&gt;
&lt;td&gt;CVSS v4.0 9.2 for both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;All PHP handling web requests: FPM, mod_php, php-cgi, FrankenPHP, LiteSpeed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most dangerous setup&lt;/td&gt;
&lt;td&gt;mod_php; a demonstrated attack works on an unauthenticated request before application code runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.1 and earlier&lt;/td&gt;
&lt;td&gt;Outside official support. Minimal source backports only for 7.4 / 8.0 / 8.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mitigation&lt;/td&gt;
&lt;td&gt;“There is no complete mitigation. Upgrading is the only safe fix.”&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The official release’s explicit “RCE” classification superseded the first draft’s statement that PHP had not yet called it RCE. That statement was true only at announcement time. The CVE numbers and fixed versions also matched the leaked guesses.&lt;/p&gt;

&lt;h3&gt;
  
  
  10.2 The root causes differed from the public PoC’s explanation
&lt;/h3&gt;

&lt;p&gt;The official GHSAs describe two causes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CVE-2026-109618 (OOM corruption)&lt;/strong&gt; abuses PHP’s use of &lt;code&gt;longjmp()&lt;/code&gt; to bail out of processing when it runs out of memory. If an allocation fails while request memory is only partially initialized, PHP does not correctly account for the early bailout and leaves request memory in an invalid state. Request shutdown later reads or branches on that corrupted state. There are several ways for multipart parsing to hit &lt;code&gt;memory_limit&lt;/code&gt; even while staying within &lt;code&gt;post_max_size&lt;/code&gt;: multipart requests without Content-Length, unlimited headers inside parts, empty file controls not counted by &lt;code&gt;max_file_uploads&lt;/code&gt;, and nested field names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CVE-2026-109619 (stale global state)&lt;/strong&gt; occurs when request-owned state remains in a worker global after the request memory has been freed. A fatal error or OOM interrupts processing before the corresponding reset runs. The request heap is released, but a worker global still points into that memory. If the next request reads the stale value before normal initialization, it triggers a use-after-free. PHP says both a producer (the operation that leaves the state behind) and a consumer (the operation that reads stale state) are required. In demonstrated cases, &lt;strong&gt;separate HTTP requests&lt;/strong&gt; must be assigned to the same worker.&lt;/p&gt;

&lt;p&gt;The official advisory names two consumers. One is INI diagnostics before request initialization (the path where &lt;code&gt;.htaccess&lt;/code&gt; or FPM &lt;code&gt;PHP_VALUE&lt;/code&gt; emits a warning or deprecation). The other is &lt;strong&gt;Session Upload Progress&lt;/strong&gt;. It touches session/decoder state before normal session initialization. The sequence is that a previous request exits on a fatal error and leaves state behind, then the next upload reads it. &lt;code&gt;session_start()&lt;/code&gt; is not needed, and the feature is enabled by default.&lt;/p&gt;

&lt;p&gt;This is the decisive difference from the public PoC. The PoC said that a second &lt;code&gt;update()&lt;/code&gt; within one request corrupts a zval in &lt;code&gt;post_bytes_processed&lt;/code&gt;. The official root cause is &lt;strong&gt;state that was not reset across requests&lt;/strong&gt;. It is not a UAF confined to one request.&lt;/p&gt;

&lt;h3&gt;
  
  
  10.3 Reading the patch diffs
&lt;/h3&gt;

&lt;p&gt;The fix is commit &lt;a href="https://github.com/php/php-src/commit/31c0ea7d7ff1d1fccb6492e52dab544011628065" rel="noopener noreferrer"&gt;&lt;code&gt;31c0ea7d7ff1d1fccb6492e52dab544011628065&lt;/code&gt;&lt;/a&gt;, titled “Avoid state corruption via bailout.” It names both GHSAs as fixes. I fetched the &lt;code&gt;php-8.5.11&lt;/code&gt; and &lt;code&gt;php-8.5.12&lt;/code&gt; tags and diffed the relevant files.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;ext/session/session.c&lt;/code&gt;, four things changed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Call &lt;code&gt;php_rinit_session_globals()&lt;/code&gt; at &lt;code&gt;MULTIPART_EVENT_START&lt;/code&gt;, initializing session globals &lt;strong&gt;before&lt;/strong&gt; RINIT.&lt;/li&gt;
&lt;li&gt;Force unserialize/serialize during upload progress to use &lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt;. Since this happens before RINIT, it must not invoke autoload, &lt;code&gt;__wakeup()&lt;/code&gt;, or &lt;code&gt;__unserialize()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;php_session_initialize()&lt;/code&gt; returns FAILURE, do not set &lt;code&gt;session_status&lt;/code&gt; to active; return instead.&lt;/li&gt;
&lt;li&gt;Do not run the callback through &lt;code&gt;request_parse_body()&lt;/code&gt; (&lt;code&gt;PG(modules_activated)&lt;/code&gt;).
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;    case MULTIPART_EVENT_START: {
&lt;span class="gi"&gt;+       /* This runs before RINIT, so we need to clear these early */
+       php_rinit_session_globals();
&lt;/span&gt;        progress = ecalloc(1, sizeof(php_session_rfc1867_progress));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gi"&gt;+    PS(in_rfc1867_update) = true;
+    if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
+        PS(in_rfc1867_update) = false;
+        return;
+    }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the &lt;code&gt;main/rfc1867.c&lt;/code&gt; side, the patch adds &lt;code&gt;ZEND_HAS_OOM()&lt;/code&gt; checks and replaces allocations that could fail. The parser now keeps only &lt;code&gt;Content-Disposition&lt;/code&gt; and &lt;code&gt;Content-Type&lt;/code&gt; headers, narrowing the route for exhausting memory through part headers. In &lt;code&gt;Zend/zend_alloc.c&lt;/code&gt;, the OOM &lt;code&gt;longjmp()&lt;/code&gt; is removed and replaced with a deferred soft OOM; a second hard limit exits via &lt;code&gt;_exit()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So, &lt;strong&gt;the public analysis was right that &lt;code&gt;php_session_rfc1867_callback()&lt;/code&gt; was among the code being fixed&lt;/strong&gt;. But the fix is not “prevent zval corruption on the second update.” It initializes session state that runs before RINIT and prevents user code from running pre-RINIT. Getting the function name right is not the same as getting the root-cause explanation right.&lt;/p&gt;

&lt;h3&gt;
  
  
  10.4 Why my lab did not reproduce the official issue
&lt;/h3&gt;

&lt;p&gt;My initial lab followed the public PoC’s story: it tried only to trigger two updates in one request and corrupt a zval. The official 619 path uses a producer and consumer in &lt;strong&gt;separate requests&lt;/strong&gt;, which must be assigned to the same worker. A single request cannot satisfy those conditions.&lt;/p&gt;

&lt;p&gt;Also, the 619 producer requires a fatal error or OOM to skip a reset. My lab’s trigger exited normally, so it was not a producer. For 618, the key is that shutdown runs even when request startup fails in mod_php. The main lab used FPM, where the child exits if startup fails, so it does not follow the same path.&lt;/p&gt;

&lt;p&gt;Therefore, “I could not reproduce it in my lab” does not prove the CVE does not exist or that PHP’s official root cause is wrong. &lt;strong&gt;I was testing different attack conditions.&lt;/strong&gt; The same distinction applies to the public PoC: “this PoC does not prove RCE” and “the CVE is a hoax” are not the same statement. That boundary from my first draft turned out to be correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  10.5 Which first-draft predictions were right?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;First-draft statement&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed versions would be 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP had not yet called it RCE&lt;/td&gt;
&lt;td&gt;True at announcement time; PHP explicitly called it RCE at release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause was a zval UAF in &lt;code&gt;php_session_rfc1867_callback()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Incorrect. PHP identified OOM and cross-request stale state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;session.upload_progress&lt;/code&gt; was involved&lt;/td&gt;
&lt;td&gt;Partly correct. It is a confirmed consumer for 619&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every PHP version from 5.4 onward was affected&lt;/td&gt;
&lt;td&gt;Still unconfirmed. Official fixes cover 8.2+; backports for 7.4–8.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;enabled=Off&lt;/code&gt; is not a complete mitigation&lt;/td&gt;
&lt;td&gt;Correct, though the reason also involves lifecycle paths beyond the multipart parser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The multipart + LFI chain is real&lt;/td&gt;
&lt;td&gt;Correct. The application-level sink remains after the patch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  10.6 The lab after patching
&lt;/h3&gt;

&lt;p&gt;I built an ASAN version with &lt;code&gt;PATCHED_VERSION=8.5.12&lt;/code&gt; and reran the same trigger and LFI chain. The build was PHP 8.5.12, and the web SAPI was &lt;code&gt;fpm-fcgi&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result on 8.5.12&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;probe&lt;/code&gt; (normal multipart)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;upload_progress=present&lt;/code&gt;; file parsing OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;trigger&lt;/code&gt; (258 bytes)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bytes_processed=258&lt;/code&gt;, &lt;code&gt;done=true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;poc618&lt;/code&gt; / &lt;code&gt;poc619&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP 200, no ASAN report, no &lt;code&gt;uid=&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LFI chain (8084)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uid=33(www-data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FPM log&lt;/td&gt;
&lt;td&gt;NOTICE messages for startup/shutdown only; no ASAN / crash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things can be concluded:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;session.upload_progress&lt;/code&gt; write primitive still works after the patch.&lt;/strong&gt; This is a feature, not a vulnerability. The official patch fixes state that was not reset across requests, not the act of writing upload progress to a session. That is why &lt;code&gt;probe&lt;/code&gt; and &lt;code&gt;trigger&lt;/code&gt; return the same results as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The application-level LFI chain still works on 8.5.12.&lt;/strong&gt; The official patch closes the core CVEs, but it does not remove an application sink that includes &lt;code&gt;sess_&amp;lt;id&amp;gt;&lt;/code&gt; or an uploaded temporary file. That must be fixed separately.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To be precise, &lt;code&gt;poc618&lt;/code&gt; / &lt;code&gt;poc619&lt;/code&gt; not crashing on 8.5.12 does not prove the patch worked. Those PoCs also did not crash in this lab on 8.5.11 before the patch. I verified the patch’s effect by reading the GHSA descriptions and diffs. The lab verified only that the feature and the application-level chain remain after patching.&lt;/p&gt;

&lt;h3&gt;
  
  
  10.7 Remaining uncertainties
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;This lab did &lt;strong&gt;not&lt;/strong&gt; reproduce the actual producer/consumer path for the official CVEs. It is an investigation based on the patch diffs and GHSA descriptions. Although PHP says RCE was demonstrated on mod_php, I did not reproduce RCE on my own machine; I keep those claims separate.&lt;/li&gt;
&lt;li&gt;The GHSA says the FPM path for 619 “depends on appropriate application actions.” Which actions produce the state depends on the application and extension combination.&lt;/li&gt;
&lt;li&gt;Impact on PHP 5.4–7.3 is not confirmed because those versions are outside the official scope. Backports exist for 7.4–8.1, but these are not supported updates.&lt;/li&gt;
&lt;li&gt;I did not check for in-the-wild (ITW) attacks after the release.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  11. Conclusion
&lt;/h2&gt;

&lt;p&gt;There are five takeaways from this investigation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Application-layer security and runtime security are different things.&lt;/strong&gt; Your code can be perfectly fine and still be affected before a request reaches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CVEs are real, and upgrading is the only complete fix.&lt;/strong&gt; Upgrade to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35. Configuration cannot fully replace the patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The causes are OOM state corruption and state left behind across requests.&lt;/strong&gt; This is not a zval UAF within one request. The first issue lets shutdown touch memory left in an invalid state during initialization; the second skips a reset after a fatal error/OOM, so the next request reads stale state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;session.upload_progress&lt;/code&gt; writes to the session before the application runs.&lt;/strong&gt; This primitive is real, and combining it with an application-level LFI can lead to RCE. PHP also confirms it as a consumer for 619.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate configuration mitigations, patches, and application-sink removal.&lt;/strong&gt; &lt;code&gt;enabled=Off&lt;/code&gt; blocks the confirmed Session Upload Progress path for 619, but other paths and application-level LFI can remain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I chose the title “The Return of the PHP2Shell Nightmare” because the incident felt familiar from previous PHP RCE scares. But this time, the nightmare was created less by a single UAF and more by &lt;strong&gt;information getting mixed up with things overlooked at the application layer&lt;/strong&gt;. Reading the official patch revealed both that the public PoC’s explanation was wrong and that the CVEs were real. Incorrect public information does not mean there is no vulnerability; a real vulnerability does not mean every public PoC is correct. Test what you can reproduce in a local lab, then check the result against official primary sources. That is the most reliable approach, both during an embargo and after it ends.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.php.net/archive/2026.php" rel="noopener noreferrer"&gt;PHP: News Archive 2026&lt;/a&gt; — The October 20 notice, October 21 security advisories, and fixed releases.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noopener noreferrer"&gt;GHSA-w8hw-w9fx-j79w — RCE via OOM corruption&lt;/a&gt; — Primary source for CVE-2026-109618, the OOM path, and mitigations.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noopener noreferrer"&gt;GHSA-6wmf-4rv5-p3rp — RCE via stale global state&lt;/a&gt; — Primary source for CVE-2026-109619, producers/consumers, and Session Upload Progress.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/php/php-src/commit/31c0ea7d7ff1d1fccb6492e52dab544011628065" rel="noopener noreferrer"&gt;php/php-src commit 31c0ea7d7f&lt;/a&gt; — “Avoid state corruption via bailout,” the fix for both CVEs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.php.net/manual/en/session.upload-progress.php" rel="noopener noreferrer"&gt;PHP Manual — Session Upload Progress&lt;/a&gt; — Progress data is written before application execution; details of &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/php/php-src/blob/master/ext/session/session.c" rel="noopener noreferrer"&gt;php/php-src — ext/session/session.c&lt;/a&gt; — &lt;code&gt;php_session_rfc1867_callback&lt;/code&gt;, the &lt;code&gt;enabled=0&lt;/code&gt; early return, and session writes.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/php/php-src/blob/master/main/rfc1867.c" rel="noopener noreferrer"&gt;php/php-src — main/rfc1867.c&lt;/a&gt; — Multipart parser and global callback.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.flatt.tech/entry/php2shell" rel="noopener noreferrer"&gt;GMO Flatt Security Blog — php2shell&lt;/a&gt; — Japanese secondary analysis that labels its connection to Session Upload Progress as a hypothesis.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sansec.io/research/php2shell" rel="noopener noreferrer"&gt;Sansec — php2shell&lt;/a&gt; — Summary of the disclosure and mitigations.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities" rel="noopener noreferrer"&gt;Huntress — php2shell Rapid Response&lt;/a&gt; — Situation at disclosure and detection perspective.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jackalsyn.com/posts/php-up-2026.html" rel="noopener noreferrer"&gt;JackalSyn — PHP-UP-2026&lt;/a&gt; — Analysis of the public bundle; its &lt;code&gt;VALIDATION.md&lt;/code&gt; says no ASAN crash was observed.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Zer061/CVE-2026-109618" rel="noopener noreferrer"&gt;Zer061/CVE-2026-109618&lt;/a&gt; — Public PoC code; I could not reproduce 618/619 in my lab.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tanahiro2010/slides/blob/main/slides/slides/PHP2SHELL%E3%81%A8%E3%81%84%E3%81%86%E6%82%AA%E5%A4%A2%E3%81%AE%E5%86%8D%E6%9D%A5%E3%82%92%E6%A4%9C%E8%A8%BC%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B/sandbox/report/REPORT.md" rel="noopener noreferrer"&gt;Investigation report&lt;/a&gt; — Experiment logs, nginx/Apache access logs, session files, and &lt;code&gt;uid=&lt;/code&gt; evidence.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tanahiro2010/slides/blob/main/slides/slides/PHP2SHELL%E3%81%A8%E3%81%84%E3%81%86%E6%82%AA%E5%A4%A2%E3%81%AE%E5%86%8D%E6%9D%A5%E3%82%92%E6%A4%9C%E8%A8%BC%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B/sandbox/README.md" rel="noopener noreferrer"&gt;Post-patch comparison (8.5.12)&lt;/a&gt; — Results of rerunning the trigger, PoCs, and LFI chain on an 8.5.12 ASAN build.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>docker</category>
      <category>php</category>
      <category>security</category>
    </item>
    <item>
      <title>I Tried Hono v5 RC: What Changed from v4, and What Breaks?</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:17:44 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/i-tried-hono-v5-rc-what-changed-from-v4-and-what-breaks-2fl4</link>
      <guid>https://dev.to/tanahiro2010/i-tried-hono-v5-rc-what-changed-from-v4-and-what-breaks-2fl4</guid>
      <description>&lt;h1&gt;
  
  
  I Tried Hono v5 RC: What Changed from v4, and What Breaks?
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Will your app break when you upgrade to v5? Mine did.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;With the Hono v5 release candidate out, I set up separate v4 and v5 environments and &lt;strong&gt;ran identical code in both&lt;/strong&gt;. I measured five areas: type checking, runtime behavior, routing, performance, and size.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every type-related breaking change was caught by the type checker.&lt;/strong&gt; Start your migration with &lt;code&gt;tsc --strict&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real risks are three changes the type checker won't catch:&lt;/strong&gt; &lt;code&gt;c.json(undefined)&lt;/code&gt;, the composition of &lt;code&gt;onError()&lt;/code&gt; handlers, and changes to wildcard matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Smaller” refers to the installed package.&lt;/strong&gt; The final bundle actually grew by 302 bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the environment I used:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Test date&lt;/td&gt;
&lt;td&gt;2026-10-18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hono@4.13.13&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hono@5.0.0-rc.1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;v24.22.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;7.0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine&lt;/td&gt;
&lt;td&gt;Apple M4 (arm64)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/tanahiro2010/slides/tree/main/slides/slides/Honov5%20RC%E3%82%92%E8%A7%A6%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B" rel="noopener noreferrer"&gt;Experimental code and raw data&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me state my main takeaway up front:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The things that break in v5 are the places where v4 was permissive.&lt;/strong&gt; &lt;code&gt;c.req.json()&lt;/code&gt; returned &lt;code&gt;any&lt;/code&gt;; &lt;code&gt;c.req.query()&lt;/code&gt; concealed the possibility of &lt;code&gt;undefined&lt;/code&gt;; and &lt;code&gt;onError()&lt;/code&gt; silently overwrote previous handlers. Those were all places where v4's API gave a misleading picture. v5 is simply more honest. Migration may be inconvenient, but that is a different question from whether the design is sound.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a hands-on account of testing Hono v5 RC, written for Hono users who want to know what will break when they upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v5 is a release candidate. Behavior may change before the stable release.&lt;/strong&gt; Everything here reflects testing on October 18, 2026.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Hono v5 Is “Hono for AI”
&lt;/h2&gt;

&lt;p&gt;Before listing breaking changes, it's worth understanding what v5 is trying to accomplish. Once you see that, the individual changes start pointing in the same direction.&lt;/p&gt;

&lt;p&gt;At Hono Conference 2026, creator Yusuke Wada described v5 as &lt;strong&gt;“Hono for AI.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;He described the goal of AI-friendly APIs as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Abstracted, with fewer choices.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the key to understanding v5.&lt;/p&gt;

&lt;p&gt;Changing &lt;code&gt;c.req.json()&lt;/code&gt; so it no longer returns &lt;code&gt;any&lt;/code&gt;, and turning &lt;code&gt;onError()&lt;/code&gt; from a special function that can only be registered once into ordinary middleware, are part of the same idea: &lt;strong&gt;remove special cases and reduce the number of ways to do things.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A human might see &lt;code&gt;any&lt;/code&gt; and reason, “Well, this will actually be a string, so it'll be fine.” When an LLM writes the code, that implicit assumption may not survive. If the types lie, the generated code can carry the same lie forward.&lt;/p&gt;

&lt;p&gt;With that in mind, the changes below make a lot more sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Setting Up the Test Environments
&lt;/h2&gt;

&lt;p&gt;I completely separated the directories to prevent v4 and v5 dependencies from mixing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;sandbox/v4
npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install &lt;/span&gt;hono@4
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; typescript tsx @types/node

&lt;span class="nb"&gt;cd&lt;/span&gt; ../v5
npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install &lt;/span&gt;hono@next      &lt;span class="c"&gt;# installed 5.0.0-rc.1&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; typescript tsx @types/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RC is distributed under the &lt;code&gt;next&lt;/code&gt; dist-tag. &lt;code&gt;npm install hono@next&lt;/code&gt; is the method recommended by the official migration guide.&lt;/p&gt;

&lt;p&gt;Here are the actual installed versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;sandbox/v4 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;ls &lt;/span&gt;hono typescript&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;v4@1.0.0
├── hono@4.13.13
└── typescript@7.0.2

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;sandbox/v5 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;ls &lt;/span&gt;hono typescript&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;v5@1.0.0
├── hono@5.0.0-rc.1
└── typescript@7.0.2

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="go"&gt;v24.22.0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All tests used &lt;code&gt;app.request()&lt;/code&gt; or &lt;code&gt;app.fetch()&lt;/code&gt;. Since I didn't start an HTTP server, no runtime adapter was needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Stricter Types
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;c.req.json()&lt;/code&gt; Changes from &lt;code&gt;any&lt;/code&gt; to &lt;code&gt;unknown&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;In v4, this code &lt;strong&gt;passes type checking&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Hono&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hono&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hono&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// any&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// TypeScript accepts this&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;If a number is sent as &lt;code&gt;name&lt;/code&gt;, the code crashes at runtime. The type system provides no protection.&lt;/p&gt;

&lt;p&gt;Running the same code through &lt;code&gt;tsc --strict&lt;/code&gt; with v5 gives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;src/typecheck-json.ts(9,11): error TS18046: 'body' is of type 'unknown'.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You now have to narrow the type before accessing its properties.&lt;/p&gt;

&lt;p&gt;There are three approaches. The first one listed in the official guide is supplying a type argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But &lt;strong&gt;this is not runtime validation&lt;/strong&gt;. The type argument merely declares what you expect. You can still make a false declaration and the compiler will accept it. Personally, I think silencing the error this way misses the point of returning &lt;code&gt;unknown&lt;/code&gt;: inspect the value before using it.&lt;/p&gt;

&lt;p&gt;The straightforward alternative is a type guard. This passed v5's type checker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;400&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In real applications, the third option—using a validator and accessing the validated data through &lt;code&gt;c.req.valid()&lt;/code&gt;—is usually the best choice. The official guide recommends it as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Destructuring &lt;code&gt;c.req.query()&lt;/code&gt; Starts Producing Errors Everywhere
&lt;/h3&gt;

&lt;p&gt;I think this change will affect more codebases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&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;upper&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;queries&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;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This passes on v4. On v5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;src/typecheck-query.ts(7,25): error TS18048: 'name' is possibly 'undefined'.
src/typecheck-query.ts(10,9): error TS2322: Type 'string[] | undefined' is not assignable to type 'string[]'.
  Type 'undefined' is not assignable to type 'string[]'.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;query()&lt;/code&gt; without a key now has a return type of &lt;code&gt;Record&amp;lt;string, string | undefined&amp;gt;&lt;/code&gt;. Consequently, destructured values start producing errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Interesting Part: Runtime Values Are Unchanged
&lt;/h3&gt;

&lt;p&gt;I sent &lt;code&gt;GET /q?a=1&amp;amp;a=2&lt;/code&gt; and compared the actual results.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;th&gt;v5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;c.req.query()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"a":"1"}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"a":"1"}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;c.req.queries()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"a":["1","2"]}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"a":["1","2"]}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;They're identical.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In other words, v5 didn't change the runtime behavior. In v4, looking up a nonexistent key could already return &lt;code&gt;undefined&lt;/code&gt;; the types merely insisted it was a &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Only the types became more honest. Every newly flagged line was already a potential bug in v4.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Error Handling Has Been Redesigned
&lt;/h2&gt;

&lt;p&gt;This is the most interesting v5 change to me.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Changed?
&lt;/h3&gt;

&lt;p&gt;In v4, &lt;code&gt;onError()&lt;/code&gt; looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Internal Server Error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;500&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;There was one handler for the whole app, with the signature &lt;code&gt;(err, c)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In v5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Path-scoped error middleware&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Error-Handler&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;api&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="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Omitting the path registers '*' under the current basePath&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Internal Server Error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;500&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 signature becomes &lt;code&gt;(c, next)&lt;/code&gt;, and you read the error from &lt;code&gt;c.error&lt;/code&gt;. You can also specify a path and register multiple handlers.&lt;/p&gt;

&lt;p&gt;The intention is stated explicitly in &lt;a href="https://github.com/honojs/hono/pull/5541" rel="noopener noreferrer"&gt;PR #5541&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The goal is to model error and not-found handling using Hono's routing system, including middleware composition, path scoping, sub-application handling, and Router-level decoration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Error handling has been &lt;strong&gt;rebuilt on top of the routing system&lt;/strong&gt;. Internally, the handlers are registered with reserved methods called &lt;code&gt;@ERROR&lt;/code&gt; and &lt;code&gt;@NOT_FOUND&lt;/code&gt;. That means error handlers are resolved using the same underlying mechanism as ordinary routes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;notFound()&lt;/code&gt; now follows the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-not-found&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;api&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="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API resource not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&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;h3&gt;
  
  
  Measurement: Does Path Scoping Work?
&lt;/h3&gt;

&lt;p&gt;I tested it directly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;&lt;code&gt;X-Error-Handler&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"Internal Server Error"}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/other/error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(absent)&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"Internal Server Error"}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It works: only &lt;code&gt;/api/*&lt;/code&gt; receives the extra header, while the common handler produces the final response.&lt;/p&gt;

&lt;p&gt;Trying the same thing in v4 yields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;this.errorHandler is not a function
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v4's &lt;code&gt;onError()&lt;/code&gt; accepts only one argument. It saves the path string as if it were the handler, then &lt;strong&gt;fails when a request arrives&lt;/strong&gt;. Registration itself doesn't complain. &lt;code&gt;notFound()&lt;/code&gt; similarly threw &lt;code&gt;TypeError: onNotFound is not a function&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #1: Composition Instead of Overwriting
&lt;/h3&gt;

&lt;p&gt;I expect this to be one of the biggest migration pitfalls.&lt;/p&gt;

&lt;p&gt;I registered &lt;code&gt;onError()&lt;/code&gt; twice and recorded which handlers ran:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Handlers executed&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;["second"]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;second&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;["first", "second"]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;second&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;v4 uses last-registration-wins replacement; v5 composes handlers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Code that depended on a later registration replacing an earlier handler will now execute both.&lt;/p&gt;

&lt;p&gt;When the first handler returns a response, the chain stops:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Handlers executed&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;["first"]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;first&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Call &lt;code&gt;next()&lt;/code&gt; to continue; return a response to stop. These are ordinary middleware rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And this won't produce a type error.&lt;/strong&gt; Once you update the signature to &lt;code&gt;(c, next)&lt;/code&gt;, &lt;code&gt;tsc&lt;/code&gt; passes. You may not notice the behavioral change until you actually run the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #2: Old v4 Handlers Cause Confusing Runtime Errors
&lt;/h3&gt;

&lt;p&gt;I also wondered what happens if you run a v4-style handler under v5.&lt;/p&gt;

&lt;p&gt;First, the type checker correctly stops it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;src/typecheck-onerror-v4style.ts(7,21): error TS2339: Property 'message' does not exist on type 'Context&amp;lt;BlankEnv, string, any&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'.
&lt;/span&gt;&lt;span class="go"&gt;src/typecheck-onerror-v4style.ts(8,12): error TS2339: Property 'json' does not exist on type 'Next'.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It shows exactly what's changed: the first argument is now &lt;code&gt;Context&lt;/code&gt;, and the second is &lt;code&gt;Next&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But if you suppress those errors with &lt;code&gt;@ts-expect-error&lt;/code&gt; and execute the code, you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Context is not finalized. Did you forget to return a Response object or `await next()`?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That message doesn't identify the root cause at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These were the arguments I observed:&lt;br&gt;
&lt;/p&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;"firstArgCtor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Context"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"firstArgIsContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"secondArgType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"function"&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;What your v4-style handler thinks is &lt;code&gt;c&lt;/code&gt; is now the &lt;code&gt;next&lt;/code&gt; function. It has no &lt;code&gt;c.text()&lt;/code&gt;, so an exception occurs and the context never gets finalized. That's why the runtime reports this message.&lt;/p&gt;

&lt;p&gt;Suppressing a type error can turn a straightforward migration fix into a long debugging session.&lt;/p&gt;

&lt;h3&gt;
  
  
  Throwing Values That Aren't &lt;code&gt;Error&lt;/code&gt; Instances
&lt;/h3&gt;

&lt;p&gt;In v4, throwing a string or object from a handler could escape past &lt;code&gt;app.fetch()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In v5, the value is wrapped in an &lt;code&gt;Error&lt;/code&gt; and sent to &lt;code&gt;onError()&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;th&gt;v5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;throw 'oops'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Escapes outward&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;message: 'oops'&lt;/code&gt; / &lt;code&gt;cause: 'oops'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;throw { code: 42 }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Escapes outward&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;message: ''&lt;/code&gt; / &lt;code&gt;cause: { code: 42 }&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One detail matters: &lt;strong&gt;when you throw an object, &lt;code&gt;message&lt;/code&gt; is empty&lt;/strong&gt;. The original value appears only in &lt;code&gt;cause&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// '' if an object was thrown&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// { code: 42 }&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wrapped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;500&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;If your logs only include &lt;code&gt;c.error.message&lt;/code&gt;, you'll be left with an unhelpful empty entry. Check &lt;code&gt;cause&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;Being able to catch strange thrown values from third-party libraries is a welcome improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Most Dangerous Change the Type Checker Won't Catch
&lt;/h2&gt;

&lt;p&gt;The behavior of &lt;code&gt;c.json(undefined)&lt;/code&gt; has changed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;th&gt;v5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;c.json(undefined)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 / body length 0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;c.json(() =&amp;gt; 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 / empty body&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the actual exception thrown in v5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;TypeError: Value is not JSON serializable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official explanation is that it now matches &lt;code&gt;Response.json()&lt;/code&gt;. That makes sense.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;strong&gt;this does not produce a type error&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If a conditional expression can yield &lt;code&gt;undefined&lt;/code&gt; and you pass it to &lt;code&gt;c.json()&lt;/code&gt;, you can run &lt;code&gt;tsc&lt;/code&gt; as many times as you like and it won't complain. Without a custom &lt;code&gt;onError&lt;/code&gt; handler, the built-in handler returns 500, and you might discover the problem only in production.&lt;/p&gt;

&lt;p&gt;Of all the migration checks in this article, I would prioritize this one the highest. You need tests to catch it.&lt;/p&gt;

&lt;p&gt;One likely example is returning a database lookup directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&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;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// undefined if no user is found&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In v4, an overlooked missing-user case could return 200 with an empty body rather than the intended 404. In v5, it becomes a 500. &lt;strong&gt;That means many of the new 500s were bugs already present in v4&lt;/strong&gt;—v5 merely exposes them.&lt;/p&gt;

&lt;p&gt;Another possible case is passing the return value of a &lt;code&gt;void&lt;/code&gt; function directly. TypeScript might catch that when the type is &lt;code&gt;void&lt;/code&gt;, but it can slip through if &lt;code&gt;any&lt;/code&gt; is involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Wildcard Matching Is Now Consistent
&lt;/h2&gt;

&lt;h3&gt;
  
  
  In v4, Different Routers Gave Different Results
&lt;/h3&gt;

&lt;p&gt;I registered the same routes in four routers under v4 and exhaustively checked the matches.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;SmartRouter&lt;/th&gt;
&lt;th&gt;RegExpRouter&lt;/th&gt;
&lt;th&gt;TrieRouter&lt;/th&gt;
&lt;th&gt;PatternRouter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/x/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/xz/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/x/z/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/xay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/wild/*/card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wild//card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/wild/*/card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wild/a/card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The results disagreed in three of six cases.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;/wild/*/card&lt;/code&gt;, only TrieRouter matched &lt;code&gt;/wild//card&lt;/code&gt;. In other words, switching routers could silently change which paths matched.&lt;/p&gt;

&lt;h3&gt;
  
  
  In v5, All Four Routers Agree
&lt;/h3&gt;

&lt;p&gt;I repeated the same checks under v5:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;SmartRouter&lt;/th&gt;
&lt;th&gt;RegExpRouter&lt;/th&gt;
&lt;th&gt;TrieRouter&lt;/th&gt;
&lt;th&gt;PatternRouter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/x/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/xz/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/x/z/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/x*y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/xay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/wild/*/card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wild//card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/wild/*/card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wild/a/card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;All four agree&lt;/strong&gt;, matching the table in the official documentation.&lt;/p&gt;

&lt;p&gt;The rules are now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;*&lt;/code&gt; &lt;strong&gt;at the end of a segment&lt;/strong&gt; is a wildcard; it can match an empty remainder.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;*&lt;/code&gt; &lt;strong&gt;in the middle of a segment&lt;/strong&gt;, as in &lt;code&gt;/x*y&lt;/code&gt;, is not a wildcard.&lt;/li&gt;
&lt;li&gt;A segment consisting of just &lt;code&gt;*&lt;/code&gt; matches one nonempty segment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The changed results are &lt;code&gt;/x*/y&lt;/code&gt; versus &lt;code&gt;/x/y&lt;/code&gt; (❌ → ✅ in every router), and &lt;code&gt;/x*y&lt;/code&gt; versus &lt;code&gt;/xay&lt;/code&gt; (✅ → ❌ for the regex-based routers). These changes also escape static type checking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #3: Adding One Route Changes the Router
&lt;/h3&gt;

&lt;p&gt;This was the most interesting discovery of the whole experiment.&lt;/p&gt;

&lt;p&gt;If you register both &lt;code&gt;/x*/y&lt;/code&gt; and &lt;code&gt;/x/*&lt;/code&gt;, v5's RegExpRouter cannot handle the combination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;UnsupportedPathError: /x/*
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SmartRouter catches that exception and &lt;strong&gt;falls back to TrieRouter&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I checked which router the app eventually selected:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Registered routes&lt;/th&gt;
&lt;th&gt;Router selected in v5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/x/*&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SmartRouter + RegExpRouter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/x*/y&lt;/code&gt; + &lt;code&gt;/x/*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SmartRouter + TrieRouter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Adding a single route changed the router used at runtime.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And when I tested those conflicting routes under v4 and v5, even the selected handler differed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;th&gt;v5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request to &lt;code&gt;/x/y&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Handler for &lt;code&gt;/x/*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Handler for &lt;code&gt;/x*/y&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Routing can change silently, making this difficult to spot without targeted tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measurement: How Much Does Fallback Cost?
&lt;/h3&gt;

&lt;p&gt;A different router should affect performance, so I measured 50,000 requests to &lt;code&gt;GET /users/:id&lt;/code&gt; across 12 rounds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Router&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;Minimum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No conflict&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SmartRouter + RegExpRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;138.68 ms&lt;/td&gt;
&lt;td&gt;152.28 ms&lt;/td&gt;
&lt;td&gt;132.35 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conflict&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SmartRouter + TrieRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;148.57 ms&lt;/td&gt;
&lt;td&gt;163.99 ms&lt;/td&gt;
&lt;td&gt;146.25 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Difference&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+7.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+7.7%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+10.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fallback was slower on all three measures.&lt;/p&gt;

&lt;p&gt;A disclosure: &lt;strong&gt;I reran this benchmark&lt;/strong&gt;. With just three rounds, I initially measured a 3.3% difference. On rerunning it, I got 8.1%. Three rounds weren't enough for a stable conclusion.&lt;/p&gt;

&lt;p&gt;Even after increasing the count to 12 rounds, the raw data contained outliers such as 287 ms and 271 ms (possibly from garbage collection or OS scheduling). I therefore consider the &lt;strong&gt;+10.5% difference in minimum times&lt;/strong&gt;, which is less affected by those outliers, closer to the underlying cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Would It Be Slower?
&lt;/h3&gt;

&lt;p&gt;According to the official documentation, RegExpRouter compiles registered routes into &lt;strong&gt;one large regular expression&lt;/strong&gt; and obtains a match in a single operation. TrieRouter uses a trie and supports the complete range of patterns, but isn't as fast as RegExpRouter.&lt;/p&gt;

&lt;p&gt;SmartRouter defaults to &lt;code&gt;[new RegExpRouter(), new TrieRouter()]&lt;/code&gt;. It tries the faster router first, then selects the more general implementation if necessary, continuing to use its choice thereafter. The design is essentially: &lt;strong&gt;try the fast one; fall back to the fully featured one if needed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This test exercised that fallback path, so a performance difference was expected.&lt;/p&gt;

&lt;p&gt;With RegExpRouter's consolidated expression, I would expect matching costs to remain fairly stable as routes are added. TrieRouter traverses a tree, so I would expect costs to depend on path segments and the route structure. If that's correct, the 7–10% gap I measured in this small app &lt;strong&gt;might be larger in an app with hundreds of routes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Conversely, in a small application the fallback probably won't be noticeable.&lt;/p&gt;

&lt;p&gt;But the exact 7–10% isn't the most important part. The bigger issue is that &lt;strong&gt;adding one route can silently switch the router&lt;/strong&gt;. You won't see that in the logs or the types.&lt;/p&gt;

&lt;p&gt;And this might be about more than speed. As shown above, a change in router selection can affect which handler matches. My measurements established that across v4 and v5; I suspect combinations and registration order within v5 could expose related issues, but &lt;strong&gt;I didn't test that hypothesis fully&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Is It Faster?
&lt;/h2&gt;

&lt;p&gt;I also benchmarked the core routing performance.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution: this is an in-process microbenchmark using &lt;code&gt;app.fetch()&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
It does not represent real HTTP-server or production Cloudflare Workers performance.&lt;br&gt;
It includes no network, TLS, or process-boundary costs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ran 100,000 requests to &lt;code&gt;GET /users/:id&lt;/code&gt;, with 20,000 warmup requests, alternating v4 and v5 for 10 rounds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statistic&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;th&gt;v5&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median&lt;/td&gt;
&lt;td&gt;270.53 ms&lt;/td&gt;
&lt;td&gt;266.50 ms&lt;/td&gt;
&lt;td&gt;v5 1.5% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mean&lt;/td&gt;
&lt;td&gt;278.24 ms&lt;/td&gt;
&lt;td&gt;271.85 ms&lt;/td&gt;
&lt;td&gt;v5 2.3% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum&lt;/td&gt;
&lt;td&gt;258.31 ms&lt;/td&gt;
&lt;td&gt;255.59 ms&lt;/td&gt;
&lt;td&gt;v5 1.1% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;My conclusion: the difference is within benchmark noise.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;v5 was slightly faster by each metric, but the distributions overlap substantially: v4's minimum (258.31 ms) is lower than v5's median (266.50 ms). This test doesn't justify claiming that v5 is faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Benchmarks Without Round Counts Are Hard to Interpret
&lt;/h3&gt;

&lt;p&gt;Another disclosure: at five rounds I measured a 2.9% difference. After increasing the test to ten rounds, it fell to 1.5%.&lt;/p&gt;

&lt;p&gt;Same code, same machine, same day. &lt;strong&gt;Changing only the number of rounds nearly halved the apparent advantage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The router fallback benchmark behaved similarly (3.3% at three rounds; 7.1% at twelve).&lt;/p&gt;

&lt;p&gt;When discussing single-digit percentage differences in microbenchmarks, publishing the round count and the distribution matters. I learned that firsthand. Every measurement in this article includes its round count.&lt;/p&gt;

&lt;p&gt;And unchanged core routing performance isn't bad news. Performance isn't the primary goal of v5's redesign.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. What Actually Got “Smaller”?
&lt;/h2&gt;

&lt;p&gt;In v5, runtime adapters were moved out of the core &lt;code&gt;hono&lt;/code&gt; package into separate &lt;code&gt;@hono/*&lt;/code&gt; packages. I expect people to describe that as “making Hono lighter,” so I measured what actually became smaller.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installed Package Size Really Did Shrink
&lt;/h3&gt;

&lt;p&gt;I queried the npm registry's &lt;code&gt;unpackedSize&lt;/code&gt; field:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Unpacked size&lt;/th&gt;
&lt;th&gt;File count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hono@4.13.13&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.23 MB&lt;/td&gt;
&lt;td&gt;574&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hono@5.0.0-rc.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.66 MB&lt;/td&gt;
&lt;td&gt;310&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Difference&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-46%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-46%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Running &lt;code&gt;du -sh&lt;/code&gt; on my local &lt;code&gt;node_modules&lt;/code&gt; showed 2.8 MB → 1.5 MB, also &lt;strong&gt;-46%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Yusuke Wada's Hono Conference 2026 presentation reported 1.29 MB → 0.70 MB, again &lt;strong&gt;-46%&lt;/strong&gt;, with file counts of 574 → 310. The file counts matched exactly. The slightly different absolute sizes probably reflect differences in the referenced versions.&lt;/p&gt;

&lt;p&gt;Three independent ways of looking at it produced the same reduction. I think that number is trustworthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  But the Bundle Got Bigger
&lt;/h3&gt;

&lt;p&gt;Here's the catch: I built actual bundles with esbuild.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entry point&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;th&gt;v5&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core only&lt;/td&gt;
&lt;td&gt;18,528 B&lt;/td&gt;
&lt;td&gt;18,830 B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+302 B (+1.6%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With adapter&lt;/td&gt;
&lt;td&gt;22,760 B&lt;/td&gt;
&lt;td&gt;22,923 B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+163 B (+0.7%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;It increased rather than decreased.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Thinking about it, that makes sense. The package shrank because the CJS build was removed and adapters were split into separate packages. A CJS build that your bundler never used wasn't contributing to the bundle anyway. And once you install &lt;code&gt;@hono/bun&lt;/code&gt;, the adapter code still comes along.&lt;/p&gt;

&lt;p&gt;Code previously excluded by tree shaking has simply been separated at the package level. The amount of code ultimately bundled hasn't meaningfully changed.&lt;/p&gt;

&lt;p&gt;When saying &lt;strong&gt;“Hono v5 is lighter,” specify whether you mean installation size or final bundle size&lt;/strong&gt;. The first is true; the second was not supported by my measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. ESM-Only Packaging and Adapter Separation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  It's ESM-Only, but &lt;code&gt;require()&lt;/code&gt; Still Works
&lt;/h3&gt;

&lt;p&gt;v5 removes the CJS build and becomes ESM-only. The official guide requires Node.js 22.12 or later.&lt;/p&gt;

&lt;p&gt;Interestingly, under that requirement, &lt;strong&gt;&lt;code&gt;require('hono')&lt;/code&gt; still works&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;require('hono')&lt;/code&gt; in v4&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;require('hono')&lt;/code&gt; in v5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OK&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;import('hono/bun')&lt;/code&gt; in v4&lt;/td&gt;
&lt;td&gt;Resolves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;import('hono/bun')&lt;/code&gt; in v5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ERR_PACKAGE_PATH_NOT_EXPORTED&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Node.js 22.12 and later support loading eligible ESM modules with &lt;code&gt;require()&lt;/code&gt;, so using &lt;code&gt;require()&lt;/code&gt; doesn't necessarily fail just because a package is ESM-only. The official guide mentions this, and my test confirmed it.&lt;/p&gt;

&lt;p&gt;However, subpaths such as &lt;code&gt;hono/bun&lt;/code&gt; have disappeared from &lt;code&gt;exports&lt;/code&gt;, so those definitely break. The issue is not necessarily CommonJS-style loading; it's the &lt;strong&gt;old subpath imports&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  All Eight Adapters Were Published on September 28, 2026
&lt;/h3&gt;

&lt;p&gt;I checked their initial publication dates in the npm registry.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;First published&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@hono/bun&lt;/code&gt; / &lt;code&gt;@hono/deno&lt;/code&gt; / &lt;code&gt;@hono/cloudflare-workers&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2026-09-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@hono/vercel&lt;/code&gt; / &lt;code&gt;@hono/netlify&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2026-09-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@hono/aws-lambda&lt;/code&gt; / &lt;code&gt;@hono/lambda-edge&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2026-09-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hono/service-worker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-09-28&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All eight released version 1.0.0 on the same day. They're the packages split out for v5.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;@hono/node-server&lt;/code&gt; is not one of these eight.&lt;/strong&gt; Its first release was on January 7, 2023, and it was already a separate package during v4. Don't confuse it with the newly extracted adapters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Adapter Separation and Supply-Chain Security
&lt;/h3&gt;

&lt;p&gt;I had heard the argument that separating adapters could reduce the attack surface for supply-chain compromises. I decided to investigate.&lt;/p&gt;

&lt;p&gt;On June 23, 2026, Yusuke Wada presented “Hono Supply Chain Compromise Countermeasures: Lessons from Three Libraries” at GMO Flatt Security's meetup on what OSS developers should do about software supply-chain attacks.&lt;/p&gt;

&lt;p&gt;The three case studies were axios, TanStack, and Mastra, each with a different attack path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Attack path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;axios&lt;/td&gt;
&lt;td&gt;Social engineering: malware on a maintainer's machine stealing credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TanStack&lt;/td&gt;
&lt;td&gt;Cache poisoning through &lt;code&gt;pull_request_target&lt;/code&gt; (“Pwn Request”); &lt;strong&gt;possible even without merging the PR&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mastra&lt;/td&gt;
&lt;td&gt;npm account takeover&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Among the Hono-side mitigations was &lt;strong&gt;removing adapter functionality from the core&lt;/strong&gt;. Other measures included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Moving npm publishing from local machines to GitHub Actions with OIDC trusted publishing.&lt;/li&gt;
&lt;li&gt;Avoiding &lt;code&gt;pull_request_target&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pinning GitHub Actions to lock down the environment.&lt;/li&gt;
&lt;li&gt;Removing Dev Containers and Gitpod configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This suggests adapter separation is not only about package size; it's also about reducing the attack surface. Size and file count were emphasized at Hono Conference 2026, but the same change had been discussed in a security context back in June.&lt;/p&gt;

&lt;h3&gt;
  
  
  But It Doesn't Reduce the Dependency Count
&lt;/h3&gt;

&lt;p&gt;Here's something I verified myself.&lt;/p&gt;

&lt;p&gt;I queried npm registry metadata for &lt;code&gt;dependencies&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Runtime dependencies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hono@4.13.13&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hono@5.0.0-rc.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hono/bun@1.0.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0&lt;/strong&gt; (only &lt;code&gt;hono&lt;/code&gt; as a peer)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For comparison, express had 28 and axios had 4 in the registry data I checked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hono already had zero external runtime dependencies in v4.&lt;/strong&gt; Splitting adapters didn't reduce that count. It's zero in both versions.&lt;/p&gt;

&lt;p&gt;What decreased was &lt;strong&gt;the amount of code shipped in the core package&lt;/strong&gt;. As shown in section 8, the file count dropped from 574 to 310.&lt;/p&gt;

&lt;p&gt;For a zero-dependency library, the package's own distributed code is itself part of the supply-chain attack surface. In that sense, removing adapters is better understood not as shrinking the dependency graph, but as &lt;strong&gt;roughly halving the code shipped together in a single package&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. &lt;code&gt;defineHandler()&lt;/code&gt;: The “Fewer Choices” Philosophy in Action
&lt;/h2&gt;

&lt;p&gt;The experimental &lt;code&gt;defineHandler()&lt;/code&gt; API is the clearest example of the “abstracted, with fewer choices” goal from section 1. I tried it because it shows where the design is heading.&lt;/p&gt;

&lt;p&gt;The simplest example is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hono/factory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ping&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;defineHandler&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pong&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;ping -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200 &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"pong"&lt;/span&gt;:true&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no &lt;code&gt;c.json()&lt;/code&gt;. &lt;strong&gt;The returned value is converted into a Response.&lt;/strong&gt; An object becomes JSON, a string or JSX becomes HTML, and &lt;code&gt;null&lt;/code&gt; or &lt;code&gt;undefined&lt;/code&gt; produces a 204.&lt;/p&gt;

&lt;p&gt;Request and response validation can also be defined in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;defineHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NameSchema&lt;/span&gt; &lt;span class="p"&gt;})((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NameSchema&lt;/code&gt; can be anything implementing &lt;a href="https://standardschema.dev/" rel="noopener noreferrer"&gt;Standard Schema&lt;/a&gt;, including Zod schemas.&lt;/p&gt;

&lt;p&gt;With invalid input, I received:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;schema ng -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;400 &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;:&lt;span class="s2"&gt;"Validation failed"&lt;/span&gt;,&lt;span class="s2"&gt;"issues"&lt;/span&gt;:[&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"slot"&lt;/span&gt;:&lt;span class="s2"&gt;"json"&lt;/span&gt;,&lt;span class="s2"&gt;"issues"&lt;/span&gt;:[&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;:&lt;span class="s2"&gt;"name must be a string"&lt;/span&gt;,&lt;span class="s2"&gt;"path"&lt;/span&gt;:&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;}]}]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran into one surprise: &lt;strong&gt;if you pass an ordinary validation function rather than a Standard Schema and throw inside it, you get a 500 instead of a 400&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;invalid -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;500 Internal Server Error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the API also accepts validation functions, I assumed failures would be handled identically. But in my testing, only the Standard Schema path wrapped failures into an &lt;code&gt;HTTPException&lt;/code&gt; that returned 400. If you need that 400 response, use the Standard Schema format.&lt;/p&gt;

&lt;p&gt;And the type definition contains a comment that captures the philosophy perfectly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A JSON Response by &lt;code&gt;c.json()&lt;/code&gt; is not accepted, because it bypasses the response validation. Return the value instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Returning &lt;code&gt;c.json()&lt;/code&gt; is explicitly forbidden by the types&lt;/strong&gt;, because doing so would bypass response validation.&lt;/p&gt;

&lt;p&gt;This is a shift from “several approaches usually work” to “there's one preferred approach and the types prevent alternatives.” It's a concrete expression of the design goal described earlier.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;defineHandler()&lt;/code&gt; is experimental and may change before the stable release.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Migration Checklist
&lt;/h2&gt;

&lt;p&gt;Here are my priorities based on what I actually measured.&lt;/p&gt;

&lt;h3&gt;
  
  
  First, Run &lt;code&gt;tsc --strict&lt;/code&gt; (Type-Detectable Changes)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Direct property access on the result of &lt;code&gt;c.req.json()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Destructuring such as &lt;code&gt;const { x } = c.req.query()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Treating &lt;code&gt;c.req.queries()&lt;/code&gt; values as guaranteed &lt;code&gt;string[]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Changing &lt;code&gt;app.onError((err, c) =&amp;gt; ...)&lt;/code&gt; to &lt;code&gt;(c) =&amp;gt; ...&lt;/code&gt; and reading &lt;code&gt;c.error&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The compiler will point these out. You can fix them systematically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next, Fix the Three Changes Types Can't Detect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any place that might pass &lt;code&gt;undefined&lt;/code&gt; to &lt;code&gt;c.json()&lt;/code&gt;&lt;/strong&gt;: it now returns 500. Without tests, this is easy to miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple registrations of &lt;code&gt;onError()&lt;/code&gt; or &lt;code&gt;notFound()&lt;/code&gt;&lt;/strong&gt;: handlers compose rather than overwrite one another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routes containing wildcards&lt;/strong&gt;: &lt;code&gt;/x*/y&lt;/code&gt; now matches &lt;code&gt;/x/y&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the real migration work. Passing the type checker is not enough.&lt;/p&gt;

&lt;p&gt;Also look for conflicting routes such as &lt;code&gt;/x*/y&lt;/code&gt; and &lt;code&gt;/x/*&lt;/code&gt;, which can trigger router fallback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update Imports and Removed APIs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hono/&amp;lt;runtime&amp;gt;&lt;/code&gt; → &lt;code&gt;@hono/&amp;lt;runtime&amp;gt;&lt;/code&gt; (eight packages).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hono/cloudflare-pages&lt;/code&gt;: no direct replacement; migrate to Workers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.mount()&lt;/code&gt; → &lt;code&gt;mount()&lt;/code&gt; from &lt;code&gt;hono/mount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.fire()&lt;/code&gt; → &lt;code&gt;fire(app)&lt;/code&gt; from &lt;code&gt;@hono/service-worker&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;c.req.routePath&lt;/code&gt; / &lt;code&gt;c.req.matchedRoutes&lt;/code&gt; → &lt;code&gt;hono/route&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getColorEnabledAsync()&lt;/code&gt; → &lt;code&gt;getColorEnabled(c.env)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;toSSG()&lt;/code&gt; hook options → &lt;code&gt;plugins&lt;/code&gt; (explicitly include &lt;code&gt;defaultPlugin()&lt;/code&gt; when needed).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One small detail: &lt;code&gt;UnOfficalStatusCode&lt;/code&gt; was corrected to &lt;code&gt;UnofficialStatusCode&lt;/code&gt;. A long-standing typo from v4 has finally been fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. What I Didn't Verify
&lt;/h2&gt;

&lt;p&gt;I couldn't investigate everything. Here are the remaining questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Priority of sub-app &lt;code&gt;notFound()&lt;/code&gt; handlers when they're integrated via &lt;code&gt;route()&lt;/code&gt;. The PR describes deeper-nested sub-apps taking priority, and registration order breaking ties at the same depth, but I didn't test it.&lt;/li&gt;
&lt;li&gt;How &lt;code&gt;basePath()&lt;/code&gt; interacts with &lt;code&gt;onError()&lt;/code&gt; path resolution.&lt;/li&gt;
&lt;li&gt;Benchmarks through a real HTTP server; all my measurements used &lt;code&gt;app.fetch()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Performance in an actual Cloudflare Workers environment.&lt;/li&gt;
&lt;li&gt;Integration of &lt;code&gt;@hono/zod-validator&lt;/code&gt; with &lt;code&gt;c.req.valid()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Hono CLI 1.0 RC.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And again, &lt;strong&gt;v5 was still an RC during testing&lt;/strong&gt;. These behaviors could change before the stable release.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Conclusion
&lt;/h2&gt;

&lt;p&gt;What struck me most was this: &lt;strong&gt;the places that break in v5 are the places v4 was too permissive&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;any&lt;/code&gt; from &lt;code&gt;c.req.json()&lt;/code&gt; and the overly confident &lt;code&gt;string&lt;/code&gt; types from &lt;code&gt;c.req.query()&lt;/code&gt; gave developers misleading information. v5 stops doing that. The actual query behavior hasn't changed at all. Making &lt;code&gt;onError()&lt;/code&gt; composable similarly replaces a special-case singleton with ordinary middleware.&lt;/p&gt;

&lt;p&gt;The direction is consistent with &lt;strong&gt;“abstracted, with fewer choices.”&lt;/strong&gt; Even &lt;code&gt;defineHandler()&lt;/code&gt; rejecting &lt;code&gt;c.json()&lt;/code&gt; through the type system follows the same philosophy.&lt;/p&gt;

&lt;p&gt;The migration cost isn't negligible. Fortunately, &lt;code&gt;tsc --strict&lt;/code&gt; highlights much of it. The three things to watch most closely are the changes static types don't detect: &lt;strong&gt;&lt;code&gt;c.json(undefined)&lt;/code&gt;, composition of &lt;code&gt;onError()&lt;/code&gt; handlers, and wildcard matching&lt;/strong&gt;. You'll need tests or manual inspection for those.&lt;/p&gt;

&lt;p&gt;Performance was effectively unchanged. Installation size dropped by nearly half, but bundle size did not. &lt;strong&gt;The reason to upgrade to v5 isn't speed or bundle size; it's a cleaner, more consistent API design.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Would I deploy the RC to production today? No, I'd wait for stable. But running a build of an existing project against &lt;code&gt;hono@next&lt;/code&gt; is absolutely worthwhile. The resulting type errors will immediately give you an idea of the migration effort.&lt;/p&gt;

&lt;p&gt;Finally, two predictions for the stable release:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If one of these changes gets revised, I think it'll be &lt;code&gt;defineHandler()&lt;/code&gt;.&lt;/strong&gt; It's explicitly marked experimental, and behavior such as “throwing in a validation function returns 500 rather than 400” still has rough edges. Conversely, the &lt;code&gt;onError()&lt;/code&gt; redesign and wildcard unification seem well specified in their PRs and implemented thoroughly, so I expect them to remain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My guess is that the removal of &lt;code&gt;hono/cloudflare-pages&lt;/code&gt; will cause the most migration pain.&lt;/strong&gt; Most changes can be fixed by rewriting code, but this one has no direct replacement and requires a separate move to Workers. &lt;code&gt;c.req.query()&lt;/code&gt; may produce far more type errors, but those are mechanical fixes. A single change without a drop-in replacement can cost more time than hundreds of compiler errors.&lt;/p&gt;

&lt;p&gt;I'll revisit these predictions when the stable version arrives.&lt;/p&gt;

&lt;p&gt;As for my project? &lt;strong&gt;It broke, just as expected.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://hono.dev/docs/guides/migrating-to-v5" rel="noopener noreferrer"&gt;Migrating to v5 — Hono&lt;/a&gt; — Official breaking-change guide; the source for my test cases.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/honojs/hono/pull/5541" rel="noopener noreferrer"&gt;feat!: add routable error and not-found middleware (honojs/hono#5541)&lt;/a&gt; — &lt;code&gt;onError()&lt;/code&gt; / &lt;code&gt;notFound()&lt;/code&gt; redesign, reserved route methods, and sub-app priority rules.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://slides.yusu.ke/honoconf-2026" rel="noopener noreferrer"&gt;Hono Conference 2026 — Yusuke Wada&lt;/a&gt; — The “Hono for AI” framing and package size figures.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/honojs/hono/releases" rel="noopener noreferrer"&gt;honojs/hono releases&lt;/a&gt; — Release dates of &lt;code&gt;5.0.0-rc.0&lt;/code&gt; (2026-10-08) and &lt;code&gt;5.0.0-rc.1&lt;/code&gt; (2026-10-10).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://speakerdeck.com/yusukebe/honodenosapuraitienqin-hai-dui-ce-3tunoraiburarinixue-bu" rel="noopener noreferrer"&gt;Hono Supply Chain Compromise Countermeasures — Yusuke Wada&lt;/a&gt; — Case studies of axios, TanStack, and Mastra; adapter extraction as a mitigation. (Japanese)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://qiita.com/kouki_engineer/items/fd371f4258941b3ae149" rel="noopener noreferrer"&gt;“What Should OSS Developers Do Now?” meetup report — kouki_engineer&lt;/a&gt; — Attendee report on the June 23, 2026 GMO Flatt Security meetup. (Japanese)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://registry.npmjs.org/hono" rel="noopener noreferrer"&gt;npm registry: hono&lt;/a&gt; — Source for &lt;code&gt;dependencies&lt;/code&gt;, &lt;code&gt;unpackedSize&lt;/code&gt;, and &lt;code&gt;fileCount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://standardschema.dev/" rel="noopener noreferrer"&gt;Standard Schema&lt;/a&gt; — Common schema interface accepted by &lt;code&gt;defineHandler()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hono.dev/docs/concepts/routers" rel="noopener noreferrer"&gt;Routers — Hono&lt;/a&gt; — RegExpRouter, TrieRouter, SmartRouter, and default selection.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hono.dev/docs/middleware/builtin/mount" rel="noopener noreferrer"&gt;Mount Middleware — Hono&lt;/a&gt; — Migration destination for the removed &lt;code&gt;app.mount()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hono</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Tested Google's DPoP Implementation. The Documentation Told a Different Story.</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Tue, 20 Oct 2026 05:13:06 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/i-tested-googles-dpop-implementation-the-documentation-told-a-different-story-38i6</link>
      <guid>https://dev.to/tanahiro2010/i-tested-googles-dpop-implementation-the-documentation-told-a-different-story-38i6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The implementation worked. It was the documentation that let me down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I investigated Google's mechanism for binding OAuth refresh tokens to a cryptographic key using DPoP from four angles: the official documentation, RFC 9449, my own measurements, and actual token exchanges. &lt;strong&gt;The implementation itself worked as intended. Every obstacle I encountered came down to a difference between the documentation and observed behavior.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The differences fell into three categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different from the RFC:&lt;/strong&gt; Access tokens remain Bearer tokens, and &lt;code&gt;jti&lt;/code&gt; isn't random in the authorization-code flow. These are design choices documented by Google.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different from what the documentation claims:&lt;/strong&gt; An official example contains an incorrectly encoded signature, different documentation pages contradict one another, and the claim that "a nonce value is used only once" doesn't match observed behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not documented at all:&lt;/strong&gt; The accepted &lt;code&gt;iat&lt;/code&gt; window and the order in which the grant and DPoP proof are validated. I had to measure these myself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the environment I used:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Test date&lt;/td&gt;
&lt;td&gt;October 19, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;v24.22.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6.2.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hono&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.13.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine&lt;/td&gt;
&lt;td&gt;Apple M4 (arm64)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Implementation: &lt;a href="https://github.com/tanahiro2010/slides/tree/main/slides/slides/Google%20OAuth%E3%81%8CDPoP%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%97%E3%81%9F%E3%82%89%E3%81%97%E3%81%84%E3%81%AE%E3%81%A7%E6%A4%9C%E8%A8%BC%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B/sandbox" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me make the main point clear up front.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google's DPoP implementation is not fundamentally broken.&lt;/strong&gt; Its design decisions have reasons behind them, and the performance argument held up in my measurements. The frustrating part for implementers is that some documented behavior differs from reality, while important details aren't documented at all. I reported the "used only once" discrepancy to Google, but the report was closed as &lt;strong&gt;Won't Fix&lt;/strong&gt;. In other words, this was treated as a documentation discrepancy rather than a demonstrated security vulnerability.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article expands on my lightning talk, "Google OAuth Apparently Supports DPoP Now, So I Tried It," with measurements and analysis I couldn't fit into the presentation. It is intended for developers who store Google API OAuth refresh tokens on their servers.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. What is DPoP?
&lt;/h2&gt;

&lt;p&gt;DPoP (Demonstrating Proof-of-Possession) is an OAuth 2.0 security mechanism standardized in &lt;a href="https://www.rfc-editor.org/rfc/rfc9449.html" rel="noopener noreferrer"&gt;RFC 9449&lt;/a&gt;. It became a Proposed Standard in September 2023, and providers such as Okta and FusionAuth have supported it for some time. &lt;strong&gt;What's new is Google's support, not DPoP itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A conventional Bearer token follows what Google's blog calls the "finders-keepers model": whoever gets the token can use it. DPoP helps prevent this through &lt;em&gt;sender-constraining&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The client generates a key pair and &lt;strong&gt;keeps the private key to itself&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;For each request to the token endpoint, the client includes a JWT signed with its private key (a &lt;em&gt;DPoP proof&lt;/em&gt;).&lt;/li&gt;
&lt;li&gt;The server issues a token &lt;strong&gt;bound to the public key&lt;/strong&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as Client (BFF)
    participant G as Google OAuth
    Note over C: Generate an EC P-256 key pair
    C-&amp;gt;&amp;gt;G: Authorization code exchange + DPoP proof
    G--&amp;gt;&amp;gt;C: refresh_token (key-bound)&amp;lt;br/&amp;gt;access_token (Bearer)&amp;lt;br/&amp;gt;DPoP-Nonce
    Note over C: Sign each subsequent refresh proof&amp;lt;br/&amp;gt;with the same key
    C-&amp;gt;&amp;gt;G: refresh_token grant + DPoP proof
    G--&amp;gt;&amp;gt;C: New access_token + new DPoP-Nonce&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;I ran the tests against a Node.js + TypeScript server acting as a Google OAuth confidential client. For JWTs I used &lt;code&gt;jose&lt;/code&gt; 6.2.12, which provides ES256 signing and JWK export without additional dependencies. I used a dedicated test client and account; I did not touch any production user's authorization grants.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Even the date of support was different from what I expected
&lt;/h2&gt;

&lt;p&gt;Google announced the feature on the Developers Blog on &lt;strong&gt;October 15, 2026&lt;/strong&gt;. But when I checked revisions of the official documentation through the Wayback Machine, the timeline didn't quite line up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;April 15, 2026&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; mention of DPoP in Best Practices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;May 20, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Sender-constrain tokens with DPoP" &lt;strong&gt;appeared&lt;/strong&gt; in Best Practices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;July 31, 2026&lt;/td&gt;
&lt;td&gt;Last update to the DPoP Adoption Guide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;September 4, 2026&lt;/td&gt;
&lt;td&gt;Google's official DPoP &lt;code&gt;SKILL.md&lt;/code&gt; committed to &lt;code&gt;google/skills&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;October 15, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Announcement on the Developers Blog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;October 16, 2026&lt;/td&gt;
&lt;td&gt;Web-server documentation updated for DPoP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Judging from the documentation, support was present by at least May—about five months before the blog announcement. I'm not suggesting anything was concealed; &lt;strong&gt;an official blog post's publication date is not necessarily a feature's release date&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And that was just the beginning. The more I verified, the more differences I found, and they fell rather neatly into three categories.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Difference #1: Different from the RFC (documented design choices)
&lt;/h2&gt;

&lt;p&gt;RFC 9449 leaves certain optional decisions to the authorization server. Google's choices differ from the general RFC model, but &lt;strong&gt;these differences are documented&lt;/strong&gt;. If you implement solely from the RFC, though, they can still trip you up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access tokens remain Bearer tokens
&lt;/h3&gt;

&lt;p&gt;RFC 9449 §5 specifies &lt;code&gt;DPoP&lt;/code&gt; as the &lt;code&gt;token_type&lt;/code&gt; using MUST language. However, Google's web-server documentation explicitly says that &lt;code&gt;token_type&lt;/code&gt; will always be &lt;code&gt;Bearer&lt;/code&gt;, even when using DPoP. Only refresh tokens are bound to keys.&lt;/p&gt;

&lt;p&gt;This isn't necessarily an RFC violation. The RFC allows an exception in which only the refresh token is sender-constrained when a public client also receives a refresh token.&lt;/p&gt;

&lt;p&gt;According to Google's blog, the motivation is performance: avoid the cost of checking asymmetric signatures on every request to a high-QPS API. I measured that cost using a loop of 20,000 operations on Node v24.22.0 / Apple M4.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;µs/op&lt;/th&gt;
&lt;th&gt;ops/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ES256 sign&lt;/td&gt;
&lt;td&gt;26.2&lt;/td&gt;
&lt;td&gt;38,227&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ES256 verify&lt;/td&gt;
&lt;td&gt;50.5&lt;/td&gt;
&lt;td&gt;19,787&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Verification took about twice as long as signing, with throughput around 20,000 verifications per second per core. &lt;strong&gt;At my application's scale, that overhead is negligible. At Gmail or Drive scale, it matters.&lt;/strong&gt; Google's performance argument seems reasonable.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;jti&lt;/code&gt; isn't random during the authorization-code exchange
&lt;/h3&gt;

&lt;p&gt;RFC 9449 §4.2 requires the &lt;code&gt;jti&lt;/code&gt; claim to contain at least 96 bits of pseudorandom data or a UUIDv4. Google, however, uses a &lt;strong&gt;deterministic value&lt;/strong&gt; specifically during the authorization-code exchange:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the value of &lt;code&gt;jti&lt;/code&gt; is the Base-64 and Url-encoded SHA256 hash of the authorization code, such as &lt;code&gt;jti = BASE64URL(SHA-256(authorization_code))&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I checked this using the authorization code and &lt;code&gt;jti&lt;/code&gt; from Google's official example:&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;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;# -&amp;gt; o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck (matches the example's jti)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It matched. For refresh requests, Google instead uses an ordinary random value (192 bits). &lt;strong&gt;The same &lt;code&gt;jti&lt;/code&gt; claim is generated according to different rules in different flows.&lt;/strong&gt; Implement only one of them, and you'll run into trouble.&lt;/p&gt;

&lt;p&gt;Why? The mechanism prescribed by RFC 9449 for binding an authorization code to a key is the &lt;code&gt;dpop_jkt&lt;/code&gt; authorization request parameter in §10. &lt;strong&gt;Google's authorization request doesn't include &lt;code&gt;dpop_jkt&lt;/code&gt;.&lt;/strong&gt; Instead, it reuses the existing &lt;code&gt;jti&lt;/code&gt; claim. Since an authorization code is single-use, the deterministic value is effectively unique for this purpose. My reading is that Google used an existing claim instead of implementing an optional RFC mechanism.&lt;/p&gt;

&lt;h3&gt;
  
  
  Binding tokens even for confidential clients
&lt;/h3&gt;

&lt;p&gt;RFC 9449 §5 says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Refresh tokens issued to confidential clients ... are &lt;strong&gt;not&lt;/strong&gt; bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yet Google's Adoption Guide example is a confidential client that sends a &lt;code&gt;client_secret&lt;/code&gt;. Meanwhile, Best Practices strongly recommends DPoP for public clients such as SPAs and installed apps, but recommends placing a BFF (a confidential client) in front of an SPA. &lt;strong&gt;You want to protect a public client, but the recommended architecture turns it into a confidential-client implementation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The RFC even notes that client authentication is more flexible than key binding, because it allows a key to be changed without invalidating the refresh token. Google's design knowingly incurs the trade-off that warning describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Difference #2: The documentation says one thing; the implementation does another
&lt;/h2&gt;

&lt;p&gt;Now for the main event.&lt;/p&gt;

&lt;h3&gt;
  
  
  An official example contains an incorrectly encoded proof signature
&lt;/h3&gt;

&lt;p&gt;The Adoption Guide contains two sample proofs. I was planning to reuse them, but decided to verify their signatures first. That's where I got stuck.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Signature length&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authorization-code exchange&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;td&gt;SIGNATURE VALID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token refresh&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70 B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SIGNATURE FAILED&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When I parsed the 70-byte signature as ASN.1, it parsed cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hex head: 30 44 02 20 ...
ASN.1 DER SEQUENCE? True | declared len: 68
  r: tag=0x02 (INTEGER) len=32
  s: tag=0x02 (INTEGER) len=32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JWS requires a raw &lt;code&gt;R || S&lt;/code&gt; signature (64 bytes for P-256, in IEEE P1363 format), but &lt;strong&gt;the example includes an ASN.1 DER-encoded signature&lt;/strong&gt;. Google's own web-server documentation specifically warns against doing this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note that JWS requires the signature to be in a raw &lt;code&gt;R | S&lt;/code&gt; concatenated format (64 bytes for P-256). If using OpenSSL directly, you must convert the default ASN.1 DER-encoded signature to this raw format.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can reproduce the trap within Node.js itself. Even for ES256, different APIs produce different signature formats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;crypto.subtle.sign     -&amp;gt; 64 bytes, first byte 0xed   (raw P1363)
node:crypto createSign -&amp;gt; 71 bytes, first byte 0x30   (ASN.1 DER)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;node:crypto&lt;/code&gt; result was DER-encoded and &lt;strong&gt;71 bytes long&lt;/strong&gt;, while Google's example was 70 bytes. DER signature lengths vary—roughly 69–72 bytes depending on leading zeros—so &lt;strong&gt;don't attempt to identify DER just by checking &lt;code&gt;length === 70&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Another detail: when I decoded the sample proof in the web-server documentation, it turned out to be &lt;strong&gt;copied straight from the example in RFC 9449&lt;/strong&gt;. Its &lt;code&gt;htu&lt;/code&gt; is &lt;code&gt;https://server.example.com/token&lt;/code&gt; (not Google's endpoint), its &lt;code&gt;iat&lt;/code&gt; is 1562262616 (July 4, 2019), and its &lt;code&gt;jti&lt;/code&gt; violates Google's own rule stated just three paragraphs earlier. Copy it verbatim, and the &lt;code&gt;htu&lt;/code&gt; mismatch gets rejected.&lt;/p&gt;

&lt;h3&gt;
  
  
  The documentation contradicts itself about carrying a nonce into the next workflow
&lt;/h3&gt;

&lt;p&gt;Can you take the &lt;code&gt;DPoP-Nonce&lt;/code&gt; returned by an authorization-code exchange and use it for the first refresh? Two Google documents give opposite answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adoption Guide (July 31, 2026):&lt;/strong&gt; Crossing workflows causes an unconditional HTTP 400 rejection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web-server documentation (October 16, 2026):&lt;/strong&gt; Reusing this nonce avoids an extra round trip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tried it. The result was &lt;code&gt;HTTP 400 use_dpop_nonce&lt;/code&gt;. &lt;strong&gt;The older Adoption Guide matched reality; the newer web-server documentation did not.&lt;/strong&gt; The first refresh therefore requires a 400 → retry sequence, costing two round trips. A &lt;code&gt;use_dpop_nonce&lt;/code&gt; retry loop is &lt;strong&gt;required&lt;/strong&gt;, not an optimization. Without it, the first refresh immediately after authorization fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  The documented error codes don't match
&lt;/h3&gt;

&lt;p&gt;The web-server documentation says that a mismatched &lt;code&gt;htm&lt;/code&gt;/&lt;code&gt;htu&lt;/code&gt;, an expired &lt;code&gt;iat&lt;/code&gt;, a reused proof, or an invalid signature all yield &lt;code&gt;invalid_dpop_proof&lt;/code&gt;. My tests found otherwise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Documentation says&lt;/th&gt;
&lt;th&gt;Observed&lt;/th&gt;
&lt;th&gt;Match?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;htu&lt;/code&gt; mismatch → &lt;code&gt;invalid_dpop_proof&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;htm&lt;/code&gt; mismatch → &lt;code&gt;invalid_dpop_proof&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expired &lt;code&gt;iat&lt;/code&gt; → &lt;code&gt;invalid_dpop_proof&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid_dpop_proof&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid signature → &lt;code&gt;invalid_dpop_proof&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid_dpop_proof&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The observed categories were more consistent than the documentation suggests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;&lt;code&gt;error&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing proof / mismatched &lt;code&gt;htm&lt;/code&gt; or &lt;code&gt;htu&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid_request&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid signature / &lt;code&gt;iat&lt;/code&gt; outside accepted range&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid_dpop_proof&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing, expired, or cross-workflow nonce&lt;/td&gt;
&lt;td&gt;&lt;code&gt;use_dpop_nonce&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid authorization grant&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid_grant&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;extractable: false&lt;/code&gt; doesn't translate cleanly to Node.js
&lt;/h3&gt;

&lt;p&gt;Google's official &lt;code&gt;SKILL.md&lt;/code&gt; calls this a "CRITICAL SECURITY GUARDRAIL":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The private key MUST be configured as &lt;strong&gt;non-extractable&lt;/strong&gt; (&lt;code&gt;extractable: false&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But if you follow that instruction literally in Node.js, you can no longer persist the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exportKey('jwk')   -&amp;gt; InvalidAccessError: key is not extractable
exportKey('pkcs8') -&amp;gt; InvalidAccessError: key is not extractable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-extractable key can't be exported from the process. When the server restarts, &lt;strong&gt;every user's refresh token bound to that key becomes unusable&lt;/strong&gt;; the only recovery is to get users to authorize again.&lt;/p&gt;

&lt;p&gt;Also, Node.js WebCrypto does not inherently provide a TPM or Secure Enclave. A non-extractable key is just a key held in process memory; Node doesn't provide the "hardware cryptographic boundary" described in &lt;code&gt;SKILL.md&lt;/code&gt;. &lt;strong&gt;This wording is geared toward browsers or mobile environments&lt;/strong&gt;, not the Node confidential-client example assumed by the Adoption Guide.&lt;/p&gt;

&lt;p&gt;In practice, you need a persistent key strategy: for example, generate with &lt;code&gt;extractable: true&lt;/code&gt;, then protect the exported JWK using encryption or KMS. That works, but it means private-key material is now stored outside the process, potentially in your database.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "a nonce value is used only once" claim didn't hold up
&lt;/h3&gt;

&lt;p&gt;The Adoption Guide says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note that &lt;strong&gt;a nonce value is used only once&lt;/strong&gt; and a missing, invalid, expired, or &lt;strong&gt;reused&lt;/strong&gt; nonce value is rejected with an HTTP 400 response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First, I tested whether nonces were validated at all. Both a completely bogus nonce and a nonce altered by one character were rejected with &lt;code&gt;400 use_dpop_nonce&lt;/code&gt;. &lt;strong&gt;Validation was happening.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next, I used the same nonce repeatedly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1st request (same nonce)       200
2nd request (same nonce)       200
3rd request (same nonce)       200
4th request (same nonce)       200
5th request (same nonce)       200
30 seconds later (same nonce)  200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All six succeeded.&lt;/strong&gt; The nonce was validated, but not consumed.&lt;/p&gt;

&lt;p&gt;The same appeared to happen with &lt;code&gt;jti&lt;/code&gt;. The web-server documentation says a reused &lt;code&gt;jti&lt;/code&gt; causes &lt;code&gt;invalid_dpop_proof&lt;/code&gt;, yet two proofs with the same &lt;code&gt;jti&lt;/code&gt; both returned 200. &lt;strong&gt;I found no evidence that the server was tracking used &lt;code&gt;jti&lt;/code&gt; values in these tests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Together, those behaviors meant I could &lt;strong&gt;replay an identical proof byte for byte&lt;/strong&gt;. Sending a proof with exactly the same &lt;code&gt;jti&lt;/code&gt;, &lt;code&gt;nonce&lt;/code&gt;, &lt;code&gt;iat&lt;/code&gt;, and signature repeatedly returned 200 and issued a new access token each time. I recorded SHA-256 hashes of the submitted proof and the returned tokens. Identical proof hashes resulted in different token hashes, indicating actual reissuance rather than a cached response.&lt;/p&gt;

&lt;p&gt;How long would that work? I created one proof and replayed it over time, matching the conditions of a captured proof in which both &lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;nonce&lt;/code&gt; age together. There was no shortcut for this test; I left my terminal open for an hour.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Elapsed time since proof creation&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–2700 s (0–45 min)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Replay accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3300 s (55 min)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Replay accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3540 s (59 min)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Replay accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3660 s (61 min)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;400&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;invalid_dpop_proof&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It succeeded at 59 minutes and failed at 61 minutes with &lt;code&gt;invalid_dpop_proof&lt;/code&gt;, &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;use_dpop_nonce&lt;/code&gt;. The nonce was still accepted at 59 minutes, so the &lt;code&gt;iat&lt;/code&gt; window appeared to expire first. &lt;strong&gt;In this test, a captured proof could be replayed repeatedly for approximately one hour after issuance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;RFC 9449 §11.1 directly addresses this situation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times ... &lt;strong&gt;As long as the &lt;code&gt;jti&lt;/code&gt; value is tracked and duplicates are rejected for the lifetime of the nonce&lt;/strong&gt;, there is no additional risk of token replay.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The RFC allows nonce reuse &lt;strong&gt;provided the server tracks &lt;code&gt;jti&lt;/code&gt; and rejects duplicates&lt;/strong&gt;. In my tests, Google's endpoint accepted reused nonces and didn't appear to reject repeated &lt;code&gt;jti&lt;/code&gt; values. That means the condition attached to the RFC's nonce-reuse allowance wasn't met in the observed behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Difference #3: Things the documentation doesn't tell you
&lt;/h2&gt;

&lt;p&gt;These are the details I needed for implementation but couldn't find documented. I had to measure them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The grant is validated before the proof
&lt;/h3&gt;

&lt;p&gt;I supplied a nonexistent refresh token while deliberately corrupting the proof in eight different ways. Even with a DER-encoded signature, a malformed &lt;code&gt;typ&lt;/code&gt;, or simply &lt;code&gt;not-a-jwt&lt;/code&gt;, &lt;strong&gt;every request returned &lt;code&gt;invalid_grant&lt;/code&gt;&lt;/strong&gt; and none returned &lt;code&gt;DPoP-Nonce&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the practical consequence: &lt;strong&gt;a grant error can conceal bugs in your proof during debugging&lt;/strong&gt;. If your refresh token has expired, you may only see &lt;code&gt;invalid_grant&lt;/code&gt;, no matter how broken the proof is. This can explain the frustrating "I fixed the proof, but it still doesn't work" experience.&lt;/p&gt;

&lt;p&gt;The best debugging sequence is to &lt;strong&gt;verify that refresh works without DPoP first, then add DPoP&lt;/strong&gt;. That way you can isolate the source of failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  The accepted &lt;code&gt;iat&lt;/code&gt; window
&lt;/h3&gt;

&lt;p&gt;Google says expired &lt;code&gt;iat&lt;/code&gt; values are rejected but doesn't publish an exact tolerance. RFC 9449 §4.3 leaves that detail to implementers. I narrowed it down to the second using binary search, with matching results from two independent runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Furthest accepted&lt;/th&gt;
&lt;th&gt;Nearest rejected&lt;/th&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Past&lt;/td&gt;
&lt;td&gt;-3599 s&lt;/td&gt;
&lt;td&gt;-3600 s&lt;/td&gt;
&lt;td&gt;Exactly 3600 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Future&lt;/td&gt;
&lt;td&gt;+10 s&lt;/td&gt;
&lt;td&gt;+11 s&lt;/td&gt;
&lt;td&gt;Exactly 10 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;3600 seconds into the past, but only 10 seconds into the future: a 360× asymmetry.&lt;/strong&gt; A generous past window accounts for delays before a proof reaches the server. A tight future window discourages proof pre-generation, which is discussed in RFC 9449 §11.2, "DPoP Proof Pre-generation." The ~one-hour replay window from §4 was governed by the same past-side 3600-second limit. Two independently measured values pointed to the same boundary.&lt;/p&gt;

&lt;p&gt;One practical lesson: &lt;strong&gt;a server clock just 11 seconds ahead can cause every proof to be rejected&lt;/strong&gt;. If you're seeing unexplained &lt;code&gt;invalid_dpop_proof&lt;/code&gt; errors on a machine without working NTP synchronization, check its clock.&lt;/p&gt;

&lt;h3&gt;
  
  
  Other undocumented details
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;challenge&lt;/code&gt; field in successful responses.&lt;/strong&gt; It appears in an Adoption Guide example but isn't explained in the prose. Its value matched the &lt;code&gt;DPoP-Nonce&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No DPoP advertisement in discovery.&lt;/strong&gt; RFC 9449 §5.1 defines authorization-server metadata named &lt;code&gt;dpop_signing_alg_values_supported&lt;/code&gt;. When I queried &lt;code&gt;accounts.google.com/.well-known/openid-configuration&lt;/code&gt;, I found no keys containing &lt;code&gt;dpop&lt;/code&gt;. A standards-based client relying on discovery for feature detection &lt;strong&gt;cannot discover Google's DPoP support&lt;/strong&gt; this way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The algorithm name for RSA.&lt;/strong&gt; The Adoption Guide allows RSA but only says it's not recommended because of computational cost. It doesn't specify RS256 versus PS256, and discovery doesn't list either. You have to try it to find out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. I reported it, and the result was Won't Fix
&lt;/h2&gt;

&lt;p&gt;I reported the last issue from §4—no apparent &lt;code&gt;jti&lt;/code&gt; tracking, nonce reuse, and replayable proofs—to Google Bug Hunters. It conflicted with the documentation and appeared to miss a condition described in the RFC's replay-hardening guidance. I attached reproduction logs with secrets replaced by irreversible hashes.&lt;/p&gt;

&lt;p&gt;The result was &lt;strong&gt;Won't Fix (Infeasible)&lt;/strong&gt;. Google's reasoning, in summary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I hadn't demonstrated account takeover, privilege escalation, or an independent authentication bypass.&lt;/li&gt;
&lt;li&gt;I had acknowledged that the practical impact appeared limited.&lt;/li&gt;
&lt;li&gt;The report didn't meet the Vulnerability Reward Program threshold of a meaningful compromise to another user's data confidentiality or integrity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I agree with that assessment. An attacker in a position to observe the proof (for example, through an adversary-in-the-middle setup, a TLS-terminating proxy, or logs) could also capture the &lt;code&gt;refresh_token&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; from the same request body. And as shown in §5, a proof &lt;strong&gt;alone&lt;/strong&gt; is rejected with &lt;code&gt;invalid_grant&lt;/code&gt;; replay requires a valid &lt;code&gt;refresh_token&lt;/code&gt;. &lt;strong&gt;The replay window did not establish an additional attacker capability in the scenario I tested.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In that sense, the response helped clarify the nature of the finding: &lt;strong&gt;an observed discrepancy between implementation and documentation, not a demonstrated exploitable security vulnerability&lt;/strong&gt;. That is the core message of this article.&lt;/p&gt;

&lt;p&gt;It's still worth documenting. Statements like "a nonce value is used only once" and "a reused &lt;code&gt;jti&lt;/code&gt; is rejected" lead implementers to expect behavior that I couldn't reproduce. You might implement unnecessary synchronization—or rely on a replay defense that isn't behaving as documented. The report and its outcome are another reminder not to treat official documentation as a substitute for testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Should you adopt DPoP?
&lt;/h2&gt;

&lt;p&gt;DPoP binding is valuable when you &lt;strong&gt;store refresh tokens long-term and want to reduce the risks of that storage being compromised&lt;/strong&gt;. If a database dump or log leak exposes only a refresh token, an attacker cannot use it without the corresponding key. That's a meaningful benefit.&lt;/p&gt;

&lt;p&gt;Its protection is narrower than it might seem, though. Access tokens remain Bearer tokens and can still be used until they expire. Google's blog explicitly says that browser session cookies are outside DPoP's scope, pointing instead to DBSC for that problem. As discussed in §4, on Node.js the private key still needs to be persisted somewhere, weakening the simplistic assumption that "the key never leaves the device."&lt;/p&gt;

&lt;p&gt;The biggest adoption cost is migration. To move existing users with unbound refresh tokens to DPoP-bound refresh tokens, you need to revoke the old grant or get a new token using &lt;code&gt;prompt=consent&lt;/code&gt;. &lt;strong&gt;For a running service, that means asking every existing user to consent again.&lt;/strong&gt; Key rotation introduces the same issue: obtaining a refresh token bound to a new key requires reauthorization. In other words, &lt;strong&gt;your private-key backup strategy effectively becomes your refresh-token backup strategy&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Conclusion
&lt;/h2&gt;

&lt;p&gt;Google's DPoP implementation works. Its design choices have rational motivations, and my measurements supported its performance argument for leaving access tokens as Bearer tokens.&lt;/p&gt;

&lt;p&gt;The difficulties all came from documentation gaps: differences from the RFC that are documented (§3), claims that don't match observed behavior (§4), and details that aren't documented at all (§5). The most eye-catching finding—the "used only once" claim—was reported and closed as Won't Fix, reinforcing that I had uncovered a documentation discrepancy rather than demonstrated a security vulnerability (§6).&lt;/p&gt;

&lt;p&gt;Here are the implementation details I wish the docs had told me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The authorization-code &lt;code&gt;jti&lt;/code&gt; is &lt;code&gt;BASE64URL(SHA-256(code))&lt;/code&gt;, while a refresh uses a random value. &lt;strong&gt;Different flows, different rules.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The first refresh triggers &lt;code&gt;use_dpop_nonce&lt;/code&gt; before succeeding. &lt;strong&gt;You need a retry loop.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Mismatched &lt;code&gt;htm&lt;/code&gt;/&lt;code&gt;htu&lt;/code&gt; returns &lt;code&gt;invalid_request&lt;/code&gt;, not &lt;code&gt;invalid_dpop_proof&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iat&lt;/code&gt; acceptance spans 3600 seconds into the past and 10 seconds into the future. &lt;strong&gt;Clock skew toward the future can break every proof.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;In a Node.js process, &lt;code&gt;extractable: false&lt;/code&gt; isn't compatible with exporting a key for persistent storage. &lt;strong&gt;Plan key persistence first.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Verify refresh works without DPoP before adding DPoP; otherwise, proof errors can be hidden behind &lt;code&gt;invalid_grant&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.google.com/identity/protocols/oauth2/resources/dpop-adoption" rel="noopener noreferrer"&gt;DPoP Adoption Guide&lt;/a&gt; — Google's DPoP implementation guide; proof construction and nonce handling.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.google.com/identity/protocols/oauth2/web-server" rel="noopener noreferrer"&gt;Using OAuth 2.0 for Web Server Applications&lt;/a&gt; — Token endpoint parameters, error codes, and notes on &lt;code&gt;prompt=consent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.google.com/identity/protocols/oauth2/resources/best-practices" rel="noopener noreferrer"&gt;OAuth 2.0 Best Practices&lt;/a&gt; — Recommendations on sender-constraining.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.googleblog.com/announcing-dpop-support-for-google-oauth-securing-refresh-tokens/" rel="noopener noreferrer"&gt;Announcing DPoP Support for Google OAuth&lt;/a&gt; — Google's announcement, performance trade-offs, and threat model.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9449.html" rel="noopener noreferrer"&gt;RFC 9449&lt;/a&gt; — DPoP specification; §5 token types, §10 &lt;code&gt;dpop_jkt&lt;/code&gt;, §11 security considerations.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/google/skills/blob/main/skills/identity/dpop-adoption/SKILL.md" rel="noopener noreferrer"&gt;google/skills — dpop-adoption&lt;/a&gt; — Google's implementation skill; non-extractable keys and signature formats.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>documentation</category>
      <category>oauth</category>
      <category>security</category>
    </item>
    <item>
      <title>Why PHP Uses snake_case Functions but camelCase Methods</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Thu, 13 Aug 2026 04:58:14 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/why-php-uses-snakecase-functions-but-camelcase-methods-16jd</link>
      <guid>https://dev.to/tanahiro2010/why-php-uses-snakecase-functions-but-camelcase-methods-16jd</guid>
      <description>&lt;p&gt;Built-in functions are usually snake_case.&lt;br&gt;
Modern framework code is usually camelCase.&lt;/p&gt;

&lt;p&gt;That is PHP: a language where naming conventions can feel oddly split.&lt;/p&gt;

&lt;p&gt;So which naming convention is actually "correct" in PHP?&lt;/p&gt;

&lt;p&gt;I'm tanahiro2010, and this is one of those small-but-persistent questions that keeps coming back whenever I write PHP.&lt;/p&gt;

&lt;p&gt;Let me say this up front: this article is not about declaring either camelCase or snake_case the one true style. Instead, it looks at why PHP's naming conventions appear to differ by layer, traces that split through PHP's history, and offers a practical way to decide how to name things in your own code.&lt;/p&gt;

&lt;p&gt;While writing this, I tried not to rely only on memory or second-hand explanations. Where possible, I checked primary sources such as the official PHP manual, the PHP-FIG website, and the PEAR manual. For historical details I could not fully verify, especially around early PHP-FIG membership and some framework-specific context, I explicitly mark them as unconfirmed or inferential.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on terminology&lt;/strong&gt;&lt;br&gt;
This article includes a few terms that may be unfamiliar if you are new to PHP or web development. When that happens, I add a short explanation in a blockquote like this. You can skip these blocks if you already know the terms; the main argument should still be readable without them.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Have you seen code like this?
&lt;/h2&gt;

&lt;p&gt;When writing PHP, you often run into functions like these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$replace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As you can see, these names use &lt;code&gt;snake_case&lt;/code&gt;: words connected with underscores.&lt;/p&gt;

&lt;p&gt;But modern PHP code, especially framework-based code, often looks more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getParsedBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStatusCode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$userRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time, the names use &lt;code&gt;camelCase&lt;/code&gt;: words joined together, with later words starting with uppercase letters.&lt;/p&gt;

&lt;p&gt;Inside the same language, PHP, two very different-looking styles coexist. This article traces where that split came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The conclusion first&lt;/li&gt;
&lt;li&gt;Why PHP built-in functions look like snake_case&lt;/li&gt;
&lt;li&gt;PHP 3 and the spread of function-based web programming&lt;/li&gt;
&lt;li&gt;PEAR: shared conventions before PSR&lt;/li&gt;
&lt;li&gt;PHP 5 and the rise of OOP PHP&lt;/li&gt;
&lt;li&gt;Framework culture in the late 2000s&lt;/li&gt;
&lt;li&gt;A side note on Symfony helpers&lt;/li&gt;
&lt;li&gt;PHP-FIG and PSR&lt;/li&gt;
&lt;li&gt;What PSR-1 says, and what it does not say&lt;/li&gt;
&lt;li&gt;Why naming still hurts&lt;/li&gt;
&lt;li&gt;Practical guidelines for modern PHP&lt;/li&gt;
&lt;li&gt;Summary&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. The conclusion first
&lt;/h2&gt;

&lt;p&gt;Before getting into the history, here is the conclusion of this article:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Regular functions, including global functions, helper functions, and procedural APIs: &lt;code&gt;snake_case&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Class methods: &lt;code&gt;camelCase&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After looking through PHP's history, this feels like the most natural compromise to me.&lt;/p&gt;

&lt;p&gt;That said, this is not an absolute law. Always prioritize the following when they apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If an existing project already has a convention, follow it.&lt;/li&gt;
&lt;li&gt;If your framework has a convention, such as Laravel, Symfony, or WordPress, follow it.&lt;/li&gt;
&lt;li&gt;If you are naming a public API, preserving backward compatibility matters more than stylistic purity.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is PSR?&lt;/strong&gt;&lt;br&gt;
PSR stands for PHP Standard Recommendation. PSRs are standards created by PHP-FIG, a group that defines coding styles and shared interfaces for the PHP ecosystem. PSR-1 and PSR-12 are examples. PSRs are not part of the PHP language specification itself; they are community standards.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One important premise: PHP does not enforce naming style at the syntax level. Whether you write names in &lt;code&gt;snake_case&lt;/code&gt;, &lt;code&gt;camelCase&lt;/code&gt;, or something else, the PHP interpreter will usually run the code just fine. In other words, PHP lets you write code that works even if it ignores naming conventions. This becomes important later when we talk about why naming still causes pain.&lt;/p&gt;

&lt;p&gt;So why does "functions use snake_case, methods use camelCase" feel natural in PHP? Let's go back to 1995.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why PHP built-in functions look like snake_case
&lt;/h2&gt;

&lt;h3&gt;
  
  
  PHP did not begin as a carefully designed language
&lt;/h3&gt;

&lt;p&gt;PHP began as a small set of CGI binaries written by Rasmus Lerdorf in 1994 to track visits to his online resume. It was called "Personal Home Page Tools" or "PHP Tools". The source code was released in June 1995 (&lt;a href="https://www.php.net/manual/en/history.php.php" rel="noopener noreferrer"&gt;PHP: History of PHP - Manual&lt;/a&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is CGI?&lt;/strong&gt;&lt;br&gt;
CGI stands for Common Gateway Interface. It is a mechanism that lets a web server call an external program and return the program's output to the browser. In the 1990s, CGI programs written in languages like Perl were a common way to generate dynamic web pages. PHP started in this world as a set of C-based executables.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In September 1995, PHP evolved into "FI" or "Forms Interpreter". In April 1996, the two were combined as "PHP/FI". Then in 1997, Andi Gutmans and Zeev Suraski, who were in Tel Aviv at the time, rewrote the parser and worked with Rasmus Lerdorf to create a new language. In June 1998, PHP 3 was released as the official successor to PHP/FI 2.0. The name also changed to the recursive acronym "PHP: Hypertext Preprocessor" (&lt;a href="https://www.php.net/manual/en/history.php.php" rel="noopener noreferrer"&gt;PHP: History of PHP - Manual&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In other words, PHP was not born from the sequence "design a complete language specification, then implement it." It grew from practical personal tools into a language because people needed it. Nobody was designing API naming conventions in anticipation of a massive global ecosystem thirty years later. That is the first point to keep in mind.&lt;/p&gt;

&lt;h3&gt;
  
  
  A function culture grew from there
&lt;/h3&gt;

&lt;p&gt;Useful web development features, such as string handling, array operations, file operations, and database access, were added as functions.&lt;/p&gt;

&lt;p&gt;Many functions that still exist in PHP today are part of that lineage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;mysqli_connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;htmlspecialchars&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As you can see, many of them use &lt;code&gt;snake_case&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A technical note: why snake_case was likely natural
&lt;/h3&gt;

&lt;p&gt;PHP's implementation is written in C. The core runtime is called the Zend Engine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is the Zend Engine?&lt;/strong&gt;&lt;br&gt;
The Zend Engine is the internal engine that parses and executes PHP code. It was developed by Andi Gutmans and Zeev Suraski and has been the execution foundation of PHP since PHP 3.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In C and its standard library ecosystem, function names are traditionally lowercase and often use underscores, although not always. Examples include names such as &lt;code&gt;strcpy&lt;/code&gt;, &lt;code&gt;memcpy&lt;/code&gt;, and &lt;code&gt;time&lt;/code&gt;. Many early PHP built-in functions were thin wrappers around C functions or C libraries, so it is reasonable to think that C naming culture influenced PHP's function names.&lt;/p&gt;

&lt;p&gt;I do not mean this as a claim that every PHP function directly follows C naming rules. I have not exhaustively verified that. It is better understood as a general tendency.&lt;/p&gt;

&lt;h3&gt;
  
  
  But it is not completely consistent
&lt;/h3&gt;

&lt;p&gt;If the story ended here, we might say "PHP built-ins are all snake_case." But that would not be accurate. Standard classes in PHP often have camelCase or PascalCase-style method names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$reflectionClass&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$reflectionMethod&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getParameters&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$dateTime&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$timezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is Reflection?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;ReflectionClass&lt;/code&gt; and &lt;code&gt;ReflectionMethod&lt;/code&gt; are part of PHP's built-in Reflection API. Reflection lets a program inspect information about classes, methods, parameters, modifiers, and more at runtime. Frameworks and testing tools often use it internally.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So we need to distinguish between "global function culture" and "standard class/method culture". That difference connects directly to the later spread of object-oriented PHP.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is OOP?&lt;/strong&gt;&lt;br&gt;
OOP stands for object-oriented programming. It is a way of designing programs around classes, where data and behavior are grouped together as properties and methods. The idea that "class methods use camelCase" comes up repeatedly later in this article.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The PHP manual also has a page called &lt;a href="https://www.php.net/manual/en/userlandnaming.rules.php" rel="noopener noreferrer"&gt;Userland Naming Guide&lt;/a&gt;. It states that function names should use underscores between words, and that both camelCase and PascalCase appear in class names. It also names &lt;code&gt;strpos()&lt;/code&gt; as an example of an old naming mistake because it does not follow the recommended extension prefix rule.&lt;/p&gt;

&lt;p&gt;So if PHP naming feels inconsistent, you are not imagining it. The official manual itself acknowledges historical inconsistency.&lt;/p&gt;

&lt;p&gt;My summary for this section is: PHP's built-in functions look snake_case not because of a perfectly planned language design, but because early PHP grew out of practical C-based implementation culture.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. PHP 3 and the spread of function-based web programming
&lt;/h2&gt;

&lt;p&gt;PHP 3 was released in June 1998 as the official successor to PHP/FI. Around this point, PHP development expanded from a personal project into a broader multi-person effort. Features were added rapidly through extension modules.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is an extension module?&lt;/strong&gt;&lt;br&gt;
An extension adds functionality to PHP itself. For example, the &lt;code&gt;curl&lt;/code&gt; extension provides HTTP communication through the cURL library, and the &lt;code&gt;mysqli&lt;/code&gt; extension provides MySQL access. Many PHP built-in functions are grouped by extensions like these.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;During this period, practical web development needs such as database access, form handling, and session management drove PHP's growth. Functions were added as needed. There was not yet a strong centralized process for reviewing naming consistency. Functionality and usefulness came first; naming consistency came later, if at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Once a name is public, changing it is hard
&lt;/h3&gt;

&lt;p&gt;Once a function name is public and widely used, it becomes difficult to change. This is a backward compatibility problem.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is backward compatibility?&lt;/strong&gt;&lt;br&gt;
Backward compatibility, often abbreviated BC, means code written for an older version of software keeps working in newer versions. Renaming or removing a function can break every existing codebase that uses it, so many languages and frameworks treat public API names as something that should not be changed casually.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;PHP's old &lt;code&gt;mysql_*&lt;/code&gt; functions, such as &lt;code&gt;mysql_query&lt;/code&gt;, are a good example. They went through deprecation and were eventually removed, but that took a long time. This illustrates how hard it is to change names and APIs after they become widely used. I am keeping this example at a high level here because I did not fully verify the detailed year-by-year timeline for this article.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naming is not decided by philosophy alone
&lt;/h3&gt;

&lt;p&gt;So far, we can see that naming depends on several axes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When it was created&lt;/td&gt;
&lt;td&gt;Pre-2000 PHP built-ins vs post-PSR code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who created it&lt;/td&gt;
&lt;td&gt;Rasmus Lerdorf individually vs a group such as PHP-FIG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which culture it came from&lt;/td&gt;
&lt;td&gt;C-style function culture vs OOP framework culture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whether compatibility can be broken&lt;/td&gt;
&lt;td&gt;Global functions with huge compatibility cost vs new framework APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which API layer it belongs to&lt;/td&gt;
&lt;td&gt;Built-in language functions vs userland classes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It is easy to say PHP naming is inconsistent because the design philosophy was inconsistent. But in practice, PHP's naming style is the result of several historical layers overlapping.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. PEAR: shared conventions before PSR
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is PEAR?
&lt;/h3&gt;

&lt;p&gt;Before PSR, PHP already had shared coding conventions. They came from PEAR.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is PEAR?&lt;/strong&gt;&lt;br&gt;
PEAR stands for PHP Extension and Application Repository. It is a repository and package distribution system for reusable PHP libraries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One fact-checking note: in an earlier draft, I wrote that PEAR was released in December 2002. After checking multiple sources, I found that PEAR was started around 1999 by Stig Bakken. It is said to have grown out of discussions at the PHP Developers' Meeting in Tel Aviv in January 2000. I could not find a primary source confirming the 2002 date, so this article treats PEAR as a project that began around 1999. I have not pinned down the exact founding date or release timeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  PEAR naming conventions
&lt;/h3&gt;

&lt;p&gt;PEAR had clear naming conventions more than a decade before PSR (&lt;a href="https://pear.php.net/manual/en/standards.naming.php" rel="noopener noreferrer"&gt;PEAR Manual: Naming Conventions&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Class names&lt;/td&gt;
&lt;td&gt;Initial uppercase, hierarchy expressed with underscores&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Log&lt;/code&gt;, &lt;code&gt;Net_Finger&lt;/code&gt;, &lt;code&gt;HTML_Upload_Error&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Method names&lt;/td&gt;
&lt;td&gt;Lowercase first word, then studly caps&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;connect()&lt;/code&gt;, &lt;code&gt;getData()&lt;/code&gt;, &lt;code&gt;buildSomeWidget()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global functions&lt;/td&gt;
&lt;td&gt;Package name prefix, then studly caps&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XML_RPC_serializeData()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constants&lt;/td&gt;
&lt;td&gt;Uppercase with underscores&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DB_DATASOURCENAME&lt;/code&gt;, &lt;code&gt;SERVICES_AMAZON_S3_LICENSEKEY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The interesting part is that PEAR recommended a camel-like style even for global functions. So the simple story "PHP functions have always been snake_case" is not quite true. Even before PSR, there was already a separate naming culture inside the PHP community that differed from PHP built-in function style.&lt;/p&gt;

&lt;h3&gt;
  
  
  PEAR class names as pseudo-namespaces
&lt;/h3&gt;

&lt;p&gt;Class names such as &lt;code&gt;Net_Finger&lt;/code&gt; and &lt;code&gt;HTML_Upload_Error&lt;/code&gt; may look strange today, but they had a technical reason.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is a namespace?&lt;/strong&gt;&lt;br&gt;
A namespace is a way to prevent name collisions by giving classes and functions a kind of address. PHP introduced the &lt;code&gt;namespace&lt;/code&gt; keyword in PHP 5.3 in 2009. Before that, PHP did not have native namespaces.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the PEAR era, PHP did not have namespaces, so libraries avoided class-name collisions by encoding hierarchy in the class name itself: package name, underscore, class name. Today you might write &lt;code&gt;Net\Finger&lt;/code&gt;; back then, &lt;code&gt;Net_Finger&lt;/code&gt; served a similar purpose through naming convention alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this section tells us
&lt;/h3&gt;

&lt;p&gt;The important point is that the "built-in function layer" and the "library distribution layer" belonged to different cultures.&lt;/p&gt;

&lt;p&gt;Also, my conclusion in this article, "regular functions use snake_case", does not come from PEAR. PEAR actually recommended camel-like global function names. My conclusion comes from PHP's built-in function culture and practical modern PHP context, not directly from PEAR.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. PHP 5 and the rise of OOP PHP
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Zend Engine 2.0 changed
&lt;/h3&gt;

&lt;p&gt;PHP 5 was released in July 2004. It introduced Zend Engine 2.0 and a new object model (&lt;a href="https://www.php.net/manual/en/history.php.php" rel="noopener noreferrer"&gt;PHP: History of PHP - Manual&lt;/a&gt;). The biggest technical change was how objects behaved.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In PHP 4, passing or assigning objects could internally copy them.&lt;/li&gt;
&lt;li&gt;In PHP 5, objects were handled through handles, closer to references in many object-oriented languages.&lt;/li&gt;
&lt;li&gt;Access modifiers such as &lt;code&gt;private&lt;/code&gt;, &lt;code&gt;protected&lt;/code&gt;, and &lt;code&gt;public&lt;/code&gt; were introduced.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;interface&lt;/code&gt; and &lt;code&gt;abstract class&lt;/code&gt; were introduced.&lt;/li&gt;
&lt;li&gt;Magic methods such as &lt;code&gt;__construct&lt;/code&gt; and &lt;code&gt;__destruct&lt;/code&gt; were clarified.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What are magic methods?&lt;/strong&gt;&lt;br&gt;
Magic methods are special methods that begin with two underscores, such as &lt;code&gt;__construct()&lt;/code&gt;. PHP calls them automatically at specific moments, such as when an object is created.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;These changes made PHP a language where serious class design became practical. This became the foundation for object-oriented PHP culture.&lt;/p&gt;

&lt;h3&gt;
  
  
  From calling functions to writing methods
&lt;/h3&gt;

&lt;p&gt;The look of PHP code also started changing around this period.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PHP 4-ish: calling functions&lt;/span&gt;
&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&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 php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PHP 5 and later: writing methods&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getParsedBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStatusCode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Method names such as &lt;code&gt;getParsedBody()&lt;/code&gt;, &lt;code&gt;getStatusCode()&lt;/code&gt;, &lt;code&gt;findById()&lt;/code&gt;, &lt;code&gt;setCreatedAt()&lt;/code&gt;, &lt;code&gt;hasPermission()&lt;/code&gt;, and &lt;code&gt;isPublished()&lt;/code&gt; became common. The &lt;code&gt;get&lt;/code&gt; / &lt;code&gt;set&lt;/code&gt; / &lt;code&gt;is&lt;/code&gt; / &lt;code&gt;has&lt;/code&gt; patterns are common not only in PHP, but also in many object-oriented programming communities.&lt;/p&gt;

&lt;p&gt;PHP 5 is where PHP really started to develop a culture of thinking about names at the method level.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Framework culture in the late 2000s
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Frameworks that adopted camelCase methods
&lt;/h3&gt;

&lt;p&gt;After PHP 5, frameworks and libraries such as CakePHP, Symfony, Zend Framework, Doctrine, and CodeIgniter appeared and grew.&lt;/p&gt;

&lt;p&gt;Here is a rough summary of their method naming tendencies, based on what I was able to check. This is not an exhaustive verification of every version or module.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Method naming tendency&lt;/th&gt;
&lt;th&gt;General character&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CakePHP&lt;/td&gt;
&lt;td&gt;Full-stack framework&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;td&gt;Convention-over-configuration MVC framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symfony&lt;/td&gt;
&lt;td&gt;Full-stack framework&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;td&gt;OOP-oriented components and framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zend Framework&lt;/td&gt;
&lt;td&gt;Framework / library collection&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;td&gt;Enterprise-oriented design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doctrine&lt;/td&gt;
&lt;td&gt;ORM library&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;td&gt;Maps database tables to objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeIgniter&lt;/td&gt;
&lt;td&gt;Lightweight framework&lt;/td&gt;
&lt;td&gt;More snake_case-oriented&lt;/td&gt;
&lt;td&gt;Simplicity closer to procedural PHP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a id="glossary-orm"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is an ORM?&lt;/strong&gt;&lt;br&gt;
ORM stands for Object-Relational Mapping. It maps database tables to program objects. Doctrine is one of the best-known ORM libraries in PHP.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As this table suggests, many major frameworks except CodeIgniter leaned toward camelCase method names. CodeIgniter is an important counterexample: not everything in that era was camelCase. It kept a style closer to procedural PHP and PHP's built-in function culture. I could not fully verify an official primary-source statement for that design intent, so I am treating it as a broad tendency rather than a definitive claim.&lt;/p&gt;

&lt;h3&gt;
  
  
  From here on, some of this is circumstantial
&lt;/h3&gt;

&lt;p&gt;Next comes the question: why did PSR-1 choose camelCase for methods?&lt;/p&gt;

&lt;p&gt;To be honest, I could not find a primary source that directly explains the reasoning behind that choice. So from here, I am presenting a hypothesis based on circumstantial evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hypothesis: camelCase culture was already widespread
&lt;/h3&gt;

&lt;p&gt;My hypothesis is this:&lt;/p&gt;

&lt;p&gt;PSR-1 did not introduce camelCase to PHP from nowhere. Instead, it formalized and stabilized a camelCase culture that had already spread through object-oriented PHP frameworks by the late 2000s.&lt;/p&gt;

&lt;p&gt;To support that hypothesis, we need to look at PHP-FIG.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A side note on Symfony helpers
&lt;/h2&gt;

&lt;p&gt;Symfony 1.x code and documentation reveal an interesting detail. In the Symfony 1.x coding standards, class names and variable names were generally expected to use UpperCamelCase, also called PascalCase, but there were two exceptions: core classes prefixed with lowercase &lt;code&gt;sf&lt;/code&gt;, such as &lt;code&gt;sfController&lt;/code&gt; and &lt;code&gt;sfRequest&lt;/code&gt;, and template variables, which used underscore-separated notation (&lt;a href="https://symfony.com/legacy/doc/gentle-introduction/1_4/en/02-Exploring-Symfony-s-Code" rel="noopener noreferrer"&gt;Symfony 1.4 legacy documentation: Exploring Symfony's Code&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I want to be careful here. In an earlier draft, I had written a more specific anecdote: that the Symfony documentation explained helper functions were not camelCase because helpers were functions, and therefore followed PHP core functions. When I rechecked primary sources, I could not confirm that specific Q&amp;amp;A-style explanation. What I did find was the documented rule about underscore notation for template variables.&lt;/p&gt;

&lt;p&gt;So I will avoid stating that helper explanation as fact.&lt;/p&gt;

&lt;p&gt;Still, the verified fact is useful: Symfony 1.x mostly used camelCase/PascalCase conventions for classes and variables, while some areas followed different conventions. This shows that two cultures coexisted even within the same framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a procedural, PHP-built-in-function-like culture&lt;/li&gt;
&lt;li&gt;an OOP framework, camelCase-oriented culture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That coexistence supports the way this article separates functions and methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. PHP-FIG and PSR
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is PHP-FIG?
&lt;/h3&gt;

&lt;p&gt;The group behind PSRs is PHP-FIG.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is PHP-FIG?&lt;/strong&gt;&lt;br&gt;
PHP-FIG stands for PHP Framework Interop Group. It is a group created by PHP framework developers to improve interoperability across the PHP ecosystem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;According to the official PHP-FIG FAQ, the group was formed in 2009 at the php|tek conference by several framework developers. It started with around five members and later grew through a voting process to include more than twenty member projects (&lt;a href="https://www.php-fig.org/faqs/" rel="noopener noreferrer"&gt;PHP-FIG FAQ&lt;/a&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is php|tek?&lt;/strong&gt;&lt;br&gt;
php|tek is a conference for the PHP community.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is another fact-checking note. In an earlier draft, I listed specific founding members and associated projects, such as Matthew Weier O'Phinney for Zend Framework, Fabien Potencier for Symfony, Paul M. Jones for Solar/Aura, Jonathan Wage for Doctrine, and Nate Abele for Lithium. However, I could not find a primary source on the PHP-FIG website that explicitly identifies the original five people.&lt;/p&gt;

&lt;p&gt;After additional research, I found a retrospective explanation by a participant on the early &lt;code&gt;php.standards&lt;/code&gt; mailing list. It says that the group that became PHP-FIG included representatives from projects such as Agavi, CakePHP, PEAR, Phing, Solar, Symfony, and Zend Framework.&lt;/p&gt;

&lt;p&gt;Combining that information with individual profiles and project histories, such as official Zend/Laminas pages and interviews, gives us this rough argument:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;People associated with early PHP-FIG were involved in these projects.&lt;/li&gt;
&lt;li&gt;Many of those projects already leaned toward camelCase method names.&lt;/li&gt;
&lt;li&gt;Therefore, the early PHP-FIG ecosystem was already strongly connected to camelCase-oriented OOP PHP culture.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Person&lt;/th&gt;
&lt;th&gt;Project associated at the time&lt;/th&gt;
&lt;th&gt;Method naming tendency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Matthew Weier O'Phinney&lt;/td&gt;
&lt;td&gt;Zend Framework&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paul M. Jones&lt;/td&gt;
&lt;td&gt;Solar&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nate Abele&lt;/td&gt;
&lt;td&gt;CakePHP at the time, later Lithium&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fabien Potencier&lt;/td&gt;
&lt;td&gt;Symfony&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Representative names not fully confirmed&lt;/td&gt;
&lt;td&gt;PEAR / Phing / Agavi&lt;/td&gt;
&lt;td&gt;PEAR recommended camel-like global functions; Phing and Agavi not verified here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two cautions:&lt;/p&gt;

&lt;p&gt;First, this is not a definitive mapping of the original five founding members in 2009. It is a combination of early project names and people known to be associated with them.&lt;/p&gt;

&lt;p&gt;Second, I removed the Jonathan Wage / Doctrine pairing from the table because I could not verify it from primary sources during this research.&lt;/p&gt;

&lt;p&gt;Even with those cautions, the evidence I could confirm still supports the idea that early PHP-FIG was surrounded by projects where camelCase methods were already common.&lt;/p&gt;

&lt;h3&gt;
  
  
  Interoperability was the keyword
&lt;/h3&gt;

&lt;p&gt;PHP-FIG's goal was interoperability.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is interoperability?&lt;/strong&gt;&lt;br&gt;
Interoperability is the ability of different software systems to work together. In PHP-FIG's context, it means code written for one framework should be easier to combine with code from another framework.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The goal was to make shared PHP code, cross-framework libraries, and reusable components easier to mix together. One of the first topics PHP-FIG worked on was coding style, which later became PSR-1 and PSR-12.&lt;/p&gt;

&lt;p&gt;PHP-FIG has produced many standards beyond naming and formatting. PSR-3 defines a logging interface, PSR-4 defines an autoloading standard, and PSR-7 defines common HTTP message interfaces. PHP-FIG is not only about coding style; it is about interoperability across the PHP ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What PSR-1 says, and what it does not say
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The purpose of PSR-1
&lt;/h3&gt;

&lt;p&gt;PSR-1, formally called "Basic Coding Standard", describes its purpose like this (&lt;a href="https://www.php-fig.org/psr/psr-1/" rel="noopener noreferrer"&gt;PSR-1: Basic Coding Standard&lt;/a&gt;):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This section of the standard comprises what should be considered the standard coding elements that are required to ensure a high level of technical interoperability between shared PHP code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In other words, PSR-1 defines basic coding elements needed for shared PHP code to interoperate well.&lt;/p&gt;

&lt;h3&gt;
  
  
  What PSR-1 defines
&lt;/h3&gt;

&lt;p&gt;PSR-1 defines the following naming conventions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Class names&lt;/td&gt;
&lt;td&gt;&lt;code&gt;StudlyCaps&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class constants&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPPER_CASE_WITH_UNDERSCORES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Method names&lt;/td&gt;
&lt;td&gt;&lt;code&gt;camelCase()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is StudlyCaps?&lt;/strong&gt;&lt;br&gt;
StudlyCaps means writing words together with each word starting with an uppercase letter. It is roughly equivalent to what many people call PascalCase, for example &lt;code&gt;HttpClient&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For property names, PSR-1 does not require one specific convention. It says &lt;code&gt;$StudlyCaps&lt;/code&gt;, &lt;code&gt;$camelCase&lt;/code&gt;, and &lt;code&gt;$under_score&lt;/code&gt; are all acceptable, but whichever convention is used should be applied consistently within a reasonable scope.&lt;/p&gt;

&lt;p&gt;PSR-12, "Extended Coding Style", adds more detailed rules for indentation, line breaks, class and method declarations, control structures, and so on. But it does not add a new rule such as "variables must be camelCase."&lt;/p&gt;

&lt;h3&gt;
  
  
  What PSR-1 does not define
&lt;/h3&gt;

&lt;p&gt;This distinction is the most important point for this article. PSR-1 does not define naming rules for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;regular global functions&lt;/li&gt;
&lt;li&gt;variable names&lt;/li&gt;
&lt;li&gt;existing PHP built-in functions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the claim "Because PSR exists, PHP functions should be camelCase" does not follow from PSR-1 itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why built-in functions do not look like PSR
&lt;/h3&gt;

&lt;p&gt;The reason is simple: many built-in functions existed long before PSR. Renaming them now would break backward compatibility. Also, PSR-1 was never meant to impose naming rules on PHP's built-in functions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why PSR does not look like built-in functions
&lt;/h3&gt;

&lt;p&gt;This part cannot be proven definitively from the sources I found. But based on the evidence from sections 6, 7, and 8, I think the most natural reading is:&lt;/p&gt;

&lt;p&gt;PSR-1 did not invent camelCase from scratch. It formalized the camelCase method culture already common in userland OOP frameworks at the time.&lt;/p&gt;

&lt;p&gt;As a side note, PSR-1 has a Meta Document that lists Paul M. Jones as an editor (&lt;a href="https://www.php-fig.org/psr/psr-1/meta/" rel="noopener noreferrer"&gt;PSR-1 Meta Document&lt;/a&gt;). According to Paul M. Jones's own blog post from June 4, 2012, PSR-1 and PSR-2 were accepted by vote, with PSR-1 passing 17 to 0 (&lt;a href="https://paul-m-jones.com/post/2012/06/04/php-fig-psr-1-and-2-accepted/" rel="noopener noreferrer"&gt;Paul M. Jones: PHP-FIG: PSR-1 and PSR-2 Accepted&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In an earlier draft, I wrote that PSR-1 was released on June 5, 2012. The primary source I confirmed is the June 4, 2012 blog report, and I could not confirm the exact vote closing date. So this article uses the more cautious phrasing "accepted in early June 2012." It is also more accurate to say "PHP-FIG created PSR-1, and Paul M. Jones was one of its editors" than to say simply "Paul M. Jones created PSR-1."&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Why naming still hurts
&lt;/h2&gt;

&lt;p&gt;PHP does not enforce naming conventions at the syntax level. Variables, functions, and methods can be snake_case, camelCase, PascalCase, or something else, and PHP will usually run the code.&lt;/p&gt;

&lt;p&gt;This has both benefits and costs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Benefit: Beginners can start writing PHP without worrying about naming rules, which lowers the learning barrier.&lt;/li&gt;
&lt;li&gt;Cost: Once code is written by a team, naming inconsistencies become visible in code review and hurt readability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Personally, I have often been confused by code whose naming style was mixed, especially code written without knowledge of PHP's history or PSR, including code I wrote myself in the past.&lt;/p&gt;

&lt;p&gt;I do not think this means the original author was bad. It is a consequence of PHP itself: you can write working code without knowing the conventions.&lt;/p&gt;

&lt;p&gt;That is why it helps to know the history and then decide what your own project should do.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Practical guidelines for modern PHP
&lt;/h2&gt;

&lt;p&gt;Based on the history above, here are the factors I would consider in real projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP built-in function culture&lt;/li&gt;
&lt;li&gt;OOP culture&lt;/li&gt;
&lt;li&gt;PSR rules&lt;/li&gt;
&lt;li&gt;project consistency&lt;/li&gt;
&lt;li&gt;framework conventions&lt;/li&gt;
&lt;li&gt;public API compatibility&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Common arguments from both sides
&lt;/h3&gt;

&lt;p&gt;In practice, this debate usually produces arguments like these. Both sides have reasonable points. This article is not trying to prove one style superior to the other.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Camp&lt;/th&gt;
&lt;th&gt;Common argument&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;snake_case&lt;/td&gt;
&lt;td&gt;Matches PHP built-in functions; word boundaries are visually clear; less worry about uppercase/lowercase typos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;td&gt;Matches PSR-1 for methods; aligns with OOP culture in Java, JavaScript, C#, and others; fits major framework conventions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My position is that we can reconcile both sides by separating the layer: inside classes or outside classes.&lt;/p&gt;

&lt;p&gt;Here is the concrete recommendation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;snake_case&lt;/code&gt; for global functions, helper functions, and procedural APIs.

&lt;ul&gt;
&lt;li&gt;Reason: This aligns with PHP's built-in function culture and keeps procedural code visually consistent with the standard library.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;camelCase&lt;/code&gt; for class methods.

&lt;ul&gt;
&lt;li&gt;Reason: This follows OOP PHP culture and PSR-1.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;For properties and variables, PSR-1 does not prescribe one rule, so prioritize consistency within the relevant scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And again, always prioritize these rules when they apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a project already consistently uses snake_case methods, follow that project.&lt;/li&gt;
&lt;li&gt;Respect the conventions of the surrounding ecosystem, such as Laravel, Symfony, or WordPress.&lt;/li&gt;
&lt;li&gt;For public APIs, preserving backward compatibility comes first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Before and after
&lt;/h3&gt;

&lt;p&gt;Suppose you have a class with mixed naming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: inconsistent method names&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;find_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;GetAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;delete_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;Applying this article's guideline, class methods become camelCase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: methods unified as camelCase&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;deleteUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;But helper functions outside a class remain snake_case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Plain helper functions outside classes stay snake_case&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;format_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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 practical rule is: separate by whether the name belongs inside a class or outside one.&lt;/p&gt;

&lt;p&gt;Here is the decision flow:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[You need to choose a name] --&amp;gt; B{Does the project already have a convention?}
    B --&amp;gt;|Yes| C[Follow it]
    B --&amp;gt;|No| D{Does the framework have a convention?}
    D --&amp;gt;|Yes| C
    D --&amp;gt;|No| E{Is it a class method?}
    E --&amp;gt;|Yes| F[camelCase]
    E --&amp;gt;|No| G{Is it a global function or helper?}
    G --&amp;gt;|Yes| H[snake_case]
    G --&amp;gt;|No| I[Other cases: prioritize consistency within scope]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;These conventions can also be checked mechanically instead of relying only on human review.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is static analysis?&lt;/strong&gt;&lt;br&gt;
Static analysis means analyzing source code without running it, usually to detect possible bugs, type issues, or style violations. In PHP, PHPStan is one of the most widely used static analysis tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tools such as PHP_CodeSniffer, which can detect and automatically fix coding style violations, and PHPStan, which performs static analysis, can help teams enforce PSR-1/PSR-12 and other style rules consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Summary
&lt;/h2&gt;

&lt;p&gt;To summarize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP naming conventions are not one single unified system.&lt;/li&gt;
&lt;li&gt;PHP built-in function culture, PEAR/library culture, OOP framework culture, and PSR culture exist as historical layers.&lt;/li&gt;
&lt;li&gt;Looking at that history, "functions use snake_case, methods use camelCase" feels like a natural practical guideline.&lt;/li&gt;
&lt;li&gt;But it is not an absolute rule. Existing project conventions, framework conventions, and backward compatibility always come first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was never especially good at history classes in school, but researching PHP's history was genuinely fun. If this article made you a little more curious, I would be happy. And if it makes you want to dig into the later history of PSR-2 to PSR-12, or the changes brought by PHP 7 and PHP 8, that would be even better.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.php.net/manual/en/history.php.php" rel="noopener noreferrer"&gt;PHP: History of PHP - Manual&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php-fig.org/psr/psr-1/" rel="noopener noreferrer"&gt;PHP-FIG: PSR-1 Basic Coding Standard&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php-fig.org/psr/psr-1/meta/" rel="noopener noreferrer"&gt;PHP-FIG: PSR-1 Meta Document&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php-fig.org/faqs/" rel="noopener noreferrer"&gt;PHP-FIG: Frequently Asked Questions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php-fig.org/personnel/" rel="noopener noreferrer"&gt;PHP-FIG: Personnel&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pear.php.net/manual/en/standards.naming.php" rel="noopener noreferrer"&gt;PEAR Manual: Naming Conventions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php.net/manual/en/userlandnaming.rules.php" rel="noopener noreferrer"&gt;PHP Manual: Userland Naming Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://symfony.com/legacy/doc/gentle-introduction/1_4/en/02-Exploring-Symfony-s-Code" rel="noopener noreferrer"&gt;Symfony 1.4 legacy documentation: Exploring Symfony's Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://paul-m-jones.com/post/2012/06/04/php-fig-psr-1-and-2-accepted/" rel="noopener noreferrer"&gt;Paul M. Jones: PHP-FIG: PSR-1 and PSR-2 Accepted (2012-06-04)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://news-web.php.net/php.standards/30" rel="noopener noreferrer"&gt;php.standards mailing list: Re: The How and the Why of this group as I remember it.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>history</category>
      <category>camelcase</category>
      <category>snakecase</category>
    </item>
    <item>
      <title>If You Can Give a Lightning Talk, You Can Probably Go Bungee Jumping</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Sat, 08 Aug 2026 12:42:03 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/if-you-can-give-a-lightning-talk-you-can-probably-go-bungee-jumping-4005</link>
      <guid>https://dev.to/tanahiro2010/if-you-can-give-a-lightning-talk-you-can-probably-go-bungee-jumping-4005</guid>
      <description>&lt;h1&gt;
  
  
  If You Can Give a Lightning Talk, You Can Probably Go Bungee Jumping
&lt;/h1&gt;

&lt;p&gt;I have a question for anyone who has ever given a lightning talk.&lt;/p&gt;

&lt;p&gt;Do you remember those few dozen seconds right before you walked on stage?&lt;/p&gt;

&lt;p&gt;Do you remember what was happening to your body?&lt;/p&gt;

&lt;p&gt;I do.&lt;/p&gt;

&lt;p&gt;In fact, this article itself started as something I talked about during a lightning talk. And while I was speaking, a strange thought suddenly popped into my head:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Wait. Aren’t lightning talks kind of like bungee jumping?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Written down like that, it sounds like an absurd claim.&lt;/p&gt;

&lt;p&gt;I know.&lt;/p&gt;

&lt;p&gt;But at least in my own experience, the excitement I feel right before stepping onto a stage is surprisingly similar to what I feel when standing on a bungee platform and looking down.&lt;/p&gt;

&lt;p&gt;So in this article, I want to develop the ridiculous-sounding claim in the title — &lt;strong&gt;“If you can give a lightning talk, you can probably go bungee jumping”&lt;/strong&gt; — while keeping the casual spirit of the original idea and giving it at least some concrete reasoning.&lt;/p&gt;

&lt;p&gt;If, by the end of this article, the thought &lt;em&gt;“Bungee jumping sounds kind of interesting”&lt;/em&gt; appears in your head before &lt;em&gt;“Lightning talks are scary,”&lt;/em&gt; then this article has accomplished its mission.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Nervousness Before a Lightning Talk and the Fear Before a Bungee Jump
&lt;/h2&gt;

&lt;p&gt;That restless feeling right before a lightning talk is probably nervousness.&lt;/p&gt;

&lt;p&gt;The similar feeling right before a bungee jump is probably fear.&lt;/p&gt;

&lt;p&gt;Strictly speaking, these are different emotions.&lt;/p&gt;

&lt;p&gt;Nervousness comes from uncertainty: &lt;em&gt;Can I do this well?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fear comes from perceived danger: &lt;em&gt;Am I safe?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Their causes are completely different.&lt;/p&gt;

&lt;p&gt;But if we move one level higher and look at what the body actually does, the two experiences start looking surprisingly similar.&lt;/p&gt;

&lt;p&gt;Your heart rate increases.&lt;/p&gt;

&lt;p&gt;Your palms get a little sweaty.&lt;/p&gt;

&lt;p&gt;Your breathing becomes shallow.&lt;/p&gt;

&lt;p&gt;You cannot quite settle down.&lt;/p&gt;

&lt;p&gt;And somehow, two contradictory thoughts exist at the same time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“I want to escape.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;and&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“I still want to do this.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More importantly, the peak of that reaction happens &lt;strong&gt;before the experience actually begins&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;With a lightning talk, the most nerve-racking moment is often right before stepping onto the stage, or just before opening your slides and saying the first sentence.&lt;/p&gt;

&lt;p&gt;Once you actually start talking, the nervousness tends to drop very quickly.&lt;/p&gt;

&lt;p&gt;Bungee jumping feels similar.&lt;/p&gt;

&lt;p&gt;Standing on the platform, having the equipment checked, and finally looking down is the scariest part.&lt;/p&gt;

&lt;p&gt;Once you jump, fear is no longer the only thing occupying your brain.&lt;/p&gt;

&lt;p&gt;There is another similarity too: once the experience begins, your body almost seems to take over.&lt;/p&gt;

&lt;p&gt;Once you start a lightning talk, your main remaining option is to keep talking.&lt;/p&gt;

&lt;p&gt;Once you jump off a bungee platform, continuing to fall is no longer exactly optional.&lt;/p&gt;

&lt;p&gt;That structure — &lt;strong&gt;the moment where starting removes most of your ways to back out&lt;/strong&gt; — might be one of the biggest reasons why the two experiences feel so similar to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;===&lt;/code&gt; Is False, but &lt;code&gt;==&lt;/code&gt; Might Be True
&lt;/h2&gt;

&lt;p&gt;Obviously, a lightning talk and bungee jumping are completely different activities.&lt;/p&gt;

&lt;p&gt;A lightning talk is an intellectual activity where you share ideas with an audience.&lt;/p&gt;

&lt;p&gt;Bungee jumping involves physically falling from a very high place while attached to a cord.&lt;/p&gt;

&lt;p&gt;The actions are different.&lt;/p&gt;

&lt;p&gt;The skills involved are different.&lt;/p&gt;

&lt;p&gt;The risks are definitely different.&lt;/p&gt;

&lt;p&gt;If we borrow a programming metaphor:&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="nx"&gt;lightningTalk&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;bungeeJumping&lt;/span&gt;
&lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strict equality does not hold.&lt;/p&gt;

&lt;p&gt;But what if we narrow the comparison?&lt;/p&gt;

&lt;p&gt;What if we only compare these three things?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The emotional peak happens before starting.&lt;/li&gt;
&lt;li&gt;Once it starts, time feels extremely short.&lt;/li&gt;
&lt;li&gt;After it ends, you somehow want to do it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under those conditions, at least in my head:&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="nx"&gt;lightningTalk&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;bungeeJumping&lt;/span&gt;
&lt;span class="c1"&gt;// surprisingly true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we allow a little type coercion, they start looking oddly equivalent.&lt;/p&gt;

&lt;p&gt;But if we require both the type and value to match exactly, of course they are not.&lt;/p&gt;

&lt;p&gt;That is roughly the level of seriousness with which this entire article should be read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing Lightning Talks and Bungee Jumping
&lt;/h2&gt;

&lt;p&gt;To make the argument slightly more structured, here is a direct comparison.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Perspective&lt;/th&gt;
&lt;th&gt;Lightning Talk&lt;/th&gt;
&lt;th&gt;Bungee Jumping&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before it starts&lt;/td&gt;
&lt;td&gt;Nervousness reaches its peak&lt;/td&gt;
&lt;td&gt;Fear reaches its peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After it starts&lt;/td&gt;
&lt;td&gt;Time feels extremely short&lt;/td&gt;
&lt;td&gt;Time feels extremely short&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After it ends&lt;/td&gt;
&lt;td&gt;You want to speak again&lt;/td&gt;
&lt;td&gt;You want to jump again&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first similarity is that the emotional peak happens before the activity begins.&lt;/p&gt;

&lt;p&gt;For a lightning talk, it happens right before going on stage.&lt;/p&gt;

&lt;p&gt;For a bungee jump, it happens when you are standing on the platform and looking down.&lt;/p&gt;

&lt;p&gt;In my experience, the anxiety or fear rarely gets stronger after starting.&lt;/p&gt;

&lt;p&gt;The second similarity is how short the experience feels once it begins.&lt;/p&gt;

&lt;p&gt;Lightning talks are usually around five to ten minutes long, and once you start speaking, they tend to end much faster than expected.&lt;/p&gt;

&lt;p&gt;A bungee jump is even shorter.&lt;/p&gt;

&lt;p&gt;The period between jumping and reaching the end of the fall is only a matter of seconds, and subjectively it feels even shorter.&lt;/p&gt;

&lt;p&gt;The third similarity is that immediately afterward, you want to do it again.&lt;/p&gt;

&lt;p&gt;After a lightning talk, I often find myself thinking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“What should I talk about next?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After bungee jumping, strangely enough, I get a similar feeling:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“I kind of want to jump again.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is entirely based on my own experience.&lt;/p&gt;

&lt;p&gt;I have no scientific evidence that everyone reacts this way.&lt;/p&gt;

&lt;p&gt;But at least for me, it happens almost every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Other Types of Public Speaking?
&lt;/h2&gt;

&lt;p&gt;At this point, there is an obvious question.&lt;/p&gt;

&lt;p&gt;If public speaking in general feels similar to bungee jumping, why specifically talk about lightning talks?&lt;/p&gt;

&lt;p&gt;My answer is that not every kind of speaking feels the same.&lt;/p&gt;

&lt;p&gt;The speaking activities I have personally experienced can roughly be divided into three categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lightning talks&lt;/li&gt;
&lt;li&gt;Regular conference or seminar talks&lt;/li&gt;
&lt;li&gt;Hands-on workshop instruction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Out of these, lightning talks feel the most like bungee jumping.&lt;/p&gt;

&lt;p&gt;A normal conference or seminar presentation tends to be much longer.&lt;/p&gt;

&lt;p&gt;It might last thirty minutes, an hour, or sometimes even longer.&lt;/p&gt;

&lt;p&gt;When you speak for that long, the nervousness gradually fades during the presentation. At some point, it starts feeling almost routine.&lt;/p&gt;

&lt;p&gt;That is not a bad thing.&lt;/p&gt;

&lt;p&gt;Preparing a longer presentation forces you to research the topic more deeply.&lt;/p&gt;

&lt;p&gt;The research itself often improves your understanding as an engineer.&lt;/p&gt;

&lt;p&gt;Questions from the audience and the preparation process can also expose gaps in your own knowledge.&lt;/p&gt;

&lt;p&gt;Those are all valuable things.&lt;/p&gt;

&lt;p&gt;But on the specific axis of &lt;strong&gt;“How similar is this to bungee jumping?”&lt;/strong&gt;, they do not matter very much.&lt;/p&gt;

&lt;p&gt;A long presentation feels less like jumping and more like walking a long distance until you finally reach the destination.&lt;/p&gt;

&lt;p&gt;Hands-on workshops are even more different.&lt;/p&gt;

&lt;p&gt;During a workshop, teaching is only part of the job.&lt;/p&gt;

&lt;p&gt;You also deal with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Differences between participants’ environments&lt;/li&gt;
&lt;li&gt;Mysterious errors&lt;/li&gt;
&lt;li&gt;Dependencies that refuse to install&lt;/li&gt;
&lt;li&gt;Sample code that suddenly stops working&lt;/li&gt;
&lt;li&gt;Problems that somehow only happen on one person’s machine&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I enjoy that too.&lt;/p&gt;

&lt;p&gt;But once a workshop begins, the dominant feeling is not &lt;strong&gt;“I’m jumping.”&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;“I am operating a live system.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So among the kinds of speaking I have experienced, lightning talks still feel the most similar to bungee jumping.&lt;/p&gt;

&lt;p&gt;They are short.&lt;/p&gt;

&lt;p&gt;The emotional peak happens right before the start.&lt;/p&gt;

&lt;p&gt;And after they end, you often want to do them again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scariest Part Is Before the Jump
&lt;/h2&gt;

&lt;p&gt;There is an obvious response to everything I have written so far:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Bungee jumping is scary.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes.&lt;/p&gt;

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

&lt;p&gt;I am not going to pretend otherwise.&lt;/p&gt;

&lt;p&gt;But for me, most of that fear is concentrated before the jump.&lt;/p&gt;

&lt;p&gt;You stand on the platform.&lt;/p&gt;

&lt;p&gt;You look down.&lt;/p&gt;

&lt;p&gt;You suddenly understand exactly how high you are.&lt;/p&gt;

&lt;p&gt;That moment is genuinely scary.&lt;/p&gt;

&lt;p&gt;But once you actually jump, the experience becomes surprisingly short.&lt;/p&gt;

&lt;p&gt;The falling sensation is somewhat similar to the drop on a roller coaster.&lt;/p&gt;

&lt;p&gt;There is definitely excitement after the jump begins, but the highest concentration of emotion exists in the moments before it.&lt;/p&gt;

&lt;p&gt;Lightning talks work in a similar way.&lt;/p&gt;

&lt;p&gt;The strongest nervousness usually happens before the talk.&lt;/p&gt;

&lt;p&gt;After it ends, instead of thinking only about how stressful it was, I naturally start wondering what I should speak about next.&lt;/p&gt;

&lt;p&gt;The important point is not that fear or nervousness disappears.&lt;/p&gt;

&lt;p&gt;It is understanding &lt;strong&gt;where the peak actually happens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And in both experiences, the peak comes before the beginning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commitment Makes Backing Out Harder
&lt;/h2&gt;

&lt;p&gt;There is one more major similarity between lightning talks and bungee jumping.&lt;/p&gt;

&lt;p&gt;Making a commitment in advance makes it harder to back out later.&lt;/p&gt;

&lt;p&gt;Before submitting a lightning talk proposal, you can keep telling yourself:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Maybe I’ll speak someday.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no problem with that.&lt;/p&gt;

&lt;p&gt;But once you submit the talk, something changes.&lt;/p&gt;

&lt;p&gt;Your name is now attached to a future event.&lt;/p&gt;

&lt;p&gt;You have accepted the role of &lt;strong&gt;“the person who is going to speak.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That suddenly turns a vague idea into an actual commitment.&lt;/p&gt;

&lt;p&gt;Now you have to make the slides.&lt;/p&gt;

&lt;p&gt;You have to research the topic.&lt;/p&gt;

&lt;p&gt;You have to prepare.&lt;/p&gt;

&lt;p&gt;Bungee jumping has a similar structure.&lt;/p&gt;

&lt;p&gt;Thinking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“I want to try bungee jumping someday.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;can remain a vague idea forever.&lt;/p&gt;

&lt;p&gt;But saying publicly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“I’m going bungee jumping.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;makes the idea slightly more real.&lt;/p&gt;

&lt;p&gt;Your psychological escape route gets a little narrower.&lt;/p&gt;

&lt;p&gt;And personally, I find that process strangely entertaining.&lt;/p&gt;

&lt;p&gt;Of course, this is not an argument for forcing people into things they genuinely cannot or do not want to do.&lt;/p&gt;

&lt;p&gt;The idea is much lighter than that.&lt;/p&gt;

&lt;p&gt;Sometimes, making a half-joking, half-serious commitment is enough to make your future self actually move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign Up for a Lightning Talk So You Can Go Bungee Jumping
&lt;/h2&gt;

&lt;p&gt;You might have reached this point thinking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Bungee jumping still sounds difficult, but maybe I could try a lightning talk.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately, that is not quite the order I am proposing.&lt;/p&gt;

&lt;p&gt;I am suggesting that you give a lightning talk &lt;strong&gt;so that you can eventually go bungee jumping&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Submitting a lightning talk is a useful exercise in creating commitment.&lt;/p&gt;

&lt;p&gt;It teaches you how to turn:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Maybe someday.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;into:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Okay. I guess I’m actually doing this.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And compared with jumping off a tall platform, a five-minute talk is a relatively manageable first step.&lt;/p&gt;

&lt;p&gt;That said, once you submit a lightning talk, another problem appears:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do you actually do next?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is roughly how I prepare.&lt;/p&gt;

&lt;p&gt;First, I research the topic as broadly as possible.&lt;/p&gt;

&lt;p&gt;I look through official documentation, related articles, implementation examples, old notes, and anything else that seems remotely relevant.&lt;/p&gt;

&lt;p&gt;At this stage, I intentionally avoid narrowing the scope too early.&lt;/p&gt;

&lt;p&gt;Then I dump everything into a note-taking app or Markdown file.&lt;/p&gt;

&lt;p&gt;I do not worry about structure.&lt;/p&gt;

&lt;p&gt;I write bullet points.&lt;/p&gt;

&lt;p&gt;I nest ideas.&lt;/p&gt;

&lt;p&gt;I add random observations.&lt;/p&gt;

&lt;p&gt;I leave things in even when I am not sure whether they will be useful later.&lt;/p&gt;

&lt;p&gt;Once I have accumulated roughly ten thousand Japanese characters’ worth of material, I finally start thinking seriously about the structure of the talk.&lt;/p&gt;

&lt;p&gt;Only then do I decide what to keep and what to remove.&lt;/p&gt;

&lt;p&gt;For me, trying to create a clean structure from the beginning often causes the work to stall because I simply do not have enough raw material yet.&lt;/p&gt;

&lt;p&gt;So instead, I prefer to collect too much information first and cut it down afterward.&lt;/p&gt;

&lt;p&gt;I also find it useful to bounce ideas off AI tools.&lt;/p&gt;

&lt;p&gt;Ideas that seem perfectly connected inside my own head often reveal logical gaps when I try to explain them to someone else.&lt;/p&gt;

&lt;p&gt;Even when the conversation partner is an AI, questions such as:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“How strongly can you actually support that claim?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;or&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“What should the audience take away from this?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;can force me to organize my thoughts much more clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Okay, So Who Wants to Go Bungee Jumping?
&lt;/h2&gt;

&lt;p&gt;So, after all of that, here is my conclusion:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you can give a lightning talk, I think you can probably go bungee jumping too.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If we inspect the two activities strictly, there are obviously countless differences.&lt;/p&gt;

&lt;p&gt;But if we only compare these three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The emotional peak happens before starting.&lt;/li&gt;
&lt;li&gt;Once it starts, time feels extremely short.&lt;/li&gt;
&lt;li&gt;After it ends, you somehow want to do it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I think lightning talks and bungee jumping are surprisingly similar experiences.&lt;/p&gt;

&lt;p&gt;And perhaps the most important part is the act of commitment itself.&lt;/p&gt;

&lt;p&gt;You make a decision in advance.&lt;/p&gt;

&lt;p&gt;You slightly reduce the number of escape routes available to your future self.&lt;/p&gt;

&lt;p&gt;And somewhere inside that situation, fear and excitement start existing at the same time.&lt;/p&gt;

&lt;p&gt;So yes.&lt;/p&gt;

&lt;p&gt;I recommend bungee jumping.&lt;/p&gt;

&lt;p&gt;Half as a joke.&lt;/p&gt;

&lt;p&gt;Half seriously.&lt;/p&gt;

&lt;p&gt;Whether you actually jump is entirely up to you.&lt;/p&gt;

&lt;p&gt;But if this article made you even slightly curious about it, feel free to reach out.&lt;/p&gt;

&lt;p&gt;Maybe we should go jump together someday.&lt;/p&gt;

</description>
      <category>career</category>
      <category>community</category>
      <category>beginners</category>
      <category>speaking</category>
    </item>
    <item>
      <title>I Built a Bridge for Google's New WebMCP Draft Spec — Here's What Broke</title>
      <dc:creator>Hirohisa Tanaka</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:22:51 +0000</pubDate>
      <link>https://dev.to/tanahiro2010/i-built-a-bridge-for-googles-new-webmcp-draft-spec-heres-what-broke-1691</link>
      <guid>https://dev.to/tanahiro2010/i-built-a-bridge-for-googles-new-webmcp-draft-spec-heres-what-broke-1691</guid>
      <description>&lt;p&gt;Hi, everyone.&lt;br&gt;
I usually go by &lt;strong&gt;tanahiro2010&lt;/strong&gt; in Japan. &lt;br&gt;
I'm a member of GDG Greater Kwansai.&lt;br&gt;
I gave the first-half talk at the Google I/O Extended Osaka 2026 hands-on session, "Let's Build WebMCP and Call It from an AI Agent!"&lt;br&gt;
This is a write-up of that hands-on session for Qiita (translated here for Dev.to).&lt;br&gt;
Here's the codelab we used:&lt;br&gt;
&lt;a href="https://learn.gdgs.jp/webmcp-agent/" rel="noopener noreferrer"&gt;https://learn.gdgs.jp/webmcp-agent/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is for people who already know MCP but have never heard of WebMCP, and for people who want to know "so what's this new spec Google put out, actually like?"&lt;/p&gt;
&lt;h2&gt;
  
  
  Have you heard of WebMCP?
&lt;/h2&gt;

&lt;p&gt;Let me just ask straight up: have you heard of WebMCP?&lt;br&gt;
I hadn't even heard it existed until I started putting together the hands-on materials.&lt;br&gt;
Just from the name, I assumed it was "the Web version of MCP."&lt;br&gt;
But once I actually read the spec and implemented it, it turned out to be a much quirkier spec than I expected.&lt;br&gt;
In this article I'll walk through what's actually in it, and what I learned by getting my hands dirty with it.&lt;/p&gt;
&lt;h2&gt;
  
  
  First, what is MCP?
&lt;/h2&gt;

&lt;p&gt;Before getting into WebMCP, let's recap MCP (Model Context Protocol).&lt;br&gt;
MCP is a common interface for connecting external tools to an AI Agent.&lt;br&gt;
The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant Agent as AI Agent
    participant Server as MCP Server
    Agent-&amp;gt;&amp;gt;Server: Launch (stdio / HTTP)
    Server--&amp;gt;&amp;gt;Agent: tools/list (list of available tools)
    Agent-&amp;gt;&amp;gt;Server: tools/call (tool name + args)
    Server-&amp;gt;&amp;gt;Server: Execute the tool
    Server--&amp;gt;&amp;gt;Agent: Return the result&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The key point is that &lt;strong&gt;the tools stay registered for as long as the Agent is running&lt;/strong&gt;.&lt;br&gt;
Whether it's a local file operation or a tool that hits an internal API, you can keep calling it as long as you don't kill the Agent.&lt;br&gt;
The &lt;code&gt;webmcp-bridge-mcp&lt;/code&gt; I built for this is also just an ordinary MCP Server listening on stdio.&lt;br&gt;
From the perspective of a client like the Antigravity CLI, it's nothing more than "one more run-of-the-mill MCP Server."&lt;br&gt;
What's unusual is the WebMCP side, which I'll get to next.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is WebMCP?
&lt;/h2&gt;

&lt;p&gt;WebMCP is a draft spec published by the W3C Web Machine Learning Community Group.&lt;br&gt;
&lt;a href="https://webmachinelearning.github.io/webmcp/" rel="noopener noreferrer"&gt;https://webmachinelearning.github.io/webmcp/&lt;/a&gt;&lt;br&gt;
As of writing, it's the February 2026 draft — still at the proposal stage.&lt;/p&gt;

&lt;p&gt;In one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A mechanism by which a web page itself declares its own features as tools for an Agent.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are two ways to register a tool.&lt;/p&gt;
&lt;h3&gt;
  
  
  Imperative
&lt;/h3&gt;

&lt;p&gt;You register a tool directly from JavaScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reserve_hotel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reserve a hotel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&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;required&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="s2"&gt;city&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;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;city&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 shape — &lt;code&gt;name&lt;/code&gt; / &lt;code&gt;description&lt;/code&gt; / &lt;code&gt;inputSchema&lt;/code&gt; / &lt;code&gt;execute&lt;/code&gt; — is almost identical to an MCP tool definition.&lt;br&gt;
Anyone who's touched MCP will look at this and immediately think, "oh, this is the same shape as that."&lt;/p&gt;
&lt;h3&gt;
  
  
  Declarative
&lt;/h3&gt;

&lt;p&gt;You turn an existing &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; into a tool just by adding attributes to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;toolname=&lt;/span&gt;&lt;span class="s"&gt;"search_hotels"&lt;/span&gt; &lt;span class="na"&gt;tooldescription=&lt;/span&gt;&lt;span class="s"&gt;"Search hotels"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"city"&lt;/span&gt; &lt;span class="na"&gt;toolparamdescription=&lt;/span&gt;&lt;span class="s"&gt;"City to search hotels in"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Search&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a form with &lt;code&gt;toolname&lt;/code&gt; / &lt;code&gt;tooldescription&lt;/code&gt; is found, a JSON Schema is automatically assembled from each &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;'s &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt;, and &lt;code&gt;toolparamdescription&lt;/code&gt; (or, if that's absent, the text of the associated &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Here's the interesting part: internally, the declarative form gets normalized into the same &lt;code&gt;registerTool()&lt;/code&gt; call as the imperative form.&lt;br&gt;
Rather than having two separate APIs, the declarative form is implemented as syntactic sugar over the imperative one.&lt;br&gt;
Personally, I genuinely like this design.&lt;/p&gt;

&lt;p&gt;The submission result is received via &lt;code&gt;SubmitEvent#respondWith()&lt;/code&gt;, as specified.&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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentInvoked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Promise&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="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;event.agentInvoked&lt;/code&gt; lets you tell whether a human clicked the button or an Agent submitted the form.&lt;br&gt;
It's a small detail, but it ends up mattering a lot later.&lt;/p&gt;
&lt;h2&gt;
  
  
  MCP and WebMCP: similar names, but...
&lt;/h2&gt;

&lt;p&gt;Because the name and the shape of the API are so similar, my initial impression was roughly "it's just the Web version of MCP."&lt;br&gt;
But once I actually dug in, they turned out to differ clearly along three axes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;MCP&lt;/th&gt;
&lt;th&gt;WebMCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;AI Agents in general&lt;/td&gt;
&lt;td&gt;Per the spec, mainly browser-embedded Agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration timing&lt;/td&gt;
&lt;td&gt;Once, at Agent startup&lt;/td&gt;
&lt;td&gt;Every time the page is opened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session lifetime&lt;/td&gt;
&lt;td&gt;Until the Agent is terminated&lt;/td&gt;
&lt;td&gt;Only while the page is open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execution location&lt;/td&gt;
&lt;td&gt;Local or a service server&lt;/td&gt;
&lt;td&gt;Inside that browser, on that page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The difference I felt most viscerally was the session lifetime.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; Unavailable
    Unavailable --&amp;gt; Available: Open the tab
    Available --&amp;gt; Unavailable: Close/leave the tab
    Unavailable --&amp;gt; Available: Return to the tab&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;An MCP tool can be called as long as the Agent is up and running.&lt;br&gt;
But a WebMCP tool is completely tied to "whether that page is currently open."&lt;br&gt;
The moment you switch tabs, that tool disappears from view; go back, and it reappears.&lt;br&gt;
The idea that "the tab's lifetime = the tool's lifetime" felt pretty fresh to someone coming from an MCP-only mindset.&lt;/p&gt;
&lt;h2&gt;
  
  
  How much of it actually works, as of August 2026
&lt;/h2&gt;

&lt;p&gt;As of this writing (August 2026), WebMCP is still a draft at the proposal stage.&lt;br&gt;
Even Chrome's own docs introduce it as an "upcoming feature."&lt;br&gt;
&lt;a href="https://developer.chrome.com/docs/ai/webmcp?hl=ja" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/ai/webmcp?hl=ja&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can join the Origin Trial starting with Chrome 149&lt;/li&gt;
&lt;li&gt;To just try it locally, use &lt;code&gt;chrome://flags/#enable-webmcp-testing&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It's explicitly noted that the API may still change&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, you can't assume "the native implementation is enabled in every attendee's browser at the hands-on venue."&lt;br&gt;
This is where the thing I built comes in.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built — WebMCP Bridge
&lt;/h2&gt;

&lt;p&gt;There was a genuine gap between the hands-on requirements and the WebMCP spec if you approached it head-on.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What the spec assumes: WebMCP is for browser-embedded Agents&lt;/li&gt;
&lt;li&gt;The reality of the hands-on: participants wanted to call it from an existing MCP-capable Agent, like the Antigravity CLI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, I decided to build something to bridge the two.&lt;br&gt;
With that in mind, I built a Chrome Extension and an MCP Server as a set.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MCP Server: &lt;a href="https://github.com/tanahiro2010/webmcp-bridge-mcp" rel="noopener noreferrer"&gt;https://github.com/tanahiro2010/webmcp-bridge-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Chrome Extension: &lt;a href="https://github.com/tanahiro2010/webmcp-bridge-extension" rel="noopener noreferrer"&gt;https://github.com/tanahiro2010/webmcp-bridge-extension&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's one thing I was particular about here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only detect and execute the APIs defined by the WebMCP spec (&lt;code&gt;document.modelContext&lt;/code&gt; / annotated &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;s) as-is.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't want to override the spec with some custom protocol of my own.&lt;br&gt;
The reason is simple: if what people learn in the spec and what actually runs end up diverging, there's no point in running the hands-on in the first place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Overview of the Bridge
&lt;/h2&gt;

&lt;p&gt;Here's the overall structure:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    Agent["AI Agent&amp;lt;br&amp;gt;(e.g. Antigravity CLI)"]
    MCP["webmcp-bridge-mcp&amp;lt;br&amp;gt;(MCP Server)"]
    Ext["webmcp-bridge-extension&amp;lt;br&amp;gt;(Chrome Extension)"]
    Page["Web page&amp;lt;br&amp;gt;(WebMCP-enabled)"]
    Agent &amp;lt;--&amp;gt;|stdio, MCP| MCP
    MCP &amp;lt;--&amp;gt;|WebSocket| Ext
    Ext &amp;lt;--&amp;gt;|content/injected script| Page&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The MCP Server (&lt;code&gt;webmcp-bridge-mcp&lt;/code&gt;) never touches the DOM directly itself.&lt;br&gt;
It sticks strictly to being a Bridge / Registry / Router between itself and the Extension, and leaves all DOM manipulation to the Extension side.&lt;br&gt;
It's a fairly unglamorous division of labor, but the responsibilities are clear, and I had few doubts while implementing it.&lt;/p&gt;

&lt;p&gt;The WebSocket binds to &lt;code&gt;ws://127.0.0.1:58787&lt;/code&gt;.&lt;br&gt;
I used 58787 instead of 8787 because 8787 collided with the default port for &lt;code&gt;wrangler dev&lt;/code&gt; (Cloudflare Workers).&lt;br&gt;
When I had Workers development running in parallel, the Extension would end up connecting to wrangler instead of my server, and I'd sit there wondering why nothing would connect — a fairly unglamorous bug I ran into early on.&lt;/p&gt;

&lt;p&gt;The Extension side is MV3-based and has a two-layer structure.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    subgraph Page["Inside the web page"]
        direction TB
        Injected["injected.ts&amp;lt;br&amp;gt;(main world)"]
        Content["content.ts&amp;lt;br&amp;gt;(isolated world)"]
        Injected &amp;lt;--&amp;gt;|postMessage| Content
    end
    Background["background.ts&amp;lt;br&amp;gt;(Service Worker)"]
    WS["MCP Server"]
    Content &amp;lt;--&amp;gt;|chrome.runtime| Background
    Background &amp;lt;--&amp;gt;|WebSocket| WS&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The reason &lt;code&gt;injected.ts&lt;/code&gt; needs to run in the main world is that accessing the page's &lt;code&gt;document.modelContext&lt;/code&gt; requires running in the main world.&lt;br&gt;
You can't touch it directly from the isolated world (a normal content script).&lt;br&gt;
When &lt;code&gt;document.modelContext&lt;/code&gt; isn't yet natively implemented in the browser, &lt;code&gt;injected.ts&lt;/code&gt; provides a minimal polyfill for &lt;code&gt;registerTool&lt;/code&gt; / &lt;code&gt;getTools&lt;/code&gt; / &lt;code&gt;executeTool&lt;/code&gt; / the &lt;code&gt;toolchange&lt;/code&gt; event.&lt;br&gt;
If a native implementation exists, it does nothing.&lt;br&gt;
By designing it as "quietly defer if a native implementation exists," I expect I won't need major code changes even as native implementations roll out.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tools visible to the Agent
&lt;/h2&gt;

&lt;p&gt;There are six tools visible to the Agent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webmcp_get_status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns the Extension's connection state, number of known tabs, and the active tab ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webmcp_list_tabs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns the list of WebMCP-enabled tabs the Extension has captured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webmcp_discover_tools&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Discovers the WebMCP tools on a given tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webmcp_call_tool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Executes a tool on a given tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webmcp_submit_tool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confirms a submission that's waiting on a human, from the Agent side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webmcp_ping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checks connectivity with the Extension&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's what the input/output look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// webmcp_discover_tools input&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;"tabId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"forceRefresh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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="c1"&gt;// output&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;"tabId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"reserve_hotel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reserve_hotel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"imperative"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// webmcp_call_tool input&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;"toolId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reserve_hotel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Osaka"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// output&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;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"result"&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;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Osaka"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"confirmationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RES-12345"&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;&lt;code&gt;webmcp_submit_tool&lt;/code&gt; is a bit unusual.&lt;br&gt;
Per the spec, a declarative form without &lt;code&gt;toolautosubmit&lt;/code&gt; is expected to stop by focusing the submit button, so that a human reviews the content and submits manually.&lt;br&gt;
This is a safety mechanism intentionally built into the WebMCP spec.&lt;br&gt;
&lt;code&gt;webmcp_submit_tool&lt;/code&gt; is a tool for explicitly overriding that from the Agent side.&lt;br&gt;
I've written a note in the README that this &lt;strong&gt;should only be used with the understanding that it bypasses the human confirmation the spec intends&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it on real hardware, it was riddled with landmines
&lt;/h2&gt;

&lt;p&gt;This is the part I most wanted to write about in this article.&lt;br&gt;
Things I never would have noticed just from reading the docs kept popping up once I actually ran it on Chrome for Testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;getTools()&lt;/code&gt; turned out to be async
&lt;/h3&gt;

&lt;p&gt;From skimming the summary in Chrome's developer docs, my impression was that it was a synchronous function.&lt;br&gt;
But when I checked on real hardware (Chrome for Testing 150), &lt;code&gt;document.modelContext.getTools()&lt;/code&gt; returned a &lt;code&gt;Promise&amp;lt;ModelContextTool[]&amp;gt;&lt;/code&gt;.&lt;br&gt;
On top of that, &lt;code&gt;executeTool()&lt;/code&gt; doesn't take a tool-name string — it requires the actual tool object obtained from &lt;code&gt;getTools()&lt;/code&gt;.&lt;br&gt;
Pass it a string, and you get a &lt;code&gt;TypeError&lt;/code&gt;.&lt;br&gt;
This was a spot where implementing based only on a summary of the docs would trip you up, plain and simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  On &lt;code&gt;file://&lt;/code&gt;, the handshake never finishes, for some reason
&lt;/h3&gt;

&lt;p&gt;For &lt;code&gt;postMessage&lt;/code&gt; between &lt;code&gt;content.ts&lt;/code&gt; and &lt;code&gt;injected.ts&lt;/code&gt;, I was using &lt;code&gt;window.location.origin&lt;/code&gt; as &lt;code&gt;targetOrigin&lt;/code&gt;.&lt;br&gt;
On a page opened via &lt;code&gt;file://&lt;/code&gt;, this becomes, for some reason, the literal string &lt;code&gt;"null"&lt;/code&gt;.&lt;br&gt;
As a result, the handshake never completed at all, and the overlay would never show up.&lt;br&gt;
I'm not sure whether this bug is fully reproducible, but thinking about it more, communication between the main world and isolated world within the same window shouldn't involve the concept of cross-origin at all in the first place.&lt;br&gt;
So I changed &lt;code&gt;targetOrigin&lt;/code&gt; to &lt;code&gt;"*"&lt;/code&gt;, and instead guarantee legitimacy via a random channel ID.&lt;br&gt;
This was the type of bug you only notice by trying it with &lt;code&gt;file://&lt;/code&gt;; if I hadn't verified the sample page by opening it directly via &lt;code&gt;file://&lt;/code&gt;, I probably would have missed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The native implementation registers declarative forms on its own
&lt;/h3&gt;

&lt;p&gt;When a native implementation is present, the browser itself may automatically register declarative forms.&lt;br&gt;
In that case, this Extension's own &lt;code&gt;registerTool()&lt;/code&gt; call fails as a "duplicate," but I'm treating that as expected behavior.&lt;br&gt;
Since &lt;code&gt;findAnnotatedFormByName()&lt;/code&gt; looks directly at the DOM to determine &lt;code&gt;source: "declarative"&lt;/code&gt;, it can report correctly regardless of who registered it.&lt;br&gt;
There were also cases where the natively-synthesized &lt;code&gt;inputSchema&lt;/code&gt; returned empty (&lt;code&gt;{ type: "object", properties: {} }&lt;/code&gt;) on this particular build.&lt;br&gt;
This is probably down to the state of the browser's implementation, not a bug in the Extension.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool execution results come back as strings, for some reason
&lt;/h3&gt;

&lt;p&gt;The WebMCP spec's &lt;code&gt;execute&lt;/code&gt; is originally meant to return "a string summary for the agent."&lt;br&gt;
Because of that, I confirmed on real hardware that even when the page returns an object like &lt;code&gt;{ ok: true, city }&lt;/code&gt;, the browser's native implementation JSON-stringifies it before returning.&lt;br&gt;
Neither the Extension nor the MCP Server touch &lt;code&gt;result&lt;/code&gt; — they pass it straight through.&lt;br&gt;
So the MCP client side needs to determine whether it got a string or structured data.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Service Worker falls asleep on its own
&lt;/h3&gt;

&lt;p&gt;An MV3 Service Worker gets suspended when it goes idle.&lt;br&gt;
During that time, the WebSocket connection also drops.&lt;br&gt;
It wakes back up automatically when a &lt;code&gt;chrome.tabs&lt;/code&gt; event or similar fires, and the reconnection logic kicks in.&lt;br&gt;
But if nothing happens right after startup — no tab activity at all — it can stay stuck at &lt;code&gt;extensionConnected: false&lt;/code&gt; for a while.&lt;br&gt;
If &lt;code&gt;webmcp_get_status&lt;/code&gt; / &lt;code&gt;webmcp_ping&lt;/code&gt; return &lt;code&gt;false&lt;/code&gt;, doing something with the target tab (switching to it, reloading it, etc.) will bring it back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool registration finishes before the handshake, so the overlay never shows up
&lt;/h3&gt;

&lt;p&gt;On declarative-only pages (with no imperative JS at all), there was a race condition where tool registration could complete before the handshake with &lt;code&gt;content.ts&lt;/code&gt; finished, leaving the overlay permanently hidden.&lt;br&gt;
&lt;code&gt;injected.ts&lt;/code&gt; registers &lt;code&gt;&amp;lt;form toolname tooldescription&amp;gt;&lt;/code&gt; elements as soon as it finds them via &lt;code&gt;MutationObserver&lt;/code&gt;.&lt;br&gt;
So this race is more likely to occur on lightweight declarative-only pages that don't run any imperative script.&lt;br&gt;
The previous implementation recorded "the manifest was sent / attempted to be sent" before the handshake had actually completed.&lt;br&gt;
As a result, once the handshake did complete, a resend request would be misjudged as "no diff" and silently swallowed.&lt;br&gt;
I fixed &lt;code&gt;reportManifestIfChanged()&lt;/code&gt; so that it doesn't record or send anything at all until the channel is established.&lt;br&gt;
I wrote a test that deliberately reproduces the bad ordering (tool registration → delayed handshake), and confirmed it reproduces the bug before the fix and resolves it after.&lt;br&gt;
More than the bug itself, what I felt made the biggest difference was the approach of "write a test that deliberately reproduces the bad ordering, and use it to prove the fix works."&lt;/p&gt;

&lt;h3&gt;
  
  
  A tool that never comes back until a human clicks
&lt;/h3&gt;

&lt;p&gt;Per the spec, a form without &lt;code&gt;toolautosubmit&lt;/code&gt; stops by just focusing the submit button.&lt;br&gt;
This is meant so a human reviews the content and submits manually.&lt;br&gt;
At least some native implementations keep &lt;code&gt;executeTool()&lt;/code&gt; itself blocked during this "waiting for a human" period.&lt;br&gt;
This is not the same behavior as this Extension's polyfill, which immediately returns &lt;code&gt;{ pending: true, ... }&lt;/code&gt;.&lt;br&gt;
If you call &lt;code&gt;webmcp_call_tool&lt;/code&gt; on a tool without &lt;code&gt;toolautosubmit&lt;/code&gt; in an automated environment with no human interaction, you won't get a response back until it times out.&lt;br&gt;
Before calling it, you need to check whether &lt;code&gt;webmcp_discover_tools&lt;/code&gt;'s result has &lt;code&gt;requiresUserGesture: true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing: two tiers, with and without a browser
&lt;/h2&gt;

&lt;p&gt;I verified the following four scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An imperative-only page (no &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A declarative-only page (no &lt;code&gt;registerTool()&lt;/code&gt; calls)&lt;/li&gt;
&lt;li&gt;A page with a normal mix of imperative and declarative&lt;/li&gt;
&lt;li&gt;A pattern where executing one tool changes whether another tool exists (calling &lt;code&gt;unlock&lt;/code&gt; makes a new tool dynamically appear, and &lt;code&gt;lock&lt;/code&gt; makes it disappear)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I actually loaded the Extension on Chrome for Testing and confirmed all patterns passed.&lt;br&gt;
The fourth one also serves as verification of dynamic detection via the &lt;code&gt;toolchange&lt;/code&gt; event and &lt;code&gt;MutationObserver&lt;/code&gt;.&lt;br&gt;
Since &lt;code&gt;webmcp_discover_tools&lt;/code&gt; returns a cached result by default, you need &lt;code&gt;forceRefresh: true&lt;/code&gt; to observe these dynamic changes.&lt;/p&gt;

&lt;p&gt;I'm using Chrome for Testing (the Chromium bundled with Playwright) because, from Chrome 137 onward, official Google Chrome builds have removed the &lt;code&gt;--load-extension&lt;/code&gt; flag for automation purposes.&lt;br&gt;
Loading an extension manually via &lt;code&gt;chrome://extensions&lt;/code&gt; works fine with regular Chrome.&lt;br&gt;
But if you want to load an extension in automated tests, you need Chrome for Testing or Chromium.&lt;/p&gt;

&lt;p&gt;On the MCP Server side, I use a mock class called &lt;code&gt;FakeExtension&lt;/code&gt;.&lt;br&gt;
It speaks the same WebSocket protocol as the real Extension, and lets me verify — without launching a browser — the connection state, the discover_tools cache/&lt;code&gt;forceRefresh&lt;/code&gt; behavior, concurrent tool call execution, and cleanup on disconnect.&lt;br&gt;
Since it doesn't launch a browser, it runs fast, so I split the work: bridge logic gets tested here, and verification that involves actual DOM manipulation goes through the Extension's Playwright tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on security (this is a prototype)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;code&gt;postMessage&lt;/code&gt; between the main world (&lt;code&gt;injected.ts&lt;/code&gt;) and the isolated world (&lt;code&gt;content.ts&lt;/code&gt;), a random channel ID is issued once per page load, shared through a one-time handshake, and attached to every subsequent message. This is to prevent unrelated page scripts from injecting a fake manifest or fake execution results.&lt;/li&gt;
&lt;li&gt;The MCP Server's WebSocket server binds only to &lt;code&gt;127.0.0.1&lt;/code&gt;, so it can't be connected to from anywhere other than the same machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is built with personal use and prototyping in mind, and doesn't include additional authorization such as token authentication.&lt;br&gt;
I stopped at the line of "good enough to run at the hands-on session" and haven't designed authorization with production use in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even so, there are real benefits
&lt;/h2&gt;

&lt;p&gt;Even though I put this together somewhat forcefully, there are benefits I genuinely felt after actually using it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Less guessing&lt;/strong&gt;: the Agent doesn't have to infer the meaning of buttons or input fields on screen. The site itself can declare its intent as a tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shorter execution&lt;/strong&gt;: no need to replay a bunch of clicks and inputs — it can be handled directly as a tool call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visually verifiable&lt;/strong&gt;: since it runs in the browser, a human can watch the result happen in real time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  But personally, I don't think it's necessary
&lt;/h2&gt;

&lt;p&gt;From here on, this is entirely my personal opinion.&lt;br&gt;
Honestly, having implemented it and used it at the hands-on session, what I felt most strongly was, "do we really need this?"&lt;/p&gt;

&lt;p&gt;There are a few reasons.&lt;/p&gt;

&lt;p&gt;First, the motivation to use a browser-embedded Agent is still weak.&lt;br&gt;
Actually, I myself didn't even know Chrome had a built-in Agent until I did this investigation.&lt;br&gt;
I imagine there are quite a few people in the same boat.&lt;/p&gt;

&lt;p&gt;And WebMCP has the constraint that it only works while the page is open.&lt;br&gt;
That's subtly restrictive.&lt;br&gt;
Having a tool disappear the moment you switch tabs felt a bit inconvenient, coming from the mindset I'm used to with MCP.&lt;/p&gt;

&lt;p&gt;In actual use, I didn't feel a particularly large difference in perceived token usage or execution speed between Agent-driven browser operations and WebMCP-driven ones.&lt;br&gt;
For people who keep an existing Agent like Claude Code or Codex open at all times, I felt the appeal of going out of your way to open a browser-embedded Agent just to use this is pretty thin.&lt;/p&gt;

&lt;p&gt;And honestly, from a layperson's perspective, I also wondered if the target audience selection itself might be a bit off.&lt;br&gt;
The benefit itself — "fewer mistaken operations from the Agent" — is appealing.&lt;br&gt;
But it's a bit of a shame that the place where you can realize that benefit is limited to something as little-known as a "browser-embedded Agent."&lt;/p&gt;

&lt;h2&gt;
  
  
  That said, it will resonate with the right people
&lt;/h2&gt;

&lt;p&gt;That said, for people who use a browser-embedded Agent as part of their daily routine, the two points — "less guessing" and "shorter execution time" — should genuinely land hard.&lt;br&gt;
I think the reason it didn't land as hard for me this time is simply that my own workflow wasn't built around a browser-embedded Agent to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I published this article anyway
&lt;/h2&gt;

&lt;p&gt;Given how harsh some of this has been, you might wonder why I'm publishing it at all.&lt;br&gt;
The reason is simple.&lt;br&gt;
It felt like a waste to build an Extension and an MCP Server and have them only get used on the day of the hands-on session and nothing more.&lt;br&gt;
Even though I have some skepticism about WebMCP as a spec, the technical insights I only gained by actually implementing it and getting my hands dirty — the async API, the origin issue, how I fixed the race condition, and so on — should be useful to someone on their own merits.&lt;br&gt;
Also, if people end up using the extension or the MCP server, that makes me happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;WebMCP is, directionally, an interesting spec: "the web page itself declares tools for an Agent."&lt;br&gt;
That said, as of August 2026 it's still at the draft stage, and its target is limited to browser-embedded Agents.&lt;br&gt;
So my personal conclusion is that, at this point, it doesn't land all that strongly for people who are already heavy users of existing MCP-capable Agents.&lt;br&gt;
On the other hand, the pitfalls I found while implementing it — the async API, the origin issue, the race condition — should be useful reference material as native implementations spread further going forward.&lt;/p&gt;

&lt;p&gt;Here's what I built and the related links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Codelab: &lt;a href="https://learn.gdgs.jp/webmcp-agent/" rel="noopener noreferrer"&gt;https://learn.gdgs.jp/webmcp-agent/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;WebMCP Bridge MCP: &lt;a href="https://github.com/tanahiro2010/webmcp-bridge-mcp" rel="noopener noreferrer"&gt;https://github.com/tanahiro2010/webmcp-bridge-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;WebMCP Bridge Extension: &lt;a href="https://github.com/tanahiro2010/webmcp-bridge-extension" rel="noopener noreferrer"&gt;https://github.com/tanahiro2010/webmcp-bridge-extension&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;connpass: &lt;a href="https://gdgkwansai.connpass.com/event/391029" rel="noopener noreferrer"&gt;https://gdgkwansai.connpass.com/event/391029&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Original Article: &lt;a href="https://qiita.com/tanahiro2010/items/66b7822e2132ec4e0a45" rel="noopener noreferrer"&gt;https://qiita.com/tanahiro2010/items/66b7822e2132ec4e0a45&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feel free to check out the repos and reach out with any questions.&lt;/p&gt;

</description>
      <category>webmcp</category>
      <category>bridge</category>
      <category>extensions</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
