<?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: Nasrul Hazim</title>
    <description>The latest articles on DEV Community by Nasrul Hazim (@nasrulhazim).</description>
    <link>https://dev.to/nasrulhazim</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%2F47230%2Fc062b1f5-2c98-4750-8877-6991f248b4bf.jpg</url>
      <title>DEV Community: Nasrul Hazim</title>
      <link>https://dev.to/nasrulhazim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nasrulhazim"/>
    <language>en</language>
    <item>
      <title>I turned my Claude Code sessions into a 3D office my team can watch</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:02:38 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/i-turned-my-claude-code-sessions-into-a-3d-office-my-team-can-watch-4jc8</link>
      <guid>https://dev.to/nasrulhazim/i-turned-my-claude-code-sessions-into-a-3d-office-my-team-can-watch-4jc8</guid>
      <description>&lt;p&gt;I run several Claude Code sessions at once, and each one spawns subagents in the background. From a terminal I could not tell who was doing what, who was stuck waiting on me, or what it all cost. Now I open one page and watch a 3D office instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — seven Claude Code hooks → one Python script → &lt;a href="https://github.com/emiliovos/agent-bus" rel="noopener noreferrer"&gt;Agent Bus&lt;/a&gt; for routing → &lt;a href="https://github.com/iamlukethedev/Hermes3D" rel="noopener noreferrer"&gt;Hermes3D&lt;/a&gt; for the office. Every session is a named staff member with a role, a live activity line, a kanban card and a token count. No LLM calls anywhere in the pipeline, so it costs nothing to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ty40ai624qpy7ia7gv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ty40ai624qpy7ia7gv9.png" alt="The Hermes3D office: the lead Nasrul and a Laravel dev, Syafiq, at their desks" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same office in Hermes3D's 2D pixel mode, lighter on a laptop:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvaqw2zy88cas1i8kkwau.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvaqw2zy88cas1i8kkwau.png" alt="The same office in 2D pixel mode, with the two staff in the Product team area" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the office&lt;/th&gt;
&lt;th&gt;Where it comes from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One character per session and per subagent&lt;/td&gt;
&lt;td&gt;Claude Code hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Label &lt;code&gt;Danial · Laravel Dev · Standardise listings&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A staff roster of 30 names, the subagent's role, and the task it was given&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The lead is me: &lt;code&gt;Nasrul · Product Owner&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The main session, titled with Claude's own session title&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live activity text: &lt;code&gt;Run PHPStan&lt;/code&gt;, &lt;code&gt;Edit app/Actions/...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Each tool call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;⏳ Tunggu kelulusan&lt;/code&gt; ("waiting for approval") when a permission prompt is open&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;Notification&lt;/code&gt; hook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kanban: working → needs attention → done&lt;/td&gt;
&lt;td&gt;One card per delegated task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens and model per staff member&lt;/td&gt;
&lt;td&gt;Read from each transcript&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The labels are in Malay because that is how my team talks; swap the strings and nothing else changes.&lt;/p&gt;

