<?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: Russell Jones</title>
    <description>The latest articles on DEV Community by Russell Jones (@jonesrussell).</description>
    <link>https://dev.to/jonesrussell</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%2F136661%2Fd812786d-8ef0-4b08-9421-35be6f99b174.png</url>
      <title>DEV Community: Russell Jones</title>
      <link>https://dev.to/jonesrussell</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonesrussell"/>
    <language>en</language>
    <item>
      <title>Fixing a metadata collision bug in Waaseyaa's file repository</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:54:41 +0000</pubDate>
      <link>https://dev.to/jonesrussell/fixing-a-metadata-collision-bug-in-waaseyaas-file-repository-1o5m</link>
      <guid>https://dev.to/jonesrussell/fixing-a-metadata-collision-bug-in-waaseyaas-file-repository-1o5m</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s &lt;code&gt;packages/media&lt;/code&gt; package stores uploaded files behind stream-wrapper URIs like &lt;code&gt;public://images/photo.jpg&lt;/code&gt;, and &lt;code&gt;LocalFileRepository&lt;/code&gt; keeps each file's metadata (filename, MIME type, owner, size) in a JSON sidecar next to the derived path. The sidecar path came from &lt;code&gt;parse_url()&lt;/code&gt;, and &lt;code&gt;parse_url()&lt;/code&gt; doesn't know these URIs aren't real hierarchical URLs. That mismatch let two completely different files quietly overwrite each other's metadata. Here's the bug, the fix, and the reconciliation problem a fix like this creates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug: &lt;code&gt;parse_url()&lt;/code&gt; Doesn't Know About Stream Wrappers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resolveMetadataPath()&lt;/code&gt; used to build a sidecar path from just the &lt;code&gt;scheme&lt;/code&gt; and &lt;code&gt;path&lt;/code&gt; components &lt;code&gt;parse_url()&lt;/code&gt; returned:&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;$parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'scheme'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sanitizeSegment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'scheme'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$segments&lt;/span&gt; &lt;span class="o"&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;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$segment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$segment&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&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 looks reasonable until you feed it a stream-wrapper URI. &lt;code&gt;public://images/shared.pdf&lt;/code&gt; isn't a hierarchical URL — it's a scheme plus a flat, ordered path — but &lt;code&gt;parse_url()&lt;/code&gt; still applies RFC 3986 grammar to it, and under that grammar the first segment after &lt;code&gt;//&lt;/code&gt; is a &lt;strong&gt;host&lt;/strong&gt;, not a path component. So &lt;code&gt;public://images/shared.pdf&lt;/code&gt; parses into &lt;code&gt;host: images&lt;/code&gt;, &lt;code&gt;path: /shared.pdf&lt;/code&gt;, and &lt;code&gt;resolveMetadataPath()&lt;/code&gt; only ever looked at &lt;code&gt;path&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That silently dropped the host segment. Two distinct, documented URIs that happened to share a trailing filename under different directories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;public://images/shared.pdf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;public://docs/shared.pdf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;collided onto the exact same &lt;code&gt;.../shared.pdf.meta.json&lt;/code&gt; sidecar. Save the second file and it silently overwrote the first file's metadata. Delete either one's metadata and both lost it. No exception, no log line — just metadata for a file you never touched disappearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Treat Every Segment After the Scheme as One Flat Path
&lt;/h2&gt;

&lt;p&gt;The fix stops using &lt;code&gt;parse_url()&lt;/code&gt;'s host/path split entirely and instead treats everything after &lt;code&gt;scheme://&lt;/code&gt; as one ordered list of segments:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolveMetadataPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$uri&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="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'#^([A-Za-z][A-Za-z0-9+.-]*)://(.*)$#s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$matches&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sanitizeSegment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$matches&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="nv"&gt;$rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$segments&lt;/span&gt; &lt;span class="o"&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;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$segment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$segment&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$safeSegments&lt;/span&gt; &lt;span class="o"&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;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sanitizeSegment'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$segments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$safeSegments&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="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'file'&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="nb"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rootDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.meta.json'&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;Every segment gets sanitized individually and stays in order, so &lt;code&gt;images&lt;/code&gt; and &lt;code&gt;docs&lt;/code&gt; are preserved as distinct path components instead of one being silently discarded. Traversal confinement under the repository root is unchanged — sanitized &lt;code&gt;..&lt;/code&gt; segments still collapse to &lt;code&gt;_&lt;/code&gt; and can't escape &lt;code&gt;rootDir&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's a good fix, but it changes the on-disk layout for any URI with more than one segment after the scheme. The reviewers caught two problems that a fix like this can't just wave away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Upgrading Doesn't Migrate Existing Sidecars
&lt;/h2&gt;

&lt;p&gt;Change how a path is derived and every sidecar an existing install already wrote sits at the &lt;em&gt;old&lt;/em&gt; location. On upgrade, &lt;code&gt;load()&lt;/code&gt; and &lt;code&gt;delete()&lt;/code&gt; would look at the new path and find nothing, silently losing access to metadata that's still sitting on disk one directory over.&lt;/p&gt;

&lt;p&gt;The fix deliberately does &lt;strong&gt;not&lt;/strong&gt; add an automatic fallback to the old path on read — a fallback would have to pick a winner among the URIs that used to collide there, which is exactly the silent-data-loss failure mode being fixed in the first place. Instead, &lt;code&gt;reconcileLegacySidecars()&lt;/code&gt; is a one-time migration operators run explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It scans every &lt;code&gt;*.meta.json&lt;/code&gt; sidecar under the repository root and reads the &lt;code&gt;uri&lt;/code&gt; each one recorded at save time.&lt;/li&gt;
&lt;li&gt;If a sidecar is already at its current-layout location, it's left alone.&lt;/li&gt;
&lt;li&gt;If it isn't, and nothing already exists at the new location, it's relocated there — the common case, since there's exactly one candidate on disk.&lt;/li&gt;
&lt;li&gt;If something already exists at the new location (a real conflict — two live candidates for one URI), the legacy sidecar is left untouched and reported as a &lt;code&gt;conflict&lt;/code&gt;, never silently overwritten.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's idempotent, so running it twice on an already-reconciled tree reports nothing to do. The candidate list is collected up front before any renaming happens, because mutating files mid-walk on a &lt;code&gt;RecursiveDirectoryIterator&lt;/code&gt; has undefined visitation order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: &lt;code&gt;save()&lt;/code&gt; Wasn't Atomic
&lt;/h2&gt;

&lt;p&gt;The original &lt;code&gt;save()&lt;/code&gt; wrote sidecars with a direct &lt;code&gt;file_put_contents()&lt;/code&gt; to the existing path — a truncate-then-rewrite of the same inode. A concurrent &lt;code&gt;load()&lt;/code&gt; opening that file mid-write could read a partial, possibly non-JSON-decodable body.&lt;/p&gt;

&lt;p&gt;The fix is the standard write-to-temp-then-rename pattern:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;writeAtomically&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$directory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;dirname&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;span class="nv"&gt;$temporary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tempnam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$directory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.meta-'&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="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$temporary&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unable to create a temporary file beside %s.'&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;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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$temporary&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="o"&gt;!==&lt;/span&gt; &lt;span class="nb"&gt;strlen&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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unable to write file metadata: %s'&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;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="nb"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$temporary&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;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unable to move file metadata into place: %s'&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;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="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$exception&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="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$temporary&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$temporary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temp file lives in the same directory as the target, so &lt;code&gt;rename()&lt;/code&gt; stays on one filesystem and is a single atomic directory-entry swap. A reader only ever sees the complete old sidecar or the complete new one — never a partial write. Any failure along the way cleans up the temp file instead of leaving it behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying It
&lt;/h2&gt;