&lt;p&gt;Nothing here calls an LLM. It is routing and bookkeeping on top of events Claude Code already emits, so it adds no token cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;Three pieces, all on localhost: Claude Code fires hooks, Agent Bus turns them into agents, and Hermes3D draws the office.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;%%{init: {"theme":"neutral","themeVariables":{"fontSize":"18px"},"flowchart":{"nodeSpacing":30,"rankSpacing":45,"padding":10}}}%%
flowchart TD
  CC["Claude Code sessions + subagents"] --&amp;gt;|"7 hooks"| H["office_hook.py"]
  H --&amp;gt;|"events"| HUB["Agent Bus hub :4000"]
  HUB --&amp;gt; GW["Agent Bus gateway :18789"]
  GW --&amp;gt;|"gateway protocol"| ST["Hermes3D Studio :3000"]
  H --&amp;gt;|"task cards"| ST
  ST --&amp;gt; RO["Read-only proxy :3100"]
  RO --&amp;gt;|"ngrok, on demand"| TEAM["Team"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The hook writes to two places: agent events go to the hub, kanban cards go straight to Studio's task store. Hub, gateway and Studio all bind to &lt;code&gt;127.0.0.1&lt;/code&gt;; only the read-only proxy is ever tunnelled, and only while I am sharing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Hermes3D and Agent Bus, on demand
&lt;/h2&gt;

&lt;p&gt;Clone both, install, and point Hermes3D at the Agent Bus gateway. I do not want daemons running all day, so one script starts everything when I want to look and Ctrl-C stops it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/iamlukethedev/Hermes3D.git ~/Projects/hermes
git clone https://github.com/emiliovos/agent-bus.git ~/Projects/agent-bus
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/Projects/hermes &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/Projects/agent-bus &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Hermes3D's &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;HERMES3D_GATEWAY_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ws://localhost:18789
&lt;span class="nv"&gt;HERMES3D_GATEWAY_ADAPTER_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hermes
&lt;span class="nv"&gt;UPSTREAM_ALLOWLIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;localhost,127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The launcher, trimmed to what matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nv"&gt;pids&lt;/span&gt;&lt;span class="o"&gt;=()&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'kill "${pids[@]}" 2&amp;gt;/dev/null'&lt;/span&gt; EXIT INT TERM
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/Projects/agent-bus
npx tsx watch src/index.ts &amp;amp; pids+&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;                          &lt;span class="c"&gt;# hub :4000&lt;/span&gt;
&lt;span class="nv"&gt;AGENT_PRUNE_HOURS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 npx tsx watch src/gateway/index.ts &amp;amp; pids+&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="c"&gt;# gateway :18789&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/Projects/hermes
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; .next/BUILD_ID &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; npm run build
npm start &amp;amp; pids+&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;                                           &lt;span class="c"&gt;# Studio :3000&lt;/span&gt;
&lt;span class="nb"&gt;wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two choices in there are deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start the hub and gateway separately, not with &lt;code&gt;npm run dev:all&lt;/code&gt;. That also starts Agent Bus's own UI on &lt;code&gt;:3000&lt;/code&gt;, which collides with Studio.&lt;/li&gt;
&lt;li&gt;Run Studio with &lt;code&gt;npm start&lt;/code&gt;, not &lt;code&gt;npm run dev&lt;/code&gt; (see Gotchas).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I aliased it: &lt;code&gt;alias office="$HOME/.agent-bus/office.sh"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the hook that hires the staff
&lt;/h2&gt;

&lt;p&gt;One Python script (stdlib only, so it runs under whatever &lt;code&gt;PATH&lt;/code&gt; Claude Code gives hooks) handles seven hook events. Register it in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.agent-bus/hermes3d-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserPromptSubmit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.agent-bus/hermes3d-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.agent-bus/hermes3d-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Notification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.agent-bus/hermes3d-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.agent-bus/hermes3d-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SubagentStop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.agent-bus/hermes3d-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SessionEnd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.agent-bus/hermes3d-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.sh&lt;/code&gt; just &lt;code&gt;exec&lt;/code&gt;s the Python file, so I never touch &lt;code&gt;settings.json&lt;/code&gt; again when the logic changes. The hook exits immediately unless the session's &lt;code&gt;cwd&lt;/code&gt; is under a folder listed in &lt;code&gt;~/.agent-bus/roots&lt;/code&gt;, so my other Claude sessions stay out of the office. It never blocks Claude: 0.5s timeouts, every error swallowed, always exit 0. It takes about 70ms.&lt;/p&gt;

&lt;p&gt;What each hook does in the office:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;Kanban card&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lead appears&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lead: &lt;code&gt;Kerja baru: &amp;lt;prompt&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;New lead card → working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Activity text, model, tokens&lt;/td&gt;
&lt;td&gt;Subagent card → working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Permission prompt → &lt;code&gt;⏳ Tunggu kelulusan&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Card → needs attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lead: &lt;code&gt;✅ Siap — tunggu Nasrul&lt;/code&gt;, or keeps supervising if staff are still busy&lt;/td&gt;
&lt;td&gt;Lead card → done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SubagentStop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subagent leaves, its name is freed&lt;/td&gt;
&lt;td&gt;Card → done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionEnd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lead leaves&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Who is who
&lt;/h3&gt;

&lt;p&gt;The hook reads everything from the JSON Claude Code sends on stdin. Subagent tool calls carry &lt;code&gt;agent_type&lt;/code&gt; (&lt;code&gt;laravel-developer&lt;/code&gt;, &lt;code&gt;tech-writer&lt;/code&gt;) and &lt;code&gt;agent_id&lt;/code&gt;, which is all it needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;Id&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main session&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lead-&amp;lt;session6&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lead&lt;/code&gt; in &lt;code&gt;staff.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Latest &lt;code&gt;aiTitle&lt;/code&gt; in the session transcript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subagent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;role&amp;gt;-&amp;lt;agent_id6&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;First free name in that role's list&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;description&lt;/code&gt; in &lt;code&gt;&amp;lt;session&amp;gt;/subagents/agent-&amp;lt;id&amp;gt;.meta.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The roster is a small JSON file; I gave 30 staff names across 17 roles, more names for roles that run in parallel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lead"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"short"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product Owner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Nasrul"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"laravel-developer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"short"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Laravel Dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Danial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Farhan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Iqbal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Syafiq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Haziq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Aiman"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tech-writer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"short"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Writer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Mei Ling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hana"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A name is held until that subagent's &lt;code&gt;SubagentStop&lt;/code&gt;, so three Laravel devs in parallel get three different names. Edits to the roster apply on the next event.&lt;/p&gt;

&lt;p&gt;The lead is marked busy while its team works: at most once a minute it posts &lt;code&gt;👀 Menyelia: Danial, Farhan, Iqbal&lt;/code&gt; ("supervising"). When the last subagent stops it says &lt;code&gt;✅ Pasukan siap — tunggu Nasrul&lt;/code&gt; ("team done, waiting on Nasrul").&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: kanban, tokens and the model
&lt;/h2&gt;

&lt;p&gt;Hermes3D already has a task board whose columns map straight onto a Claude session: &lt;code&gt;inbox&lt;/code&gt;, &lt;code&gt;scheduled&lt;/code&gt;, &lt;code&gt;working&lt;/code&gt;, &lt;code&gt;needs_attention&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;. The hook upserts cards through Studio's own endpoint, only when a card's status actually changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT http://127.0.0.1:3000/api/task-store &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"task":{"id":"cc-sub-&amp;lt;agent_id&amp;gt;","title":"Standardise listings","status":"working","source":"hermes_event","channel":"claude-code"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The board is under &lt;strong&gt;OPEN HQ → Kanban&lt;/strong&gt; on the right edge. The "Kanban board" desk in the 3D scene opens an install prompt instead — that is upstream behaviour, not your cards missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tokens.&lt;/strong&gt; Every assistant message in a transcript carries its &lt;code&gt;usage&lt;/code&gt;. Claude Code writes one line per content block, each repeating the same usage, so the hook de-duplicates by message id before summing input, output, cache read and cache write. A patched Agent Bus serves the totals through &lt;code&gt;sessions.usage&lt;/code&gt;, which Studio's Analytics panel reads. Cost stays at zero on purpose: there is no pricing table in the pipeline, and a wrong cost is worse than none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model.&lt;/strong&gt; The newest &lt;code&gt;"model":"..."&lt;/code&gt; in the tail of each transcript, sent on every event. Studio reads it from the agent's main session in &lt;code&gt;sessions.list&lt;/code&gt;, not from &lt;code&gt;config.get&lt;/code&gt;, because it caches &lt;code&gt;config.get&lt;/code&gt; for the whole connection — agents that appear later would never get one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Office opens populated.&lt;/strong&gt; Everything the hook sends is also appended to a spool file. When the launcher starts, it waits for hub, gateway and Studio, then replays the last 30 minutes. Without that, the office opens empty and fills only as each session next uses a tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily report.&lt;/strong&gt; The same spool feeds &lt;code&gt;office-summary&lt;/code&gt;: staff, active time, tool calls, approval waits, output tokens and every task with its final status, as a markdown table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;p&gt;Most of the work was here. Each looked like "it doesn't work" and had one specific cause.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Every session collapsed into one character&lt;/td&gt;
&lt;td&gt;Agent Bus's sample hooks read the agent name from a shell env var, shared by every session&lt;/td&gt;
&lt;td&gt;Take identity from the &lt;code&gt;session_id&lt;/code&gt; / &lt;code&gt;agent_id&lt;/code&gt; in the hook's stdin JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 characters for 4 sessions, some named after vendor packages&lt;/td&gt;
&lt;td&gt;I named characters after the &lt;code&gt;cwd&lt;/code&gt; folder; subagents roam into worktrees, &lt;code&gt;vendor/&lt;/code&gt;, docs&lt;/td&gt;
&lt;td&gt;Identity is the session or subagent, never the folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample hooks sent &lt;code&gt;Using unknown&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;They read &lt;code&gt;CLAUDE_TOOL_NAME&lt;/code&gt;, which current Claude Code does not set&lt;/td&gt;
&lt;td&gt;Read &lt;code&gt;tool_name&lt;/code&gt; from stdin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A closed session stood at its desk for a day&lt;/td&gt;
&lt;td&gt;Agent Bus marks &lt;code&gt;session_end&lt;/code&gt; as idle; pruning runs hourly with a 24h TTL&lt;/td&gt;
&lt;td&gt;Patch: remove the agent on &lt;code&gt;session_end&lt;/code&gt;; &lt;code&gt;AGENT_PRUNE_HOURS=1&lt;/code&gt; as the backstop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Working agents showed as idle&lt;/td&gt;
&lt;td&gt;Studio re-infers "running" on every roster reload from &lt;code&gt;sessions.preview&lt;/code&gt; — only if the last item is from the user. Agent Bus returned a different shape, all assistant messages&lt;/td&gt;
&lt;td&gt;Return &lt;code&gt;previews[].items[]&lt;/code&gt;; append the task as a trailing user item while the agent is active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The lead was always idle&lt;/td&gt;
&lt;td&gt;It delegates to background subagents and ends its turn; subagent activity only sent heartbeats&lt;/td&gt;
&lt;td&gt;Lead posts &lt;code&gt;👀 Menyelia: …&lt;/code&gt; while its team works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Waiting for approval" when nothing needed approving&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Notification&lt;/code&gt; also fires for the 60-second idle nudge ("waiting for your input")&lt;/td&gt;
&lt;td&gt;Only messages about permission become approvals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kanban cards titled &lt;code&gt;&amp;lt;task-notification&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A finishing background subagent is injected into the session as a user prompt&lt;/td&gt;
&lt;td&gt;Ignore prompts that start with &lt;code&gt;&amp;lt;task-notification&lt;/code&gt;, &lt;code&gt;&amp;lt;system-reminder&lt;/code&gt;, &lt;code&gt;&amp;lt;command-&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Connecting to your runtime…" forever in a fresh tab&lt;/td&gt;
&lt;td&gt;In dev mode the Next.js hot-reload socket hangs, and Chrome queues the gateway socket behind it&lt;/td&gt;
&lt;td&gt;Run Studio with &lt;code&gt;npm start&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;studio.gateway_url_blocked&lt;/code&gt; after switching to production&lt;/td&gt;
&lt;td&gt;Production refuses every upstream gateway not listed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPSTREAM_ALLOWLIST=localhost,127.0.0.1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hub and gateway reachable from the LAN&lt;/td&gt;
&lt;td&gt;Agent Bus listens on &lt;code&gt;0.0.0.0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Patch both to bind &lt;code&gt;127.0.0.1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A jq filter silently dropped every event without a file path&lt;/td&gt;
&lt;td&gt;`(.x // empty) \&lt;/td&gt;
&lt;td&gt;length &amp;gt; 0&lt;code&gt; — &lt;/code&gt;empty` short-circuits the whole object&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Agent Bus changes live as commits on a local &lt;code&gt;local-patches&lt;/code&gt; branch, so a &lt;code&gt;git pull&lt;/code&gt; shows exactly what I changed instead of silently undoing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing a read-only view for a few minutes
&lt;/h2&gt;

&lt;p&gt;Studio is not a viewer. Anyone who reaches it can change settings, move cards, and hit endpoints that read local files (&lt;code&gt;/api/gateway/media&lt;/code&gt;), list your home directory (&lt;code&gt;/api/path-suggestions&lt;/code&gt;) or call GitHub with your credentials (&lt;code&gt;/api/office/github&lt;/code&gt;). So I never tunnel Studio itself. A 120-line Node proxy on &lt;code&gt;127.0.0.1:3100&lt;/code&gt; sits in front of it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Proxy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages and static assets&lt;/td&gt;
&lt;td&gt;Pass through&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only an allowlist: &lt;code&gt;studio&lt;/code&gt;, &lt;code&gt;task-store&lt;/code&gt;, &lt;code&gt;health&lt;/code&gt;, &lt;code&gt;office&lt;/code&gt;, &lt;code&gt;office/layout&lt;/code&gt;, &lt;code&gt;office/presence&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PUT /api/studio&lt;/code&gt; (the UI saves view state constantly)&lt;/td&gt;
&lt;td&gt;Answered with the current settings, nothing written&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any other write&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway RPC &lt;code&gt;connect&lt;/code&gt;, &lt;code&gt;health&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;*.list&lt;/code&gt;, &lt;code&gt;*.get&lt;/code&gt;, &lt;code&gt;*.preview&lt;/code&gt;, &lt;code&gt;*.history&lt;/code&gt;, &lt;code&gt;*.usage&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Forwarded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any other RPC (&lt;code&gt;chat.send&lt;/code&gt;, &lt;code&gt;config.set&lt;/code&gt;, &lt;code&gt;agents.delete&lt;/code&gt;, …)&lt;/td&gt;
&lt;td&gt;Answered with a &lt;code&gt;read_only&lt;/code&gt; error&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An allowlist, not a blocklist: when a new Studio endpoint appears, it is closed until I decide otherwise.&lt;/p&gt;

&lt;p&gt;Then one command shares it and Ctrl-C ends it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
curl &lt;span class="nt"&gt;-sf&lt;/span&gt; http://127.0.0.1:3100/api/health &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"start the office first"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;pass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.agent-bus/share-password&lt;span class="si"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# chmod 600&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"user: team  password: &lt;/span&gt;&lt;span class="nv"&gt;$pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;ngrok http 127.0.0.1:3100 &lt;span class="nt"&gt;--basic-auth&lt;/span&gt; &lt;span class="s2"&gt;"team:&lt;/span&gt;&lt;span class="nv"&gt;$pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The link changes every time, and nothing is exposed once I stop it. For named people instead of a shared password, ngrok's Google login works: &lt;code&gt;ngrok http 127.0.0.1:3100 --oauth google --oauth-allow-email ali@example.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One thing to say to yourself before sharing: viewers see your prompts, task titles and file paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and what's next
&lt;/h2&gt;

&lt;p&gt;The whole thing is two cloned repos, one hook script, a roster file and a launcher. My day now looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;office            &lt;span class="c"&gt;# office + read-only view, Ctrl-C stops all&lt;/span&gt;
office-share      &lt;span class="c"&gt;# only while showing the team&lt;/span&gt;
office-summary    &lt;span class="c"&gt;# end of day: who did what&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; Tokens are there; dollars need a pricing table I trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels.&lt;/strong&gt; The 3D label shows only the first name. Role and task are in the panels and on the board.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One floor.&lt;/strong&gt; Hermes3D removed multi-floor offices upstream, so several products share one office with &lt;code&gt;[product]&lt;/code&gt; in the label.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Credit where it is due: &lt;a href="https://github.com/iamlukethedev/Hermes3D" rel="noopener noreferrer"&gt;Hermes3D&lt;/a&gt; by LukeTheDev draws the office, and &lt;a href="https://github.com/emiliovos/agent-bus" rel="noopener noreferrer"&gt;Agent Bus&lt;/a&gt; by emiliovos does the routing. I only wired Claude Code into them.&lt;/p&gt;

&lt;p&gt;If you build something similar, I would like to see how you name your staff.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Dev Log: 6 September 2026 — A Guarantee You Only Wrote Down Isn't One</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:40:04 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-6-september-2026-a-guarantee-you-only-wrote-down-isnt-one-408m</link>
      <guid>https://dev.to/nasrulhazim/dev-log-6-september-2026-a-guarantee-you-only-wrote-down-isnt-one-408m</guid>
      <description>&lt;p&gt;92 commits today across six repositories, two products from scratch — a learning platform in Laravel and a puzzle game in Flutter. Very different stacks. Reading the day back in one sitting, almost every decision I made was the same decision:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do I put a fact so that it cannot become wrong?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "where is it convenient." Not "where will I remember to update it." Where can it &lt;em&gt;not&lt;/em&gt; drift. Every good call today was some version of &lt;em&gt;derive it, or enforce it&lt;/em&gt; — and the one call I got wrong was a guarantee I enforced on people who never asked for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The event stream that refuses to be updated
&lt;/h2&gt;

&lt;p&gt;The learning platform records every analytic — funnel, item analysis, verification counts — into one append-only event table. The obvious way to build that is a table with a comment above it saying &lt;em&gt;append only, please don't update&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I don't trust that comment. Neither should you. So the model has no &lt;code&gt;updated_at&lt;/code&gt;, and it actively refuses:&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;UPDATED_AT&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="cd"&gt;/**
 * Refuse any mutation of recorded history.
 *
 * Enforced on the model rather than only by convention: an append-only
 * stream that can be quietly rewritten is just a table.
 */&lt;/span&gt;
&lt;span class="k"&gt;protected&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;booted&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;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;updating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;never&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;LogicException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The event stream is append-only; a recorded event cannot be updated.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deleting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;never&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;LogicException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The event stream is append-only; a recorded event cannot be deleted.'&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;: never&lt;/code&gt; return type is doing real work there — static analysis knows the closure can't fall through, so nothing downstream has to pretend an update might succeed.&lt;/p&gt;

&lt;p&gt;An append-only stream that can be quietly rewritten is just a table. The word "guarantee" is doing no work unless something throws.&lt;/p&gt;

&lt;p&gt;One nicety in the writer action: an unmapped event name records as &lt;code&gt;unknown&lt;/code&gt; rather than throwing. Losing an event because someone typo'd the name is worse than an untidy label in a report. Strict about the shape, forgiving about the vocabulary.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The count that is never stored
&lt;/h2&gt;

&lt;p&gt;Same system, immediately downstream. There's a public verification page, and I need to know how many times a credential has been verified.&lt;/p&gt;

&lt;p&gt;Tempting: a &lt;code&gt;verification_count&lt;/code&gt; column, incremented on each check. Fast reads, one integer.&lt;/p&gt;

&lt;p&gt;It's also a number that can disagree with reality — a failed increment, a replayed request, a backfill that forgot, and now you have a count that nobody can reconcile against anything. So the count is a query over the event stream, always. The test that matters isn't "does the counter go up"; it's a &lt;strong&gt;replay test&lt;/strong&gt;: rebuild the derived figure from the stream and assert it matches. That's the property that makes a single source of truth worth the join cost. A denormalised counter is a cache, and if you're going to keep a cache you have to be able to prove it's right — which means you needed the stream anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A level is a name for a band, not a second number
&lt;/h2&gt;

&lt;p&gt;Over on the puzzle game, the whole day's cleanest change. The game has a per-genre Glicko-2 rating — one number for "how good you are at this." Players don't read 1,732 as skill, so I wanted levels.&lt;/p&gt;

&lt;p&gt;The lazy version: add a &lt;code&gt;level&lt;/code&gt; field, bump it when the rating crosses a threshold. Now you have two numbers competing to mean the same thing, and they will disagree, and when they disagree neither one is trustworthy.&lt;/p&gt;

&lt;p&gt;So nothing is stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// The rating at which each level begins. Index 0 is level 1.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kLevelThresholds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1850&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2100&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;/// The level a rating sits in, counting from 1.&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;levelFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;num&lt;/span&gt; &lt;span class="n"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;kLevelThresholds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rating&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kLevelThresholds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;level&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;Forty lines, no migration, no sync bug possible. Level and rating cannot disagree, because there is only one of them.&lt;/p&gt;

&lt;p&gt;The genuinely useful part fell out sideways, and this is why I keep pushing on derived state. Each of the three puzzle genres had its own difficulty formula — its own magic constants, none of them agreeing on where a band started. Once "level" existed as a single derived idea, all three difficulty curves could be expressed in terms of it. "Level 4" went from being three formulas that happened to look similar, to being one idea. And now crossing a level is &lt;em&gt;exactly&lt;/em&gt; the moment the puzzles get bigger, in every genre, which is a promise the UI can make honestly. A test pins that all three step at the same ratings.&lt;/p&gt;

&lt;p&gt;I didn't set out to unify the difficulty curves. Naming the shared concept made the duplication visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The solve check that doesn't look at the answer
&lt;/h2&gt;

&lt;p&gt;Second genre landed today — a nonogram, the grid puzzle where numbers along the edges tell you the runs of filled cells. Generating one is easy: make a pattern, derive the clues from it.&lt;/p&gt;

&lt;p&gt;The trap is checking the solve. The obvious implementation compares the player's grid to the generated pattern. That's wrong, and it's wrong in a way that only shows up as an angry player: &lt;strong&gt;a clue set doesn't always describe a unique picture.&lt;/strong&gt; Sometimes two different grids satisfy the same numbers. Rejecting a player's valid alternative because it isn't the one you happened to generate is a bug you'd never reproduce yourself.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;solveState&lt;/code&gt; compares clues to clues — derive the clues from what the player built, compare to the clues on screen, never look at the stored pattern. The test enumerates every grid the clues allow across 40 seeds and asserts all of them are accepted.&lt;/p&gt;

&lt;p&gt;Same theme, third time: the pattern was a stored answer. The clues are the actual contract. Check against the contract.&lt;/p&gt;

&lt;p&gt;Worth noting what &lt;em&gt;didn't&lt;/em&gt; change: adding this second genre required zero edits to the engine core. The puzzle contract, the registry, the seeded PRNG, the rating maths, the attempt rules — all untouched. Shared code changed in two places: five lines in the composition root to register the engine, and a genre dropdown in the session screen that any second genre would need. That's the checkpoint I set the abstraction up for, and it's the only honest way to find out whether a one-implementation abstraction was real.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A parsed question, when it stopped being an array
&lt;/h2&gt;

&lt;p&gt;Small refactor, unusually instructive. The learning platform imports question banks from Markdown — parse line by line, build up each question as you go.&lt;/p&gt;

&lt;p&gt;The first version built each question as an associative array and mutated it as more lines arrived. It worked, then it started attaching options to the &lt;em&gt;wrong question&lt;/em&gt;, silently, because the version before it juggled array references. Nothing in the type system was watching, and nothing in the reader's head was either — an array grown line by line has no shape you can hold.&lt;/p&gt;

&lt;p&gt;Replacing it with a small object fixed the class of bug rather than the instance:&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;$questions&lt;/span&gt;&lt;span class="p"&gt;[]&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;ParsedQuestion&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;$m&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;$outcome&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="nv"&gt;$questions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$last&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;addOption&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;$m&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="n"&gt;correct&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;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validation moved onto the object that owns it, so the "more than one correct option means multi-select, not a mistake" rule lives with the thing it describes instead of in a loop 40 lines below. And the static analyser can finally see the shape, which is the part that would have caught the original bug for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The one I got wrong
&lt;/h2&gt;

&lt;p&gt;I required two-factor authentication for staff roles. It wasn't asked for. I did it because it seemed obviously correct, and it was the wrong call — you don't get to impose a security policy on somebody else's operational reality because it's good hygiene. It's now opt-in behind a config flag, off by default, and it switches on when the platform actually holds credentials worth protecting.&lt;/p&gt;

&lt;p&gt;Turning it on for a day did earn its keep, though, because it exposed two dormant defects on the one page it herded people to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The middleware redirected staff to the two-factor setup page — which the auth scaffolding guards behind password confirmation — and the middleware then bounced the confirmation screen straight back. &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt;. &lt;strong&gt;My test checked only the first hop.&lt;/strong&gt; First hop was correct. The chain wasn't. The test now follows redirects to the end and insists it terminates, and I verified it reproduced the loop before fixing it.&lt;/li&gt;
&lt;li&gt;The scaffold shipped views calling an OTP component it never provided. Dormant for as long as nobody was forced onto that page. Requiring 2FA turned a missing component into a lockout with nothing else reachable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two lessons, and the redirect one is the sharper: &lt;strong&gt;asserting on the first response of a redirect chain proves almost nothing.&lt;/strong&gt; A loop is exactly a sequence of individually correct hops.&lt;/p&gt;

&lt;p&gt;The second is about blast radius. An error on a page nobody visits is a bug. The same error on the &lt;em&gt;only&lt;/em&gt; page a blocked user can reach is a lockout. So enrolment is now tested end to end — enable, QR, confirm with a real TOTP — and there's a console command to enrol or reset an account from the CLI. That last one isn't a dev shortcut: an admin who loses their authenticator is locked out of every page including the one that would fix it, and on a one-person team there's no second admin to unlock them.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. And the boundaries
&lt;/h2&gt;

&lt;p&gt;I also drew eleven bounded contexts in the learning platform and made them executable the same afternoon — which got its own post — &lt;em&gt;"Eleven Empty Folders and a Test"&lt;/em&gt; (companion post, link at review time) — because Pest's &lt;code&gt;arch()&lt;/code&gt; helpers can't help you when the modules don't have any classes in them yet, and the workaround (read &lt;code&gt;use&lt;/code&gt; statements out of the source text) has trade-offs worth spelling out.&lt;/p&gt;

&lt;p&gt;Short version: an architectural rule that isn't a failing test is a preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;One question, asked seven times today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can this fact be &lt;strong&gt;derived&lt;/strong&gt; instead of stored? (level, verification count) → then store nothing.&lt;/li&gt;
&lt;li&gt;Can this rule &lt;strong&gt;throw&lt;/strong&gt; instead of being documented? (append-only, module boundaries) → then make it throw.&lt;/li&gt;
&lt;li&gt;Am I checking against the &lt;strong&gt;answer&lt;/strong&gt; or against the &lt;strong&gt;contract&lt;/strong&gt;? (nonogram clues) → check the contract.&lt;/li&gt;
&lt;li&gt;Does this data have a &lt;strong&gt;shape&lt;/strong&gt; the analyser can see? (parsed question) → give it a class.&lt;/li&gt;
&lt;li&gt;Did I test the &lt;strong&gt;first hop&lt;/strong&gt; or the whole chain? (2FA loop) → the chain.&lt;/li&gt;
&lt;li&gt;Is this guarantee &lt;strong&gt;mine to impose&lt;/strong&gt;? (staff 2FA) → probably not; make it a flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The through-line: a fact stored in two places is a bug with a delay on it. Every one of today's good decisions was refusing to accept that delay, and the bad one was refusing somebody else a choice. Different mistake, same root — deciding on behalf of a future you can't see.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Eleven Empty Folders and a Test: Enforcing Module Boundaries Before the First Class Exists</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:39:18 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/eleven-empty-folders-and-a-test-enforcing-module-boundaries-before-the-first-class-exists-7m9</link>
      <guid>https://dev.to/nasrulhazim/eleven-empty-folders-and-a-test-enforcing-module-boundaries-before-the-first-class-exists-7m9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I started a modular monolith today and drew eleven bounded contexts before writing a single class in any of them. Then I tried to enforce the boundaries with Pest's &lt;code&gt;arch()&lt;/code&gt; helpers and couldn't, because &lt;code&gt;arch()&lt;/code&gt; needs classes to exist and mine didn't yet. So the boundary test reads &lt;code&gt;use&lt;/code&gt; statements out of the source text instead. It's cruder than reflection and it catches the violation on the day the boundary is drawn, which is the only day that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure mode I was trying to avoid
&lt;/h2&gt;

&lt;p&gt;Every modular monolith I've seen go bad went bad the same way. Somebody draws the modules in a wiki page. Everybody agrees. Six weeks later a controller in &lt;code&gt;Billing&lt;/code&gt; does &lt;code&gt;Catalogue\Models\Course::find($id)&lt;/code&gt; because it was two in the morning and the alternative was writing an action. Nobody notices, because nothing was watching.&lt;/p&gt;

&lt;p&gt;The wiki page is still there. It's just not true any more.&lt;/p&gt;

&lt;p&gt;So the rule I actually care about isn't "draw good boundaries." It's &lt;strong&gt;a boundary that isn't executable is a preference, not a boundary.&lt;/strong&gt; Same class of thing as a comment that says &lt;code&gt;// don't call this directly&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The structure I settled on is ordinary — eleven modules under &lt;code&gt;app/Platform/&lt;/code&gt;, each shaped the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/Platform/&amp;lt;Module&amp;gt;/
├── Actions/     public entry points — the only way in from another module
├── Contracts/   interfaces this module owns
├── Events/      what it publishes
├── Models/      private to this module
└── README.md    what it owns and publishes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules, and I wanted all three green from the first commit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A module may not reference another module's &lt;code&gt;Models&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Cross-module access goes through the owning module's &lt;code&gt;Actions&lt;/code&gt;, &lt;code&gt;Contracts&lt;/code&gt; or &lt;code&gt;Events&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No domain action may import a vendor SDK — it calls a contract.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where &lt;code&gt;arch()&lt;/code&gt; couldn't help
&lt;/h2&gt;

&lt;p&gt;Pest's architecture testing is the obvious tool here. You write something like:&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="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing does not touch catalogue models'&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Platform\Billing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toUse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Platform\Catalogue\Models'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean. Reads like the rule. And on day one it does nothing at all, because &lt;code&gt;arch()&lt;/code&gt; works off the class map: it resolves classes, walks their dependencies, and reasons about real symbols. Point it at a namespace with no classes in it and there's nothing to reason about. The expectation passes on emptiness.&lt;/p&gt;

&lt;p&gt;That's fine when you're retrofitting boundaries onto a codebase that already has 400 classes. It's exactly wrong when you're declaring boundaries on a codebase that has none, which is the moment the boundary is cheapest to hold and the moment the first violation is most likely — the very first class someone drops into &lt;code&gt;Billing/Models&lt;/code&gt; is the one that decides what "normal" looks like in that folder.&lt;/p&gt;

&lt;p&gt;I wanted the test to be red on a violation the same afternoon I drew the lines. So I gave up on reflection and went to the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading &lt;code&gt;use&lt;/code&gt; statements like a linter
&lt;/h2&gt;

&lt;p&gt;The whole enforcement is a Pest feature test that globs the filesystem and regexes imports:&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="cd"&gt;/** @return array&amp;lt;int, string&amp;gt; Module names, discovered from the filesystem. */&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;modules&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;$names&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="s1"&gt;'basename'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;app_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Platform/*'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;GLOB_ONLYDIR&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="nb"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$names&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;$names&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/** @return array&amp;lt;int, string&amp;gt; The fully-qualified imports in a file. */&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;importsIn&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;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^use\s+(?:function\s+)?([^\s;]+)/m'&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="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$m&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;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="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;Note &lt;code&gt;modules()&lt;/code&gt; discovers from the filesystem rather than a hardcoded list. That matters for the rule itself — a new module folder is automatically subject to every rule below, so nobody can opt out by creating a twelfth context quietly.&lt;/p&gt;

&lt;p&gt;The Models rule then falls out in about fifteen lines:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'never references another module\'s models'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$violations&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="nf"&gt;modules&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;$module&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="nf"&gt;moduleFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$module&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;$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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;importsIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&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;$import&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'App\\Platform\\'&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="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$layer&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="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;$import&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="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;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;$import&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

                &lt;span class="c1"&gt;// Reaching into another module is allowed only via its public&lt;/span&gt;
                &lt;span class="c1"&gt;// Actions, Contracts or Events — never its Models.&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;$owner&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$module&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;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$layer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Actions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Contracts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Events'&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="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$violations&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&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;'%s imports %s (reach %s through its Actions, Contracts or Events)'&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="nf"&gt;app_path&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="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'app/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="nv"&gt;$import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="nv"&gt;$owner&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;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$violations&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;toBe&lt;/span&gt;&lt;span class="p"&gt;([],&lt;/span&gt; &lt;span class="s2"&gt;"cross-module boundary violations:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&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="nv"&gt;$violations&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deliberate choices in there worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collect, then assert once.&lt;/strong&gt; The naive version asserts inside the loop and dies on the first violation. If you've just merged a branch that broke the rule in nine places, you want all nine in one failure message, not nine round trips through the suite. The message is written as an instruction — &lt;em&gt;reach &lt;code&gt;Catalogue&lt;/code&gt; through its Actions&lt;/em&gt; — because the person reading it at 2am is the person the rule exists for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The allowed layers are a whitelist, not a blacklist.&lt;/strong&gt; I'm not banning &lt;code&gt;Models&lt;/code&gt;; I'm permitting &lt;code&gt;Actions&lt;/code&gt;, &lt;code&gt;Contracts&lt;/code&gt;, &lt;code&gt;Events&lt;/code&gt; and nothing else. When someone adds a &lt;code&gt;Support/&lt;/code&gt; or &lt;code&gt;Queries/&lt;/code&gt; folder to a module later, it is private by default and the test says so. A blacklist would have silently let it through.&lt;/p&gt;

&lt;p&gt;The vendor-SDK rule is the same shape with a prefix allowlist:&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;$allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'App\\'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Illuminate\\'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Carbon\\'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Spatie\\LaravelData\\'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything else imported inside an &lt;code&gt;Actions/&lt;/code&gt; directory fails. That's what keeps the payment provider, the storage provider and the video provider behind contracts, which in turn is what lets the whole suite run offline with fake drivers — and makes swapping a provider a driver change rather than a migration project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that asserts documentation exists
&lt;/h2&gt;

&lt;p&gt;This one felt slightly ridiculous when I wrote it and I've come around on it:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gives every module a README stating what it owns and publishes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&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="nf"&gt;modules&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;$module&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$readme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Platform/'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$module&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/README.md'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$readme&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;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"module [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$module&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] has no README"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$readme&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'**Owns:**'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'**Publishes:**'&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;It's a very dumb check — the README could say &lt;code&gt;**Owns:** stuff&lt;/code&gt;. It isn't checking quality. What it's actually enforcing is that &lt;strong&gt;creating a module is a decision you have to write down&lt;/strong&gt;, and the two headings force the only two sentences that matter: what data is yours, and what other modules are allowed to react to.&lt;/p&gt;

&lt;p&gt;I've written a lot of architecture docs that rotted. This one can't rot to &lt;em&gt;absent&lt;/em&gt;, which turns out to be most of the rot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Be clear-eyed about the trade. Text-level checking is weaker than reflection in specific, knowable ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inline FQCNs slip through.&lt;/strong&gt; &lt;code&gt;\App\Platform\Catalogue\Models\Course::find(1)&lt;/code&gt; has no &lt;code&gt;use&lt;/code&gt; statement, so the regex never sees it. In practice this is rare in a codebase with a formatter and an IDE that auto-imports, but it is a real hole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grouped and aliased imports need care.&lt;/strong&gt; &lt;code&gt;use App\Platform\Catalogue\{Models\Course, Actions\Publish};&lt;/code&gt; isn't parsed by that pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It knows nothing about runtime.&lt;/strong&gt; A container binding, a string class name, an event listener resolved by name — all invisible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want those closed properly, the answer is a real parser (&lt;code&gt;nikic/php-parser&lt;/code&gt;) or &lt;code&gt;deptrac&lt;/code&gt;, and at some point I'll probably graduate to one. But the honest cost/benefit today: the fifteen-line regex version was working before lunch, it fails loudly, it has zero dependencies, and it covers the way violations actually get written. A perfect checker I hadn't finished yet would have caught nothing.&lt;/p&gt;

&lt;p&gt;There's also a deliberate hole I &lt;em&gt;want&lt;/em&gt;: the shared kernel. &lt;code&gt;App\Models&lt;/code&gt;, &lt;code&gt;App\Enums&lt;/code&gt;, &lt;code&gt;App\Contracts&lt;/code&gt;, &lt;code&gt;App\Support&lt;/code&gt; are usable from anywhere. &lt;code&gt;App\Models\User&lt;/code&gt; in particular is shared across all eleven modules, because every persona in the system is a user and duplicating identity per module would be worse than sharing it. Purists will wince. I'd rather have one honest shared kernel that everyone knows about than eleven private &lt;code&gt;User&lt;/code&gt; copies pretending to be independent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit I keep coming back to
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Directories appear as the first class lands in them. An empty module is a declared boundary, not dead scaffolding.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Eleven folders with nothing in them but a README looks like over-engineering right up until you notice the test is already guarding them. The boundaries are drawn where a service split would go, so that option stays open — and unexercised, which is the point. I'm not running eleven services. I'm keeping the seam visible so that if one of these ever needs to leave, it can leave without a six-month untangling.&lt;/p&gt;

&lt;p&gt;The general version, and the reason this is worth 150 lines of test:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every architectural rule you can state in a sentence should be a failing test before it is a paragraph in a README.&lt;/strong&gt; If you can't make it fail, you haven't decided anything — you've expressed a hope.&lt;/p&gt;

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

&lt;p&gt;Rule four is written down but not yet enforced: every entitlement decision must route through one module. It needs classes to exist before it means anything, so that one genuinely does wait. When it lands it'll probably be an &lt;code&gt;arch()&lt;/code&gt; test, and that'll be the right tool by then — which is the whole shape of this post. Use reflection when there's something to reflect on. Until then, read the text.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Four Env Vars and No Foreign Key: What It Means to Actually Attach a Resource</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sun, 06 Sep 2026 02:20:42 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/four-env-vars-and-no-foreign-key-what-it-means-to-actually-attach-a-resource-2mf9</link>
      <guid>https://dev.to/nasrulhazim/four-env-vars-and-no-foreign-key-what-it-means-to-actually-attach-a-resource-2mf9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — One commit today, and it's a Markdown file. No migration, no action class, no tests. I spent the day writing down the shape of a feature instead of building it, and the thing I wrote down is embarrassingly small: an application that uses object storage should have a &lt;strong&gt;foreign key&lt;/strong&gt; to that storage, not four env vars somebody typed in. Everything else in this post is downstream of that one sentence.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap that hides behind "it works"
&lt;/h2&gt;

&lt;p&gt;Here's the setup. A deployment platform can do two things with object storage:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It can &lt;strong&gt;run&lt;/strong&gt; it — provision a storage server, create buckets, mint access keys.&lt;/li&gt;
&lt;li&gt;It can &lt;strong&gt;record&lt;/strong&gt; somebody else's — an S3-compatible connection to whatever the operator already pays for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both work. Both have UIs. Both have tests. And neither one is connected to an application in any way.&lt;/p&gt;

&lt;p&gt;So the actual integration procedure is: provision a bucket, open the workload's environment editor, and hand-copy four values across — endpoint, region, key id, secret. It works on the first deploy. Everybody moves on.&lt;/p&gt;

&lt;p&gt;What that copy-paste can't do is the whole point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It can't be &lt;strong&gt;gated&lt;/strong&gt;. Nothing stops a deployment starting against a bucket that hasn't finished provisioning.&lt;/li&gt;
&lt;li&gt;It can't be &lt;strong&gt;rotated&lt;/strong&gt;. Rotate the access key and the running app breaks, with no way to answer "which apps did I just break?"&lt;/li&gt;
&lt;li&gt;It can't be &lt;strong&gt;audited&lt;/strong&gt;. There is no query that returns "which workloads use this bucket."&lt;/li&gt;
&lt;li&gt;It can't be &lt;strong&gt;detached&lt;/strong&gt;. Nobody knows the link exists, so nobody can cleanly remove it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A copied string is not a relationship. It's a coincidence that currently holds.&lt;/p&gt;

&lt;p&gt;This is the same shape as the bug family I wrote about yesterday, one level up: the system took something it &lt;em&gt;did&lt;/em&gt; know — this app uses that bucket — and stored it somewhere it couldn't reason about. Not a wrong fact. A fact with no home.&lt;/p&gt;




&lt;h2&gt;
  
  
  Copy the shape you already solved
&lt;/h2&gt;

&lt;p&gt;The useful thing about this gap is that it isn't new. Managed databases went through it months ago and came out the other side with a shape: one action, nullable foreign keys on the deployment workload, environment variables written per language preset, and a gate that refuses to deploy against a database that isn't ready.&lt;/p&gt;

&lt;p&gt;So the storage plan mirrors it. Deliberately, and almost literally.&lt;/p&gt;

&lt;p&gt;That's a decision worth stating out loud, because "just do it the same way" is not automatically right. You inherit whatever's slightly wrong with the original, and you make the two things harder to evolve independently later. I took that trade knowingly. The alternative — a second vocabulary for the same concept, sitting one tab away from the first — is how a platform starts to feel arbitrary to the people operating it. When two features answer the same question ("how do I connect this app to that thing?") they should answer it with the same words.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Credentials&lt;/th&gt;
&lt;th&gt;What the platform creates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Managed&lt;/td&gt;
&lt;td&gt;minted per deployment&lt;/td&gt;
&lt;td&gt;bucket + access key + permission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External&lt;/td&gt;
&lt;td&gt;operator's, already stored encrypted&lt;/td&gt;
&lt;td&gt;nothing — it names an existing bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two sources, one attach. Both end in the same place: env vars on the workload, &lt;strong&gt;and&lt;/strong&gt; a foreign key so the link is a fact rather than a string.&lt;/p&gt;




&lt;h2&gt;
  
  
  Nullable columns, and where the rule lives
&lt;/h2&gt;

&lt;p&gt;The schema part is dull, which is a good sign:&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="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'deployment_workloads'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'storage_bucket_id'&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;nullable&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;constrained&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'storage_access_key_id'&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;nullable&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;constrained&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'object_storage_connection_id'&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;nullable&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;constrained&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'storage_prefix'&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;nullable&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;Managed and external are mutually exclusive — you attach a bucket the platform manages, or you name one it doesn't, never both. The interesting question is &lt;em&gt;where that rule lives&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I'm putting it in the action, not in a &lt;code&gt;CHECK&lt;/code&gt; constraint:&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="nv"&gt;$bucket&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$connection&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="nc"&gt;StorageAttachmentInvalid&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bothSourcesGiven&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 honest counter-argument first, because it's a real one: a database constraint is the only rule that survives a seeder, a console command, or somebody in &lt;code&gt;tinker&lt;/code&gt; at 2am. Application-level invariants are invariants right up until code writes around them.&lt;/p&gt;

&lt;p&gt;I still went with the action, for two reasons. The rule is about &lt;strong&gt;intent&lt;/strong&gt;, not data integrity — two populated columns isn't corrupt data, it's an ambiguous request, and it deserves an error message that says so rather than a constraint violation. And the platform targets several database engines, so a &lt;code&gt;CHECK&lt;/code&gt; here means either a portability problem or the same rule written five ways. When the constraint would be lying about &lt;em&gt;why&lt;/em&gt; it exists, I'd rather it live where the reason is readable.&lt;/p&gt;

&lt;p&gt;If you disagree, add the constraint too. Belt and braces is fine. What isn't fine is having the rule in neither place because you assumed the form would enforce it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The secret you can read exactly once
&lt;/h2&gt;

&lt;p&gt;This one shapes the whole flow, and it's easy to miss until you're halfway through building.&lt;/p&gt;

&lt;p&gt;A well-behaved object storage engine hands you the secret key &lt;strong&gt;at creation and never again&lt;/strong&gt;. It doesn't keep the plaintext. There's no "show secret" endpoint to fall back on, because there's nothing to show.&lt;/p&gt;

&lt;p&gt;Which means the moment of creation is the only moment you can persist it. Practically:&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;ProvisionDeploymentStorageAction&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Deployment&lt;/span&gt; &lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Workload&lt;/span&gt; &lt;span class="nv"&gt;$workload&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;$bucket&lt;/span&gt; &lt;span class="o"&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;createBucket&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nv"&gt;$deployment&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;bucketName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt;    &lt;span class="o"&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;createAccessKey&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nv"&gt;$bucket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ← plaintext exists only here&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;attachPermission&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nv"&gt;$bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Permission&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;ReadWrite&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="s1"&gt;'bucket'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'key'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'env'&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="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// written into encrypted env now&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;Two consequences fall straight out of that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't design an "attach later, fetch the credentials then" flow.&lt;/strong&gt; There is no "then". If your UI implies one, you'll discover it the first time an operator tries to re-attach a bucket whose key was minted last week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No daemon calls in the action.&lt;/strong&gt; Rows first; queued jobs make them real. The action's job is to record the intent atomically — provisioning a bucket over the network inside a request is how you get a half-created attachment when the connection blips.&lt;/p&gt;

&lt;p&gt;And the bucket name is derived, not free text, through a small identifier value object. Object storage naming is stricter than people expect — lowercase, digits, hyphens, dots; no uppercase, no underscores. Deriving it from the deployment and workload slugs means the name is reproducible, which matters more than it sounds like, for reasons in a moment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real values, not template tokens
&lt;/h2&gt;

&lt;p&gt;For services that live inside the deployment, env vars can be &lt;code&gt;{{ tokens }}&lt;/code&gt; resolved against a service registry at deploy time — the database host isn't known until the thing is placed.&lt;/p&gt;

&lt;p&gt;Object storage isn't like that. It lives outside the deployment. There's no registry entry to resolve against, so the values go in real, at attach time. Same reasoning the database attach already uses.&lt;/p&gt;

&lt;p&gt;Per language preset, because the ecosystem conventions differ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;laravel  → AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION,
           AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT=true
node     → S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
python   → AWS_* (boto3 reads the same names)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two traps worth writing down before you hit them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path style vs virtual-host style.&lt;/strong&gt; Self-hosted engines generally want &lt;code&gt;use_path_style = true&lt;/code&gt; (&lt;code&gt;https://host/bucket/key&lt;/code&gt;). Hosted providers usually want virtual-host style (&lt;code&gt;https://bucket.host/key&lt;/code&gt;). Get it wrong and you get a 403 or a DNS failure that reads like a credentials problem. For an external connection this has to be whatever the operator recorded, not whatever your managed default is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the endpoint through a helper, never string concatenation.&lt;/strong&gt; An IPv6 literal needs brackets in a URL:&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;// "http://fd00::1:3900" — silently wrong, the port isn't a port&lt;/span&gt;
&lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// "http://[fd00::1]:3900"&lt;/span&gt;
&lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$publishedHost&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authority&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the kind of bug that only shows up on one operator's network, six weeks after you shipped.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gate: don't start something that can't work yet
&lt;/h2&gt;

&lt;p&gt;The deployment gets a check beside the existing managed-database one, with the same three answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The row is &lt;strong&gt;gone&lt;/strong&gt; — the bucket was deleted out from under the attachment.&lt;/li&gt;
&lt;li&gt;The row &lt;strong&gt;failed&lt;/strong&gt; — provisioning errored; surface the recorded error, don't just say "not ready".&lt;/li&gt;
&lt;li&gt;The row is &lt;strong&gt;pending&lt;/strong&gt; — it's coming, but it isn't here yet.
&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;managedStorageGate&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;$bucket&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;storageBucket&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;match&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="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;storage_bucket_id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$bucket&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The attached bucket no longer exists.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$bucket&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;StorageResourceStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;
            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Storage provisioning failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$bucket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;last_error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$bucket&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nc"&gt;StorageResourceStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Ready&lt;/span&gt;
            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Storage is still being provisioned.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value of a gate is entirely about &lt;strong&gt;where the failure surfaces&lt;/strong&gt;. Without it, the deployment starts, the app runs happily for however long, and then the first user upload gets a 403 from an endpoint that doesn't have that bucket. The symptom lands in application logs, hours later, nowhere near the cause. With the gate, the deploy refuses at the point where the missing thing is named.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refuses to deploy when the attached bucket is not ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$workload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DeploymentWorkload&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&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;withManagedBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StorageResourceStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Pending&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;managedStorageGate&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'still being provisioned'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reports the recorded error when provisioning failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$workload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DeploymentWorkload&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&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;withManagedBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StorageResourceStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'no space on device'&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;managedStorageGate&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'no space on device'&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;h2&gt;
  
  
  Detach must never delete
&lt;/h2&gt;

&lt;p&gt;The hardest rule in the whole plan, and the shortest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Data outlives deployments.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Detaching a bucket removes the link and the env vars. It does not touch a single object. Someone tearing down a staging deployment at 6pm on a Friday must not be able to delete a year of uploads by clicking a button labelled &lt;em&gt;Detach&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which is why the derived bucket name matters: re-attaching finds a live bucket under the name it would have generated and &lt;strong&gt;reuses it&lt;/strong&gt; rather than refusing. Detach, re-attach, and you're back where you were, with your files. If the operator genuinely wants the data gone, that's a separate, louder, differently-worded action.&lt;/p&gt;

&lt;p&gt;The same idempotency thinking as treating a vendor 404 as success on delete: a repeated operation has to be able to &lt;em&gt;finish&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rotation is the part that bites
&lt;/h2&gt;

&lt;p&gt;Key rotation already exists here: mint a successor, keep the old key valid for an overlap window so nothing dies mid-request.&lt;/p&gt;

&lt;p&gt;The overlap is the safety mechanism, and it's also the thing that makes the bug easy to miss. Rotate, everything keeps working, ship it. Then the overlap expires and apps start failing — at a moment with no deploy, no code change, and nothing in the timeline to point at.&lt;/p&gt;

&lt;p&gt;So completing a rotation has to walk the attachments pointing at the old key and update them. Otherwise the platform is scheduling its own outage, on a delay, for a reason its own logs won't explain.&lt;/p&gt;

&lt;p&gt;One more bit of honesty owed to the operator: updating a stored env var doesn't change the environment of a process that's already running. The new value reaches the app on the next deploy. The toast has to say &lt;strong&gt;"redeploy to apply"&lt;/strong&gt; rather than implying it's live — a UI that overstates what just happened is how you get someone confidently debugging the wrong layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bit I got wrong first: knowing what &lt;em&gt;not&lt;/em&gt; to model
&lt;/h2&gt;

&lt;p&gt;This plan exists because of a wrong turn.&lt;/p&gt;

&lt;p&gt;The new-application wizard briefly offered "uploads on their own storage server" as a component you could compose into an app's blueprint, alongside the runtime and the database. It looked consistent. It was wrong twice over.&lt;/p&gt;

&lt;p&gt;The shallow reason: the specific engine I reached for went into maintenance mode upstream, so the platform's own unsupported-components check rejected it. Fair enough — that's a dependency risk, and the validator did exactly its job.&lt;/p&gt;

&lt;p&gt;The reason that actually matters: &lt;strong&gt;object storage isn't a per-application component at all.&lt;/strong&gt; It reads its own config file and owns its own cluster layout. A per-app layer can't own that — you'd be asking one application's blueprint to hold configuration that belongs to a service several applications share.&lt;/p&gt;

&lt;p&gt;That's a useful test for any composable system:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a thing owns its own config file and its own cluster identity, it's a &lt;strong&gt;peer service you attach to&lt;/strong&gt;, not a &lt;strong&gt;component you compose in&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Getting that wrong doesn't produce an error. It produces an abstraction that works for the first case, then quietly fails the second — the moment two applications want the same bucket, or the storage needs a setting that isn't any single app's business.&lt;/p&gt;

&lt;p&gt;The fix wasn't to make the component work harder. It was to delete it and model the relationship instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I left out on purpose
&lt;/h2&gt;

&lt;p&gt;Two things, both tempting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrating existing objects into a new bucket.&lt;/strong&gt; There's no safe general answer — prefixes differ, sizes differ, and a half-finished copy loses files. A feature that &lt;em&gt;sometimes&lt;/em&gt; loses uploads is worse than no feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple buckets per workload.&lt;/strong&gt; One bucket, one key, one prefix. The second bucket arrives with the first real request for one, not on the strength of me imagining it at design time.&lt;/p&gt;




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

&lt;p&gt;A plan is the cheapest place to be wrong. Today's entire output is one Markdown file, and it already caught one bad abstraction, one dependency risk, and one silent-outage path in the rotation flow. Those are much more expensive to find in a migration.&lt;/p&gt;

&lt;p&gt;The question I'd take to any integration in your own system: &lt;strong&gt;what happens when the credential rotates?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer involves a human remembering which apps to update, you don't have an integration. You have four env vars and no foreign key.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Unknown Is Not Failed: Four Bugs Where My Control Plane Claimed to Know Something It Didn't</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Sat, 05 Sep 2026 01:47:26 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/unknown-is-not-failed-four-bugs-where-my-control-plane-claimed-to-know-something-it-didnt-gnm</link>
      <guid>https://dev.to/nasrulhazim/unknown-is-not-failed-four-bugs-where-my-control-plane-claimed-to-know-something-it-didnt-gnm</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Nineteen commits into a deployment control plane today. Reading them back, four of them are the same bug wearing different clothes: the system took &lt;em&gt;"I could not find out"&lt;/em&gt; and stored it as &lt;em&gt;"I found out, and the answer is bad."&lt;/em&gt; A timed-out health probe became a failed workload. An IP address appearing became a machine being reachable. A cached object became a class that would still resolve later. A record being retired became a machine that had stopped billing. Different subsystems, same missing distinction — &lt;strong&gt;absence of evidence recorded as evidence of absence.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I work on a control plane: software whose entire job is to hold an opinion about the state of machines and workloads it doesn't run in-process. Everything it knows, it knows by asking something else over a network — SSH, a vendor API, a container runtime — and every one of those questions can fail to produce an answer.&lt;/p&gt;

&lt;p&gt;Which means a control plane has &lt;em&gt;three&lt;/em&gt; possible outcomes per question, not two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It's healthy.&lt;/li&gt;
&lt;li&gt;It's broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;I don't know.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every bug below is what happens when option three gets folded into option two — or, worse, into option one.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. A timed-out probe is not a failed workload
&lt;/h2&gt;

&lt;p&gt;The one that made me sit up. The control plane's own application was showing &lt;strong&gt;Failed&lt;/strong&gt; on a page it was, at that moment, successfully serving to me. Its service unit was &lt;code&gt;active running&lt;/code&gt; continuously. A root shell on the box couldn't reproduce a single failing read. And yet roughly one health sample in three was recording a 100% error rate.&lt;/p&gt;

&lt;p&gt;Here's the shape. Each runtime driver — the systemd one, the Docker one — implements a &lt;code&gt;WorkloadRuntime&lt;/code&gt; contract and probes the workload's state. Each one wrapped the probe like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Workload&lt;/span&gt; &lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WorkloadState&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;WorkloadState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ← the lie starts here&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 the only consumer — a scheduled sampler — did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$runtime&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&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;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;WorkloadState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Running&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Outcome&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Healthy&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Outcome&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// ← and gets laundered into truth here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those two together. An SSH connection that took one second too long returns &lt;code&gt;Pending&lt;/code&gt;. &lt;code&gt;Pending&lt;/code&gt; is not &lt;code&gt;Running&lt;/code&gt;. Not &lt;code&gt;Running&lt;/code&gt; maps to &lt;code&gt;Failed&lt;/code&gt;. So a slow minute on the network becomes a red badge next to a site answering 200, plus a row in the health log claiming a 100% error rate.&lt;/p&gt;

&lt;p&gt;That's already bad as a dashboard bug. It got genuinely dangerous one layer up, because &lt;strong&gt;automatic rollback reads those same rows&lt;/strong&gt;. A healthy release could be rolled back on the strength of a connection that timed out. The safety mechanism was being fed fiction.&lt;/p&gt;

&lt;p&gt;The fix is a third channel, not a third enum 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;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkloadStateUnreadable&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Workload&lt;/span&gt; &lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$previous&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&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;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"Could not read state for workload &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The driver now throws it &lt;strong&gt;where the reading failed&lt;/strong&gt;, and never where the runtime answered:&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;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Workload&lt;/span&gt; &lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WorkloadState&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;$unit&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;ssh&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"systemctl show &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; --property=ActiveState,LoadState"&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="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;WorkloadStateUnreadable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&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="c1"&gt;// I don't know&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;match&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;activeState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$unit&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reloading'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;WorkloadState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Running&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'failed'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;WorkloadState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;''&lt;/span&gt;                    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;WorkloadStateUnreadable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&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="k"&gt;default&lt;/span&gt;               &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;WorkloadState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Pending&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;Two details in that &lt;code&gt;match&lt;/code&gt; are worth more than the exception itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;reloading&lt;/code&gt; is Running.&lt;/strong&gt; A unit taking new config is serving traffic the whole time. It was falling through to the &lt;code&gt;default&lt;/code&gt; arm and turning into a false red.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An empty &lt;code&gt;ActiveState&lt;/code&gt; is unreadable, not a state.&lt;/strong&gt; Empty string is &lt;em&gt;output that never arrived&lt;/em&gt;. Treating it as "some state I don't recognise" is exactly the category error the whole commit is about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the flip side, which is the part people get wrong when they first reach for this pattern: &lt;strong&gt;&lt;code&gt;LoadState=not-found&lt;/code&gt; stays &lt;code&gt;Pending&lt;/code&gt;.&lt;/strong&gt; A missing systemd unit, a missing Docker container, a missing Kubernetes deployment — those are &lt;em&gt;answers&lt;/em&gt;. The runtime replied. The reply was "it isn't here." That's knowledge, and it belongs in the state enum, not in the exception.&lt;/p&gt;

&lt;p&gt;The sampler needed no new branch, because it already had the right instinct in a &lt;code&gt;catch&lt;/code&gt; that was never being reached:&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;$state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$runtime&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&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;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$workload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$state&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;WorkloadStateUnreadable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Record nothing. Not enough evidence.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A gap in the sample history is honest. A row saying "100% errors" is not.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'records nothing when the runtime cannot be read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mockery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WorkloadRuntime&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="nv"&gt;$runtime&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;shouldReceive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'state'&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;andThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WorkloadStateUnreadable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&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;workload&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;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'timeout'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

    &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeploymentHealthSampler&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sample&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;workload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeploymentHealthLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;count&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;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'treats a missing unit as pending, not unreadable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&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;ssh&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;respondsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ActiveState=inactive'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="kc"&gt;PHP_EOL&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'LoadState=not-found'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;state&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;workload&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;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WorkloadState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Pending&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;One deliberate non-fix: I left the Kubernetes driver alone. Its probe surfaces a missing deployment through the same failure path as a broken &lt;code&gt;kubectl&lt;/code&gt;, and telling those two apart means matching on stderr strings I can't verify against a live cluster right now. Fixing it by guessing would replace a known-wrong classifier with an unknown-wrong one. Leaving a documented gap beats shipping confidence I don't have — which, you'll notice, is the same principle as the bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. An IP address is not a reachable machine
&lt;/h2&gt;

&lt;p&gt;Every first-time setup of a freshly provisioned machine failed on &lt;code&gt;Connection refused&lt;/code&gt;, and only a manual &lt;strong&gt;Retry&lt;/strong&gt; ever got past it.&lt;/p&gt;

&lt;p&gt;The wait job's completion condition was: &lt;em&gt;the cloud provider has reported an address for this machine.&lt;/em&gt; Reasonable-sounding. Wrong. The address is published tens of seconds before &lt;code&gt;sshd&lt;/code&gt; actually binds to the port. The provider is telling you the truth about the allocation; you're reading it as a claim about the daemon.&lt;/p&gt;

&lt;p&gt;So the wait now waits for the thing it actually depends on:&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;SshPortProbe&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;accepts&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;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.0&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="nv"&gt;$socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;fsockopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errno&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errstr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$timeout&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;$socket&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;return&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="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;Released every 10 seconds against the same overall deadline the job already had, so this tightens the condition without inventing a new timeout budget.&lt;/p&gt;

&lt;p&gt;The important line in that class is &lt;code&gt;@fsockopen&lt;/code&gt;, not for the suppression but for what it &lt;em&gt;doesn't&lt;/em&gt; do: &lt;strong&gt;it does not authenticate.&lt;/strong&gt; That was a real fork in the road. It's tempting to make the readiness check a full SSH handshake — surely a better signal? No. If the credential is wrong, that's not a race, it's a misconfiguration, and it must fail loudly at the bootstrap step in five seconds rather than get retried politely for ten minutes. A readiness probe that also validates config will happily spend your entire deadline on a problem that will never resolve.&lt;/p&gt;

&lt;p&gt;Readiness probes answer &lt;em&gt;"can I start yet?"&lt;/em&gt;. They should never be answering &lt;em&gt;"will it work?"&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. A cached object is not a class that will still resolve
&lt;/h2&gt;

&lt;p&gt;This one cost me the most time, because the stack trace pointed three frames away from the cause.&lt;/p&gt;

&lt;p&gt;Picking a region on the New Server page threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Argument #1 ($s) must be of type CloudSize, __PHP_Incomplete_Class given
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt; is what &lt;code&gt;unserialize()&lt;/code&gt; hands back when the class named in the payload &lt;strong&gt;cannot be resolved at the moment the payload is read&lt;/strong&gt;. It doesn't throw. It doesn't warn. It returns an object that satisfies no type declaration anywhere in your codebase, and lets that object travel until something eventually type-hints against it. So the explosion happens in a &lt;code&gt;fn (CloudSize $s) =&amp;gt; ...&lt;/code&gt; callback, and the cache write that caused it is nowhere in the trace.&lt;/p&gt;

&lt;p&gt;Why couldn't the class resolve? Because on this platform it genuinely might not, and I'd never thought about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every release installs into its own directory tree.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;composer install --optimize-autoloader&lt;/code&gt; writes a classmap of &lt;strong&gt;absolute paths&lt;/strong&gt; into that tree.&lt;/li&gt;
&lt;li&gt;Old releases get pruned.&lt;/li&gt;
&lt;li&gt;A long-lived PHP-FPM worker can be holding a classmap pointing into a directory that no longer exists — while the shared cache still holds a payload naming a class in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this isn't a Laravel bug or a cache-driver bug. It's a structural consequence of atomic-release deploys: &lt;strong&gt;the process that writes a cache entry and the process that reads it are not guaranteed to agree on what classes exist.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I could have fixed the symptom — flush on deploy, version the cache key, guard with &lt;code&gt;instanceof&lt;/code&gt;. I went structural instead, because the rule is easy to hold in your head and impossible to half-apply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caches hold arrays of scalars. Never objects.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which, with promoted constructor properties, is about six lines per DTO:&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;CloudSize&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;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$vcpus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$memoryMb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;CloudFamily&lt;/span&gt; &lt;span class="nv"&gt;$family&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;toCache&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nb"&gt;get_object_vars&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;'family'&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;family&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;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;fromCache&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;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&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;new&lt;/span&gt; &lt;span class="nc"&gt;self&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="mf"&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;'family'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CloudFamily&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tryFrom&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;'family'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nc"&gt;CloudFamily&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Unknown&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;Two things I'd point at in a review:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;get_object_vars()&lt;/code&gt; works here only because every constructor parameter is promoted and public.&lt;/strong&gt; Spreading a string-keyed array back into the constructor passes them as named arguments, so the round trip is symmetric for free. Add one non-promoted property and this silently drops it. That's a real fragility — it's bought deliberately, in exchange for not hand-writing four mapping methods per DTO, and it's the kind of thing that deserves a comment rather than a shrug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;tryFrom&lt;/code&gt;, not &lt;code&gt;from&lt;/code&gt;.&lt;/strong&gt; A backed enum is an object too, so it has to travel as its scalar value. But the vendor can add a new machine family tomorrow, and &lt;code&gt;from()&lt;/code&gt; would turn "a distribution I haven't heard of" into a fatal on a cached read — which would be &lt;em&gt;this exact bug class again&lt;/em&gt;, one level down. An unknown value is not an invalid one.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. A retired record is not a stopped bill
&lt;/h2&gt;

&lt;p&gt;The last one isn't a state-reading bug, but it's the same family, and it's the one with an invoice attached.&lt;/p&gt;

&lt;p&gt;Two machines were retired from the fleet and went on running — and on billing — because the cloud provider contract shipped with &lt;strong&gt;no destroy path at all&lt;/strong&gt;. That absence was deliberate, and I want to be fair to the reasoning, because it was mine: destroy was sequenced last in the plan, and "last" was enforced by the interface simply not having the method, rather than by anyone remembering.&lt;/p&gt;

&lt;p&gt;That's usually a good technique. Make the unsafe thing unrepresentable and you can't do it by accident. But here it produced a platform that can spend a customer's money and cannot stop spending it — and it &lt;em&gt;hid&lt;/em&gt; that, because "retire" removing a record looks like the operation finished. The record moving is not the machine stopping.&lt;/p&gt;

&lt;p&gt;So destroy landed, and the safety a missing method used to buy got re-bought explicitly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provenance decides.&lt;/strong&gt; Only a machine the platform itself created at a connected account is eligible for deletion. A machine someone adopted by typing in an address is refused — the platform is not what made it exist, and that address might be somebody's employer's VM. The option is &lt;strong&gt;absent&lt;/strong&gt; on the page for an adopted machine, not disabled. Disabled invites a support ticket asking how to enable it.&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;RetireManagedNodeAction&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ManagedNode&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$destroyMachine&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="kt"&gt;NodeRetirement&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;$destroyMachine&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="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isCloudCreated&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="nc"&gt;NodeNotDestroyable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;notCreatedHere&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&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;$destroyMachine&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;cloud&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// vendor call FIRST&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="nf"&gt;retireRecords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&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;strong&gt;Opt-in everywhere, defaulting to the old behaviour.&lt;/strong&gt; The UI requires the machine's address retyped. The equivalent MCP tool takes &lt;code&gt;destroy_machine&lt;/code&gt;, defaulting to &lt;code&gt;false&lt;/code&gt;, on top of the scope and confirmation it already required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The vendor call runs before any record moves.&lt;/strong&gt; This ordering is the whole design. If the API token is missing the delete scope, a 403 retires nothing, and the operator retries against records that still describe reality. Move the records first and a failed vendor call leaves you with a machine nobody is tracking and everybody is paying for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A vendor 404 is success.&lt;/strong&gt; Already-gone is the desired end state. A retry has to be able to &lt;em&gt;finish&lt;/em&gt;, not die on the absence of the thing it was trying to remove. Idempotency here isn't a nicety; without it the only way out of a partial failure is manual database surgery.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refuses to destroy a machine it did not create'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ManagedNode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&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;adopted&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&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;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetireManagedNodeAction&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="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destroyMachine&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NodeNotDestroyable&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;retired_at&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;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// nothing moved&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'treats an already-deleted machine as destroyed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&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;cloud&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;respondsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetireManagedNodeAction&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destroyMachine&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;destroyed&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;toBeTrue&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;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If your system's job is to hold an opinion about something it can only reach over a network, then &lt;strong&gt;"I don't know" is a first-class outcome and needs somewhere to live.&lt;/strong&gt; Not a nullable field you forget to check. Not a &lt;code&gt;catch&lt;/code&gt; that returns a plausible-looking default. Somewhere the type system makes you handle.&lt;/p&gt;

&lt;p&gt;A useful audit, and it took me about twenty minutes across this codebase: grep for &lt;code&gt;catch&lt;/code&gt; blocks that &lt;code&gt;return&lt;/code&gt; instead of &lt;code&gt;throw&lt;/code&gt;. Every one is a place where a failure to &lt;em&gt;find out&lt;/em&gt; is being converted into a &lt;em&gt;finding&lt;/em&gt;. Some of those are correct and considered. Some of them are quietly feeding your alerting, your dashboards, and — in my case — your automatic rollback.&lt;/p&gt;

&lt;p&gt;The general shape, stated once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;th&gt;What the system recorded&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The probe timed out&lt;/td&gt;
&lt;td&gt;The workload failed&lt;/td&gt;
&lt;td&gt;Rollback of a healthy release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An address was allocated&lt;/td&gt;
&lt;td&gt;The machine was reachable&lt;/td&gt;
&lt;td&gt;Every first setup failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An object was cached&lt;/td&gt;
&lt;td&gt;The class will resolve on read&lt;/td&gt;
&lt;td&gt;Intermittent 500s, no trace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A record was retired&lt;/td&gt;
&lt;td&gt;The machine stopped&lt;/td&gt;
&lt;td&gt;An invoice that never stops&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those four were reported to me as flaky. None of them were flaky. They were all deterministic consequences of a missing third case.&lt;/p&gt;

&lt;p&gt;What's next: the Kubernetes probe still can't tell "no such deployment" from "kubectl is broken", and I'd rather fix that against a real cluster than against my assumptions about stderr formatting. Same principle — don't ship certainty you haven't earned.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Dev Log: 3 September 2026 — Everything I Fixed Today Was Already Written</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:40:37 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-3-september-2026-everything-i-fixed-today-was-already-written-1emi</link>
      <guid>https://dev.to/nasrulhazim/dev-log-3-september-2026-everything-i-fixed-today-was-already-written-1emi</guid>
      <description>&lt;p&gt;Six commits across two codebases today — a deployment platform and an identity system. Different stacks, different problems. Reading them back in one sitting, the same shape six times:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The feature was written. Someone just couldn't reach it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not one of these was a missing implementation. The role management existed. The setup-command runner existed. The delivery tracking existed. The "already verified" message existed. All of it shipped, tested, working — and unreachable, because of a default, a branch, a missing entry point, or a token that had already been spent.&lt;/p&gt;

&lt;p&gt;That's a genuinely different category of bug from "we haven't built it yet", and it's much harder to see, because every code review of the feature passes. The feature is fine. The path to it isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The badge in the &lt;code&gt;@else&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Start with the smallest one, because it's the clearest.&lt;/p&gt;

&lt;p&gt;A team page shows members and lets an owner change their roles. The component had &lt;code&gt;addMember&lt;/code&gt;, &lt;code&gt;changeRole&lt;/code&gt; and &lt;code&gt;removeMember&lt;/code&gt; all along. But the page read as though role management didn't exist.&lt;/p&gt;

&lt;p&gt;The badge naming a member's &lt;strong&gt;current&lt;/strong&gt; role was inside the &lt;code&gt;@else&lt;/code&gt; branch of &lt;code&gt;@can('manageMembers')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sit with what that means. A plain member saw everyone's role and could change nothing. An owner could change any role and &lt;strong&gt;never saw what it currently was&lt;/strong&gt; — and the dropdown offered "Set as Lead", "Set as Member", "Set as Viewer" identically, with no marker on the one already true. The page never named a role to the one person allowed to act on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- Before: the person with the power was the one kept in the dark --}}
@can('manageMembers', $team)
    &amp;lt;x-dropdown&amp;gt;...&amp;lt;/x-dropdown&amp;gt;
@else
    &amp;lt;x-badge&amp;gt;{{ ucfirst($member-&amp;gt;pivot-&amp;gt;role) }}&amp;lt;/x-badge&amp;gt;
@endcan

{{-- After: the badge is information, not a permission --}}
&amp;lt;x-badge&amp;gt;{{ $roleLabels[$member-&amp;gt;pivot-&amp;gt;role] }}&amp;lt;/x-badge&amp;gt;
@can('manageMembers', $team)
    &amp;lt;x-dropdown&amp;gt;...&amp;lt;/x-dropdown&amp;gt;
@endcan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two follow-ons worth naming:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The label came from &lt;code&gt;ucfirst()&lt;/code&gt; on a raw pivot value.&lt;/strong&gt; Untranslated, and it disagreed with the labels in the dropdown sitting &lt;em&gt;right next to it&lt;/em&gt;, which came from the enum's &lt;code&gt;label()&lt;/code&gt;. If you've got an enum with &lt;code&gt;label()&lt;/code&gt;/&lt;code&gt;color()&lt;/code&gt;, that enum is the only place a role should ever be turned into words. I pass a &lt;code&gt;[value =&amp;gt; label]&lt;/code&gt; map in from &lt;code&gt;render()&lt;/code&gt; rather than resolving the enum in the Blade file — an inline &lt;code&gt;@php()&lt;/code&gt; holding a nested static call is its own landmine in a component view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three of four confirmations were arriving a page late.&lt;/strong&gt; The toast component lives in the layout and &lt;em&gt;pulls&lt;/em&gt; session &lt;code&gt;toast.*&lt;/code&gt; keys as it renders. So a &lt;code&gt;session()-&amp;gt;flash()&lt;/code&gt; from a Livewire action that doesn't redirect surfaces on the next full page load — which, for &lt;code&gt;addMember&lt;/code&gt;, &lt;code&gt;removeMember&lt;/code&gt; and &lt;code&gt;changeRole&lt;/code&gt;, means never at the moment it meant anything. Those dispatch a browser event now. &lt;code&gt;delete()&lt;/code&gt; still flashes, because it redirects, and that's what the flash mechanism is actually for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you're mixing Livewire actions and a layout-level toast: &lt;strong&gt;flash is for redirects, dispatch is for stays.&lt;/strong&gt; Getting that backwards produces a bug where the user's action appears to do nothing and then a stale confirmation appears somewhere unrelated.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And a third: &lt;code&gt;removeMember()&lt;/code&gt; refuses to remove the organisation owner, and reported that refusal via &lt;code&gt;addError('memberEmail')&lt;/code&gt; — a field on the &lt;em&gt;add member&lt;/em&gt; form. So clicking Remove put a validation error on an input the operator wasn't touching, and said nothing about what actually happened. It's a dispatched error toast now, and the menu item is disabled for the owner instead of offered and then refused. The server-side guard stays, obviously: &lt;strong&gt;a disabled control is not an authorisation check.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The runner with no door
&lt;/h2&gt;