&lt;p&gt;The test suite added &lt;strong&gt;eight cases&lt;/strong&gt; specifically to close coverage gaps the CI gate flagged (&lt;strong&gt;73.21%&lt;/strong&gt; on the new code), on top of the regression tests for the original collision fix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Distinct authorities, same relative path&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;public://images/shared.pdf&lt;/code&gt; and &lt;code&gt;public://docs/shared.pdf&lt;/code&gt; save, load, and delete independently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traversal-sanitized paths&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;..&lt;/code&gt; segments still collapse and stay confined to the repository root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty root directory&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reconcileLegacySidecars()&lt;/code&gt; on a not-yet-created root returns &lt;code&gt;[]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Corrupt or missing &lt;code&gt;uri&lt;/code&gt; in a legacy sidecar&lt;/td&gt;
&lt;td&gt;Reported as &lt;code&gt;unreadable&lt;/code&gt;, doesn't abort the pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy sidecar with a conflicting target&lt;/td&gt;
&lt;td&gt;Left untouched, reported as &lt;code&gt;conflict&lt;/code&gt;, never overwritten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already-reconciled tree&lt;/td&gt;
&lt;td&gt;A second run reports nothing to do (idempotent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;save()&lt;/code&gt; against an existing sidecar&lt;/td&gt;
&lt;td&gt;The write swaps the file's inode, proving rename-based replacement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename failure during &lt;code&gt;writeAtomically()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Cleans up the temp file, throws &lt;code&gt;RuntimeException&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The General Lesson
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;parse_url()&lt;/code&gt; is built for real URLs, and stream-wrapper URIs like &lt;code&gt;scheme://&lt;/code&gt; only &lt;em&gt;look&lt;/em&gt; like them. Feeding a flat, ordered identifier through a parser that assumes host/path semantics will happily produce a result — it just won't be the result you meant, and nothing will tell you that at runtime. The two-line diff that dropped one path segment didn't fail loudly; it just started routing some writes to the wrong file.&lt;/p&gt;

&lt;p&gt;The other lesson is about what "fix the derivation" actually obligates you to do. Changing how a path is computed is easy. Owning up to the fact that existing installs have data sitting at the &lt;em&gt;old&lt;/em&gt; derivation is the harder part. Giving them an explicit, conflict-reporting way to bring it forward — instead of a silent fallback that would repeat the same bug in miniature — is the work that's easy to punt to a follow-up ticket and never ship.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>filesystem</category>
      <category>storage</category>
    </item>
    <item>
      <title>Configuring identity-only OAuth requests in Waaseyaa</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:54:37 +0000</pubDate>
      <link>https://dev.to/jonesrussell/configuring-identity-only-oauth-requests-in-waaseyaa-3cmn</link>
      <guid>https://dev.to/jonesrussell/configuring-identity-only-oauth-requests-in-waaseyaa-3cmn</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s &lt;code&gt;oauth-provider&lt;/code&gt; package wraps Google and GitHub OAuth 2.0 behind one &lt;code&gt;OAuthProviderInterface&lt;/code&gt;. Until recently, both bundled providers only knew how to over-ask: Google always requested offline access with a forced consent prompt, and GitHub always made a second API call for the user's email, even when a consumer just needed a stable, verified identity to log someone in. The package now takes optional constructor arguments so an identity-only consumer can say exactly that, without touching the interface or forking the provider. What follows is the over-asking, the additive fix, and the two unsafe casts that got hardened along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Over-Asking Looked Like
&lt;/h2&gt;

&lt;p&gt;Before this change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GoogleOAuthProvider::getAuthorizationUrl()&lt;/code&gt;&lt;/strong&gt; hardcoded &lt;code&gt;access_type=offline&lt;/code&gt; and &lt;code&gt;prompt=consent&lt;/code&gt; into every authorization URL. Every login requested refresh-token eligibility and forced a re-consent screen, whether or not the consumer ever stored a refresh token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GitHubOAuthProvider::getUserProfile()&lt;/code&gt;&lt;/strong&gt; called &lt;code&gt;GET /user&lt;/code&gt; and then unconditionally called &lt;code&gt;GET /user/emails&lt;/code&gt;, even for a consumer that only reads the numeric &lt;code&gt;id&lt;/code&gt; GitHub already returns from &lt;code&gt;GET /user&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional profile fields were unsafe.&lt;/strong&gt; Google's &lt;code&gt;email&lt;/code&gt;/&lt;code&gt;name&lt;/code&gt; were read with a bare &lt;code&gt;(string) $data['email']&lt;/code&gt; cast, and GitHub's &lt;code&gt;name&lt;/code&gt; fallback cast &lt;code&gt;$userData['login']&lt;/code&gt; the same way. A response missing either key triggered an "Undefined array key" warning; a malformed non-string value (an array, say) triggered an "Array to string conversion" warning and silently became the string &lt;code&gt;'Array'&lt;/code&gt;. Waaseyaa's &lt;code&gt;phpunit.xml.dist&lt;/code&gt; sets &lt;code&gt;failOnWarning="true"&lt;/code&gt;, so either defect turned into a hard test failure, not a quietly-ignored notice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Additive Configuration, Not a Parameter Bag
&lt;/h2&gt;

&lt;p&gt;The fix rejected two easier options: a generic &lt;code&gt;array $options&lt;/code&gt; bag on either provider (arbitrary, unvalidated URL rewriting) and a single &lt;code&gt;IdentityOnlyMode&lt;/code&gt; toggle (which would have silently coupled scopes and refresh-token eligibility — concerns the interface already keeps separate). Instead, both providers gained optional trailing constructor parameters that default to the old behavior:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Default (unchanged)&lt;/th&gt;
&lt;th&gt;Identity-only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;accessType: Offline&lt;/code&gt;, &lt;code&gt;forceConsent: true&lt;/code&gt; → &lt;code&gt;access_type=offline&amp;amp;prompt=consent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;accessType: Online&lt;/code&gt;, &lt;code&gt;forceConsent: false&lt;/code&gt; → &lt;code&gt;access_type=online&lt;/code&gt;, &lt;code&gt;prompt&lt;/code&gt; omitted entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fetchEmail: true&lt;/code&gt; → &lt;code&gt;GET /user&lt;/code&gt; then &lt;code&gt;GET /user/emails&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fetchEmail: false&lt;/code&gt; → only &lt;code&gt;GET /user&lt;/code&gt;; profile has &lt;code&gt;email: ''&lt;/code&gt;, &lt;code&gt;emailVerified: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A new &lt;code&gt;@api&lt;/code&gt; enum backs the Google option instead of a raw string:&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="n"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;GoogleAccessType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Offline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'offline'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Online&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'online'&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;GoogleOAuthProvider&lt;/code&gt; builds the authorization URL from it, and only adds &lt;code&gt;prompt&lt;/code&gt; when consent is forced:&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;$params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'client_id'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'redirect_uri'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;redirectUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'response_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'scope'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$scopes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'state'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'access_type'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accessType&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="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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;forceConsent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'prompt'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'consent'&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AUTH_URL&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'?'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;http_build_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per &lt;a href="https://developers.google.com/identity/protocols/oauth2/web-server" rel="noopener noreferrer"&gt;Google's docs&lt;/a&gt;, online is already the default when &lt;code&gt;access_type&lt;/code&gt; is absent — so omitting &lt;code&gt;prompt&lt;/code&gt; lets Google decide whether re-consent is needed instead of the framework forcing it. &lt;code&gt;forceConsent&lt;/code&gt; is independent of &lt;code&gt;accessType&lt;/code&gt;: turning off the prompt doesn't silently flip an offline consumer to online access.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GitHubOAuthProvider&lt;/code&gt; skips the secondary lookup entirely rather than making the call and discarding the result:&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;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$emailVerified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;fetchEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$emailsResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;EMAILS_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$headers&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="nv"&gt;$emailsResponse&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSuccess&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$emailsResponse&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;as&lt;/span&gt; &lt;span class="nv"&gt;$entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// ...find the primary, verified email&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The existing fail-loud checks are untouched on both providers: a non-2xx response, a missing or empty &lt;code&gt;id&lt;/code&gt;, both still block. An identity-only configuration still refuses a failed or unidentifiable response. It just stops paying for a lookup it would discard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Waaseyaa\OAuthProvider\Provider\GoogleAccessType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Waaseyaa\OAuthProvider\Provider\GoogleOAuthProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Waaseyaa\OAuthProvider\Provider\GitHubOAuthProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Google: online access, no forced re-consent prompt.&lt;/span&gt;
&lt;span class="nv"&gt;$google&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;GoogleOAuthProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$clientSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;redirectUri&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$redirectUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$httpClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;accessType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GoogleAccessType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Online&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;forceConsent&lt;/span&gt;&lt;span class="o"&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="c1"&gt;// GitHub: skip the secondary /user/emails lookup.&lt;/span&gt;
&lt;span class="nv"&gt;$github&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;GitHubOAuthProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$clientSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;redirectUri&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$redirectUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$httpClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fetchEmail&lt;/span&gt;&lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The existing four-argument constructor call on either class still compiles. It still produces byte-identical behavior — offline access with a forced prompt for Google, an email lookup for GitHub. An offline consumer that stores a refresh token, or one that needs a verified GitHub email, just keeps using the defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening the Optional Fields
&lt;/h2&gt;

&lt;p&gt;The unsafe casts got fixed alongside the new parameters, since identity-only responses are exactly where a missing &lt;code&gt;email&lt;/code&gt; or &lt;code&gt;name&lt;/code&gt; shows up in practice. Google's profile parsing now guards both fields:&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;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub's &lt;code&gt;name&lt;/code&gt; fallback got the same treatment — falling back to &lt;code&gt;''&lt;/code&gt; instead of casting a missing or non-string &lt;code&gt;login&lt;/code&gt;. The required identity check is unchanged on both providers: a non-2xx response or an absent &lt;code&gt;id&lt;/code&gt; still throws before any optional-field logic runs. Only the optional path stopped assuming the upstream payload is well-formed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters Beyond Waaseyaa
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefer optional, default-preserving parameters over a config bag.&lt;/strong&gt; A typed enum plus a couple of booleans is auditable at a glance; a generic &lt;code&gt;array $options&lt;/code&gt; invites arbitrary, unvalidated behavior that's hard to review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't make a call you'll discard the result of.&lt;/strong&gt; If a consumer configures identity-only, skip the secondary request outright rather than fetching and ignoring it — it's fewer round trips and one less thing that can rate-limit you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional response fields need &lt;code&gt;isset()&lt;/code&gt; and a type check, not a bare cast.&lt;/strong&gt; Any code path that only exercises the full/happy response will pass tests right up until a real provider omits a field you assumed was always present.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>oauth</category>
      <category>security</category>
    </item>
    <item>
      <title>Host-bound session and CSRF cookies in Waaseyaa</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:54:02 +0000</pubDate>
      <link>https://dev.to/jonesrussell/host-bound-session-and-csrf-cookies-in-waaseyaa-586j</link>
      <guid>https://dev.to/jonesrussell/host-bound-session-and-csrf-cookies-in-waaseyaa-586j</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;__Host-&lt;/code&gt; cookie prefix is one of the stronger security guarantees browsers give you: a cookie named &lt;code&gt;__Host-something&lt;/code&gt; can only be set and read by the exact origin that set it, no matter how many subdomains share the parent domain. That closes a real attack: a compromised or malicious subdomain writing a cookie that your main app then trusts as its own session.&lt;/p&gt;

&lt;p&gt;The catch is that &lt;code&gt;__Host-&lt;/code&gt; isn't a flag you flip. It's a contract with four parts, and if your server violates any one of them, the browser doesn't reject the request or throw an error — it just silently drops the cookie. &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; recently added a &lt;code&gt;host_bound&lt;/code&gt; mode to its &lt;code&gt;SessionCookiePolicy&lt;/code&gt; that enforces the full contract for both the session cookie and the CSRF cookie, and refuses to boot if the configuration can't satisfy it. What follows is the four-part contract, how &lt;code&gt;host_bound&lt;/code&gt; enforces it for both cookies, and where it refuses to boot instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Constraints
&lt;/h2&gt;

&lt;p&gt;A cookie name starting with &lt;code&gt;__Host-&lt;/code&gt; is only valid if all of these hold at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;Domain&lt;/code&gt; attribute.&lt;/strong&gt; The cookie must be host-only — omitting &lt;code&gt;Domain&lt;/code&gt; scopes it to the exact host, not &lt;code&gt;Domain=example.com&lt;/code&gt; which would let subdomains see it too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Path=/&lt;/code&gt;.&lt;/strong&gt; Any narrower path is rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Secure&lt;/code&gt; is set.&lt;/strong&gt; The cookie only travels over HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure context, implicitly.&lt;/strong&gt; The cookie has to originate from a secure context in the first place — &lt;code&gt;Secure&lt;/code&gt; alone doesn't retroactively fix an insecure origin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get any of these wrong and the browser doesn't error — it drops the &lt;code&gt;Set-Cookie&lt;/code&gt; header for that cookie entirely. Your session cookie silently never gets set, a user hits a login loop, and there's no log line pointing at the cause. That's the gap Waaseyaa's &lt;code&gt;host_bound&lt;/code&gt; option closes: instead of trusting config to be correct, it validates the contract at construction time.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Policy, Two Cookies
&lt;/h2&gt;

&lt;p&gt;Waaseyaa mints two cookies that need the same hardening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The PHP session cookie.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CSRF double-submit cookie&lt;/strong&gt; (&lt;code&gt;XSRF-TOKEN&lt;/code&gt; by default), read by &lt;a href="https://inertiajs.com/" rel="noopener noreferrer"&gt;Inertia&lt;/a&gt;'s axios adapter and forwarded as the &lt;code&gt;X-XSRF-TOKEN&lt;/code&gt; header on every mutation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before this change, &lt;code&gt;CsrfMiddleware&lt;/code&gt; hard-coded the &lt;code&gt;XSRF-TOKEN&lt;/code&gt; name. Now both cookies are governed by the same &lt;code&gt;session.cookie&lt;/code&gt; config, resolved through &lt;code&gt;SessionCookiePolicy&lt;/code&gt;:&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="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SessionCookiePolicy&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;const&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_CSRF_COOKIE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'XSRF-TOKEN'&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;const&lt;/span&gt; &lt;span class="no"&gt;HOST_BOUND_SESSION_COOKIE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'__Host-waaseyaa_session'&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;const&lt;/span&gt; &lt;span class="no"&gt;HOST_BOUND_CSRF_COOKIE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'__Host-XSRF-TOKEN'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;array&lt;/span&gt; &lt;span class="no"&gt;SECURE_COOKIE_DEFAULTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'httponly'&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="s1"&gt;'secure'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'samesite'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Lax'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'use_strict_mode'&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="s1"&gt;'path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'csrf_name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DEFAULT_CSRF_COOKIE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'host_bound'&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;session.cookie.host_bound =&amp;gt; true&lt;/code&gt; switches both cookies to the &lt;code&gt;__Host-&lt;/code&gt; profile:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Host-bound&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Secure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Follows the &lt;code&gt;secure&lt;/code&gt; setting (&lt;code&gt;'auto'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Forced on, regardless of &lt;code&gt;secure&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;td&gt;Forced to &lt;code&gt;/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Domain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;td&gt;Forced unset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie name&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;waaseyaa_session&lt;/code&gt; / &lt;code&gt;XSRF-TOKEN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;__Host-waaseyaa_session&lt;/code&gt; / &lt;code&gt;__Host-XSRF-TOKEN&lt;/code&gt; (or an explicit &lt;code&gt;__Host-&lt;/code&gt;-prefixed override)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;SessionMiddleware&lt;/code&gt; applies the resolved policy to the PHP session cookie ini. &lt;code&gt;CsrfMiddleware&lt;/code&gt; applies the same policy object to the CSRF cookie. Same source, so the two cookies can't drift out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rejected at Construction, Not Discovered in the Browser
&lt;/h2&gt;

&lt;p&gt;The policy validates the whole contract when it's built, before a single request is handled:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;assertConfigurationCompatible&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hostBound&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// non-host-bound cookie name validation&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&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="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionCookiePolicyException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Host-bound session cookies require path "/", got "%s".'&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;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'domain'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$domain&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionCookiePolicyException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Host-bound session cookies must omit Domain; got "%s".'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$domain&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="nv"&gt;$secure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'secure'&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="nv"&gt;$secure&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'auto'&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="nb"&gt;filter_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FILTER_VALIDATE_BOOLEAN&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionCookiePolicyException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Host-bound session cookies require secure=true (or auto); secure=false is incompatible.'&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="nv"&gt;$sessionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sessionName&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="nv"&gt;$sessionName&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sessionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__Host-'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionCookiePolicyException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Host-bound session cookie name must use the __Host- prefix; got "%s".'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$sessionName&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="c1"&gt;// same check repeated for the CSRF cookie name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you set &lt;code&gt;host_bound =&amp;gt; true&lt;/code&gt; and also set an explicit &lt;code&gt;Domain&lt;/code&gt;, or a non-root &lt;code&gt;Path&lt;/code&gt;, or &lt;code&gt;secure =&amp;gt; false&lt;/code&gt;, the app refuses to start. That trade-off is deliberate: a config error that fails loudly in CI or on deploy is recoverable. A config error that fails silently in production means some users can't log in, and there's no exception anywhere to explain why.&lt;/p&gt;

&lt;p&gt;The policy also checks &lt;strong&gt;already-active&lt;/strong&gt; PHP sessions, not just fresh config. If something upstream (another bootstrap path, an inherited &lt;code&gt;php.ini&lt;/code&gt;) already started a session before the policy runs, &lt;code&gt;assertCompatibleWithActiveSession()&lt;/code&gt; compares the live &lt;code&gt;session_name()&lt;/code&gt; and &lt;code&gt;session_get_cookie_params()&lt;/code&gt; against the resolved policy and throws if they disagree — catching the case where host-bound mode is configured but a prestarted session is still running under the old, non-&lt;code&gt;__Host-&lt;/code&gt; name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Malformed Config Is Rejected Too
&lt;/h2&gt;

&lt;p&gt;Beyond the host-bound-specific checks, the policy rejects structurally unsafe values on construction regardless of mode — a &lt;code&gt;path&lt;/code&gt; or &lt;code&gt;domain&lt;/code&gt; containing a &lt;code&gt;;&lt;/code&gt; (which would inject another &lt;code&gt;Set-Cookie&lt;/code&gt; attribute) or control characters, and a non-boolean &lt;code&gt;host_bound&lt;/code&gt; value:&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_key_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'host_bound'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;filter_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'host_bound'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="no"&gt;FILTER_VALIDATE_BOOLEAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;FILTER_NULL_ON_FAILURE&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="nv"&gt;$parsed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionCookiePolicyException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'session.cookie.host_bound must be a boolean (or a documented boolean string such as "true"/"false").'&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="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'host_bound'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$parsed&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;Documented legacy string forms (&lt;code&gt;"1"&lt;/code&gt;, &lt;code&gt;"0"&lt;/code&gt;, &lt;code&gt;"on"&lt;/code&gt;, &lt;code&gt;"off"&lt;/code&gt;) still parse correctly — this rejects garbage, not backward compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Consumers See
&lt;/h2&gt;

&lt;p&gt;None of this changes how Inertia/Vue or vanilla &lt;code&gt;fetch&lt;/code&gt; consumers read the CSRF cookie — that contract is unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useForm&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;@inertiajs/vue3&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;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;form&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;/ingest/upload&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;forceFormData&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="c1"&gt;// Inertia's axios reads the cookie and forwards X-XSRF-TOKEN automatically.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inertia's adapter looks for a cookie literally named &lt;code&gt;XSRF-TOKEN&lt;/code&gt; unless you tell it otherwise. That's why the Admin SPA's cookie readers were unified into one shared decoder (&lt;code&gt;packages/admin/app/utils/csrfCookie.ts&lt;/code&gt;) that reads the &lt;em&gt;configured&lt;/em&gt; &lt;code&gt;csrfCookieName&lt;/code&gt; from runtime config, rather than each consumer hard-coding the default name. When host-bound mode renames the cookie to &lt;code&gt;__Host-XSRF-TOKEN&lt;/code&gt;, every packaged Admin HTML response — the SPA fallback and the prebuilt &lt;code&gt;.html&lt;/code&gt; assets — has that name rewritten into it from the runtime policy, so the served bundle and the actual cookie name never disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters Beyond Waaseyaa
&lt;/h2&gt;

&lt;p&gt;The pattern generalizes past this one framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you support &lt;code&gt;__Host-&lt;/code&gt;-prefixed cookies anywhere, &lt;strong&gt;validate the full contract programmatically&lt;/strong&gt;, not just at code review. The failure mode is silent.&lt;/li&gt;
&lt;li&gt;When two cookies (session + CSRF) share a security posture, &lt;strong&gt;govern them from one policy object&lt;/strong&gt;, not two copies of the same logic that can drift.&lt;/li&gt;
&lt;li&gt;Prefer &lt;strong&gt;rejecting bad config at boot&lt;/strong&gt; over "best-effort" defaults that paper over a mistake. A crash on deploy is a bug report with a stack trace. A silently dropped cookie is a support ticket with no clues.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>security</category>
      <category>cookies</category>
    </item>
    <item>
      <title>Claude Code and Codex skills are directories, not files</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:53:57 +0000</pubDate>
      <link>https://dev.to/jonesrussell/claude-code-and-codex-skills-are-directories-not-files-od2</link>
      <guid>https://dev.to/jonesrussell/claude-code-and-codex-skills-are-directories-not-files-od2</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;If you're building tooling that installs "skills" for AI coding agents, it's tempting to treat a skill as just another markdown file you drop somewhere in the repo. It isn't. Both &lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; and &lt;a href="https://learn.chatgpt.com/docs/build-skills" rel="noopener noreferrer"&gt;OpenAI Codex&lt;/a&gt; discover skills by walking the project tree for a &lt;strong&gt;directory&lt;/strong&gt; that contains a &lt;code&gt;SKILL.md&lt;/code&gt; file, not a flat file. Here's what each client actually looks for, the bug a shape mismatch causes, and how &lt;a href="https://waaseyaa.org/" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s installer package, Bimaaji, fixed it by sharing one renderer across both clients instead of maintaining two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Code actually discovers
&lt;/h2&gt;

&lt;p&gt;A Claude Code project skill lives at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.claude/skills/&amp;lt;skill-name&amp;gt;/SKILL.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The command name comes from the directory name&lt;/strong&gt;, not from the &lt;code&gt;name&lt;/code&gt; field in the file's frontmatter. The frontmatter &lt;code&gt;name&lt;/code&gt; is only the display label shown in skill listings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontmatter is only recognized when the opening &lt;code&gt;---&lt;/code&gt; is the file's first line.&lt;/strong&gt; If anything precedes it — a comment, a blank line, a provenance marker — Claude Code won't parse it as frontmatter at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A flat &lt;code&gt;.claude/skills/&amp;lt;name&amp;gt;.md&lt;/code&gt; file is not a documented layout, and Claude Code doesn't discover it. Bimaaji's installer originally emitted exactly that flat shape, and every "file written" count it reported was quietly counting output the client would never load.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Codex actually discovers
&lt;/h2&gt;

&lt;p&gt;Codex's convention is split in two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A root &lt;code&gt;AGENTS.md&lt;/code&gt; for always-loaded project guidance — the same vendor-neutral file &lt;a href="https://agents.md" rel="noopener noreferrer"&gt;Devin Desktop and JetBrains Junie&lt;/a&gt; read.&lt;/li&gt;
&lt;li&gt;Detailed, on-demand skills under &lt;code&gt;.agents/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;, discovered by walking from the current working directory up to the repository root.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Codex's per-skill directory shape is the same structural contract as Claude Code's: a directory per skill, a &lt;code&gt;SKILL.md&lt;/code&gt; inside it, &lt;code&gt;name&lt;/code&gt;/&lt;code&gt;description&lt;/code&gt; metadata in frontmatter. Bimaaji's installer used to fold every skill body straight into &lt;code&gt;AGENTS.md&lt;/code&gt; as one consolidated file. That worked, in the sense that Codex could read it, but it threw away the on-demand loading both clients are designed around — the whole point of a skill is that its detail loads only when needed, not on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one renderer, not two
&lt;/h2&gt;

&lt;p&gt;Once both clients turned out to want the same shape — a concise always-loaded guidance file plus one &lt;code&gt;SKILL.md&lt;/code&gt; per skill — Bimaaji stopped maintaining separate Claude and Codex renderers and introduced a shared base class both transformers extend. Each subclass supplies only two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;its client id (&lt;code&gt;claude&lt;/code&gt; or &lt;code&gt;codex&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;its guidance file's title line&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else — the per-skill file layout, whether frontmatter is required, the guidance index, and a provenance footer — comes from the shared renderer plus a small per-client capabilities lookup (skill file paths, whether frontmatter must sit at byte zero, and so on).&lt;/p&gt;

&lt;p&gt;That provenance footer is the detail worth stealing for your own installers. Two pieces of metadata do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every generated skill file carries an HTML-comment footer with the &lt;strong&gt;sha256 of the whole skill inventory&lt;/strong&gt; it was rendered from.&lt;/li&gt;
&lt;li&gt;The guidance index lists each skill's own &lt;strong&gt;source sha256&lt;/strong&gt; next to its target path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together they let you &lt;em&gt;prove&lt;/em&gt;, rather than assume, that Claude and Codex regenerated their skill files from the same canonical source — and that the two clients' output is byte-identical for the same input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code vs. Codex, side by side
&lt;/h2&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;Claude Code&lt;/th&gt;
&lt;th&gt;Codex&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Always-loaded guidance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.claude/CLAUDE-WAASEYAA.md&lt;/code&gt; (kept separate from a consumer's own &lt;code&gt;CLAUDE.md&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;root &lt;code&gt;AGENTS.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-skill file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.agents/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery key&lt;/td&gt;
&lt;td&gt;directory name&lt;/td&gt;
&lt;td&gt;directory name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontmatter required at byte zero&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;per capability lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command name source&lt;/td&gt;
&lt;td&gt;directory name, not frontmatter &lt;code&gt;name&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;directory name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What this means if you're building similar tooling
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust your own success counters.&lt;/strong&gt; A "files written" count that doesn't check whether the client's discovery mechanism actually finds those files will lie to you convincingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cite the client's own docs, not an issue description.&lt;/strong&gt; Bimaaji's changelog is explicit that the Codex per-skill layout only shipped once there was a citable, verified discovery mechanism from OpenAI's own docs — not because it seemed like a reasonable guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One renderer beats one renderer per client&lt;/strong&gt; once you notice two clients want the same shape. Two copies of "how a skill file gets rendered" is exactly the kind of thing that drifts quietly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>codex</category>
      <category>aiagents</category>
      <category>waaseyaa</category>
    </item>
    <item>
      <title>Building a conformant stdio MCP server in PHP</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:16:24 +0000</pubDate>
      <link>https://dev.to/jonesrussell/building-a-conformant-stdio-mcp-server-in-php-4e2i</link>
      <guid>https://dev.to/jonesrussell/building-a-conformant-stdio-mcp-server-in-php-4e2i</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; lets a coding agent call tools over a small &lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;JSON-RPC 2.0&lt;/a&gt; protocol. The simplest transport for it is stdio: the agent spawns your process, writes requests to its stdin, and reads responses from its stdout. &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s &lt;code&gt;packages/cli&lt;/code&gt; package ships exactly that, as a &lt;code&gt;mcp:serve&lt;/code&gt; command backing a &lt;code&gt;StdioMcpServer&lt;/code&gt; class. Getting stdio right turned out to hinge on a handful of rules that have nothing to do with MCP specifically and everything to do with sharing a byte stream with a client that trusts every byte on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only One Thing May Write to Stdout
&lt;/h2&gt;

&lt;p&gt;A stdio JSON-RPC server's entire contract is: every line on stdout is a complete JSON-RPC frame, and nothing else is ever written there. Break that contract once and every frame after it is garbage - there's no delimiter to resync on. A stray &lt;code&gt;echo&lt;/code&gt;, a PHP warning printed to stdout, or a library that logs a banner is all it takes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StdioMcpServer&lt;/code&gt; treats this as a structural rule, not a habit: exactly one private method ever touches the output stream.&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;writeFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$frame&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$encoded&lt;/span&gt; &lt;span class="o"&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;$frame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\JSON_THROW_ON_ERROR&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;\JSON_UNESCAPED_SLASHES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;fwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$encoded&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;fflush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;out&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;Everything else - parse errors, unhandled exceptions, operator notes - goes through a separate &lt;code&gt;$diagnostic&lt;/code&gt; closure the caller wires to stderr instead. Even a crash follows that rule: a &lt;code&gt;\Throwable&lt;/code&gt; that escapes a tool dispatch gets a fixed, sanitized message on stderr and a generic &lt;strong&gt;&lt;code&gt;-32603 Internal error&lt;/code&gt;&lt;/strong&gt; frame on stdout, never the exception's own message, which could contain a credential or an absolute machine path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounding a Read That Has No Natural Limit
&lt;/h2&gt;

&lt;p&gt;Stdin doesn't tell you how long a line is before you've read it. PHP's &lt;code&gt;fgets()&lt;/code&gt; will happily keep growing a buffer until it finds a newline, which means one misbehaving or hostile caller can exhaust the process before your protocol layer gets a chance to reject it with a proper error.&lt;/p&gt;

&lt;p&gt;The fix is to give &lt;code&gt;fgets()&lt;/code&gt; a hard ceiling - &lt;strong&gt;1 MiB&lt;/strong&gt; (1,048,576 bytes) - and treat "the ceiling was hit with no newline" as its own 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;int&lt;/span&gt; &lt;span class="no"&gt;MAX_FRAME_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1_048_576&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;run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fgets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MAX_FRAME_BYTES&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isCompleteBoundedFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;discardRemainderOfOversizedFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;StdioJsonRpcErrorCode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;INVALID_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Invalid Request: frame exceeds %d bytes.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MAX_FRAME_BYTES&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// ... decode and route the line&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&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;The &lt;code&gt;+2&lt;/code&gt; matters: it's the room for a maximum-size payload plus its own terminating newline, so a legitimate frame that exactly fills the limit isn't mistaken for an oversized one. When a frame &lt;em&gt;is&lt;/em&gt; oversized, the server still has to drain the rest of that line off the stream before it can read the next one - otherwise the leftover bytes get parsed as the start of the following request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JSON Object/Array Ambiguity
&lt;/h2&gt;

&lt;p&gt;PHP's &lt;code&gt;json_decode($json, true)&lt;/code&gt; erases a distinction JSON-RPC actually cares about: both &lt;code&gt;{}&lt;/code&gt; and &lt;code&gt;[]&lt;/code&gt; decode to the same empty PHP array, and a non-empty JSON array decodes to a PHP list that &lt;code&gt;is_array()&lt;/code&gt; can't tell apart from an object. A &lt;code&gt;params&lt;/code&gt; field is supposed to be an object; a naive &lt;code&gt;is_array($params)&lt;/code&gt; check would silently accept &lt;code&gt;"params": [1, 2, 3]&lt;/code&gt; and hand a handler a value it can't read &lt;code&gt;$params['name']&lt;/code&gt; off of - producing a confusing downstream error for what's really a malformed frame.&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isDecodedJsonObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;\is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;array_is_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&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;A non-empty list is unambiguously a JSON array and gets rejected. The empty array is genuinely ambiguous - it's what both &lt;code&gt;{}&lt;/code&gt; and &lt;code&gt;[]&lt;/code&gt; decode to - and gets accepted, because every handler in the server treats an empty params object and an absent one identically anyway.&lt;/p&gt;

&lt;p&gt;The request ID gets the same strict treatment. JSON-RPC allows a string, a number, or null for &lt;code&gt;id&lt;/code&gt;, but MCP narrows that to "string or integer, never null." &lt;code&gt;StdioMcpServer&lt;/code&gt; takes the narrower rule and rejects anything else - including a fractional number - with a &lt;code&gt;null&lt;/code&gt;-id error response rather than echoing the bad value back, since an object or array &lt;code&gt;id&lt;/code&gt; echoed into a response frame would itself be malformed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telling Old Clients From New Ones
&lt;/h2&gt;

&lt;p&gt;MCP's protocol revisions split into two eras that don't mix.&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;Handshake era (through &lt;code&gt;2025-11-25&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Modern era (from &lt;code&gt;2026-07-28&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Version negotiation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client sends &lt;code&gt;initialize&lt;/code&gt;; server responds with the version it's willing to speak&lt;/td&gt;
&lt;td&gt;Every request carries its own protocol version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Startup sequence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client must send &lt;code&gt;notifications/initialized&lt;/code&gt; before anything else&lt;/td&gt;
&lt;td&gt;No handshake - any method can be called first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Discovery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not part of the protocol&lt;/td&gt;
&lt;td&gt;Server must implement &lt;code&gt;server/discover&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;StdioMcpServer&lt;/code&gt; only implements the handshake lifecycle, so it only ever negotiates a handshake-era revision:&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="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StdioMcpProtocol&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;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="no"&gt;LATEST_HANDSHAKE_REVISION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025-11-25'&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;const&lt;/span&gt; &lt;span class="no"&gt;array&lt;/span&gt; &lt;span class="no"&gt;SUPPORTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;LATEST_HANDSHAKE_REVISION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'2025-06-18'&lt;/span&gt;&lt;span class="p"&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;negotiate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$requested&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;\in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUPPORTED&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="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$requested&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;LATEST_HANDSHAKE_REVISION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part isn't the negotiation - it's what happens when a &lt;em&gt;modern&lt;/em&gt;-era client probes this server first. The spec's own recommendation is that a dual-era client should call &lt;code&gt;server/discover&lt;/code&gt; before anything else, and fall back to the handshake if it gets an error that isn't specifically one of the modern protocol's own recognized codes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StdioMcpServer&lt;/code&gt; answers &lt;code&gt;server/discover&lt;/code&gt;, along with every other method it doesn't know, with a plain &lt;strong&gt;&lt;code&gt;-32601 Method not found&lt;/code&gt;&lt;/strong&gt;. That's deliberate. It's the exact signal a dual-era client is watching for to know it should fall back to &lt;code&gt;initialize&lt;/code&gt;. Answering with a recognized modern-era error, or answering &lt;code&gt;server/discover&lt;/code&gt; at all, would make this server look modern and break that fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolving the Interpreter Without Trusting PATH
&lt;/h2&gt;

&lt;p&gt;The last piece is mundane but easy to get wrong: how does a launcher know what command to run to start the server? The obvious answer is &lt;code&gt;"command": "php"&lt;/code&gt;. That depends on &lt;code&gt;php&lt;/code&gt; being on the launching process's &lt;code&gt;PATH&lt;/code&gt;, which isn't guaranteed on every platform or every editor's spawn environment.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StdioServerExecutableResolver&lt;/code&gt; sidesteps &lt;code&gt;PATH&lt;/code&gt; entirely. It resolves the interpreter the same way the framework's dev server does: through &lt;code&gt;PHP_BINARY&lt;/code&gt;, an absolute path PHP resolves for itself, never a bare name searched on &lt;code&gt;PATH&lt;/code&gt;.&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$projectRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$phpBinary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DEFAULT_PROFILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$php&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$phpBinary&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="no"&gt;\PHP_BINARY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'command'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$php&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'args'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'-d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'display_errors=stderr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$normalizedRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/vendor/bin/waaseyaa'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mcp:serve'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--profile='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-d display_errors=stderr&lt;/code&gt; flag does real work too. It's applied before PHP even loads the target script, so a bootstrap or autoload warning can't land on stdout and corrupt the JSON-RPC stream before the server has a chance to enforce its own one-writer rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The General Lesson
&lt;/h2&gt;

&lt;p&gt;None of this is MCP-specific. Any time you put a JSON-RPC (or line-delimited-anything) protocol on top of a raw byte stream, you inherit a short list of problems that have nothing to do with your actual RPC methods:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who is allowed to write to the stream&lt;/li&gt;
&lt;li&gt;What happens when a read has no natural length limit&lt;/li&gt;
&lt;li&gt;Whether your decoder throws away a distinction your protocol needs&lt;/li&gt;
&lt;li&gt;How a client on a newer version of your protocol recognizes that you're older&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip any one of those and the bug doesn't show up as a clean error. It shows up as a corrupted frame, an exhausted process, or a client that can't tell what it's talking to.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>Fixing a silent message-drop bug in Waaseyaa's queue worker</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:22:26 +0000</pubDate>
      <link>https://dev.to/jonesrussell/fixing-a-silent-message-drop-bug-in-waaseyaas-queue-worker-41i6</link>
      <guid>https://dev.to/jonesrussell/fixing-a-silent-message-drop-bug-in-waaseyaas-queue-worker-41i6</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s &lt;code&gt;packages/queue&lt;/code&gt; package runs background jobs through a &lt;code&gt;Worker&lt;/code&gt; that pops a message off a transport and hands it to whichever handler in its roster &lt;code&gt;supports()&lt;/code&gt; that message type. If nothing in the roster claimed a message, the worker didn't fail the delivery — it just finished the loop, returned normally, and acknowledged the message as done. Here's the bug, the fix, and why "no handler matched" needs to be a failure, not a no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug: An Empty Loop Still Counts as Success
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Worker::handleMessage()&lt;/code&gt; walked the handler list looking for the first one that supported the message:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;handlers&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$handler&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="nv"&gt;$handler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no handler supports the message, the loop just runs out. The method returns void either way, so from the caller's point of view a message nobody handled looks identical to a message a handler successfully processed. &lt;code&gt;processJob()&lt;/code&gt; treats that return as success and acks the delivery:&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... envelope/occurrence handling ...&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&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;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$envelope&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;occurrence&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;QueueInterface&lt;/code&gt;/&lt;code&gt;DbalQueue&lt;/code&gt; accept any object for dispatch, not just &lt;code&gt;Job&lt;/code&gt; — but the worker's own handler roster, by default, only knows how to run &lt;code&gt;Job&lt;/code&gt;. Dispatch a plain message object with no registered handler and the worker pulls it off the queue, runs an empty loop, and acks it. No retry, no dead-letter row, nothing written to the failed-job repository. The durable row just disappears, and nothing downstream can tell the difference between "handled" and "nobody was listening."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Throw Instead of Falling Through
&lt;/h2&gt;

&lt;p&gt;The fix adds a typed, payload-free exception:&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="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UnhandledQueueMessage&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'No queue handler supports message type "%s".'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;handleMessage()&lt;/code&gt; throws it once the roster is exhausted instead of returning:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;handlers&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$handler&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="nv"&gt;$handler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$handler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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="p"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnhandledQueueMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&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 single &lt;code&gt;throw&lt;/code&gt; is enough to route an unsupported message through machinery &lt;code&gt;processJob()&lt;/code&gt; already had for every other kind of failure. A few things about that path are worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It reuses the existing retry policy.&lt;/strong&gt; The &lt;code&gt;catch&lt;/code&gt; block hands the exception to &lt;code&gt;handleFailure()&lt;/code&gt;, which applies the worker's normal bounded retry/backoff — &lt;code&gt;Job::$tries&lt;/code&gt; for jobs, &lt;code&gt;WorkerOptions::$maxTries&lt;/code&gt; otherwise (three attempts by default for non-&lt;code&gt;Job&lt;/code&gt; messages).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure record names only the class, not the payload.&lt;/strong&gt; &lt;code&gt;UnhandledQueueMessage&lt;/code&gt;'s message is &lt;code&gt;No queue handler supports message type "..."&lt;/code&gt; — useful for an operator, safe to log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ordering matters.&lt;/strong&gt; The failed-job row is persisted &lt;em&gt;before&lt;/em&gt; the delivery is rejected. If the failed-job repository itself is down, the rejection doesn't happen either, so the message stays reserved for lease recovery instead of being lost a second way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing about dispatch changed.&lt;/strong&gt; &lt;code&gt;QueueInterface&lt;/code&gt; still accepts any object. This isn't about narrowing what you're allowed to queue — it's about not silently discarding what the worker can't run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The package README now says this out loud instead of leaving it to be discovered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Persistent dispatch accepts any object, but successful consumption requires a supporting worker handler. If no handler supports an accepted message, &lt;code&gt;Worker&lt;/code&gt; raises a typed &lt;code&gt;UnhandledQueueMessage&lt;/code&gt; failure and applies its configured bounded retry/backoff policy. On exhaustion, the signed payload and failure are stored in the failed-job repository before the delivery is rejected; it is never silently acknowledged.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Verifying It
&lt;/h2&gt;

&lt;p&gt;A new &lt;code&gt;QueueServiceProviderUnhandledMessageTest&lt;/code&gt; drives the fix through the real database-backed composition — &lt;code&gt;QueueServiceProvider&lt;/code&gt;, &lt;code&gt;DbalQueue&lt;/code&gt;, &lt;code&gt;DbalTransport&lt;/code&gt;, and &lt;code&gt;DatabaseFailedJobRepository&lt;/code&gt; — instead of a bare &lt;code&gt;Worker&lt;/code&gt; in isolation:&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;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;acceptedUnsupportedMessageRetriesThenFailsDurablyInsteadOfBeingAcknowledged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An unsupported message is released once on the first attempt, then durably failed and recorded on the second — never silently acked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;firstSupportingCustomHandlerExecutesOnceAndAcknowledgesNormally&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A message with a matching handler still runs exactly once and acks normally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;providerJobHandlerStillExecutesVoidJobAndAcknowledgesNormally&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Existing &lt;code&gt;Job&lt;/code&gt; dispatch through the provider is unaffected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A fourth case, &lt;code&gt;failedRepositoryOutagePreservesUnsupportedDeliveryForLeaseRecovery&lt;/code&gt;, goes the other way on purpose: it's added to the existing &lt;code&gt;WorkerTest&lt;/code&gt; and wires up a bare &lt;code&gt;Worker&lt;/code&gt; with a stub &lt;code&gt;FailedJobRepositoryInterface&lt;/code&gt; that throws on &lt;code&gt;record()&lt;/code&gt; — not something you can provoke on demand through the real &lt;code&gt;DatabaseFailedJobRepository&lt;/code&gt;. That's the test that would have caught a version of this fix that traded "silently ack an unhandled message" for "silently lose it if the failed-job store is unavailable": when the stub throws, the delivery stays &lt;code&gt;in_progress&lt;/code&gt; for lease recovery instead of vanishing a second way.&lt;/p&gt;

&lt;p&gt;Both files pass alongside the rest of the suite. The queue package's unit and contract tests ran at &lt;strong&gt;236 tests, 629 assertions&lt;/strong&gt; after the change — the provider-composition cases alone account for 3 tests and 40 assertions, the outage control for 1 test and 5 assertions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The General Lesson
&lt;/h2&gt;

&lt;p&gt;A dispatch loop that runs out of candidates without doing anything is easy to write and easy to miss, because "no handler matched" doesn't read like an error — it reads like the absence of work. But a message pulled off a durable queue was never optional work; it's a promise made to whoever dispatched it. If your dispatch loop can finish without ever calling a handler, and the caller can't tell that apart from a real success, you don't have a queue — you have a way to accept work and then forget it happened. The fix here was one &lt;code&gt;throw&lt;/code&gt; statement. What made it correct was that it plugged into retry, backoff, and dead-letter paths that already existed, instead of inventing a bespoke error path for one more corner case.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>queue</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Fixing a cache:clear command that couldn't run and couldn't be trusted</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:21:51 +0000</pubDate>
      <link>https://dev.to/jonesrussell/fixing-a-cacheclear-command-that-couldnt-run-and-couldnt-be-trusted-272a</link>
      <guid>https://dev.to/jonesrussell/fixing-a-cacheclear-command-that-couldnt-run-and-couldnt-be-trusted-272a</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s &lt;code&gt;cache:clear&lt;/code&gt; CLI command had two bugs stacked on top of each other: it couldn't be constructed by the real CLI at all, and once that was worked around, it cleared a hardcoded list of bins that didn't match what the application actually configured. Here's both bugs, the fix, and why a command that prints "cleared" needs to be checked against real state, not just its own exit code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug: A Command That Couldn't Run, Then Lied When It Could
&lt;/h2&gt;

&lt;p&gt;The first problem was structural. &lt;code&gt;CacheFactoryInterface&lt;/code&gt; had no binding anywhere in the CLI's &lt;code&gt;ConsoleKernel&lt;/code&gt; boot path — only &lt;code&gt;HttpKernel::finalizeBoot()&lt;/code&gt; ever built a &lt;code&gt;CacheFactory&lt;/code&gt;. Every real invocation of &lt;code&gt;cache:clear&lt;/code&gt; failed before it could do anything:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No binding for &lt;code&gt;Waaseyaa\Cache\CacheFactoryInterface&lt;/code&gt; in &lt;code&gt;KernelHandlerContainer&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second problem was hiding behind the first. Once constructible, the handler cleared a fixed list of bin 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="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CacheClearHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;array&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_BINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'render'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'discovery'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'config'&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;CacheFactoryInterface&lt;/span&gt; &lt;span class="nv"&gt;$cacheFactory&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SymfonyCommandIO&lt;/span&gt; &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DEFAULT_BINS&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$binName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cacheFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$binName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;deleteAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'All cache bins cleared.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That list had drifted from what the application actually registers: &lt;code&gt;render&lt;/code&gt;, &lt;code&gt;discovery&lt;/code&gt;, and &lt;code&gt;mcp_read&lt;/code&gt;. Two things followed from the mismatch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcp_read&lt;/code&gt; was unreachable.&lt;/strong&gt; It was never in &lt;code&gt;DEFAULT_BINS&lt;/code&gt;, so &lt;code&gt;cache:clear&lt;/code&gt; never touched it, no matter what you asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;default&lt;/code&gt; and &lt;code&gt;config&lt;/code&gt; printed "cleared" for nothing.&lt;/strong&gt; &lt;code&gt;CacheFactory::get()&lt;/code&gt; hands back a usable backend for any bin name, configured or not — a fresh, empty, unconfigured fallback. Clearing it "succeeds," but there was never any real data behind that name. The command reported success for two bins that didn't exist and silently skipped the one that did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exit code was &lt;strong&gt;0&lt;/strong&gt; whether the bin was real or not. That's what makes this worse than an outright crash — a crash gets noticed; a wrong success message doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: One Canonical List, Shared by HTTP and CLI
&lt;/h2&gt;

&lt;p&gt;The fix starts with a single accessor for "what bins does this configuration actually register":&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="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CacheConfiguration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&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;getConfiguredBins&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$bins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;binMapping&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$bins&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;binFactories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$bins&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// PHP coerces a numeric-string array key to int, so array_keys() would&lt;/span&gt;
        &lt;span class="c1"&gt;// hand back int(123) for a bin registered as '123' -- violating the&lt;/span&gt;
        &lt;span class="c1"&gt;// declared list&amp;lt;string&amp;gt; and making the consumer report a configured&lt;/span&gt;
        &lt;span class="c1"&gt;// bin as "not configured". Cast back to the registered string form.&lt;/span&gt;
        &lt;span class="k"&gt;return&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;strval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bins&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;AbstractKernel::buildCacheFactory()&lt;/code&gt; becomes the one place the framework's production bins (&lt;code&gt;render&lt;/code&gt;, &lt;code&gt;discovery&lt;/code&gt;, &lt;code&gt;mcp_read&lt;/code&gt;) get composed:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;buildCacheFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RuntimeEpochInterface&lt;/span&gt; &lt;span class="nv"&gt;$runtimeEpoch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;CacheFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$providerFactory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;providerCacheFactory&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="nv"&gt;$providerFactory&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$providerFactory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... derive $pdo, $cacheHmacKey, $projectionDiagnostic ...&lt;/span&gt;

    &lt;span class="nv"&gt;$cacheConfig&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;CacheConfiguration&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$cacheConfig&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setFactoryForBin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'render'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DatabaseBackend&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DatabaseBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cache_render'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmacKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$cacheHmacKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectionDiagnostic&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$projectionDiagnostic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nv"&gt;$cacheConfig&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setFactoryForBin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'discovery'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DatabaseBackend&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DatabaseBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cache_discovery'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmacKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$cacheHmacKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectionDiagnostic&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$projectionDiagnostic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nv"&gt;$cacheConfig&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setFactoryForBin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mcp_read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;RuntimeEpochCacheBackend&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeEpochCacheBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DatabaseBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cache_mcp_read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmacKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$cacheHmacKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectionDiagnostic&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$projectionDiagnostic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;$runtimeEpoch&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fingerprint&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cacheFactory&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;CacheFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cacheConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$projectionDiagnostic&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;HttpKernel::finalizeBoot()&lt;/code&gt; now calls that method instead of building its own &lt;code&gt;CacheConfiguration&lt;/code&gt; inline, and the CLI's handler container gains bindings for both &lt;code&gt;CacheFactoryInterface&lt;/code&gt; and &lt;code&gt;CacheConfiguration&lt;/code&gt; that go through the same method. HTTP-serving boot and the CLI can no longer register a different set of bins from each other — which also fixes the original "no binding" failure, since the CLI now has one.&lt;/p&gt;

&lt;p&gt;With a real, shared list to enumerate, &lt;code&gt;CacheClearHandler&lt;/code&gt; stops hardcoding anything:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;clearConfigured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SymfonyCommandIO&lt;/span&gt; &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$bins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cacheConfiguration&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConfiguredBins&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="nv"&gt;$bins&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'No cache bins are configured.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&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="nv"&gt;$cleared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bins&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$binName&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cacheFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$binName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;deleteAll&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="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Cache bin "%s" failed to clear: %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$binName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
            &lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$binName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Cache bin "%s" cleared.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$binName&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$cleared&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$binName&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="nv"&gt;$failed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'All cache bins cleared.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cleared&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'No cache bins were cleared.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Partially cleared: %d of %d cache bins failed (%s).'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bins&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$failed&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="mi"&gt;1&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;A few behavior changes fall out of this that are worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One failing bin no longer aborts the rest.&lt;/strong&gt; Each bin is attempted independently, and the exit status reflects total success, total failure, or partial failure — never a blanket "All cache bins cleared." when that isn't true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--bin&lt;/code&gt; naming an unconfigured bin is now a reported failure, not a false success.&lt;/strong&gt; &lt;code&gt;clearOne()&lt;/code&gt; checks &lt;code&gt;getConfiguredBins()&lt;/code&gt; first and exits 1 with "nothing to clear" instead of quietly clearing an empty fallback backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--bin&lt;/code&gt;/&lt;code&gt;--tags&lt;/code&gt; option shapes are unchanged.&lt;/strong&gt; This was a bin-inventory and reporting fix, not a CLI interface change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix doesn't claim this ever caused a production stale-read incident. &lt;code&gt;mcp_read&lt;/code&gt; is namespaced by a runtime epoch fingerprint, so prior-epoch entries were already unreadable regardless of &lt;code&gt;cache:clear&lt;/code&gt;. What it fixes is narrower and still worth fixing: the command couldn't run, and once it could, its success message wasn't describing reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying It
&lt;/h2&gt;

&lt;p&gt;The existing test suite mocked a factory and asserted &lt;code&gt;deleteAll()&lt;/code&gt; was called &lt;strong&gt;four times&lt;/strong&gt;. That assertion holds regardless of which four bin names were actually used, so it couldn't have caught the mismatch. The new tests assert against real backend state through the actual registered command:&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;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clearsAllConfiguredBins&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only the bins a &lt;code&gt;CacheConfiguration&lt;/code&gt; actually registers get cleared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;noConfiguredBinsReportsNothingToClear&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An empty configuration reports "No cache bins are configured," never a false success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;explicitUnconfiguredBinIsNotReportedAsCleared&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--bin nonexistent&lt;/code&gt; exits 1 with "nothing to clear," never "cleared"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backendThatThrowsIsReportedAsFailureWithoutAbortingOtherBins&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One bin throwing still lets the others clear, with a truthful partial-failure message and exit code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allBinsThrowingIsTotalFailure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every bin failing reports "No cache bins were cleared," not "All cache bins cleared."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of the discriminating cases drive the real registered &lt;code&gt;cache:clear&lt;/code&gt; command through the production &lt;code&gt;KernelHandlerContainer&lt;/code&gt;, with a real &lt;code&gt;CacheFactory&lt;/code&gt;/&lt;code&gt;CacheConfiguration&lt;/code&gt; and a &lt;code&gt;DatabaseBackend&lt;/code&gt; over SQLite — checking actual table state before and after, not just stdout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The General Lesson
&lt;/h2&gt;

&lt;p&gt;A command that hardcodes "the list of things to process" instead of asking the configuration what it actually registers will drift the first time someone adds or renames a bin elsewhere in the codebase. Drift alone isn't the scary part. What's scary is that &lt;code&gt;$factory-&amp;gt;get($name)&lt;/code&gt; doesn't fail for an unconfigured name — it hands back an empty fallback backend, so the command keeps reporting success on names that no longer mean anything. A test suite that mocks &lt;code&gt;deleteAll()&lt;/code&gt; and counts calls can't tell the difference: it passes against the correct list of bins and the wrong one alike. If "cleared" can be true for a bin that was never real, the tests have to check actual backend state, not call counts. That check is worth more than removing &lt;code&gt;DEFAULT_BINS&lt;/code&gt; itself.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>cli</category>
      <category>caching</category>
    </item>
    <item>
      <title>A loopback-only proxy for prototyping northway's feed reader</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:21:47 +0000</pubDate>
      <link>https://dev.to/jonesrussell/a-loopback-only-proxy-for-prototyping-northways-feed-reader-10ni</link>
      <guid>https://dev.to/jonesrussell/a-loopback-only-proxy-for-prototyping-northways-feed-reader-10ni</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jonesrussell/northway" rel="noopener noreferrer"&gt;northway&lt;/a&gt; is a Go service that turns approved sources into small, ranked, source-backed news feeds for AI agents, deployed Pi-first. Before committing that UX to the single-process Go build, I prototyped it in the browser first — a plain HTML/CSS/JS reader backed by a small Node.js proxy. What follows is why that prototype needed its own proxy, and the checks that keep it from becoming anything more than a local UX reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a proxy, and why disposable
&lt;/h2&gt;

&lt;p&gt;The prototype had one job: settle the reader's interaction design before any of it went into the Go service. That meant nailing down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Five feed tabs — &lt;strong&gt;Mixed&lt;/strong&gt;, &lt;strong&gt;Development&lt;/strong&gt;, &lt;strong&gt;Entertainment&lt;/strong&gt;, &lt;strong&gt;Canada&lt;/strong&gt;, &lt;strong&gt;World&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A compact dark layout&lt;/li&gt;
&lt;li&gt;Honest handling of empty or failed results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Doing that in a browser means calling northway's real API from client-side code, and that creates an immediate problem: the API key can't go anywhere client-side JavaScript can read it.&lt;/p&gt;

&lt;p&gt;The fix is a small &lt;code&gt;node:http&lt;/code&gt; server (&lt;code&gt;prototype/server.mjs&lt;/code&gt;) that serves the static files and exposes one endpoint, &lt;code&gt;/api/news&lt;/code&gt;, which holds the key and forwards requests upstream:&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NORTHWAY_API_KEY&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 javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;northwayURL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1/feed-queries`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&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;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&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="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;feed_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selected&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="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;technologies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;max_age_hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxAgeHours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser only ever talks to &lt;code&gt;/api/news&lt;/code&gt; on the same origin. It never sees the key, the upstream URL, or the feed ID mapping — those live entirely on the proxy side. The &lt;code&gt;Idempotency-Key&lt;/code&gt; and a 10-second &lt;code&gt;AbortSignal.timeout&lt;/code&gt; guard against duplicate or hung upstream calls, which matters when every query is a paid AI-provider request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refuse to bind anywhere but loopback
&lt;/h2&gt;

&lt;p&gt;A proxy that holds a live API key is a liability the moment it's reachable from anything but the machine running it. The server checks its own bind address before it does anything else:&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;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HOST&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;127.0.0.1&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;localhost&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;::1&lt;/span&gt;&lt;span class="dl"&gt;"&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;host&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HOST must be a loopback address; this prototype cannot be exposed.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no flag to override this — the only way past the check is to not pass a non-loopback &lt;code&gt;HOST&lt;/code&gt; in the first place. The README spells out the same constraint in plain language: keep it bound to loopback, don't put it on a LAN, don't expose it to the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't trust requests just because they're local
&lt;/h2&gt;

&lt;p&gt;Loopback-only isn't a substitute for validating what shows up on &lt;code&gt;/api/news&lt;/code&gt;. The handler rejects anything that doesn't look like the reader's own frontend before routing even happens:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Rejects when&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;Host header&lt;/td&gt;
&lt;td&gt;Doesn't match &lt;code&gt;127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;, &lt;code&gt;localhost:&amp;lt;port&amp;gt;&lt;/code&gt;, or &lt;code&gt;[::1]:&amp;lt;port&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content-Type&lt;/td&gt;
&lt;td&gt;Anything other than &lt;code&gt;application/json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;415&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sec-Fetch-Site&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Present and not &lt;code&gt;same-origin&lt;/code&gt; (blocks other tabs and pages)&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body size&lt;/td&gt;
&lt;td&gt;Over &lt;strong&gt;16 KB&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Aborted, never buffered&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every response also carries a restrictive &lt;code&gt;Content-Security-Policy&lt;/code&gt; (&lt;code&gt;default-src 'self'&lt;/code&gt;, locked-down &lt;code&gt;script-src&lt;/code&gt;/&lt;code&gt;style-src&lt;/code&gt;, no inline scripts or styles), &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt;, and &lt;code&gt;Referrer-Policy: no-referrer&lt;/code&gt;. None of it is exotic, but skipping any row in that table turns "only my machine can reach this" into "anything on my machine can reach this."&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the last good feed on failure
&lt;/h2&gt;

&lt;p&gt;On the client side, a failed refresh shouldn't blank out a working feed. &lt;code&gt;app.js&lt;/code&gt; tracks whether a snapshot has ever loaded successfully and falls back to it on error instead of clearing the screen:&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&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;requestNumber&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;activeRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="s2"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;activeFeed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;displayedFeed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;selectFeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;displayedFeed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;briefingHeading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;feedLabels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;displayedFeed&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;briefingMeta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasSnapshot&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Refresh failed · showing last available &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;feedLabels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;displayedFeed&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; feed`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Service unavailable&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;activeRequest&lt;/code&gt; counter also discards any response that isn't from the most recent fetch, so clicking between tabs quickly can't let a slow, stale response overwrite a newer one. When a feed genuinely comes back empty, the reader says so directly ("No current stories matched this feed") rather than padding the list. That's the same rule spelled out in &lt;code&gt;app.js&lt;/code&gt; itself: the empty result is preserved rather than padded.&lt;/p&gt;

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

&lt;p&gt;This prototype has clear limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It doesn't deploy northway&lt;/li&gt;
&lt;li&gt;It doesn't poll unattended&lt;/li&gt;
&lt;li&gt;It isn't the Pi runtime — it's explicitly excluded from the production Go image&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Its only job was to prove out the interaction design against real, live snapshots (all five feeds, desktop and mobile), so the accepted UX could guide a single-process Go implementation instead of being designed twice.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>northway</category>
      <category>node</category>
      <category>security</category>
      <category>prototyping</category>
    </item>
    <item>
      <title>Fixing a schema-mutating access check in Waaseyaa's taxonomy package</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:40:38 +0000</pubDate>
      <link>https://dev.to/jonesrussell/fixing-a-schema-mutating-access-check-in-waaseyaas-taxonomy-package-e3c</link>
      <guid>https://dev.to/jonesrussell/fixing-a-schema-mutating-access-check-in-waaseyaas-taxonomy-package-e3c</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s &lt;code&gt;packages/taxonomy&lt;/code&gt; package manages vocabularies and terms, with a foreign key tying every term row back to the vocabulary it belongs to. Two places in that package called the same "make sure this foreign key exists" helper unconditionally, and one of them ran on every delete-access check against a vocabulary — meaning ordinary request traffic could issue an &lt;code&gt;ALTER TABLE&lt;/code&gt; under load. Here's the bug, the fix, and why "no DDL on the request path" needs to be a &lt;em&gt;contract&lt;/em&gt;, not just a habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug: DDL Behind an Access Check
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TaxonomyServiceProvider::boot()&lt;/code&gt; and &lt;code&gt;VocabularyAccessPolicy::access()&lt;/code&gt; both called &lt;code&gt;VocabularyReferenceConstraint::ensure()&lt;/code&gt; unconditionally. &lt;code&gt;ensure()&lt;/code&gt; issues DDL: it adds the &lt;code&gt;taxonomy_term&lt;/code&gt; → &lt;code&gt;taxonomy_vocabulary&lt;/code&gt; foreign key if it's missing.&lt;/p&gt;

&lt;p&gt;Calling that from &lt;code&gt;boot()&lt;/code&gt; is already risky — a production deployment whose schema hadn't finished a coordinated sync yet could have a request trigger the &lt;code&gt;ALTER TABLE&lt;/code&gt; under live traffic. But the call inside &lt;code&gt;VocabularyAccessPolicy::access()&lt;/code&gt; was worse, because it wasn't bounded to boot at all. It ran on &lt;em&gt;every&lt;/em&gt; delete-access check against a vocabulary, for the lifetime of the process:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EntityInterface&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AccountInterface&lt;/span&gt; &lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;AccessResultInterface&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="nv"&gt;$operation&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'delete'&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="nc"&gt;AccessResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;neutral&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VocabularyReferenceConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ensure&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityTypeManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'taxonomy_term'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'vid'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
        &lt;span class="n"&gt;limit&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="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An access policy's job is to answer "is this delete allowed," not to mutate the schema on the way to answering. The framework had already closed this exact class of defect once, in &lt;code&gt;AttachmentServiceProvider&lt;/code&gt; (issue &lt;strong&gt;#2478&lt;/strong&gt;) — this was the same mistake resurfacing in a package that hadn't been through that fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: DDL Belongs Only to Coordinated Schema Sync
&lt;/h2&gt;

&lt;p&gt;Both unconditional &lt;code&gt;ensure()&lt;/code&gt; calls were removed (issue &lt;strong&gt;#2761&lt;/strong&gt;). &lt;code&gt;VocabularyAccessPolicy&lt;/code&gt; no longer accepts a database at all — its existing &lt;code&gt;findBy()&lt;/code&gt; check (does any term still reference this vocabulary?) is the real enforcement. The foreign key becomes a storage-level backstop, installed exclusively by coordinated schema sync (&lt;code&gt;db:init&lt;/code&gt;, &lt;code&gt;schema:sync&lt;/code&gt;):&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="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VocabularyAccessPolicy&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AccessPolicyInterface&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EntityTypeManagerInterface&lt;/span&gt; &lt;span class="nv"&gt;$entityTypeManager&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EntityInterface&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AccountInterface&lt;/span&gt; &lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;AccessResultInterface&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="nv"&gt;$operation&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'delete'&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="nc"&gt;AccessResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;neutral&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityTypeManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'taxonomy_term'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'vid'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
            &lt;span class="n"&gt;limit&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="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No new migration was needed for the sync path — the entity type already declares its &lt;code&gt;_foreignKeys&lt;/code&gt;, and &lt;code&gt;SqlSchemaHandler&lt;/code&gt;'s generic &lt;code&gt;ensureDeclaredForeignKeys()&lt;/code&gt; (used by every entity type with declared foreign keys) picks it up automatically once the unconditional call in &lt;code&gt;boot()&lt;/code&gt; is gone.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TaxonomyServiceProvider::boot()&lt;/code&gt; keeps a &lt;strong&gt;local/development-only&lt;/strong&gt; convenience materialization, gated the same way &lt;code&gt;AttachmentServiceProvider&lt;/code&gt; gates its own schema convenience:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... event listener wiring unchanged ...&lt;/span&gt;

    &lt;span class="nv"&gt;$database&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolveOptional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DatabaseInterface&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&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="nv"&gt;$database&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;DatabaseInterface&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allowsConvenientSchemaMaterialization&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VocabularyReferenceConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$database&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ensure&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;allowsConvenientSchemaMaterialization&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RuntimePolicy&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isDevelopment&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;Production and staging boot no longer touch the foreign key at all, and neither does any request that reaches the access policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the "Silent Skip" Gap
&lt;/h2&gt;

&lt;p&gt;Removing the DDL calls raises an obvious question: what happens in production if the foreign key is genuinely missing — say, a deploy where schema sync hasn't run yet? Silently skipping the constraint would be its own bug. The framework already had a no-DDL runtime contract, &lt;code&gt;SqlSchemaHandler::assertRuntimeSchema()&lt;/code&gt;, which every &lt;code&gt;getRepository()&lt;/code&gt; resolution runs and which already asserted declared unique keys were present. It now asserts declared foreign keys too:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;assertDeclaredForeignKeysReady&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityType&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;EntityTypeForeignKeyDefinitionInterface&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&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="nv"&gt;$schema&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ForeignKeySchemaInterface&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityType&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStorageForeignKeys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$definition&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="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tableExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$definition&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'table'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignKeyExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$definition&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'[S1-DB106] Required runtime schema is unavailable for table "%s"; missing: foreign key %s. Apply migration "waaseyaa schema:sync" through the schema coordinator.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$definition&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That required a new read-only capability: &lt;code&gt;ForeignKeySchemaInterface&lt;/code&gt; already had &lt;code&gt;addForeignKey()&lt;/code&gt; (DDL) but nothing to check whether a key already exists without mutating anything. A &lt;code&gt;foreignKeyExists()&lt;/code&gt; companion method was added to the interface — contract-only, since the concrete &lt;code&gt;DBALSchema&lt;/code&gt; implementation already had the underlying check available.&lt;/p&gt;

&lt;p&gt;A declared key whose &lt;em&gt;referenced&lt;/em&gt; table doesn't exist yet is skipped rather than treated as an error, because entity type registration order isn't guaranteed (&lt;code&gt;taxonomy_term&lt;/code&gt; registers before &lt;code&gt;taxonomy_vocabulary&lt;/code&gt;) — that table's own readiness is a separate concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying It
&lt;/h2&gt;

&lt;p&gt;The risk with a "read-only" schema check is that it quietly isn't, on some database platform. That got its own test, proving &lt;code&gt;foreignKeyExists()&lt;/code&gt; and &lt;code&gt;tableExists()&lt;/code&gt; never call &lt;code&gt;executeStatement()&lt;/code&gt; — using mocked DBAL connections that report MySQL, PostgreSQL, and SQLite platforms, the same technique the existing DDL-generation tests already used for portability:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;foreignKeyExistsIsReadOnlyOnRealSqlite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Real SQLite in-memory database: false before creating the key, true after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;foreignKeyExistsNeverIssuesDdlAndReportsAbsence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MySQL, PostgreSQL, SQLite: reports &lt;code&gt;false&lt;/code&gt;, never calls &lt;code&gt;executeStatement()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;foreignKeyExistsNeverIssuesDdlAndReportsPresence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same three platforms: reports &lt;code&gt;true&lt;/code&gt;, never calls &lt;code&gt;executeStatement()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tableExistsNeverIssuesDdl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same guarantee for the existing &lt;code&gt;tableExists()&lt;/code&gt; check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the &lt;code&gt;assertRuntimeSchema()&lt;/code&gt; side, new unit tests cover a table materialized &lt;em&gt;without&lt;/em&gt; its declared foreign key, proving the assertion throws with the &lt;code&gt;[S1-DB106]&lt;/code&gt; message instead of silently passing. There's no live MySQL or PostgreSQL server in this environment, so the mocked-connection tests are what stand in for cross-platform proof; real fresh-install and concurrent-upgrade behavior against live servers is explicitly called out as deferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  The General Lesson
&lt;/h2&gt;

&lt;p&gt;"No DDL on the request path" sounds obvious once you say it. It's easy to violate by accident anyway, because DDL doesn't announce itself — &lt;code&gt;ensure()&lt;/code&gt; reads like a harmless idempotent helper, not a schema mutation. The tell was where the call lived. A &lt;code&gt;boot()&lt;/code&gt; method is at least bounded to process startup; an access-check method runs on every matching request for as long as the process is up. So anywhere your code "makes sure X exists" as a side effect of answering an unrelated question, ask whether that assurance is DDL — and if it is, ask who's actually allowed to run it. The fix here wasn't just deleting the two bad calls. It was making the runtime &lt;em&gt;assert&lt;/em&gt; the schema is already correct and fail loudly if it isn't, so removing the convenience path couldn't quietly turn into a silent skip.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>database</category>
      <category>schema</category>
    </item>
    <item>
      <title>Stop walking the filesystem in your CI gates — ask git instead</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 09 Sep 2026 14:23:54 +0000</pubDate>
      <link>https://dev.to/jonesrussell/stop-walking-the-filesystem-in-your-ci-gates-ask-git-instead-4ibd</link>
      <guid>https://dev.to/jonesrussell/stop-walking-the-filesystem-in-your-ci-gates-ask-git-instead-4ibd</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; has a family of "S1 roster" gate scripts that scan the tree for specific governed patterns — raw SQLite construction, schema-boundary crossings — and compare what they find against a recorded, reviewed roster. A related &lt;code&gt;check-access-hardening&lt;/code&gt; gate scans for missing authorization guards. All of them needed the same thing first: a list of files to scan. Getting that list right turned out to be harder than it sounds, and the eventual fix replaced a hand-maintained exclusion list with one line: ask git.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with walking the filesystem
&lt;/h2&gt;

&lt;p&gt;The original scanners enumerated files with a &lt;code&gt;RecursiveDirectoryIterator&lt;/code&gt; and then subtracted paths that matched a denylist — &lt;code&gt;vendor/&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;, and so on. That denylist was a stand-in for "repository content," rebuilt by hand instead of asked from the one tool that actually knows the answer. Every time a new kind of untracked tree showed up, the denylist needed another entry.&lt;/p&gt;

&lt;p&gt;That's exactly what happened. Nested git worktrees under &lt;code&gt;.worktrees/&lt;/code&gt; or &lt;code&gt;.claude/worktrees/&lt;/code&gt;, and a populated &lt;code&gt;packages/&amp;lt;pkg&amp;gt;/vendor/&lt;/code&gt;, weren't on the list. On the primary checkout, that gap produced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;24,780 phantom findings&lt;/strong&gt; across three gates — 18,844, 5,932, and 4 — as nested worktree build caches and vendored trees read as governed pattern matches.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--write-construction-roster&lt;/code&gt; would have &lt;strong&gt;committed &lt;code&gt;.worktrees/...&lt;/code&gt; paths into the tracked roster&lt;/strong&gt;, permanently baking a developer-local artifact into shared state.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check-access-hardening&lt;/code&gt; had &lt;strong&gt;no exclusion at all&lt;/strong&gt; beyond requiring &lt;code&gt;/src/&lt;/code&gt; in the path, so a vendored library shipping its own &lt;code&gt;src/&lt;/code&gt; directory was scanned as if it were first-party code — the source of that gate's 4.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The root cause wasn't any single missing entry — it was structural. A walk-minus-denylist re-creates the repository boundary by hand, and a denylist only ever grows in response to the last thing that broke it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: &lt;code&gt;git ls-files&lt;/code&gt;, not a walk
&lt;/h2&gt;

&lt;p&gt;Both scanners now go through one shared helper, &lt;code&gt;bin/lib/repository-files.php&lt;/code&gt;:&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;repositoryFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$pathspecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$normalizedRoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'\\'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$arguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ls-files'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-z'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--cached'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--others'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--exclude-standard'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$pathspecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&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="nv"&gt;$pathspecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$pathspec&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$pathspec&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pathspecs&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$arguments&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'--'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;array_push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$pathspecs&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="nv"&gt;$exitCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$listing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;repositoryGit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$normalizedRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$arguments&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="nv"&gt;$exitCode&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'repository-files: git could not enumerate repository files under %s (exit %d)%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$normalizedRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$exitCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;': '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$error&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="nv"&gt;$files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$listing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$relative&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="nv"&gt;$relative&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$normalizedRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$relative&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$relative&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$paths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$files&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$paths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SORT_STRING&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$paths&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;git ls-files --cached --others --exclude-standard&lt;/code&gt; returns tracked files plus untracked files git would add, honoring &lt;code&gt;.gitignore&lt;/code&gt; along the way. That single command replaces the denylist entirely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt; becomes the exclusion boundary by construction.&lt;/strong&gt; Nothing to maintain, nothing to forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git never descends into another repository's work tree&lt;/strong&gt;, so a nested worktree is invisible whether or not it happens to be ignored. No &lt;code&gt;.worktrees/&lt;/code&gt; special case needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A root git can't enumerate fails closed&lt;/strong&gt; — &lt;code&gt;repositoryFiles()&lt;/code&gt; throws instead of silently falling back to a filesystem walk. If the gate can't trust its own file list, it doesn't run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The second bug: git answered the wrong repository's question
&lt;/h2&gt;

&lt;p&gt;Shipping the git-native scanner exposed a subtler problem. A pre-push hook run from a linked worktree exports &lt;code&gt;GIT_DIR=&amp;lt;main&amp;gt;/.git/worktrees/&amp;lt;name&amp;gt;&lt;/code&gt; into the environment. The scanner's &lt;code&gt;git -C $root ls-files&lt;/code&gt; inherited that variable — and &lt;code&gt;GIT_DIR&lt;/code&gt; overrides &lt;code&gt;-C&lt;/code&gt; entirely, so the command enumerated the hook's repository instead of the scan root.&lt;/p&gt;

&lt;p&gt;Worse, the access-hardening gate's self-test calls &lt;code&gt;git init&lt;/code&gt; on a fixture directory to set up test data. With &lt;code&gt;GIT_DIR&lt;/code&gt; still pointing at the developer's own worktree gitdir, that &lt;code&gt;git init&lt;/code&gt; "reinitialized" the developer's real repository as bare (&lt;code&gt;core.bare=true&lt;/code&gt; in the shared &lt;code&gt;.git/config&lt;/code&gt;), breaking every git command in the main checkout. Any contributor with project hooks installed who pushed from &lt;code&gt;.claude/worktrees/*&lt;/code&gt; or &lt;code&gt;.worktrees/*&lt;/code&gt; could hit this.&lt;/p&gt;

&lt;p&gt;The fix scrubs every environment variable git itself uses to select a repository before running a child process:&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="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;REPOSITORY_LOCAL_GIT_ENVIRONMENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_ALTERNATE_OBJECT_DIRECTORIES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_COMMON_DIR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_CONFIG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_CONFIG_COUNT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_CONFIG_PARAMETERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_DIR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_GRAFT_FILE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_IMPLICIT_WORK_TREE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_INDEX_FILE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_NO_REPLACE_OBJECTS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_OBJECT_DIRECTORY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_PREFIX'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_REPLACE_REF_BASE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_SHALLOW_FILE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'GIT_WORK_TREE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;repositoryGitEnvironment&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$environment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;REPOSITORY_LOCAL_GIT_ENVIRONMENT&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$environment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$name&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="nv"&gt;$environment&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 is git's own &lt;code&gt;local_repo_env&lt;/code&gt; list — the set git clears before running a command against another repository. With it scrubbed, &lt;code&gt;-C $root&lt;/code&gt; is the only thing left that selects which repository a child git process operates on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed, and what didn't
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RecursiveDirectoryIterator&lt;/code&gt; + hand-maintained denylist&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git ls-files -z --cached --others --exclude-standard&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New untracked-tree kind → new denylist entry, reactively&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.gitignore&lt;/code&gt; is the boundary; nothing to add&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access-hardening gate: no exclusion beyond an &lt;code&gt;/src/&lt;/code&gt; path check&lt;/td&gt;
&lt;td&gt;Same git-backed enumeration as the roster scanners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git child processes inherited hook environment&lt;/td&gt;
&lt;td&gt;Repository-selecting env vars scrubbed on every git call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Silent fallback risk if enumeration failed&lt;/td&gt;
&lt;td&gt;Fails closed with a clear error; no fallback walk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Regenerating all four &lt;code&gt;support/s1-*-roster.json&lt;/code&gt; files against the new scanner produced a &lt;strong&gt;zero diff&lt;/strong&gt; — the fix removed phantom candidates without touching a single legitimate one. All three gates then passed read-only against a checkout holding sixteen nested worktrees and a populated &lt;code&gt;packages/ai-agent/vendor/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;If you're writing a script that scans "the repository" — a linter, a gate, an audit tool — resist the urge to walk the filesystem and subtract what you don't want. A denylist encodes everything you've already been burned by; it says nothing about what you haven't hit yet. Git already knows the answer to "what files are in this repository," &lt;code&gt;.gitignore&lt;/code&gt; and all, and &lt;code&gt;git ls-files&lt;/code&gt; will give it to you in one call.&lt;/p&gt;

&lt;p&gt;And if that script ever shells out to git from inside a hook or a nested worktree, don't assume the environment is clean. Hooks can carry repository-selecting variables that override the very &lt;code&gt;-C&lt;/code&gt; flag you're relying on to scope the command — scrub them, or you may find &lt;code&gt;git init&lt;/code&gt; pointed at the wrong repository entirely.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>git</category>
      <category>ci</category>
    </item>
    <item>
      <title>Stop letting a failing audit logger crash requests that already succeeded</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 09 Sep 2026 14:23:51 +0000</pubDate>
      <link>https://dev.to/jonesrussell/stop-letting-a-failing-audit-logger-crash-requests-that-already-succeeded-191f</link>
      <guid>https://dev.to/jonesrussell/stop-letting-a-failing-audit-logger-crash-requests-that-already-succeeded-191f</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s MCP endpoint writes a durable audit record around every tool call: a reservation before the tool runs, a terminal record after. That ledger is deliberately fail-closed — if it can't record a write attempt, the write is refused. But the code path that &lt;em&gt;reports&lt;/em&gt; a ledger failure to the application logger had the opposite problem: it was fail-open in the worst way. If the logger itself threw, the throw escaped uncaught, after the real outcome had already been decided. Here's the bug, the fix, and the worse variant of the same gap one layer up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: a logger call with no safety net
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AuditedToolDispatcher::finalizeQuietly()&lt;/code&gt; runs after a tool has already executed. It tries to write the terminal audit record, and if that write fails, it reports the failure — but the report itself wasn't guarded:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;finalizeQuietly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;StrictAuditReceipt&lt;/span&gt; &lt;span class="nv"&gt;$receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AuditStage&lt;/span&gt; &lt;span class="nv"&gt;$stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$toolName&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;metadata&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="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reportAuditFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'agent_tool.audit_finalize_failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'correlation_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'surface'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'receipt_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$receipt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tool'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$toolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'stage'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$stage&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="s1"&gt;'exception'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'note'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Dangling reservation: outcome unknown, side effect may have committed.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the fix, &lt;code&gt;reportAuditFailure()&lt;/code&gt; called &lt;code&gt;$this-&amp;gt;logger?-&amp;gt;critical(...)&lt;/code&gt; directly. If the logger itself threw — a broken handler, a full disk, a downed log shipper — that &lt;code&gt;critical()&lt;/code&gt; call escaped &lt;code&gt;dispatch()&lt;/code&gt; as an uncaught exception, even though the tool had &lt;strong&gt;already run and its side effect had already committed&lt;/strong&gt;. The caller would see a crash instead of the completed result, and a naive retry would repeat an action that already happened.&lt;/p&gt;

&lt;p&gt;Two sibling call sites in the same class — &lt;code&gt;agent_tool.audit_reservation_failed&lt;/code&gt; and &lt;code&gt;agent_tool.audit_terminal_record_failed&lt;/code&gt; — had the identical unguarded shape. All three report an outcome that is already final; none of them should be able to overturn it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: swallow the logger's own failures
&lt;/h2&gt;

&lt;p&gt;The fix wraps every one of those report sites in a private helper that catches and discards:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;reportAuditFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&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="nc"&gt;\Throwable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Deliberately empty — see method doc.&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 empty catch is intentional, not an oversight. The method's docblock spells out why the failure isn't re-logged: there's no framework &lt;code&gt;LoggerInterface&lt;/code&gt; convention for logging a logging failure, recursing into a broken sink risks looping, and &lt;code&gt;error_log()&lt;/code&gt; is reserved for the logging infrastructure itself rather than its callers. A logging failure must never be allowed to replace a caller-visible outcome with an unrelated crash — so the safest thing the helper can do is nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same gap, one layer up — with a worse ordering problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;McpEndpoint&lt;/code&gt; (the layer above &lt;code&gt;AuditedToolDispatcher&lt;/code&gt;) dispatches &lt;code&gt;tools/call&lt;/code&gt; directly, so nothing downstream catches for it. It carried the identical unguarded logger call at its own post-execution finalize path — meaning a throwing logger there became an HTTP transport failure for a write that had &lt;em&gt;already committed&lt;/em&gt;. &lt;code&gt;McpEndpoint&lt;/code&gt; got its own &lt;code&gt;reportAuditFailure()&lt;/code&gt;, deliberately duplicated rather than shared: &lt;code&gt;mcp&lt;/code&gt; is Layer 6 and &lt;code&gt;ai-tools&lt;/code&gt; is Layer 5 in Waaseyaa's layering, and a containment detail doesn't justify a new cross-package public symbol.&lt;/p&gt;

&lt;p&gt;A follow-up sweep found &lt;strong&gt;six more unguarded sites&lt;/strong&gt; in &lt;code&gt;McpEndpoint&lt;/code&gt;, all at &lt;code&gt;error()&lt;/code&gt; level rather than &lt;code&gt;critical()&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the rate-limiter durability check&lt;/li&gt;
&lt;li&gt;the protocol dispatch path&lt;/li&gt;
&lt;li&gt;the resource dispatch path&lt;/li&gt;
&lt;li&gt;the malformed-response branches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those got a sibling helper:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;reportOperationalFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&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="nc"&gt;\Throwable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Deliberately empty — see method doc.&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 family turned out to have a worse failure mode than the audit-critical one, even though it guards no committed side effect. Four of its six call sites report &lt;strong&gt;before&lt;/strong&gt; their own &lt;code&gt;auditTerminal()&lt;/code&gt; call:&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="k"&gt;try&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;=&lt;/span&gt; &lt;span class="nv"&gt;$handler&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="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reportOperationalFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mcp.protocol_execution_failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'correlation_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'method'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'exception'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;auditTerminal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;AuditStage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;ExecutionFailed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$actorUid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$method&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="s1"&gt;'reason'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'protocol_handler_threw'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'exception'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unguarded throw at &lt;code&gt;reportOperationalFailure()&lt;/code&gt; here wouldn't just crash the request — it would also skip the &lt;code&gt;auditTerminal()&lt;/code&gt; call three lines below, silently erasing the terminal audit record for a request that already failed. That's a strictly worse outcome than the &lt;code&gt;reportAuditFailure()&lt;/code&gt; gap, where the reported outcome is always already decided &lt;em&gt;and&lt;/em&gt; recorded.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Reports relative to outcome&lt;/th&gt;
&lt;th&gt;Guard added&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AuditedToolDispatcher&lt;/code&gt; reservation/terminal/finalize&lt;/td&gt;
&lt;td&gt;&lt;code&gt;critical&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After outcome is final&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reportAuditFailure()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;McpEndpoint&lt;/code&gt; reservation/finalize/approval paths&lt;/td&gt;
&lt;td&gt;&lt;code&gt;critical&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After outcome is final&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reportAuditFailure()&lt;/code&gt; (duplicated per layer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;McpEndpoint&lt;/code&gt; rate limiter, protocol/resource dispatch, malformed response&lt;/td&gt;
&lt;td&gt;&lt;code&gt;error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4 of 6 &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;auditTerminal()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reportOperationalFailure()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How it was tested
&lt;/h2&gt;

&lt;p&gt;Both fixes came with a &lt;code&gt;ThrowingLogger&lt;/code&gt; test double driven through the real request boundary — &lt;code&gt;dispatch()&lt;/code&gt; for the ai-tools side, &lt;code&gt;serve()&lt;/code&gt; for MCP — not by calling the private helpers directly. Each test followed the same discipline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Red first.&lt;/strong&gt; Verified against the unmodified code, failing at the exact line the throw would have escaped from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Green after.&lt;/strong&gt; Re-run once the guard was added.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two assertions per &lt;code&gt;error()&lt;/code&gt;-level site.&lt;/strong&gt; For the four sites that precede their own &lt;code&gt;auditTerminal()&lt;/code&gt; call, each test checks that the request still completes &lt;em&gt;and&lt;/em&gt; that the terminal audit record still gets written despite the broken logger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reflection for the unreachable branches.&lt;/strong&gt; Two malformed-response branches can't be hit through any conforming handler, so those are driven directly on the private methods via reflection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;Audit and observability code that runs &lt;em&gt;after&lt;/em&gt; a decision has already been made must never be allowed to overturn that decision. If a logging call inside a catch block can throw, and nothing catches that throw, your logger has quietly become a second failure mode for every code path that touches it — one your test suite may never exercise, because tests almost always run against a logger that works. The fix isn't clever: catch &lt;code&gt;\Throwable&lt;/code&gt; around the logging call itself, swallow it, and don't try to log the failure to log. But you have to go looking for every site with that shape, because a refactor can reintroduce an unguarded one without any test noticing — unless you specifically test with a logger that fails.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>logging</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Fixing a silent truncation bug in Waaseyaa's StreamHttpClient</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:28:25 +0000</pubDate>
      <link>https://dev.to/jonesrussell/fixing-a-silent-truncation-bug-in-waaseyaas-streamhttpclient-4hnl</link>
      <guid>https://dev.to/jonesrussell/fixing-a-silent-truncation-bug-in-waaseyaas-streamhttpclient-4hnl</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;'s &lt;code&gt;packages/http-client&lt;/code&gt; package wraps PHP streams behind a small &lt;code&gt;HttpClientInterface&lt;/code&gt;, with &lt;code&gt;StreamHttpClient&lt;/code&gt; as the production implementation. It caps how many bytes of a response body it will read, so a runaway or hostile endpoint can't exhaust worker memory. That cap had a bug: hitting it didn't fail the request. It silently handed back a truncated body as a successful &lt;code&gt;HttpResponse&lt;/code&gt;. What follows walks through the bug, the fix, and the broader lesson about bounding a read without lying about what you actually read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug: A Byte Ceiling That Didn't Fail
&lt;/h2&gt;

&lt;p&gt;The original &lt;code&gt;fetch()&lt;/code&gt; method read the body with a single call:&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;// m4: cap the body so a runaway/hostile endpoint can't OOM the worker.&lt;/span&gt;
&lt;span class="nv"&gt;$responseBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;stream_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;maxResponseBytes&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="nv"&gt;$responseBody&lt;/span&gt; &lt;span class="o"&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;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;transportFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&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="nv"&gt;$responseBody&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;stream_get_contents()&lt;/code&gt; with a length argument stops reading once it hits that many bytes and returns whatever it has. It doesn't return &lt;code&gt;false&lt;/code&gt; just because the stream had more data waiting — an endpoint returning a body larger than &lt;code&gt;maxResponseBytes&lt;/code&gt; produced a &lt;strong&gt;200 OK&lt;/strong&gt; with a truncated prefix instead of an error. The caller had no way to know the body was incomplete unless it happened to check the length itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Fail Closed, Not Silent
&lt;/h2&gt;

&lt;p&gt;The fix, tracked as &lt;a href="https://github.com/waaseyaa/framework/blob/main/docs/change-records/FW-HTTP-STREAM-TRUNCATION-01.md" rel="noopener noreferrer"&gt;FW-HTTP-STREAM-TRUNCATION-01&lt;/a&gt;, replaces the single &lt;code&gt;stream_get_contents()&lt;/code&gt; call with a loop that reads in chunks and checks completeness against what the response actually declared:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Over-limit bodies throw.&lt;/strong&gt; If the body would exceed &lt;code&gt;maxResponseBytes&lt;/code&gt;, the client throws a typed &lt;code&gt;HttpRequestException&lt;/code&gt; instead of returning a partial string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Content-Length&lt;/code&gt; mismatches throw.&lt;/strong&gt; If the connection closes (or times out) before the declared length is reached, that's a failure, not a short success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A declared length above the ceiling is rejected before reading starts&lt;/strong&gt;, so memory stays bounded even for a body the client will never accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact-limit bodies still succeed.&lt;/strong&gt; A body whose size equals &lt;code&gt;maxResponseBytes&lt;/code&gt; exactly, including chunked and connection-close bodies with no &lt;code&gt;Content-Length&lt;/code&gt; at all, is a valid, complete response.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;readCompleteBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;http_get_last_response_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parseStatusCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// HEAD and these status codes carry metadata, never a response body.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtoupper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'HEAD'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;304&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="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$contentLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;headerContentLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$headers&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="nv"&gt;$contentLength&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$contentLength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;maxResponseBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boundedBodyFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'HTTP response body exceeded the configured maximum'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;feof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;maxResponseBytes&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&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="nv"&gt;$contentLength&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$contentLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$readBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$contentLength&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="nv"&gt;$contentLength&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$remaining&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="nv"&gt;$chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;fread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;max&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="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$readBytes&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;stream_get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&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="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'timed_out'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boundedBodyFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'HTTP response body was incomplete'&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="nv"&gt;$chunk&lt;/span&gt; &lt;span class="o"&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;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;transportFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&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="nv"&gt;$chunk&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$chunk&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="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;maxResponseBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boundedBodyFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'HTTP response body exceeded the configured maximum'&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contentLength&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$contentLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boundedBodyFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'HTTP response body was incomplete'&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="nv"&gt;$body&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;Reading one extra byte beyond &lt;code&gt;maxResponseBytes&lt;/code&gt; when there's no declared &lt;code&gt;Content-Length&lt;/code&gt; is what turns "we stopped because we're at the limit" into "we detected the body actually exceeds the limit." Without that extra byte, an exact-limit body and an over-limit body look identical.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;stream_set_timeout()&lt;/code&gt; call was also added before the read loop. A connection that stalls mid-body now times out and fails closed instead of hanging or silently truncating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Framing Boundary Actually Sits
&lt;/h2&gt;

&lt;p&gt;Part of getting this right is knowing which responses have no body at all, regardless of what their headers claim:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&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;HEAD&lt;/code&gt; request&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;1xx&lt;/code&gt; status&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;204 No Content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;304 Not Modified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declared &lt;code&gt;Content-Length&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stops exactly at that length, doesn't wait for connection close&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;Content-Length&lt;/code&gt; (connection-close framing)&lt;/td&gt;
&lt;td&gt;EOF defines completion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://github.com/waaseyaa/framework/blob/main/docs/change-records/FW-HTTP-STREAM-TRUNCATION-01.md" rel="noopener noreferrer"&gt;change record&lt;/a&gt; is honest about the limits of this fix, too. PHP's HTTP stream wrapper dechunks &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt; responses and strips the header before the client ever sees the stream, so the byte ceiling still applies to the decoded body. But &lt;code&gt;StreamHttpClient&lt;/code&gt; can't independently verify a missing chunk terminator — that's a problem for PHP's stream layer, not this client. Scoping the fix to what the client can actually observe, and documenting what it can't, beats pretending it covers every framing edge case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying It
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;packages/http-client/tests/Unit/StreamHttpClientTransportTest.php&lt;/code&gt; covers the cases that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bodies below, at, and above the limit&lt;/li&gt;
&lt;li&gt;Chunked transfer&lt;/li&gt;
&lt;li&gt;Absent &lt;code&gt;Content-Length&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Mismatched &lt;code&gt;Content-Length&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A mid-body timeout&lt;/li&gt;
&lt;li&gt;An over-limit &lt;code&gt;5xx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A complete &lt;code&gt;404&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The suite also gained a small raw HTTP test server (&lt;code&gt;tests/Support/RawHttpServer.php&lt;/code&gt;), so these cases could be driven against real socket behavior instead of mocked streams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The General Lesson
&lt;/h2&gt;

&lt;p&gt;Bounding a read and detecting truncation are two different problems, and it's easy to solve only the first one. &lt;code&gt;stream_get_contents($handle, $limit)&lt;/code&gt; does exactly what it says: it reads at most &lt;code&gt;$limit&lt;/code&gt; bytes and returns them. It was never going to tell you whether that's &lt;em&gt;all&lt;/em&gt; the bytes there were. Any time you cap a read for memory safety, whether that's an HTTP body, a file, or a queue message, ask what happens at the boundary: does hitting the cap look identical to a legitimate response that happens to be exactly that size? If your code can't tell those two cases apart, it's not bounding the read — it's lying about it when the answer would be inconvenient.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>http</category>
      <category>reliability</category>
    </item>
  </channel>
</rss>