&lt;p&gt;Same shape, one level up.&lt;/p&gt;

&lt;p&gt;There's an action and an MCP tool for running an application's declared post-install commands. Both have existed for weeks. What didn't exist was any way to reach them from the application's page in the UI.&lt;/p&gt;

&lt;p&gt;So the workaround for "I need to run a seeder on this host" had become: &lt;strong&gt;push an empty commit&lt;/strong&gt; to trigger a deploy. A workaround with a commit in it. That's the tell that something's unreachable rather than unbuilt — the workaround is absurd and everyone's fine with it.&lt;/p&gt;

&lt;p&gt;The panel I added is mostly wiring, but two decisions in it are worth writing down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The list of commands is read from the same config the deploy reads.&lt;/strong&gt; Not a copy. Two lists drift, and the drift doesn't arrive as a diff — it arrives as "it worked when I ran it by hand and failed on deploy", six weeks later, on someone else's shift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The refusal order matters, and it's the opposite of the obvious one.&lt;/strong&gt; My deployment policy already refuses while an application is provisioning or being destroyed. So authorizing first handed an operator who is mid-deploy a bare &lt;code&gt;403&lt;/code&gt; — for something that is not a permission problem at all. The status check runs first now, and says what's actually true:&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RunSetupRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Application&lt;/span&gt; &lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Status BEFORE authorization: a mid-deploy operator is not&lt;/span&gt;
    &lt;span class="c1"&gt;// unauthorized, and telling them so sends them to the wrong person.&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;$application&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;canRunSetup&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="nf"&gt;back&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;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setupBlockedReason&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;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'runSetup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$application&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;The usual objection to ordering it this way is information disclosure — you've told an unauthenticated-ish caller something about the resource before checking their rights. Worth checking every time, and here it's fine: the page is already tenancy-scoped, so anyone who can read that status could read it from the deployment list beside it. &lt;strong&gt;If that weren't true, authorize first and accept the worse error message.&lt;/strong&gt; That trade-off is real, and it goes the other way often enough that it's worth naming rather than assuming.&lt;/p&gt;

&lt;p&gt;Two smaller things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The output is kept and shown.&lt;/strong&gt; The reason an operator reached for this is that the app was refusing to start. "Setup completed" with no output leaves them exactly as unable to distinguish a seeded database from a silently skipped seeder as they were before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The force checkbox carries a sentence, not the word "force".&lt;/strong&gt; These commands are first-install by nature and mostly &lt;em&gt;not&lt;/em&gt; idempotent — a seeder calling &lt;code&gt;User::create()&lt;/code&gt; fails the second time, and re-provisioning a tenant database is worse than failing. The label says that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It runs what the application already declared, in order, once. It is not a shell and not a step toward one. An arbitrary-command surface on a customer's production node is a different feature with a different threat model, and I'd want to design it as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tracking that was complete, and off
&lt;/h2&gt;

&lt;p&gt;Over in the identity system, every message on the mail-history detail screen read &lt;strong&gt;"No delivery events recorded"&lt;/strong&gt; — including the account-verification mail whose delivery we're asked to prove more often than any other.&lt;/p&gt;

&lt;p&gt;The package was complete. The &lt;strong&gt;defaults&lt;/strong&gt; weren't. The config declared tracking as &lt;code&gt;env('MAIL_HISTORY_TRACK_OPENS', false)&lt;/code&gt;, and production's &lt;code&gt;.env&lt;/code&gt; carried neither key. So the tracking routes were never registered and no pixel was ever injected. I probed it live before touching anything: both the open and click endpoints answered &lt;code&gt;404&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="c1"&gt;// config/mailhistory.php&lt;/span&gt;
&lt;span class="s1"&gt;'track_opens'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_HISTORY_TRACK_OPENS'&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="s1"&gt;'track_clicks'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_HISTORY_TRACK_CLICKS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flipping a default is a one-line diff and a real decision. &lt;strong&gt;There is deliberately no UI switch for it&lt;/strong&gt; — whether an activation email was opened is not an operator preference, it's an audit question. And defaulting to &lt;code&gt;true&lt;/code&gt; means no environment needs an &lt;code&gt;.env&lt;/code&gt; edit to be correct, which matters when the environment you most need it in is the one you touch least.&lt;/p&gt;

&lt;p&gt;The consequence you have to think about: click rewriting genuinely starts running in production for the first time. So Laravel's &lt;strong&gt;signed&lt;/strong&gt; email-verification URL joins the exclusion list, because its &lt;code&gt;?expires=...&amp;amp;signature=...&lt;/code&gt; is exactly the escaped-ampersand round trip that breaks under rewriting — and a broken signature locks an account out of verifying itself. &lt;strong&gt;Turning on a feature that has never run in production is a deploy, not a config change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two counting rules that went into the statistics service, both of which are the kind of thing that silently produces a plausible wrong number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engagement is counted from the events table, never from &lt;code&gt;status&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;status&lt;/code&gt; holds only the &lt;em&gt;latest&lt;/em&gt; state, so a click hides the open that preceded it. Count events, not states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count distinct messages, not event rows.&lt;/strong&gt; One recipient opening four times must not push an open rate past 100%. If a percentage can exceed 100, the denominator is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then a set of surfaces on top: summary cards computed &lt;em&gt;inside&lt;/em&gt; the Livewire component so they follow the active filters instead of silently contradicting them; an empty state that distinguishes &lt;strong&gt;"nobody opened it"&lt;/strong&gt; from &lt;strong&gt;"nothing was ever measured"&lt;/strong&gt; (they used to render identically, which is how the original bug hid for so long); a report page with period, status and origin breakdowns; and three MCP tools behind the same permission as the screens.&lt;/p&gt;

&lt;p&gt;The MCP tools never return a message body or a clicked URL — only the destination host. Verification and reset links live in there, and a read-only reporting tool is not a place to hand them out.&lt;/p&gt;

&lt;p&gt;Every surface now reports the tracking state next to the figures. A number whose collection was off for an unknown period is not a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token that was already spent
&lt;/h2&gt;

&lt;p&gt;The best one, and the one with real user pain attached.&lt;/p&gt;

&lt;p&gt;A personal-email verification link works once. The token is nulled the moment it's spent — correctly, that's the point. But the lookup is &lt;strong&gt;by that token&lt;/strong&gt;. So a second click on a link that had &lt;em&gt;worked perfectly&lt;/em&gt; fell into the &lt;code&gt;Invalid or expired verification link&lt;/code&gt; branch. The &lt;code&gt;already_verified&lt;/code&gt; branch sitting right below it was &lt;strong&gt;unreachable by construction&lt;/strong&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="nv"&gt;$subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Subject&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'verification_token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&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;first&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;$subject&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="nf"&gt;invalidLink&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ← every second click lands here&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;$subject&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;personal_email_verified_at&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="nf"&gt;alreadyVerified&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ← unreachable: token is gone by now&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users whose accounts had verified flawlessly were being told their account was broken. That's a support ticket, then six more.&lt;/p&gt;

&lt;p&gt;Nulling the token stays. What changes is the response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Post/Redirect/Get.&lt;/strong&gt; On success it redirects to a landing route carrying the subject id in the session, so a refresh no longer replays a spent token URL. Verification is a state change; it should not be sitting behind a re-runnable GET in someone's history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unresolvable token redirects to that same landing page&lt;/strong&gt;, which reports &lt;em&gt;"Email Already Verified"&lt;/em&gt; rather than a red failure. And here's the honest part: &lt;strong&gt;once the token is gone, the two cases genuinely cannot be told apart.&lt;/strong&gt; A never-issued token and a spent one look identical. So the page says what's true for the overwhelming majority, and keeps a quiet "never verified?" hint for the rest. When you can't distinguish two cases, pick the message that's right most of the time and leave a door for the exception — don't show a failure page to be safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mail no longer claims the link "will expire in 24 hours".&lt;/strong&gt; There's no expiry column, and the verification never checked the token's age. The claim was fiction — and it's precisely what made the old failure page read as &lt;em&gt;plausible&lt;/em&gt;. A user clicks their link twice, sees "invalid or expired", remembers the email said 24 hours, and concludes the system is right and they're late. &lt;strong&gt;A false reassurance in an email is what turns a confusing error into a believed one.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The two dead failure views are deleted, along with the branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one that wasn't already written
&lt;/h2&gt;

&lt;p&gt;One bug today was mine, fresh, and shipped an hour earlier: a baseline of security headers I'd added to every generated nginx vhost was &lt;strong&gt;appending&lt;/strong&gt; rather than deferring, so applications that set their own &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt; were answering with &lt;code&gt;DENY&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;SAMEORIGIN&lt;/code&gt;, which browsers read as neither.&lt;/p&gt;

&lt;p&gt;That one got its own post, because the nginx mechanics deserve the space — &lt;em&gt;"nginx add_header appends. It doesn't override."&lt;/em&gt; (companion post, link at review time). Short version: &lt;code&gt;add_header&lt;/code&gt; can't ask whether the upstream already sent a header, a &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt; can answer before you ask, and a security header added carelessly is a downgrade rather than an addition.&lt;/p&gt;

&lt;p&gt;Worth noting &lt;em&gt;how&lt;/em&gt; it was found, though, because it fits today's theme from the other side: I went and checked the live response headers right after claiming they were proven. The test suite was green. The suite verified the config generator, which was working exactly as written. &lt;strong&gt;Nothing in CI could have caught it&lt;/strong&gt;, and I'd have believed CI for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Six bugs, one shape. If I had to write the search query for tomorrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anything inside an &lt;code&gt;@else&lt;/code&gt;.&lt;/strong&gt; Ask who's in the other branch and what they lose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any branch that can't be reached&lt;/strong&gt; given how the state above it is mutated. The &lt;code&gt;already_verified&lt;/code&gt; check was dead code that looked like a safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any &lt;code&gt;env(..., false)&lt;/code&gt; default&lt;/strong&gt; in a config for a feature you assume is on. Then go probe the endpoint in production instead of assuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any action reachable only from an MCP tool, a console command, or a deploy.&lt;/strong&gt; If the workaround involves an empty commit, the door is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any two states that render identically.&lt;/strong&gt; "No events recorded" meaning both &lt;em&gt;nobody engaged&lt;/em&gt; and &lt;em&gt;nothing was measured&lt;/em&gt; is how an off switch hides for months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these show up as failing tests, because the code under test is correct. They show up when you use the thing as the person it was built for — which is the cheapest verification step available and the one easiest to skip.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Agent Skills All Said How to Start. None Said When to Stop.</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:37:55 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/my-agent-skills-all-said-how-to-start-none-said-when-to-stop-4a72</link>
      <guid>https://dev.to/nasrulhazim/my-agent-skills-all-said-how-to-start-none-said-when-to-stop-4a72</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I read someone else's skill library, went back to my own 32 skills, and found every one of them was a tutorial. They all explained how to begin a task. Not one explained how to know you were finished, or what a wrong turn looks like on the way. &lt;a href="https://github.com/nasrulhazim/claude" rel="noopener noreferrer"&gt;Claude Toolkit&lt;/a&gt; 2.5.0 adds three sections — Common Rationalizations, Red Flags, Verification — to the nine skills where being wrong is expensive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;A skill file for a coding agent is a strange document. It's not documentation, because nobody reads it for reference. It's not a prompt, because it's loaded conditionally. It's closest to a &lt;strong&gt;playbook you hand a competent contractor on their first day&lt;/strong&gt;: here's how we do this, here's what we care about, here's what will get you a phone call.&lt;/p&gt;

&lt;p&gt;Mine were only ever the first third of that. Take the deploy skill before this release. It knew the deploy command, the SSH flow, the order of operations, how to run migrations. Everything about starting.&lt;/p&gt;

&lt;p&gt;It said nothing about the moment that actually matters, which is when the agent — or a tired human — decides the deploy is done. &lt;code&gt;bin/deploy&lt;/code&gt; exited 0. Is that success? The skill had no opinion. So the answer became whatever the model felt like, which on a good day is thorough and on a bad day is "the command exited 0, deploy complete."&lt;/p&gt;

&lt;p&gt;That's not a model problem. That's a missing section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sections, and why each exists
&lt;/h2&gt;

&lt;p&gt;I added the same three headings to nine skills. Each one targets a different way work goes wrong, and the order matters — they run at different moments.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;## Common Rationalizations&lt;/code&gt; — the arguments you'll make to yourself
&lt;/h3&gt;

&lt;p&gt;A table. Left column: the excuse. Right column: what's actually true.&lt;/p&gt;

&lt;p&gt;This is the one I expected to feel patronising and it's turned out to be the most useful. Bad engineering decisions are rarely made in ignorance. They're made with a &lt;em&gt;reason&lt;/em&gt; — a sentence that sounds fine when you say it in your head at 6pm. Writing the sentence down next to its rebuttal takes away the "well, in this case…" move, because the case is right there in the table.&lt;/p&gt;

&lt;p&gt;From the code-quality skill:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rationalization&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Just baseline it, it's legacy code"&lt;/td&gt;
&lt;td&gt;A baseline entry is a permanent, invisible ignore. Baseline to unblock CI, then file the issue — a baseline that only ever grows is a broken quality gate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Level 5 is good enough for now"&lt;/td&gt;
&lt;td&gt;Levels only ever ratchet up when someone forces them. "For now" has been the level for two years in most codebases.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Pint fixed it, so the code is reviewed"&lt;/td&gt;
&lt;td&gt;Pint fixes spacing and ordering. It has no opinion on the god class it just reformatted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Adding &lt;code&gt;@var&lt;/code&gt; fixed the error"&lt;/td&gt;
&lt;td&gt;It silenced the error by lying to the analyser. If the annotation is wrong, you have made the codebase less safe, not more.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That first row cost me a real bug this week, in a different repo. Static analysis had flagged two classes as used zero times. Someone baselined the warning rather than deleting the files — and one of those "unused" classes was supposed to be doing PII redaction. The baseline hid it for months.&lt;/p&gt;

&lt;p&gt;The rebuttal has to be &lt;strong&gt;specific and mechanical&lt;/strong&gt;, not moral. "Don't be lazy" changes nothing. "A baseline that only ever grows is a broken quality gate" is an argument.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;## Red Flags&lt;/code&gt; — signals visible &lt;em&gt;during&lt;/em&gt; the work
&lt;/h3&gt;

&lt;p&gt;A flat list of observable things. Not principles — sightings. If you can see it in a diff, a terminal, or a PR, it belongs here.&lt;/p&gt;

&lt;p&gt;From the debugging skill:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sleep()&lt;/code&gt; or &lt;code&gt;--retry&lt;/code&gt; added to make a test pass&lt;/li&gt;
&lt;li&gt;The same bug fixed in three different callers in one PR&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;-&amp;gt;skip()&lt;/code&gt; or &lt;code&gt;-&amp;gt;markTestIncomplete()&lt;/code&gt; added during a bug fix&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dd()&lt;/code&gt;, &lt;code&gt;dump()&lt;/code&gt;, &lt;code&gt;ray()&lt;/code&gt; or &lt;code&gt;Log::debug('here')&lt;/code&gt; left in the diff&lt;/li&gt;
&lt;li&gt;A diff that touches files unrelated to the reported failure&lt;/li&gt;
&lt;li&gt;Blaming the framework, the database or the browser before reading your own stack trace&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And from deploy-app, where the stakes are higher:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deploying without a fresh, size-checked backup&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;migrate:fresh&lt;/code&gt;, &lt;code&gt;db:wipe&lt;/code&gt;, or &lt;code&gt;--seed&lt;/code&gt; typed against a production host&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;.env&lt;/code&gt; keys added to the repo but never set on the host&lt;/li&gt;
&lt;li&gt;A deploy that also contains a refactor&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;APP_KEY&lt;/code&gt; in the diff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice the second entry in the debugging list. "The same bug fixed in three different callers" is not a style complaint — it's near-proof that the fix is at the wrong layer. That's the value of the format: a red flag encodes a diagnosis, compressed into something you can &lt;em&gt;spot&lt;/em&gt; rather than something you have to &lt;em&gt;reason about&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;## Verification&lt;/code&gt; — the exit gate
&lt;/h3&gt;

&lt;p&gt;A checkbox list run before the work is called done. Not a summary of the skill; a set of conditions that can each be false.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Verification&lt;/span&gt;

Before calling a bug fixed:
&lt;span class="p"&gt;
-&lt;/span&gt; [ ] The original failure reproduces on demand (or its impossibility is documented)
&lt;span class="p"&gt;-&lt;/span&gt; [ ] The root cause is stated in one sentence, and it is a cause, not a symptom
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Every caller of the changed function was checked, not just the reported path
&lt;span class="p"&gt;-&lt;/span&gt; [ ] A regression test exists, and it fails when the fix is reverted
&lt;span class="p"&gt;-&lt;/span&gt; [ ] The full test suite passes, not just the new test
&lt;span class="p"&gt;-&lt;/span&gt; [ ] No debug output (&lt;span class="sb"&gt;`dd`&lt;/span&gt;, &lt;span class="sb"&gt;`dump`&lt;/span&gt;, &lt;span class="sb"&gt;`ray`&lt;/span&gt;, &lt;span class="sb"&gt;`Log::debug`&lt;/span&gt;) remains in the diff
&lt;span class="p"&gt;-&lt;/span&gt; [ ] The diff contains the fix and its test — nothing else
&lt;span class="p"&gt;-&lt;/span&gt; [ ] If the bug reached production, the blast radius is stated: who was affected, what data, over what window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"A regression test exists, and it fails when the fix is reverted."&lt;/strong&gt; If I could keep one line out of this entire release, it'd be that one. A test that passes both with and against your fix is a test of something else. Almost nobody checks, because checking means deliberately breaking working code, and it feels like a waste of ninety seconds. It's the only thing that distinguishes a guard from a decoration.&lt;/p&gt;

&lt;p&gt;Same energy in the last box. "The blast radius is stated" is not a coding task at all — it's the part of incident work that gets skipped because the code is already fixed and the adrenaline is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the end of the file, not the top
&lt;/h2&gt;

&lt;p&gt;The obvious objection: an agent reads the whole skill, so why does placement matter?&lt;/p&gt;

&lt;p&gt;It matters because &lt;strong&gt;the failure isn't at the entry, it's at the exit.&lt;/strong&gt; Instructions at the top of the file compete with the user's actual request and lose. A checklist under a &lt;code&gt;## Verification&lt;/code&gt; heading is a thing you can be &lt;em&gt;asked to run&lt;/em&gt; — "run the verification section" — and the answer is auditable, box by box. It's a different mode: not guidance, but a gate.&lt;/p&gt;

&lt;p&gt;That's the reframe I'd offer anyone writing skills for a coding agent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A skill is a &lt;strong&gt;policy document&lt;/strong&gt;, not a tutorial. The tutorial part is the cheap part — the model mostly knows the framework. What it doesn't know is your organisation's definition of &lt;em&gt;done&lt;/em&gt;, and which shortcuts you've been burned by.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The happy path is where the model is already strong. The failure modes are where it needs you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage, hygiene, and the boring half
&lt;/h2&gt;

&lt;p&gt;Three other things landed in 2.5.0, and two of them are only interesting as process lessons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two agents had no backing skill.&lt;/strong&gt; The role personas — code reviewer, QA engineer, DevOps engineer and so on — load skills as their playbook. Two were pointing at nothing. So the release adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;debugging&lt;/code&gt;&lt;/strong&gt; — the five-step triage (reproduce, localise, reduce, fix at the root, guard), a table of Laravel-specific traps (N+1 masked as a timeout, queue jobs failing silently on the wrong connection, config cache serving stale values, Livewire state desync), flaky-test diagnosis, and two reference files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;security-hardening&lt;/code&gt;&lt;/strong&gt; — defensive OWASP-in-Laravel, authorization coverage auditing, uploads, secrets, production config, CI/CD hardening. It's the proactive counterpart to the reactive soc-analyst and log-monitor pair I already had.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Step 1 of that triage is blunter than I'd usually write:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You have not started debugging until the failure happens on demand.&lt;/strong&gt; … If you cannot reproduce it, say so plainly and stop. A fix for a bug you never saw fail is a guess wearing a diff.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;My own convention file was being violated by my own skills.&lt;/strong&gt; The repo's &lt;code&gt;CLAUDE.md&lt;/code&gt; says every skill ships a &lt;code&gt;references/&lt;/code&gt; directory and a Reference Files table. Two skills had neither, and both were orphaned — no agent loaded them. Fixed, and both are now wired to an agent.&lt;/p&gt;

&lt;p&gt;That's the unglamorous lesson and it applies well beyond agent skills: &lt;strong&gt;a convention nobody checks is a preference.&lt;/strong&gt; Mine had drifted in the exact way today's other repo drifted, where a hand-maintained &lt;code&gt;SECURITY.md&lt;/code&gt; mirroring &lt;code&gt;composer.json&lt;/code&gt; had gone wrong in every single entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Companions, installed not vendored.&lt;/strong&gt; Two external tools are now referenced from the skills that benefit — a codebase knowledge-graph tool for research and architecture work, and a minimalism-ladder plugin for the code-review and refactoring skills. Both are installed from upstream, never copied into the repo. Both are optional: the installer reports a skip when prerequisites are missing, and &lt;strong&gt;every referencing skill states its fallback&lt;/strong&gt;, so a missing companion degrades the skill rather than breaking it. New &lt;code&gt;--no-companions&lt;/code&gt; flag for anyone who'd rather not.&lt;/p&gt;

&lt;p&gt;Copying someone else's tool into your repo is how you end up maintaining a fork of it by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  And a smaller thing that changed every response
&lt;/h2&gt;

&lt;p&gt;The default output format is now &lt;strong&gt;TLDR + tables&lt;/strong&gt;, written as a delimited managed block into &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; so it applies to every prompt. Replaced — never duplicated — on reinstall, and the user's own instructions are untouched.&lt;/p&gt;

&lt;p&gt;A new &lt;code&gt;/output&lt;/code&gt; command switches mode per session: &lt;code&gt;tldr&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;verbose&lt;/code&gt;, &lt;code&gt;bullets&lt;/code&gt;, &lt;code&gt;narrative&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;raw&lt;/code&gt;. Three rules keep it from becoming a footgun:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A one-off instruction in the prompt always beats the session mode.&lt;/strong&gt; "explain in detail" wins for that reply, then the mode returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never drop required content to satisfy a format.&lt;/strong&gt; A safety caveat, a failing test, an unmet requirement or a stated assumption gets reported in every mode — &lt;code&gt;tldr&lt;/code&gt; and &lt;code&gt;json&lt;/code&gt; included.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;tldr&lt;/code&gt; is not permission to omit; &lt;code&gt;verbose&lt;/code&gt; is not permission to pad.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That middle rule is the same idea as the verification section, honestly. A format is allowed to shape the prose. It is not allowed to quietly delete the bad news.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you're writing skills, playbooks, or &lt;code&gt;CLAUDE.md&lt;/code&gt; files for a coding agent, the highest-leverage paragraph you can add isn't another example of the happy path. It's the answer to two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;What excuse will be made for cutting this corner, and what's the rebuttal?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What has to be true before this is done — as boxes that can each be false?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model already knows the framework better than your docs do. What it can't know is which shortcut burned you in March.&lt;/p&gt;

&lt;p&gt;34 skills, 20 agents, 12 commands. MIT, and it's all at &lt;a href="https://github.com/nasrulhazim/claude" rel="noopener noreferrer"&gt;github.com/nasrulhazim/claude&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next on the list: making the nine verification sections runnable as an actual gate rather than a prompt the agent is trusted to honour. A checklist you can skip is, by today's standard, a decoration.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>architecture</category>
      <category>laravel</category>
    </item>
    <item>
      <title>nginx add_header Appends. It Doesn't Override — And That Can Remove Protection.</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:35:37 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/nginx-addheader-appends-it-doesnt-override-and-that-can-remove-protection-54pf</link>
      <guid>https://dev.to/nasrulhazim/nginx-addheader-appends-it-doesnt-override-and-that-can-remove-protection-54pf</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I shipped a baseline of security headers into every generated nginx vhost, meaning it as a &lt;em&gt;fallback&lt;/em&gt; for applications that send none of their own. Within the hour, production was answering with two &lt;code&gt;X-Frame-Options&lt;/code&gt; values: &lt;code&gt;DENY&lt;/code&gt; from the app and &lt;code&gt;SAMEORIGIN&lt;/code&gt; from my baseline. Browsers treat a contradictory set as &lt;em&gt;no directive at all&lt;/em&gt;, so a header added to increase protection had removed it. The fix is a &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt;, because &lt;code&gt;add_header&lt;/code&gt; has no way to ask a question.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I maintain a system that generates nginx server blocks for applications it deploys. The applications aren't mine. Some are well-built Laravel apps that set their own security headers in middleware; some are ten-year-old PHP that sets nothing at all and is being moved off a managed hosting panel that used to set headers on its behalf.&lt;/p&gt;

&lt;p&gt;So the requirement is easy to state: &lt;strong&gt;give every vhost a sane baseline, without stepping on an application that already knows what it wants.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first cut looked like this, rendered into every server block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server_tokens&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests green. Config valid. Deployed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What production actually answered
&lt;/h2&gt;

&lt;p&gt;I went to check the thing I had just claimed was proven — a habit that has earned its keep — and curl gave me this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;x-content-type-options: nosniff
x-content-type-options: nosniff
x-frame-options: DENY
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
referrer-policy: strict-origin-when-cross-origin
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every header, twice. The application's own middleware was setting &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;. My baseline had added &lt;code&gt;SAMEORIGIN&lt;/code&gt; beside it.&lt;/p&gt;

&lt;p&gt;Here's the thing I want to be blunt about, because I think it's the part people assume their way past:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two contradictory &lt;code&gt;X-Frame-Options&lt;/code&gt; values is not "the stricter one wins."&lt;/strong&gt; There is no merge rule. Browsers that receive an unparseable set of directives for that header treat the header as absent. So &lt;code&gt;DENY&lt;/code&gt; — the strongest possible value, which the application had correctly set — became nothing, because I added a weaker value next to it.&lt;/p&gt;

&lt;p&gt;On that particular host, &lt;code&gt;Content-Security-Policy: frame-ancestors 'none'&lt;/code&gt; still covered the gap; CSP takes precedence over &lt;code&gt;X-Frame-Options&lt;/code&gt; in every modern browser. But the applications this baseline was &lt;em&gt;written for&lt;/em&gt; are exactly the ones relying on &lt;code&gt;X-Frame-Options&lt;/code&gt; alone. For all of them, running my "hardening" left them &lt;strong&gt;worse off than before it ran&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three nginx facts worth internalising
&lt;/h2&gt;

&lt;p&gt;The bug wasn't a typo. It was three separate properties of &lt;code&gt;add_header&lt;/code&gt;, none of which I had actually sat with.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;add_header&lt;/code&gt; appends. Always.
&lt;/h3&gt;

&lt;p&gt;The name is honest and I read it as if it said &lt;code&gt;set_header&lt;/code&gt;. It adds a field to the response. It does not replace an existing one, and there is no &lt;code&gt;add_header ... if_not_present&lt;/code&gt; flag. Think of it as &lt;code&gt;$response-&amp;gt;headers-&amp;gt;set()&lt;/code&gt; versus &lt;code&gt;-&amp;gt;add()&lt;/code&gt; — nginx only gives you &lt;code&gt;-&amp;gt;add()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;add_header&lt;/code&gt; cannot ask a question.
&lt;/h3&gt;

&lt;p&gt;This is the real constraint. Inside a &lt;code&gt;location&lt;/code&gt;, there is no expression available to &lt;code&gt;add_header&lt;/code&gt; that means "unless the upstream already sent this." &lt;code&gt;if&lt;/code&gt; won't help you — &lt;code&gt;if&lt;/code&gt; inside &lt;code&gt;location&lt;/code&gt; is famously &lt;a href="https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/" rel="noopener noreferrer"&gt;not what you want&lt;/a&gt;, and it can't inspect response headers anyway, because at request-evaluation time the response doesn't exist yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;add_header&lt;/code&gt; does not merge across levels — it replaces the whole set.
&lt;/h3&gt;

&lt;p&gt;One &lt;code&gt;add_header&lt;/code&gt; in a nested block silently discards &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;add_header&lt;/code&gt; inherited from the parent. Not the matching one. All of them.&lt;/p&gt;

&lt;p&gt;This one bit me in a place I would never have looked. My generated config has a fallback location that returns a friendly 503 while an application is starting up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;@app_unavailable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Retry-After&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;!doctype&lt;/span&gt; &lt;span class="s"&gt;html&amp;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 single &lt;code&gt;Retry-After&lt;/code&gt; wiped the entire inherited baseline. Which means the response an operator is &lt;em&gt;most&lt;/em&gt; likely to see with their own eyes — the "just a moment, starting up" page during a restart — was the one response on the whole node with no protection headers on it. The fix is to repeat the set inside the location, with a comment explaining that the repetition is load-bearing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;@app_unavailable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Repeated, not inherited: nginx replaces the whole add_header set at the&lt;/span&gt;
    &lt;span class="c1"&gt;# deepest level that defines one, so Retry-After above would otherwise&lt;/span&gt;
    &lt;span class="c1"&gt;# strip the baseline.&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Retry-After&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xcto&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xfo&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="nv"&gt;$fallback_rp&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;!doctype&lt;/span&gt; &lt;span class="s"&gt;html&amp;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 while we're here: &lt;code&gt;always&lt;/code&gt; matters. Without it nginx omits the header from 4xx and 5xx responses — protecting only the responses that were never the risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;add_header&lt;/code&gt; can't ask a question, but a &lt;code&gt;map&lt;/code&gt; can answer one before the question is asked. Two behaviours combine into exactly what I needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$upstream_http_&amp;lt;header&amp;gt;&lt;/code&gt; holds the value the upstream sent for that header, or the empty string if it sent nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;nginx omits an &lt;code&gt;add_header&lt;/code&gt; whose value evaluates to an empty string.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those together and the fallback writes itself. In &lt;code&gt;http&lt;/code&gt; context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The baseline security headers are a FALLBACK: each value is empty when the&lt;/span&gt;
&lt;span class="c1"&gt;# application already sent that header, and nginx skips an add_header with an&lt;/span&gt;
&lt;span class="c1"&gt;# empty value.&lt;/span&gt;
&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$upstream_http_x_content_type_options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xcto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;"nosniff"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&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;map&lt;/span&gt; &lt;span class="nv"&gt;$upstream_http_x_frame_options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&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;map&lt;/span&gt; &lt;span class="nv"&gt;$upstream_http_referrer_policy&lt;/span&gt; &lt;span class="nv"&gt;$fallback_rp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&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;Then the vhost uses the variables instead of literals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server_tokens&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xcto&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="nv"&gt;$fallback_xfo&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="nv"&gt;$fallback_rp&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An application that sets its own headers keeps &lt;strong&gt;exactly&lt;/strong&gt; what it set — no duplicate, no contradiction. An application that sets none gets the baseline. That's the behaviour the requirement asked for, and it's what the first cut didn't do.&lt;/p&gt;

&lt;p&gt;Two properties of &lt;code&gt;$upstream_http_*&lt;/code&gt; that made me comfortable shipping this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's populated for &lt;strong&gt;fastcgi&lt;/strong&gt; and &lt;strong&gt;uwsgi&lt;/strong&gt; upstreams too, not just &lt;code&gt;proxy_pass&lt;/code&gt;. So a PHP-FPM vhost behaves the same as a reverse-proxied one.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;empty when nginx serves the file itself&lt;/strong&gt;, which is the correct answer for a static vhost: nothing upstream spoke, so apply the fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The deployment trap that nearly cost more than the bug
&lt;/h2&gt;

&lt;p&gt;Here's the operational edge case, and it's the one I'd want a reviewer to catch if I'd missed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A vhost that references &lt;code&gt;$fallback_xfo&lt;/code&gt; when no &lt;code&gt;map&lt;/code&gt; defines it is a config nginx refuses outright.&lt;/strong&gt; Not a warning. Not that vhost failing. &lt;code&gt;nginx -t&lt;/code&gt; fails, the reload is rejected, and if a deploy pushes the vhost without the map, you have taken down &lt;em&gt;every site on the node&lt;/em&gt; — including the ones you weren't touching.&lt;/p&gt;

&lt;p&gt;So the maps and the vhosts have to land together, in all three of the config-writing paths in my system (a systemd driver writing to disk, a container-oriented writer, and a Kubernetes ConfigMap). All three render vhosts from the same shared trait, so all three needed the maps added — the shared trait is exactly what makes it easy to forget, because the change &lt;em&gt;looks&lt;/em&gt; like one place.&lt;/p&gt;

&lt;p&gt;If you're adding this to an existing setup: deploy the &lt;code&gt;map&lt;/code&gt; block first, on its own, and reload. Then deploy the vhosts. A map nothing references is harmless. A reference with no map is a node-wide outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't copy
&lt;/h2&gt;

&lt;p&gt;The panel these applications are migrating from also sends &lt;code&gt;X-XSS-Protection: 1; mode=block&lt;/code&gt;. I deliberately left it out. Every current browser has removed the XSS auditor that header names, and on the browsers that kept it, the header introduced a cross-site information leak of its own. &lt;strong&gt;Matching the old platform's protection level is the goal; matching its config file is not.&lt;/strong&gt; Worth writing that distinction into a comment, because "the old system sent it" is a very persuasive argument at review time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing this honestly
&lt;/h2&gt;

&lt;p&gt;The config generator is unit-testable, and the test is worth writing because the &lt;em&gt;shape&lt;/em&gt; of the output is the whole contract:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'renders baseline headers as a fallback, not a second opinion'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$config&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;proxy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;renderVhost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'server_tokens off;'&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header X-Frame-Options $fallback_xfo always;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// The literal is the bug: a hardcoded value cannot defer to the app.&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header X-Frame-Options "SAMEORIGIN"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Not copied on purpose — the auditor it names no longer exists.&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-XSS-Protection'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'repeats the baseline inside the unavailable location'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// One add_header at a deeper level discards the entire inherited set,&lt;/span&gt;
    &lt;span class="c1"&gt;// so the 503 page needs its own copy or it ships bare.&lt;/span&gt;
    &lt;span class="nf"&gt;expect&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;proxy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;renderVhost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$application&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header Retry-After 5 always;'&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'add_header X-Frame-Options $fallback_xfo always;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But be clear-eyed about what that proves. &lt;strong&gt;It asserts a directive was emitted. It cannot assert what a browser does with the response.&lt;/strong&gt; The original bug passed a green suite. A test over generated config verifies the generator, not the behaviour — and the difference between "CI-proven" and "live-proven" is exactly the gap this bug lived in for an hour.&lt;/p&gt;

&lt;p&gt;The only thing that caught it was hitting the live host and reading the response headers, right after shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;add_header&lt;/code&gt; appends, can't be conditional, and replaces the entire inherited set at the deepest level that defines one. All three of those will surprise you at least once.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;map&lt;/code&gt; on &lt;code&gt;$upstream_http_*&lt;/code&gt; plus nginx's empty-value rule is the idiomatic way to express "only if the upstream didn't". No &lt;code&gt;if&lt;/code&gt;, no Lua, no duplicated header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A security header added carelessly can be a downgrade, not an addition.&lt;/strong&gt; "More headers" isn't a safety property.&lt;/li&gt;
&lt;li&gt;Ship the &lt;code&gt;map&lt;/code&gt; before the reference. A dangling variable in a vhost fails the whole config, not just that site.&lt;/li&gt;
&lt;li&gt;Verify the claim you just made, against the live thing, in the hour you made it. A green suite is evidence about the generator. It is not evidence about the response.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Dev Log: 2 September 2026 — Every Bug Today Was a Claim Nobody Checked</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:34:42 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2-september-2026-every-bug-today-was-a-claim-nobody-checked-5ghh</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2-september-2026-every-bug-today-was-a-claim-nobody-checked-5ghh</guid>
      <description>&lt;p&gt;Twenty-two commits across four repos today. Different stacks, different problems, and — reading them back in one sitting — the same bug wearing four costumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something asserted a state. Nothing verified it. The assertion was wrong, and it had been wrong for a while.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A compliance control was green on evidence that didn't exist. A kitchen screen said there was nothing to cook while paid orders were sitting there. A dashboard reported a 97.8% success rate directly above thirteen failed rows. A service worker claimed to be serving today's CSS and was serving the first CSS it ever saw.&lt;/p&gt;

&lt;p&gt;None of these threw. That's the shared trait. A stack trace is a gift — it tells you exactly where to look. A confident wrong number tells you nothing, and it keeps telling you nothing until someone counts by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  The control that was green on nothing
&lt;/h2&gt;

&lt;p&gt;Start with the worst one, because it's the cleanest illustration.&lt;/p&gt;

&lt;p&gt;A compliance reporter generates control evidence automatically — for each control, a line saying &lt;em&gt;why&lt;/em&gt; the system considers it satisfied. One data-minimisation control cited two mechanisms: encrypted casts on the sensitive columns, and a redaction trait applied to the audited models.&lt;/p&gt;

&lt;p&gt;The first half was true. The second half was not. The trait existed. It was applied to &lt;strong&gt;no model at all&lt;/strong&gt;. The control had been permanently green on a citation that pointed at nothing.&lt;/p&gt;

&lt;p&gt;And it wasn't simply forgotten. As written it &lt;em&gt;could not&lt;/em&gt; be applied to the main user model — the audit package it needed to cooperate with declares a method of the same name, and two traits declaring the same method in one class is a fatal composition error in PHP:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&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;Auditable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RedactsPiiInAudit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;RedactsPiiInAudit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;transformAudit&lt;/span&gt; &lt;span class="k"&gt;insteadof&lt;/span&gt; &lt;span class="nc"&gt;Auditable&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 &lt;code&gt;insteadof&lt;/code&gt; is doing real work. Without it the class doesn't compile. Descendants of a base class that had one of the two were fine — a trait on the child beats a method inherited through the parent — so the conflict only appeared on the one model that mattered.&lt;/p&gt;

&lt;p&gt;Two things I'd generalise from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated evidence needs a test that the evidence is reachable.&lt;/strong&gt; A reporter that emits &lt;code&gt;evidence_source: "...trait X"&lt;/code&gt; should be able to answer "which classes actually use X?" and fail when the answer is none. Otherwise the report is a string, and strings don't know if they're true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration on a trait belongs in a method, not a property.&lt;/strong&gt; A trait and its consumer cannot both declare the same property with different defaults — also fatal. So:&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="kd"&gt;trait&lt;/span&gt; &lt;span class="nc"&gt;RedactsPiiInAudit&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Overridable. A property here would be a fatal clash the first time&lt;/span&gt;
    &lt;span class="c1"&gt;// a consumer wants its own list — and a consumer that forgets to&lt;/span&gt;
    &lt;span class="c1"&gt;// declare one would have no default at all.&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;redactedAuditAttributes&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ic_number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'address'&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 bonus find, which is the part I'd actually lose sleep over: &lt;strong&gt;password hashes were being written into the audit trail.&lt;/strong&gt; An audit table is immutable by design and retained for a year. A hash written there can't be withdrawn. Masking is the right treatment for PII that has to stay recognisable in an audit — a credential is never in that category, so credentials and 2FA secrets are now excluded outright, not masked.&lt;/p&gt;

&lt;p&gt;The same commit deleted three classes referenced from nowhere. Static analysis had flagged two of them as used zero times, and someone had &lt;strong&gt;baselined the warning instead of deleting the files&lt;/strong&gt; — which is its own version of today's theme. A baseline entry is a permanent, invisible ignore.&lt;/p&gt;

&lt;p&gt;And a &lt;code&gt;SECURITY.md&lt;/code&gt; that hand-mirrored &lt;code&gt;composer.json&lt;/code&gt; had drifted in every single entry: it named a framework version two majors behind, and listed four dependencies the project doesn't have. A hand-maintained copy of a lockfile does not stay true. It now points at &lt;code&gt;composer.json&lt;/code&gt; and names the audit commands instead. A stale security document is worse than no security document, because it looks like diligence.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two screens that contradicted themselves
&lt;/h2&gt;

&lt;p&gt;Same shape, two different products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An audit dashboard&lt;/strong&gt; rendered its summary cards from the controller using an unfiltered count, while the table below them came from the datatable's filtered query. Filter to "failed, last four days" and you got 1,949 attempts and a 97.8% success rate printed directly above thirteen failed rows. Both halves of one screen, disagreeing — and the reassuring half was the wrong one.&lt;/p&gt;

&lt;p&gt;The fix is architectural, not arithmetic: the cards moved &lt;em&gt;inside&lt;/em&gt; the Livewire component and are computed from the same &lt;code&gt;baseQuery()&lt;/code&gt; that produces the rows.&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;// Whatever narrows the rows must narrow the summary. One source, two renders.&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;summary&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;$base&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;baseQuery&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="s1"&gt;'total'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;clone&lt;/span&gt; &lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'failed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;clone&lt;/span&gt; &lt;span class="nv"&gt;$base&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&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 a number and a list on the same screen come from two different builders, they will eventually disagree. Not might — will.&lt;/p&gt;

&lt;p&gt;The rebuilt report also added a period selector, because all-time was the only view and a large intake of successful records pinned the headline near 98% no matter what broke today. A metric with no time window is a metric that can't report a bad day.&lt;/p&gt;

&lt;p&gt;Best change in the lot: a &lt;strong&gt;"why they failed"&lt;/strong&gt; breakdown, grouping the recorded error messages and collapsing multi-line database stack traces to their error code. That data had been persisted from day one and never read once. The step tells you &lt;em&gt;where&lt;/em&gt; something stopped; only the message tells you &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A kitchen display screen&lt;/strong&gt; in a point-of-sale app was the same bug in food form. The display queried for orders whose status was &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;confirmed&lt;/code&gt;. But the till takes payment first and cooks after — checkout creates the order, then processes payment, which sets the order to &lt;code&gt;PAID&lt;/code&gt; while every line item is still &lt;code&gt;PENDING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So no order ever taken at the counter reached the kitchen. The screen had only ever shown "No pending orders", which reads exactly like a quiet afternoon.&lt;/p&gt;

&lt;p&gt;The insight is a modelling one worth stealing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Order status tracks money. Item status tracks cooking.&lt;/strong&gt; A cook is looking at food, so the query has to key on items.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The query now excludes only orders that were killed — cancelled, voided — and keys everything else on item state. A ticket drops off the board once every item is &lt;code&gt;READY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two statuses that both say "pending" are not the same status. Naming them for what they measure (&lt;code&gt;PaymentStatus&lt;/code&gt; vs &lt;code&gt;PreparationStatus&lt;/code&gt;) would have made the bug hard to write in the first place — which is most of the argument for enums over strings.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caches that were confidently out of date
&lt;/h2&gt;

&lt;p&gt;Two variations, both ending in "works after a hard refresh", which is the single most misleading symptom in web development because it makes the developer the only person who can't reproduce it.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;service worker&lt;/strong&gt; was matching static assets by pathname alone. In development that meant it also intercepted the dev server's own CSS and JS on a different origin and cached them cache-first — permanently. &lt;code&gt;Cmd+Shift+R&lt;/code&gt; bypasses the service worker entirely, which is precisely why only that showed the new styling.&lt;/p&gt;

&lt;p&gt;Two fixes, and the second is the general one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ignore every cross-origin request. Nothing from another origin belongs in an offline shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-first is only safe for content-hashed URLs.&lt;/strong&gt; Anything at a stable path — &lt;code&gt;/js/app.js&lt;/code&gt;, &lt;code&gt;/icons/*&lt;/code&gt; — freezes at whatever shipped first, forever. Those moved to stale-while-revalidate: instant from cache, refreshed in the background. Bump the cache name so the frozen entries get evicted on activation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The other variant: a &lt;strong&gt;generated report was styled by a bundle built before the report existed&lt;/strong&gt;. Same family — a build artifact asserting it was current when it predated the thing it was styling.&lt;/p&gt;

&lt;p&gt;While I was in there, a third one, and this is a nasty little Livewire trap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught TypeError: $wire.view is not a function
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A list/grid toggle introduced a &lt;code&gt;$view&lt;/code&gt; property on a component that already had a &lt;code&gt;view()&lt;/code&gt; method. On the &lt;code&gt;$wire&lt;/code&gt; proxy, &lt;strong&gt;a property shadows a method of the same name.&lt;/strong&gt; PHP is happy. Blade is happy. Static analysis is happy. The only symptom is a click that silently stops working.&lt;/p&gt;

&lt;p&gt;The guard is a test, because nothing else in the stack can see it:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'has no property shadowing a method on any Livewire component'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&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="nf"&gt;livewire_components&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;$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;$properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&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;ReflectionClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$class&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;getProperties&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$methods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&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;ReflectionClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$class&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;getMethods&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$properties&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;intersect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$methods&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;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: property shadows a method of the same name on &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;wire"&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;Same day, same repo, a related guard: a missing icon component throws at render time, so only a test that opens that exact page catches it — and a page nobody covers stays broken until a user clicks it. The new test walks every Blade file for icon references and asserts the file exists. That's the right level. Don't test that page 47 renders; test that &lt;strong&gt;no page can reference an icon that isn't there.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The one where the bug was blamed outward
&lt;/h2&gt;

&lt;p&gt;My favourite, because I've made this exact mistake and so has everyone reading this.&lt;/p&gt;

&lt;p&gt;Four directory attributes came back &lt;code&gt;null&lt;/code&gt; on live objects. This was written up as a missing read permission on the service account — a plausible story, an external cause, a ticket for someone else.&lt;/p&gt;

&lt;p&gt;It wasn't. The profile screens in the same application display one of those very attributes, from the same directory, on the same bind, with no special grant. The evidence that the story was wrong was already on screen.&lt;/p&gt;

&lt;p&gt;The actual defect: the helper normalises the &lt;em&gt;needle&lt;/em&gt; to lowercase, but the attribute bag is keyed exactly as the directory returned it. A mixed-case name like &lt;code&gt;pwdLastSet&lt;/code&gt; silently missed and returned &lt;code&gt;null&lt;/code&gt;. The profile views never hit it because they iterate the raw bag and lowercase &lt;strong&gt;both sides&lt;/strong&gt; of the comparison.&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;// Lowercasing one side of a comparison is not case-insensitive matching.&lt;/span&gt;
&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$object&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttributes&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;first&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="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An earlier "fix" had added an explicit attribute selection — which only narrowed what came back, since an unrestricted query was already returning those attributes. That's the tell for a wrong diagnosis: &lt;strong&gt;the fix makes the system do less and the symptom stays.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The part that took the longest wasn't the code. It was going back to correct the audit finding that said the evidence was unobtainable without a permission change. It was obtainable the whole time. If the write-up survives and the diagnosis doesn't, the write-up is the thing people act on next year.&lt;/p&gt;




&lt;h2&gt;
  
  
  And the same idea, one layer up
&lt;/h2&gt;

&lt;p&gt;The public repo today got a version of this on purpose. My &lt;a href="https://github.com/nasrulhazim/claude" rel="noopener noreferrer"&gt;Claude Toolkit&lt;/a&gt; shipped 2.5.0, and the substance of the release is that &lt;strong&gt;none of its 32 skills said when to stop&lt;/strong&gt; — they were all "how to start".&lt;/p&gt;

&lt;p&gt;Nine high-stakes skills now carry &lt;code&gt;## Common Rationalizations&lt;/code&gt;, &lt;code&gt;## Red Flags&lt;/code&gt; and &lt;code&gt;## Verification&lt;/code&gt;. The verification section is a checklist you run before claiming the work is done, and it's aimed squarely at today's failure mode:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The root cause is stated in one sentence, and it is a cause, not a symptom&lt;/li&gt;
&lt;li&gt;[ ] Every caller of the changed function was checked, not just the reported path&lt;/li&gt;
&lt;li&gt;[ ] A regression test exists, &lt;strong&gt;and it fails when the fix is reverted&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third box is the whole discipline in one line. Full write-up in the companion post.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The bugs that cost the most today weren't the ones that threw. They were the ones that answered confidently.&lt;/p&gt;

&lt;p&gt;A stack trace is a system admitting it doesn't know. A green control, a 97.8%, an empty kitchen queue and a cached stylesheet are systems claiming they do. Only one of those two categories can be found by waiting for it to break.&lt;/p&gt;

&lt;p&gt;So the pattern I'd take out of today:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whenever code emits a claim — a compliance status, a summary count, a "no results", a cached response — ask what would fail if the claim were false.&lt;/strong&gt; If the answer is "nothing", you don't have a feature. You have a decoration that people will make decisions from.&lt;/p&gt;

&lt;p&gt;Every fix today shipped with a guard: a test that fails if the trait goes unused, a test that fails on a property/method collision, a test that fails on a missing icon, a query that can't disagree with the list beneath it. Not because the tests are impressive — most of them are ten lines — but because a claim that nothing can falsify will drift back the moment nobody's watching.&lt;/p&gt;

&lt;p&gt;What's next: I want the compliance reporter to refuse to emit an evidence string it can't resolve to a live class. Right now it will happily cite a ghost.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dev Log: 1 September 2026 — Every Bug Today Was a Name Problem</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:29:29 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-1-september-2026-every-bug-today-was-a-name-problem-30i9</link>
      <guid>https://dev.to/nasrulhazim/dev-log-1-september-2026-every-bug-today-was-a-name-problem-30i9</guid>
      <description>&lt;p&gt;Fifty-three commits across eight repos today. Reading them back in one sitting, they sorted themselves into a single shape: &lt;strong&gt;something used a name as if it were an identity, and the name wasn't unique.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two people sharing a username. Two accounts sharing an email. Sixteen environments sharing the word "Production". A class whose name got rewritten under a caller that looks it up by name. Nineteen URLs sharing one document. Different stacks, same bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  Case-sensitive database, case-insensitive directory
&lt;/h2&gt;

&lt;p&gt;The one that took the longest to explain, and it's four words: &lt;strong&gt;&lt;code&gt;=&lt;/code&gt; is case-sensitive.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL and Oracle both compare strings case-sensitively. Active Directory, by specification, does not. So a uniqueness gate written as &lt;code&gt;where('username', $input)&lt;/code&gt; will happily tell you &lt;code&gt;AliceW&lt;/code&gt; is available while &lt;code&gt;alicew&lt;/code&gt; exists — and when the downstream sync writes to the directory, both names resolve to the &lt;em&gt;same object&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The gate says yes. The directory says "oh, that one". Two different answers to what everyone assumed was one question.&lt;/p&gt;

&lt;p&gt;The fix is a small support class rather than a scope, because it's needed on both Eloquent and query builders in several places:&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;UsernameQuery&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;whereMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;QueryBuilder&lt;/span&gt; &lt;span class="nv"&gt;$query&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;$column&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;$username&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;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&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="nb"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&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="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Two usernames collide when they differ only by case or surrounding
     * whitespace — the comparison the directory makes, and the one the
     * gates must make too.
     */&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;matches&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;$a&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;$b&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$b&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&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="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;normalize&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;$username&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="nb"&gt;mb_strtolower&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="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$username&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;Three decisions in there are worth more than the code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;LOWER(col) = ?&lt;/code&gt;, not a case-insensitive &lt;code&gt;LIKE&lt;/code&gt;.&lt;/strong&gt; This is an exact-match gate. Usernames legitimately contain &lt;code&gt;_&lt;/code&gt;, and &lt;code&gt;LIKE&lt;/code&gt; treats &lt;code&gt;_&lt;/code&gt; as a single-character wildcard — you'd get false collisions on every underscore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on the generated SQL, not on the returned rows.&lt;/strong&gt; SQLite folds ASCII case for &lt;code&gt;=&lt;/code&gt;. A test that only checks "did I get the row back" passes &lt;em&gt;before and after&lt;/em&gt; the fix when it runs on SQLite. The test that actually pins the behaviour asserts the query contains &lt;code&gt;LOWER(&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lowercases both sides of the comparison'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&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;tap&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="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UsernameQuery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'username'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'AliceW'&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;toSql&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sql&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;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LOWER('&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;strong&gt;Stopping new collisions surfaces none of the old ones.&lt;/strong&gt; So there's now a sweep that answers the inverse question. Every other lookup in that system is subject-scoped — "who owns X?", and you need X up front. This one is an aggregate over the entire name space, which no existing tool could express.&lt;/p&gt;

&lt;p&gt;Two details from writing it that generalise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The parts are combined with &lt;code&gt;UNION&lt;/code&gt;, not &lt;code&gt;UNION ALL&lt;/code&gt;, because one person can hold the same name in two different columns. Without the dedup, every ordinary user is reported as colliding with themselves.&lt;/li&gt;
&lt;li&gt;Any column with a stored prefix (&lt;code&gt;ext_&lt;/code&gt; and friends) has to have the prefix stripped &lt;em&gt;for grouping&lt;/em&gt;, or that whole population silently contributes nothing to the sweep. Strip it with &lt;code&gt;SUBSTR&lt;/code&gt;, not &lt;code&gt;LIKE 'ext_%'&lt;/code&gt; — &lt;code&gt;_&lt;/code&gt; is a wildcard, remember.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the finding I nearly shipped wrong: the first sweep flagged dozens of collisions as live takeovers. Almost none were. The risk label assumed a single directory namespace, and there isn't one — different identity types derive their directory object from different attributes entirely. Two owners can share a name without sharing an object.&lt;/p&gt;

&lt;p&gt;Grouping now aggregates in two steps — per &lt;code&gt;(name, type)&lt;/code&gt; first — so the outer query can distinguish "two of the same type share this" from "two different types do". The ordering keys on that signal too, because sorting on the raw count buried the one case worth acting on beneath thirty that weren't collisions at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A risk score that ranks noise above signal is worse than no score.&lt;/strong&gt; You'll read the top of the list and stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  A picker that collected a choice nobody consumed
&lt;/h2&gt;

&lt;p&gt;Different system, same species.&lt;/p&gt;

&lt;p&gt;A personal email address is not unique in most institutional databases — a person who progresses through two programmes keeps the same email on two ids. So the password reset flow shows a picker: here are your accounts, choose one.&lt;/p&gt;

&lt;p&gt;The picker was correct. It rendered the accounts, it validated the selection, it carried it through the form. And then the resolver ran:&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;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IdmUser&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereEmailAlt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&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;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// by email. unordered.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selection was collected and then ignored. Resets reported success, wrote to a stale id, and the live account was never touched — so the directory's &lt;code&gt;pwdLastSet&lt;/code&gt; never moved and the client read it as "your reset isn't doing anything".&lt;/p&gt;

&lt;p&gt;What makes this one worth writing down isn't the bug, it's the history. This was logged and closed a year ago. The fix that closed it &lt;em&gt;shipped the picker&lt;/em&gt; — the UI half — and was marked Resolved. No test covered the multi-account path, so the incomplete fix shipped green and the gap survived twelve months behind a ticket marked done.&lt;/p&gt;

&lt;p&gt;Four changes went in, and only the first is the fix:&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;// 1. Scope on the selected username AND the email, not the email alone.&lt;/span&gt;
&lt;span class="nc"&gt;UserCategory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereUsernameAndEmailAlt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Backstop: abort and log before any backend write if the resolved&lt;/span&gt;
&lt;span class="c1"&gt;//    identity doesn't match the one that was selected.&lt;/span&gt;
&lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UsernameQuery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$resolved&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$selected&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The picker now labels each account with its directory status, so two bare ids are actually distinguishable to the human choosing between them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The success screen names the account that was written.&lt;/strong&gt; A bare "Success" is what hid this for a year — from the user &lt;em&gt;and&lt;/em&gt; from support. If an operation picks one of several candidates, the confirmation has to say which one it picked. That's not UX polish; it's the only observability that costs nothing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's a diagnostics footnote too. The first report masked emails with one asterisk per character, so two unrelated people whose local parts shared their opening letters and length printed identically — the report read as one group where there were two. The counts were never wrong; the &lt;em&gt;rendering&lt;/em&gt; was ambiguous. It now carries a stable group ref (first 8 hex of a sha256 over the lowercased address) and a fixed-width mask, because per-character masking leaks the local part's length for no benefit at all.&lt;/p&gt;

&lt;p&gt;An identifier in a report needs to be unambiguous and stable across runs. A mask is neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Confirm by typing the name" only works if the name is unique
&lt;/h2&gt;

&lt;p&gt;Shipped a set of destructive MCP tools today — delete a project, an environment, a provider; retire a machine — and the guard pattern is the usual one: the caller must retype an identifier to confirm.&lt;/p&gt;

&lt;p&gt;Except sixteen of the seventeen environments on the live control plane are called &lt;code&gt;Production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Retyping a name that sixteen records share confirms nothing an agent couldn't have guessed. So the confirmation is on the &lt;strong&gt;slug&lt;/strong&gt;, and for a machine it's on the SSH host address rather than the hostname, which can be blank or shared.&lt;/p&gt;

&lt;p&gt;The pattern, as a trait:&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="kd"&gt;trait&lt;/span&gt; &lt;span class="nc"&gt;RequiresTypedConfirmation&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;confirmOrFail&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;$argument&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;$expected&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;$noun&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;// The argument key is passed in so a refusal is audited identically&lt;/span&gt;
        &lt;span class="c1"&gt;// whichever tool refused.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two other things went in the response text of every one of those tools, both because they're ways an agent reports a teardown wrongly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deleting a provider removes the &lt;strong&gt;credential&lt;/strong&gt;, not the host. An adopted machine keeps running and keeps costing money until its own hosting provider destroys it.&lt;/li&gt;
&lt;li&gt;The deletion is soft. "Deleted" in a tool response and "deleted" in a database are not the same claim, and an agent will relay the first as if it were the second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building MCP tools, that second one deserves a rule of its own: &lt;strong&gt;say what the tool did not do.&lt;/strong&gt; An LLM will fill any silence with the most obvious inference, and for a delete tool the obvious inference is "the thing is gone".&lt;/p&gt;

&lt;p&gt;There was also a dependency rule to move. The "can this be deleted?" check lived as a private method on a Livewire component, with a comment from its author warning it would be forgotten at the fifth call site. This was the fifth. It's now a &lt;code&gt;RecordDeletionGuard&lt;/code&gt; service asked by both the UI and the tool — and extracting it exposed that the rule was also &lt;em&gt;wrong&lt;/em&gt;: it counted every deployment row including destroyed ones, so the final step of a teardown was refused forever by the very deployments the teardown had just destroyed.&lt;/p&gt;

&lt;p&gt;Related, same day: a "has history" rule that was blocking deletes. History is not a dependency. Every provider anyone ever bootstrapped accumulates job rows, so the rule read, in practice, "you may delete a provider only if you never used it". The rows are kept on purpose, the parent is soft-deleted anyway, and they still resolve through &lt;code&gt;withTrashed()&lt;/code&gt;. Dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  A relation cached before the rows existed
&lt;/h2&gt;

&lt;p&gt;This one is pure Laravel, and it's the sharpest edge I hit today.&lt;/p&gt;

&lt;p&gt;A 24-step provisioning pipeline carries one &lt;code&gt;Deployment&lt;/code&gt; model instance through every step in a context object. Step 11 reads &lt;code&gt;$deployment-&amp;gt;routingRules&lt;/code&gt; — as a &lt;strong&gt;property&lt;/strong&gt;. That loads the relation and caches it on the instance. At step 11 it's empty, because the rules are written at step 16.&lt;/p&gt;

&lt;p&gt;Steps 20 and 21 then read the same cached, empty collection, and took the "an empty set is an honest success" branch. So DNS and SSL both reported &lt;strong&gt;success&lt;/strong&gt; on a deployment that finished 24/24 Active with no DNS record and no certificate. A second run worked, because it got a fresh instance.&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;// Cached at first access. Every later read on this instance sees step 11's answer.&lt;/span&gt;
&lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;routingRules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Re-queries. In a long-lived pipeline this is the one you want.&lt;/span&gt;
&lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$deployment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;routingRules&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone knows relation access is cached. The part that bites is that it's cached &lt;strong&gt;on the instance&lt;/strong&gt;, and in a request/response app instances don't live long enough for it to matter. Move that same model into a pipeline, a queued job chain, or a long-running worker and the lifetime changes underneath you without the code changing at all.&lt;/p&gt;

&lt;p&gt;The general rule I'm taking from it: &lt;strong&gt;if a model outlives the thing that loaded its relations, treat property access as a snapshot, not a query.&lt;/strong&gt; And be suspicious of any branch that reads "empty set → success". Empty is ambiguous. It means both "nothing to do" and "I looked at the wrong moment", and only one of those is a success.&lt;/p&gt;

&lt;h2&gt;
  
  
  A coverage test that could only see what today's fixtures happened to draw
&lt;/h2&gt;

&lt;p&gt;Spent a chunk of the day on localisation — making English the key language, with the second language selectable rather than forced by an org-level setting.&lt;/p&gt;

&lt;p&gt;I'd added a translation-coverage test the day before. It reported clean. Dozens of public-facing keys were untranslated.&lt;/p&gt;

&lt;p&gt;The test was render-driven: render the pages, ask Laravel which keys were looked up and not found. Reasonable design. But the local seed had no priced membership tiers, so the tier cards never rendered, their keys were never looked up, and the test was satisfied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A render-driven test is only as good as your fixtures.&lt;/strong&gt; It can only fail on the paths your seed data happens to reach.&lt;/p&gt;

&lt;p&gt;The fix is not to replace it but to put a second pass beside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source-driven:&lt;/strong&gt; every &lt;code&gt;__()&lt;/code&gt; key found in the public view tree must have a translation, whether or not anything renders it today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render-driven:&lt;/strong&gt; kept, because it still catches strings emitted from outside that tree — a component library's own labels, for instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither alone is sufficient, and the two failure modes are exactly complementary.&lt;/p&gt;

&lt;p&gt;Then a third one that neither pass can catch. Page titles were being set as literal view attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-layouts.auth.card title="Apply for membership"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never a key at all. The source scan looks for &lt;code&gt;__(&lt;/code&gt;; the render pass needs a key to be looked up. A string that was never a key is invisible to both. Found it by opening the page.&lt;/p&gt;

&lt;p&gt;Two lessons in one afternoon: automated coverage has a shape, and things outside that shape are not merely uncovered — they're &lt;em&gt;reported as covered&lt;/em&gt;. And the most important page in a funnel is the one most worth loading manually before you trust a green test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build tool renamed a class the native layer looks up by name
&lt;/h2&gt;

&lt;p&gt;The mobile side of the day. An app was dying mid-match — no Dart exception, no error screen, the process just vanished.&lt;/p&gt;

&lt;p&gt;R8 (Android's shrinker/obfuscator) had renamed most of an ML runtime's Java classes down to two-letter names, the way it's supposed to. That runtime's native layer resolves those classes &lt;strong&gt;by their original names&lt;/strong&gt; through JNI, so the first inference hit a null class in &lt;code&gt;GetMethodID&lt;/code&gt; and the Android runtime aborted the process.&lt;/p&gt;

&lt;p&gt;The rule R8 can't know: a class referenced only from native code has no Java-side reference to keep it, so as far as the shrinker is concerned nothing is using that name. Keeping it takes an explicit &lt;code&gt;-keep&lt;/code&gt; — the same reason reflection needs one.&lt;/p&gt;

&lt;p&gt;A crash with no stack trace in your language is almost always a layer boundary. JNI, FFI, a native plugin — something on the other side of a boundary that your exception handler doesn't cross.&lt;/p&gt;

&lt;p&gt;And it's the name problem again, in the most literal form of the day: a tool rewrote a name, and something else was still holding the old one.&lt;/p&gt;

&lt;p&gt;Sibling fix in the same release: two macOS crash reports were SIGSEGVs inside the bundled SQLite framework, and one of them showed the framework mapped &lt;strong&gt;twice&lt;/strong&gt; — same UUID, two base addresses. Two independent copies of SQLite live in one process, each with its own global config, handles crossing between them. The database was being hosted on a spawned background isolate with handles sent across the boundary; opening it on one isolate removes the surface entirely.&lt;/p&gt;

&lt;p&gt;Same name, two live things. Again.&lt;/p&gt;

&lt;h2&gt;
  
  
  And nineteen URLs sharing one document
&lt;/h2&gt;

&lt;p&gt;The public one, and the reason it's the whole other post: &lt;a href="https://devhub.my" rel="noopener noreferrer"&gt;devhub.my&lt;/a&gt; was serving the identical &lt;code&gt;index.html&lt;/code&gt; for every path, at HTTP 200, because of the standard SPA catch-all rule. An SEO crawl reported five pages; the sitemap listed nineteen. Both were correct.&lt;/p&gt;

&lt;p&gt;That's now a prerender step that writes a real HTML document per route and fails the build on three things no validator catches — a page that never declared its head, a JSON-LD graph with a dangling &lt;code&gt;@id&lt;/code&gt;, and a canonical pointing somewhere the page isn't. Full write-up in the companion post; repo is &lt;a href="https://github.com/developers-hub-my/website" rel="noopener noreferrer"&gt;developers-hub-my/website&lt;/a&gt; if you want to read the scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A framework upgrade plus a design-system pass&lt;/strong&gt; on one product: Laravel 13 / PHP 8.5, a starter kit dragged forward across thirty-odd minor versions, unified design tokens, and a shared list pattern rolled out screen by screen with one reference screen built first. Building the reference screen properly and then porting is slower on screen one and much faster on screens two through twelve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale published assets after a package update.&lt;/strong&gt; If your framework has a "publish these vendor assets" step, it has a "these are now out of date and nobody noticed" state. Wire the republish into the update path rather than the install path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration planning as documentation&lt;/strong&gt;, not as a ticket: what the current platform actually does, observed rather than assumed, before deciding what to replace it with. The probe that produced the observations got committed alongside the notes, which is the part I'd have skipped a year ago.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If today had a single rule in it, it's this: &lt;strong&gt;a name is not an identity.&lt;/strong&gt; It's a label that happens to be unique in the sample you looked at.&lt;/p&gt;

&lt;p&gt;Every bug above is what happens when code treats a label as a key — and every fix is the same move in a different dialect: normalise before comparing, confirm on something actually unique, name the thing you acted on in the response, and check the assumption at the layer that will be surprised, not the one that's convenient.&lt;/p&gt;

&lt;p&gt;The cheap tell: any time you're about to compare, group, or confirm on a human-readable string, ask who else could hold that string. If the answer isn't "nobody, by construction", you have a bug waiting for a coincidence.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dev Log: 31 August 2026 — Loaded Is Not Serving</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:05:05 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-31-august-2026-loaded-is-not-serving-38d</link>
      <guid>https://dev.to/nasrulhazim/dev-log-31-august-2026-loaded-is-not-serving-38d</guid>
      <description>&lt;p&gt;Thirty-eight commits across five repos today. Reading them back, most were the same species of bug: &lt;strong&gt;two different things that a system had been treating as one word.&lt;/strong&gt; Loaded and serving. Both-S3 and copyable. Present and correct. Every one of them was cheap to fix and expensive to find.&lt;/p&gt;




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

&lt;p&gt;Found in production, and my favourite bug of the day.&lt;/p&gt;

&lt;p&gt;A site was answering 200 to every request. The dashboard showed the workload as &lt;code&gt;failed&lt;/code&gt;, with its last release green and its deployment active. The journal was clean — php-fpm up, migrations run, no errors anywhere. A red workload sitting next to a perfectly healthy site, which is exactly the thing that sends somebody to go and fix a deployment that is fine.&lt;/p&gt;

&lt;p&gt;The blue-green runtime picks the "active" colour like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="no"&gt;SLOT_BLUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SLOT_GREEN&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;$slot&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="nf"&gt;unitLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slot&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;$slot&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;Here's the thing: &lt;strong&gt;systemd keeps a unit loaded after it dies.&lt;/strong&gt; &lt;code&gt;systemctl show&lt;/code&gt; on a stopped-but-known unit still reports &lt;code&gt;LoadState=loaded&lt;/code&gt;. So a retired blue outranked the green that was actually serving every request, the status probe went and asked blue how it was doing, blue said &lt;code&gt;ActiveState=failed&lt;/code&gt;, and the UI rendered red.&lt;/p&gt;

&lt;p&gt;The quieter half of the same bug: &lt;code&gt;standbySlot()&lt;/code&gt; is the inverse of that answer. With blue permanently "active", every release went to green. Two consecutive production releases landed on the same colour — blue-green alternating had silently stopped, which defeats the entire arrangement.&lt;/p&gt;

&lt;p&gt;The fix reads both properties in one round trip and lets an &lt;em&gt;active&lt;/em&gt; unit win outright, even though blue is examined first:&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;$state&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;unitState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// LoadState + ActiveState, one call&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;$state&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isRunning&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;           &lt;span class="c1"&gt;// active or activating&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$loadedFallback&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// only used if NEITHER is running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback is deliberate — it's what keeps a genuinely stopped workload reporting &lt;em&gt;Stopped&lt;/em&gt; rather than &lt;em&gt;Pending&lt;/em&gt;. And the test that scripted the old single-property probe was updated rather than deleted, because it now expresses &lt;em&gt;serving&lt;/em&gt; instead of merely &lt;em&gt;loaded&lt;/em&gt;, which is the distinction the whole bug turned on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both endpoints being S3 is not "can copy server-side"
&lt;/h2&gt;

&lt;p&gt;While wiring bring-your-own object storage, I hit a variant of the same mistake in my own contract design.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CopyObject&lt;/code&gt; in the S3 API is served by the endpoint you send it to. That endpoint cannot reach into a &lt;em&gt;different&lt;/em&gt; service's buckets. So "source is S3 and destination is S3" does not imply a server-side copy is available — same endpoint does.&lt;/p&gt;

&lt;p&gt;The awkward part is that the implementation is resolved per &lt;strong&gt;driver&lt;/strong&gt;, not per row. An S3 destination asked "can you server-side copy from this?" genuinely cannot tell which of its own configured endpoints it's being asked about. So the method grew a parameter:&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;supportsServerSideCopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;BackupDestination&lt;/span&gt; &lt;span class="nv"&gt;$destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;BackupArtefact&lt;/span&gt; &lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="p"&gt;,&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHPStan caught the arity break across one call site and two test doubles, which is the cheapest possible way to find out you changed a contract.&lt;/p&gt;

&lt;p&gt;The consequence is worth stating out loud: cross-endpoint S3-to-S3 has to &lt;strong&gt;stream&lt;/strong&gt;, which makes the streaming path the common case rather than the rare one. That promotes the transfer size ceiling from a defensive guard to a load-bearing part of the design — and it's why the byte counter counts bytes through the control plane, so a genuine server-side copy correctly records zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  A version that is a fact beats a version that is a guess
&lt;/h2&gt;

&lt;p&gt;A one-click Drupal install on an AlmaLinux 9 node bootstrapped, built, deployed, passed the health gate — and then died at step 18 with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The database server version 13.23 is less than the minimum required version 16.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EL9's AppStream defaults the &lt;code&gt;postgresql&lt;/code&gt; module to 13. Drupal 11 pins a minimum of 16. Those two can never agree, which means that recipe could not have installed on any RHEL-family node, ever — and the cost of learning it was a full composer build on a 1 vCPU box, reported on a step whose name points nowhere near a package stream.&lt;/p&gt;

&lt;p&gt;Two fixes, because either one alone leaves the failure sitting there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install a server modern apps accept.&lt;/strong&gt; A per-OS hook runs before a service's packages go on. Empty on Debian, whose &lt;code&gt;postgresql&lt;/code&gt; metapackage tracks the release's own server. On RHEL it switches the module stream, guarded three ways, each load-bearing: only when 16 actually exists in that node's AppStream, so an older minor keeps working; &lt;code&gt;module reset&lt;/code&gt; first, because enabling a second stream over an enabled one is an error rather than a switch; and only from the "no server installed yet" branch, because &lt;strong&gt;a stream switch does not upgrade a cluster&lt;/strong&gt; — it would leave the data on 13 and the packages on 16, which is worse than either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refuse early when it still can't be satisfied.&lt;/strong&gt; A recipe declares its minimum engine versions and a guard checks them at the end of the provisioning step — &lt;em&gt;not&lt;/em&gt; in preflight, and this is the part worth arguing about.&lt;/p&gt;

&lt;p&gt;Preflight has no database yet. The only thing available there is a guess from a table of distribution defaults: a table that goes stale silently and is wrong the moment somebody installs a version by hand. Six steps later, the version is a &lt;strong&gt;fact&lt;/strong&gt; — the provisioner has read &lt;code&gt;SHOW server_version&lt;/code&gt; off the running daemon. Still ten steps earlier than the failure it replaces, and it cannot be wrong.&lt;/p&gt;

&lt;p&gt;Two details from the same guard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An engine that isn't in the deployment at all is &lt;em&gt;unconstrained&lt;/em&gt;, not forbidden. The app might use SQLite, or a database this platform never provisioned.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;version_compare&lt;/code&gt;, not string comparison. &lt;code&gt;'9.6'&lt;/code&gt; sorts after &lt;code&gt;'16'&lt;/code&gt; as text, which is how a naive check waves through an engine seven majors too old.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A hostname nobody typed
&lt;/h2&gt;

&lt;p&gt;An application with no domain isn't merely unrouted — on a plain VM it's usually &lt;strong&gt;undeployable&lt;/strong&gt;. With no domain the runtime drops off fpm serving and onto the recipe's &lt;code&gt;php -S 0.0.0.0:${PORT}&lt;/code&gt; fallback, on the port the catalogue declared for the container image, and on any node already serving a domain, nginx is holding 80.&lt;/p&gt;

&lt;p&gt;Cloning is what makes this the common case rather than an edge case. Cloning strips every domain, correctly — a hostname is served by exactly one deployment — so the copy of a working site arrives in precisely the state that can't run, and the operator is asked to invent a hostname before they can see whether the copy even works.&lt;/p&gt;

&lt;p&gt;So a preview hostname gets issued rather than typed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{workload-slug}-{deployment-suffix}.{preview-base}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deployment's own random suffix carries the uniqueness, and that's the entire reason it's in the label. A scheme that &lt;em&gt;can&lt;/em&gt; collide needs a retry loop, a "name taken" error, and a human to resolve it — for a name nobody asked for in the first place. The workload slug is in there because one deployment may attach more than one HTTP application.&lt;/p&gt;

&lt;p&gt;The nice part: it's almost all wiring that already existed. One new step writes the name onto the routing rule; the reverse-proxy, DNS and SSL steps then do exactly what they already do for a hostname somebody typed, and none of them needs to know where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who owns the decision — a menu refactor that's really an access refactor
&lt;/h2&gt;

&lt;p&gt;On a multi-tenant product I work on, the Administration section had quietly become where anything unclassifiable landed: 22 items, six sub-groups, three levels deep, mixing a tenant's own settings with the vendor's diagnostics. A committee secretary was being shown Telescope, Horizon, an Artisan runner and an MCP console — tools they will never touch and shouldn't see.&lt;/p&gt;

&lt;p&gt;The dividing line I took wasn't &lt;em&gt;what kind of screen is this&lt;/em&gt;. It was &lt;strong&gt;who owns the decision.&lt;/strong&gt; Vendor-owned tooling moved into its own section, invisible to a tenant entirely. Tenant-owned administration stayed and flattened — single destinations moved to the top level instead of each sitting inside a sub-group that costs a click to reveal one item. Net effect on a tenant's sidebar: 22 items to 9, three levels to two.&lt;/p&gt;

&lt;p&gt;One implementation note that generalises. The new section gates on a landlord-level flag rather than a permission, for the same reason the platform routes do: &lt;strong&gt;these screens run with no tenant current, and a permission check then has no table to consult.&lt;/strong&gt; It throws rather than returning false.&lt;/p&gt;

&lt;p&gt;And the test that had checked the moved item is now &lt;em&gt;stronger&lt;/em&gt; than before — it reads the new section and short-circuits on that section's own authorisation, so the item can't leak through a per-item gate even if somebody adds one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Re-pricing moved out of a Livewire component and into an action.&lt;/strong&gt; Two callers now share one definition of what re-pricing means — a form and an MCP tool. If the tool had copied the "retire the active version, cut the next one" logic, there'd be two definitions free to drift, and the thing that drifts is what people get charged. The write tool also carries forward anything the caller didn't name rather than defaulting to zero, so an agent asked to raise one number can't silently wipe the parameters sitting beside it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A statistics dashboard that couldn't reach its own rows.&lt;/strong&gt; A mail-history package renders eight status counts and a banner reading "N messages stuck in Sending for over 1 hour" — with no way to reach those N. It can't have a list, because the table has no recipient or subject column; both live inside a JSON headers blob, which is why status is the only thing it can aggregate. Fix: add the two columns, fill them on &lt;code&gt;saving&lt;/code&gt; in a model that extends the package's, and backfill via a deploy operation that's idempotent and deliberately does &lt;strong&gt;not&lt;/strong&gt; bump &lt;code&gt;updated_at&lt;/code&gt; — the stuck-message window measures from it, so a backfill mustn't make a stuck message look fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateway credentials got a UI, and a rule.&lt;/strong&gt; A stored secret is never sent back to the browser; the form shows only whether one is set, and a blank field on save means "leave it alone" — so saving the page to change a URL can't silently blank a credential. Switching to a gateway with empty credentials is refused outright, because allowing it means every checkout throws and the operator hears about it from a customer instead of from that screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Livewire trap worth writing down:&lt;/strong&gt; &lt;code&gt;wire:model="settings.payment.stripe.secret"&lt;/code&gt; doesn't bind one value — Livewire reads each dot as nesting and builds a three-level array. It fails as "Array to string conversion", which names nothing useful. Flat field names mapped to dotted storage keys through an explicit &lt;code&gt;fieldMap()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sandbox route with no guard.&lt;/strong&gt; A pay-by-button-press endpoint that marks an invoice paid with no money involved — registered unconditionally by a glob-based route loader, checking nothing but that the invoice exists. That's fine in a sandbox and very much not fine anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And one genuinely small thing:&lt;/strong&gt; a mobile app version bump for optional player accounts and Google Sign-In, plus its changelog entry and two new BSD-3 licence notices. Third-party notices are one of those chores that's trivial the day you do it and awful the day you're asked for them at once.&lt;/p&gt;




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

&lt;p&gt;Every bug in the first half of today was a word doing two jobs. &lt;code&gt;loaded&lt;/code&gt; meant both "systemd knows about this" and "this is serving traffic". &lt;code&gt;s3&lt;/code&gt; meant both "speaks the protocol" and "can copy from that". A distribution default meant both "probably installed" and "definitely installed".&lt;/p&gt;

&lt;p&gt;They're hard to spot because the collapsed version reads fine — it's usually the shorter, more natural sentence. The tell is when a screen and reality disagree and nobody can immediately say which one is wrong. That's not a display bug. That's two concepts sharing a name, and the name picked the wrong one.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>php</category>
    </item>
    <item>
      <title>The Most Dangerous Backup Is the One That Reports Success</title>
      <dc:creator>Nasrul Hazim</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:05:00 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/the-most-dangerous-backup-is-the-one-that-reports-success-2bn4</link>
      <guid>https://dev.to/nasrulhazim/the-most-dangerous-backup-is-the-one-that-reports-success-2bn4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I spent today building out a backup subsystem, and almost every design decision that mattered came down to the same question: can this thing fail in a way that still renders as a green tick? A backup that fails loudly is an annoyance. A backup that fails quietly is the reason the restore doesn't exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure mode nobody designs for
&lt;/h2&gt;

&lt;p&gt;Backups have an unusual property among features: the moment you find out whether yours works is the worst possible moment to find out. Everything in between is inference. And inference is exactly where a system gets to lie to you politely.&lt;/p&gt;

&lt;p&gt;So the rule I kept coming back to today: &lt;strong&gt;a component may not report a success it did not earn.&lt;/strong&gt; Not "should not" — may not, structurally, because the code path that would produce the false green isn't there.&lt;/p&gt;

&lt;p&gt;Here's what that looked like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sync is not backup
&lt;/h2&gt;

&lt;p&gt;The first one is the one people get wrong most confidently, because the tool that does it wrong is excellent at its job.&lt;/p&gt;

&lt;p&gt;A sync tool mirrors a source into a destination. If a key disappears from the source, it disappears from the destination. That is correct behaviour for a mirror and catastrophic behaviour for a backup, because the thing you are backing up &lt;em&gt;against&lt;/em&gt; is somebody deleting the wrong folder at 2am — and a mirror propagates that deletion to the only copy that could have undone it, usually within the hour, and then reports success.&lt;/p&gt;

&lt;p&gt;So the bucket driver I wrote today records deletions and never replays them:&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="cd"&gt;/**
 * A key that vanished at the source is marked deleted in the manifest.
 * Its object is LEFT WHERE IT IS until retention expires it.
 * Nothing in this driver deletes an object at a destination.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tests pin the pair of consequences, and the second one matters as much as the first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an object deleted at the source is still restorable from the last run that had it;&lt;/li&gt;
&lt;li&gt;restoring a &lt;em&gt;later&lt;/em&gt; run does not resurrect it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second test is the one people forget. A restore reproduces the bucket as of that run. An operator recovering one folder did not also ask to undo every intentional deletion since — and if your restore does that, you've handed them a second incident while they were cleaning up the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Move" is copy, verify, then delete — in that order, always
&lt;/h2&gt;

&lt;p&gt;Moving a backup between destinations is the most obviously dangerous operation in the whole subsystem, because there's a window where the artefact exists in exactly one place and something is actively trying to remove it.&lt;/p&gt;

&lt;p&gt;The ordering rule is boring. What's interesting is making the rule &lt;em&gt;checkable&lt;/em&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="c1"&gt;// source_deleted_at stays null until the destination copy is Verified,&lt;/span&gt;
&lt;span class="c1"&gt;// and is written in its own statement, after verified_at.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two separate writes, deliberately. If they went out together you could argue forever about whether a bad row was a race or a bug. Kept apart, a query can find the violation:&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a transfer never deletes the source before the destination verifies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BackupTransfer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deletedSourceBeforeVerification&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;toBeEmpty&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 other half: the destination copy gets its &lt;strong&gt;own&lt;/strong&gt; artefact row, with its own key, size, checksum and verification state — and the driver is asked to verify &lt;em&gt;that&lt;/em&gt; row. A move that verified the source's checksum would have proved nothing about the transfer it existed to prove. It would have been a green tick describing the wrong object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification that admits what it didn't check
&lt;/h2&gt;

&lt;p&gt;This is my favourite one, because the honest version is uglier than the dishonest version and that's the whole point.&lt;/p&gt;

&lt;p&gt;Verifying a bucket copy properly means re-hashing every object. To do that, the control plane has to read every object back through itself — during the verification of a transfer whose entire purpose was to keep that data out of the control plane. You can't have both.&lt;/p&gt;

&lt;p&gt;The dishonest option is to check what's cheap and call it verified. What shipped instead: check object count, total size, and a spread sample of per-object sizes — and then say so.&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="s1"&gt;'verification'&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;'objects_checked'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1_284&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'bytes_expected'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4_118_233_712&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'etags_rehashed'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;etags_rehashed: false&lt;/code&gt; sits in the record permanently. A passing verification cannot be read, later, by somebody who wasn't there, as proving something it never checked. The same instinct produced a test that scans every operator-visible string in the feature for the words "point-in-time", "versioning" and "snapshot" — because the driver doesn't do those, and prose that merely &lt;em&gt;declines&lt;/em&gt; to claim something is not the same as prose that states the opposite. Somebody reading the screen in two years will assume whatever the words allow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrity is not restorability
&lt;/h2&gt;

&lt;p&gt;Every run checks that the artefact is present, the right size, the right checksum, and that it opens. All cheap, all worth having, and &lt;strong&gt;none of them prove it restores.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An archive that passes &lt;code&gt;pg_restore --list&lt;/code&gt; and then dies part-way through a real restore — a missing extension, an incompatible server version, an encoding mismatch — is indistinguishable from a good one right up until 3am.&lt;/p&gt;

&lt;p&gt;So: restore drills. A policy can opt into &lt;code&gt;verify_level = restorability&lt;/code&gt;, on its own cadence and its own cursor (&lt;code&gt;drill_cron&lt;/code&gt;, &lt;code&gt;next_drill_at&lt;/code&gt; — a nightly backup does not want a nightly drill; a drill costs a real target and a real restore).&lt;/p&gt;

&lt;p&gt;Two design calls in there I'd defend anywhere:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The drill uses the ordinary restore path.&lt;/strong&gt; Not a drill-specific one. A drill with its own code path proves the drill works, which is the one thing it was never in doubt about. It builds a restore record in new-target mode and hands it to the same &lt;code&gt;restore()&lt;/code&gt; an operator would trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed drill flags the policy; it never condemns the artefact.&lt;/strong&gt; Tempting to auto-demote a backup whose drill failed. That is precisely how your last good copy disappears on the strength of a drill-&lt;em&gt;environment&lt;/em&gt; problem. The artefact keeps its verified status. The policy goes red. There's a test pinning that, because it's the kind of rule a future refactor would "fix".&lt;/p&gt;

&lt;p&gt;And a bonus the drill gave me for free: &lt;strong&gt;restore time is a number nothing else in the system ever measures.&lt;/strong&gt; An operator planning around a backup needs to know if they're committing to ten minutes or six hours. There's no honest way to find out except doing it. So restore time is recorded from the drill's own clock, or from a real restore's — and where neither exists, the screen says "never measured". Never an estimate. An estimate here is a fabricated green tick wearing a number.&lt;/p&gt;

&lt;p&gt;Teardown, incidentally, goes in a &lt;code&gt;finally&lt;/code&gt; and is best-effort silent. A teardown error must not replace the real reason a drill failed. And a drill that leaves its target behind fills the server with drill databases — a backup feature causing the outage is a bad look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet ones: pagination and fakes
&lt;/h2&gt;

&lt;p&gt;Two smaller things, same family.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ListObjectsV2&lt;/code&gt; returns 1000 keys per call.&lt;/strong&gt; Back up the alphabetical first thousand objects of every bucket, report success, and you have built the worst possible version of this feature — the one that is indistinguishable from the correct one until somebody needs a file starting with "z". Paginate, and pin it with a test that seeds more than a thousand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fake is handed out only with a reason.&lt;/strong&gt; The resolver that picks an object-store client can fall back to a no-op in environments that have no daemon. It never does that silently:&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;$store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$resolver&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$destination&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;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isFake&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;BackupOutcome&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reason&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 driver turns that reason into a refusal. A silent fake records a beautifully successful backup of nothing — which is, again, the same bug: a green tick with no work behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incremental baseline has to be verified, not just complete
&lt;/h2&gt;

&lt;p&gt;Objects are compared on &lt;code&gt;(key, etag, size)&lt;/code&gt; against the last run that &lt;strong&gt;both completed and has a verified copy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Seeding from a merely-completed run inherits its gaps silently and forever — every future incremental agrees with a baseline that was already wrong, and the gap only surfaces during the restore that needed it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lastModified&lt;/code&gt; is deliberately excluded from the identity triple. A re-uploaded object gets a new timestamp with identical bytes, and comparing on a clock turns an incremental quietly back into a full — the opposite failure, but still the system being wrong about its own work.&lt;/p&gt;

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

&lt;p&gt;Every one of these is the same shape. Somewhere there's a path where the system can report a success it didn't earn, and the fix is never "add a check" — it's usually to make the dishonest path impossible to express:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;delete-after-verify, in separate writes, so the violation is queryable rather than debatable;&lt;/li&gt;
&lt;li&gt;verification that records what it &lt;em&gt;didn't&lt;/em&gt; do;&lt;/li&gt;
&lt;li&gt;a drill that shares the production restore path;&lt;/li&gt;
&lt;li&gt;a fake that must carry a reason;&lt;/li&gt;
&lt;li&gt;a baseline that must be verified, not merely finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything in this space, the question I'd start with isn't "does it work?" It's &lt;strong&gt;"if this quietly stopped working, how long until anyone noticed, and what would the screen say in the meantime?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer to the second half is "success", that's the bug. Everything else is detail.&lt;/p&gt;

&lt;p&gt;Next up on my side: the restore-source selector — when several verified copies exist, picking the cheapest one to restore from is its own small pile of trade-offs.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>devops</category>
      <category>php</category>
    </item>
  </channel>
</rss>
