<?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: HideyukiMORI</title>
    <description>The latest articles on DEV Community by HideyukiMORI (@hideyukimori).</description>
    <link>https://dev.to/hideyukimori</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%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg</url>
      <title>DEV Community: HideyukiMORI</title>
      <link>https://dev.to/hideyukimori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hideyukimori"/>
    <language>en</language>
    <item>
      <title>I Ban the Second Way of Doing Anything: What That Costs</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Sat, 05 Sep 2026 15:23:40 +0000</pubDate>
      <link>https://dev.to/hideyukimori/i-ban-the-second-way-of-doing-anything-what-that-costs-24fm</link>
      <guid>https://dev.to/hideyukimori/i-ban-the-second-way-of-doing-anything-what-that-costs-24fm</guid>
      <description>&lt;p&gt;For a long time I believed a good reviewer was enough to keep a codebase honest. Then I counted how many times I had written the same review comment.&lt;/p&gt;

&lt;p&gt;"There's already a function for that." "We read the clock through the port, not directly." "That's the third way we format a date."&lt;/p&gt;

&lt;p&gt;None of these were bugs. All of them were the same failure: a meaning that had grown a second implementation path. And every one of them had passed review, because reviewers hold rules in memory, and memory is exactly the thing that doesn't scale.&lt;/p&gt;

&lt;p&gt;So I adopted one rule and made it the constitution of everything I build since: &lt;strong&gt;every meaning gets exactly one implementation path, and a machine keeps it that way.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "prefer". Not "should". One path, and the build fails if you add another.&lt;/p&gt;

&lt;h2&gt;
  
  
  what the rule actually says
&lt;/h2&gt;

&lt;p&gt;It sounds like a style preference. It isn't. It's a claim about where truth lives.&lt;/p&gt;

&lt;p&gt;If there is one way to read the current time, then "does this code depend on the wall clock?" is a question with a mechanical answer: does it import the one adapter that's allowed to? If there are two ways, the question needs a human, and the human needs to remember both ways, and next month there are three.&lt;/p&gt;

&lt;p&gt;The rule cascades. One way to read time means one adapter that's allowed to. One adapter means a build check that rejects &lt;code&gt;now()&lt;/code&gt; everywhere else. A build check means someone has to prove it fires. A proof means a document that quotes real output. Every step is boring. Every step is the thing that was missing the last time I found a second path in review.&lt;/p&gt;

&lt;p&gt;I tested how far this goes by starting five small repositories in six days — Kotlin, C#, Go, Rust, Java — each with the same constitution, the same rule IDs, the same demand that every rule declare whether a machine enforces it or a person does. Five languages, one rule, 159 merged pull requests. That's the evidence behind everything below.&lt;/p&gt;

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

&lt;p&gt;Be honest about this part or the rule is just a slogan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adding a rule touches three places.&lt;/strong&gt; The rule text, the enforcement matrix that says how it's held, and the actual check. Change one and the other two drift. So I wrote a check for that too — the docs and the matrix are validated against each other in the build. Now adding a rule is slower than not adding it. That's the point, but it's also the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The checks themselves become a codebase.&lt;/strong&gt; The Java clock has about 1,800 lines of dependency-free conformance checking to guard maybe 5,000 lines of application. For a desktop clock. I am not going to pretend that ratio is comfortable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You have to prove the machine is watching.&lt;/strong&gt; A check you have never seen fail is not a check. I learned this the hard way this week: my determinism gate rejected the first violation I planted, I wrote "active" in the log, and a second probe walked straight through. The config file behind the gate was being loaded by no build script. A different, bundled rule had caught my first probe by accident. Proving each gate fires — and reading &lt;em&gt;which&lt;/em&gt; rule fired — is a real tax, and you pay it every time you add one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some of it never becomes mechanical.&lt;/strong&gt; Across the five repos, the rules that stayed with humans were always the same kind: "this name is too generic", "this invariant is now written in two places", "this shortcut bypasses the boundary". Rules about &lt;em&gt;meaning&lt;/em&gt;. Every language's toolchain could hold structure — dependency direction, module boundaries, forbidden calls — and none of them could hold meaning. I write those rules down as "planned" or "impossible" and leave them to review. The rule about one path applies to the enforcement too: don't claim the machine holds what it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  what I got for it
&lt;/h2&gt;

&lt;p&gt;The second path stopped appearing. Not because people got better — because the build got red.&lt;/p&gt;

&lt;p&gt;And something I didn't expect: the rule catches &lt;em&gt;me&lt;/em&gt;. The five repositories were built by one person with an AI agent. The AI drifts toward the second path constantly, because the second path is usually the locally obvious one. The machine doesn't care who wrote it. In one afternoon the checks caught a half-wired gate, a test suite that had never run, and a proof document that quoted expected output instead of real output. All three were mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  where this is wrong
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want to read if I were skeptical, so here it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throwaway code.&lt;/strong&gt; If the repository will not exist in a month, the three-place sync is pure overhead. I don't do this for prototypes, and I don't do it for scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams that can't agree on which way is the one way.&lt;/strong&gt; The rule assumes a decision. If two people each believe their path is canonical, mechanical enforcement just automates the argument. Decide first, then enforce. I have the easy version of this problem: it's one person and an agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule can't fully hold itself.&lt;/strong&gt; When I put the five repositories side by side, the word "active" — as in "this rule is actively enforced by a machine" — meant something different in each one. The C# repo counted "tests and review" as enforcement. The Java repo would have called the same thing "planned". The Kotlin repo left 41 of 56 rules out of its matrix entirely, as review obligations. Same author, same week, five definitions. The principle demands one path for every meaning, and the meaning of "enforced" had five. I only found that out by counting. So: the rule is still held by a person at the top, and that person drifts too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's six days, not six years.&lt;/strong&gt; Everything above is what it looks like when you start this way. I don't yet have evidence for what happens when a codebase built like this is three years old and the checks have to change. My older PHP tools have a lighter version of the same idea and it has held, but that's a different rule set and a different claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  the shape of it
&lt;/h2&gt;

&lt;p&gt;If you take one thing: the rule isn't "one way". Everybody says that. The rule is &lt;strong&gt;one way, held by something that doesn't forget&lt;/strong&gt; — and then being honest, in writing, about every place where that something is still a human.&lt;/p&gt;

&lt;p&gt;The five repos and their enforcement matrices are public: &lt;a href="https://github.com/hideyukiMORI/nene-clock" rel="noopener noreferrer"&gt;nene-clock&lt;/a&gt; (Java) is the most complete, and its &lt;a href="https://github.com/hideyukiMORI/nene-clock/blob/main/docs/QUALITY_GATES.md" rel="noopener noreferrer"&gt;QUALITY_GATES.md&lt;/a&gt; is the matrix I mean — 46 rows, 31 fully mechanical, 8 partially, 6 planned, 1 impossible. The half-wired gate story is &lt;a href="https://dev.to/hideyukimori/my-gate-failed-for-the-wrong-reason-nothing-was-loading-the-config-3kde"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Where does your codebase have two ways of doing the same thing right now — and who is holding the rule that says there should be one?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>programming</category>
      <category>architecture</category>
      <category>opensource</category>
      <category>discuss</category>
    </item>
    <item>
      <title>My Gate Failed for the Wrong Reason: Nothing Was Loading the Config</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Sat, 05 Sep 2026 15:03:52 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-gate-failed-for-the-wrong-reason-nothing-was-loading-the-config-3kde</link>
      <guid>https://dev.to/hideyukimori/my-gate-failed-for-the-wrong-reason-nothing-was-loading-the-config-3kde</guid>
      <description>&lt;p&gt;I planted a forbidden call in my core module, ran the build, watched it fail, and wrote down "determinism gate: active."&lt;/p&gt;

&lt;p&gt;The file that defined that gate wasn't being loaded by anything.&lt;/p&gt;

&lt;p&gt;This is a small Java 21 desktop clock (&lt;a href="https://github.com/hideyukiMORI/nene-clock" rel="noopener noreferrer"&gt;nene-clock&lt;/a&gt;, MIT) that I built in two days with an AI agent as a test bed for one rule: &lt;strong&gt;every meaning has exactly one implementation path, and a machine — not a reviewer — keeps it that way.&lt;/strong&gt; Part of that rule is "core never reads the wall clock." I had wired &lt;a href="https://github.com/policeman-tools/forbidden-apis" rel="noopener noreferrer"&gt;forbidden-apis&lt;/a&gt; to reject &lt;code&gt;Instant.now()&lt;/code&gt;, &lt;code&gt;System.nanoTime()&lt;/code&gt; and friends by method signature, and I had a written proof that it worked.&lt;/p&gt;

&lt;p&gt;What you'll get from this post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how a gate can fail for the right rule and the wrong reason, with the exact lines&lt;/li&gt;
&lt;li&gt;two more dead checks the same afternoon exposed, including a test suite that had never run&lt;/li&gt;
&lt;li&gt;the three habits I now apply before I write the word "active" next to anything&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;My repo keeps a file called &lt;code&gt;gate-proofs.md&lt;/code&gt;. For every custom check, it records a planted violation, the task that ran, the output, and confirmation that removing the violation turns the build green again. The idea is simple: a check I have never seen fail is not a check.&lt;/p&gt;

&lt;p&gt;The first entry, P1, was for the determinism rule. I put &lt;code&gt;LocalDateTime.now()&lt;/code&gt; in &lt;code&gt;:core:application&lt;/code&gt;, ran &lt;code&gt;./gradlew check&lt;/code&gt;, and it failed. I recorded the proof and moved on.&lt;/p&gt;

&lt;p&gt;Before starting the next piece of work I tried a second probe, this time with two different APIs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nanoTime&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toEpochMilli&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;The build passed.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;The forbidden signatures live in &lt;code&gt;config/forbiddenapis/determinism.txt&lt;/code&gt;. The convention plugin that every module applies was supposed to hand that file to forbidden-apis. Here is what it actually handed over, from the diff of the fix:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// build-logic/src/main/kotlin/neneclock.java-conventions.gradle.kts — before&lt;/span&gt;
&lt;span class="nf"&gt;forbiddenApis&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;bundledSignatures&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jdk-unsafe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"jdk-deprecated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"jdk-non-portable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                              &lt;span class="s"&gt;"jdk-internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"jdk-reflection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"jdk-system-out"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;signaturesFiles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rootProject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"config/forbiddenapis/base.txt"&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;Only &lt;code&gt;base.txt&lt;/code&gt;. The determinism file existed, was committed, was referenced in the docs, and was read by nobody.&lt;/p&gt;

&lt;p&gt;So why did P1 fail? Because forbidden-apis ships bundled signature sets, and &lt;code&gt;jdk-unsafe&lt;/code&gt; forbids &lt;strong&gt;methods that silently use the default time zone&lt;/strong&gt;. &lt;code&gt;LocalDateTime.now()&lt;/code&gt; happens to be one of them. My probe had been caught by a rule about locale safety, not by my rule about determinism. &lt;code&gt;System.nanoTime()&lt;/code&gt; and &lt;code&gt;Instant.now()&lt;/code&gt; are not in that bundle, so they walked straight through.&lt;/p&gt;

&lt;p&gt;It got slightly worse. The &lt;code&gt;:adapters:system-time&lt;/code&gt; module — the one place allowed to read the clock — has an override that removes &lt;code&gt;determinism.txt&lt;/code&gt; from its own signature list, so the exception is visible in the build file. That override was a no-op. You cannot remove a file from a list that never contained it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gate had failed for the right rule and the wrong reason, and the proof I wrote down was true and useless.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The other two things that were dead
&lt;/h2&gt;

&lt;p&gt;Fixing the wiring meant rerunning everything, and rerunning everything found two more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The conformance engine's own tests had never run.&lt;/strong&gt; My project-specific checks live in &lt;code&gt;build-logic&lt;/code&gt;, which is a Gradle &lt;em&gt;included build&lt;/em&gt;. Tasks in an included build are not part of the root &lt;code&gt;check&lt;/code&gt; unless you say so. I had written "every rule has positive and negative unit tests" — true — and neither &lt;code&gt;./gradlew check&lt;/code&gt; nor CI had executed them once. The first time I ran &lt;code&gt;./gradlew -p build-logic test&lt;/code&gt;, two failed. One of them meant that a correctly written waiver comment was being rejected, and the test for it was green because it only asserted the rule ID, not the &lt;em&gt;reason&lt;/em&gt; the violation was reported. Failing for the wrong reason, again, one layer down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The proof document's first version quoted output I expected, not output I got.&lt;/strong&gt; When I retook P1 from the real console, the excerpt did not match what I had pasted. I had written the proof from memory of what the tool &lt;em&gt;should&lt;/em&gt; say. That is not a proof; that is a wish with a code block around it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/nene-clock/pull/27" rel="noopener noreferrer"&gt;PR #27&lt;/a&gt; (+238 / −59), merged the same day as &lt;a href="https://github.com/hideyukiMORI/nene-clock/issues/26" rel="noopener noreferrer"&gt;Issue #26&lt;/a&gt; was opened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build the signature list once and hand it to &lt;strong&gt;both&lt;/strong&gt; the main and test forbidden-apis tasks. Tests reading real time are a determinism failure too.&lt;/li&gt;
&lt;li&gt;Make &lt;code&gt;check&lt;/code&gt; depend on &lt;code&gt;:build-logic:test&lt;/code&gt;, so the engine's own negative proofs run on every gate.&lt;/li&gt;
&lt;li&gt;Add a new rule, &lt;strong&gt;CNF-011&lt;/strong&gt;: if a file under &lt;code&gt;config/&lt;/code&gt; is not referenced by any build script, &lt;code&gt;validateConformance&lt;/code&gt; fails. "Placed but not wired" is now something the machine says, not something I have to remember to look for.&lt;/li&gt;
&lt;li&gt;Retake every proof from real output and keep the wrong first version in the history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The probe now fails like this — quoted from the console, not from my head:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Forbidden method invocation: java.lang.System#nanoTime() [現在時刻は WallClockPort / TickSource からのみ得る。
ここで読むと決定性が壊れる（ARC-007）]
Forbidden method invocation: java.time.Instant#now() [現在時刻は WallClockPort / TickSource からのみ得る。
ここで読むと決定性が壊れる（ARC-007）]
Scanned 17 class file(s) for forbidden API invocations (in 0.02s), 2 error(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;(The messages are in Japanese because the repo is; they say "the current time comes only from WallClockPort / TickSource — reading it here breaks determinism (ARC-007).")&lt;/p&gt;

&lt;p&gt;And CNF-011's own negative proof, P12, planting an orphan file:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CNF-011 config/forbiddenapis/orphan.txt
      — この設定ファイルを読み込むビルドスクリプトが無い。置いても効かない
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;"No build script loads this configuration file. Placing it here has no effect."&lt;/p&gt;

&lt;p&gt;The registry now has 14 proofs. Each one records what was planted, what task ran, what the tool printed, and that the build went green again after the violation was removed.&lt;/p&gt;

&lt;p&gt;One thing I want to be precise about, because I got it wrong in a first draft elsewhere: this is not a story about Java being special. My Go repo enforces the same rule with &lt;code&gt;depguard&lt;/code&gt; at the package level, and my Rust repo makes &lt;code&gt;std::time&lt;/code&gt; a name-resolution error with &lt;code&gt;#![no_std]&lt;/code&gt;. Java's forbidden-apis just names the ban at the finest grain — a method signature — which is what let me keep &lt;code&gt;LocalDateTime.now(Clock)&lt;/code&gt; legal in the one adapter that needs it. The wiring mistake could have happened in any of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A red build proves that &lt;em&gt;something&lt;/em&gt; rejected your probe. It does not prove that &lt;em&gt;your rule&lt;/em&gt; did.&lt;/strong&gt; Read the message. Check the rule ID. If the tool that fired isn't the one you were testing, you have no proof yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test that asserts only the rule ID will happily pass on the wrong rejection.&lt;/strong&gt; Assert the reason, or at least assert on a fixture that can only fail one way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paste output; never type it.&lt;/strong&gt; The moment I wrote what the tool "would" say, my proof document became documentation of my expectations. Those were exactly the expectations that were wrong.&lt;/p&gt;

&lt;p&gt;And one for the toolchain rather than the person: presence of a config file is not evidence that anything reads it. If your gate has a config directory, write the check that fails when a file in it is orphaned. Mine is 53 lines plus a 46-line test, and it would have caught the original bug before I wrote a single proof.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Issue #26 — &lt;em&gt;determinism.txt is not loaded by any build&lt;/em&gt;: &lt;a href="https://github.com/hideyukiMORI/nene-clock/issues/26" rel="noopener noreferrer"&gt;https://github.com/hideyukiMORI/nene-clock/issues/26&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PR #27 — the fix and the retaken proofs: &lt;a href="https://github.com/hideyukiMORI/nene-clock/pull/27" rel="noopener noreferrer"&gt;https://github.com/hideyukiMORI/nene-clock/pull/27&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gate-proofs.md&lt;/code&gt; §3 — the section titled "what we learned while proving", including the wrong first version: &lt;a href="https://github.com/hideyukiMORI/nene-clock/blob/main/docs/quality/gate-proofs.md" rel="noopener noreferrer"&gt;https://github.com/hideyukiMORI/nene-clock/blob/main/docs/quality/gate-proofs.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The enforcement matrix, where this rule went from "active" back to honest: &lt;a href="https://github.com/hideyukiMORI/nene-clock/blob/main/docs/QUALITY_GATES.md" rel="noopener noreferrer"&gt;https://github.com/hideyukiMORI/nene-clock/blob/main/docs/QUALITY_GATES.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related posts from the same repo family: &lt;a href="https://dev.to/hideyukimori/ci-paid-for-itself-on-the-first-run-4-repos-real-bugs-2m61"&gt;CI Paid for Itself on the First Run: 4 Repos, Real Bugs&lt;/a&gt; and &lt;a href="https://dev.to/hideyukimori/my-generator-hid-a-lint-error-ci-never-checks-what-nobody-commits-3186"&gt;My Generator Hid a Lint Error: CI Never Checks What Nobody Commits&lt;/a&gt; — both are the same shape: a green result that was measuring the wrong thing.&lt;/p&gt;

&lt;p&gt;Which check in your build have you seen fail for the reason you think it fails for?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>programming</category>
      <category>java</category>
      <category>gradle</category>
      <category>testing</category>
    </item>
    <item>
      <title>Two AI Agents Shared a Todo File and a Line Vanished</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Sat, 08 Aug 2026 08:06:55 +0000</pubDate>
      <link>https://dev.to/hideyukimori/two-ai-agents-shared-a-todo-file-and-a-line-vanished-22jd</link>
      <guid>https://dev.to/hideyukimori/two-ai-agents-shared-a-todo-file-and-a-line-vanished-22jd</guid>
      <description>&lt;p&gt;A line disappeared from my todo file.&lt;/p&gt;

&lt;p&gt;Not corrupted. Not moved to the archive where completed items go. Just absent — present in one version, missing in the next, with nothing in between reporting a failure.&lt;/p&gt;

&lt;p&gt;I run several AI coding agents in parallel, one per repository, and they share a single plain-text todo file as their cross-repo coordination point. I've &lt;a href="https://dev.to/hideyukimori/a-todotxt-shared-by-a-human-and-ai-agents-why-plain-text-beat-a-saas-board-for-my-workflow-5dbh"&gt;written before about why plain text beat a SaaS board for this&lt;/a&gt;. This post is about the failure mode I hadn't accounted for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the bug
&lt;/h2&gt;

&lt;p&gt;Every writer to that file — a CLI, a terminal UI, and the agents themselves — does the same thing: read the whole file, change one line in memory, write the whole file back.&lt;/p&gt;

&lt;p&gt;Three processes doing full-file read-modify-write on one path, with no locking anywhere.&lt;/p&gt;

&lt;p&gt;That's a lost update, and it's textbook. If session A reads the file, session B reads it, A writes, then B writes, B's copy never contained A's change and now the file doesn't either. No error surfaces because nothing went wrong at the filesystem level. Both writes succeeded. One of them just described a world that no longer existed by the time it landed.&lt;/p&gt;

&lt;p&gt;I had built a coordination mechanism whose entire job was to be shared, and left out the only part that makes sharing safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that was harder than the fix
&lt;/h2&gt;

&lt;p&gt;Here's what I actually want to pass on, because the concurrency bug is the boring half.&lt;/p&gt;

&lt;p&gt;When I noticed the file looked wrong, I did the obvious thing: diff it against the last backup. Which produced a wall of differences, because between those two points several agents had legitimately edited many lines — rewording items, compressing finished work, updating deadlines. Almost every line had changed &lt;em&gt;somehow&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So "what's different" was useless. Nearly everything was different. The question I actually needed to answer was narrower: &lt;strong&gt;is there anything that exists in the old version and has no counterpart in the new one?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's a set-difference question, not a line-diff question. And answering it meant picking a distinctive word from each candidate line — a repo name, an error string, a ticket number — and searching for that word across the current file. Most of the "missing" lines turned up immediately in reworded form. One didn't. That one was really gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A line diff tells you what changed. It doesn't tell you what's missing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your recovery instinct is &lt;code&gt;diff old new&lt;/code&gt;, it will work beautifully on a file nobody edits and drown you on a file that several writers touch all day.&lt;/p&gt;

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

&lt;p&gt;The honest status: the analysis is done, the fix isn't shipped. The candidates are file locking around every writer, or a modification-time check before each write that aborts if the file moved underneath you, or simply backing up more than once a day — because right now that daily snapshot is the only restore point I have, which is itself a finding.&lt;/p&gt;

&lt;p&gt;I'm not going to pretend I've closed this. What I did do immediately was change my recovery procedure, since that cost nothing and would have saved the hour I spent chasing phantom deletions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same bug, one layer down
&lt;/h2&gt;

&lt;p&gt;What makes this worth writing up rather than filing away is that I hit the identical structure the same week, in a product, in a database.&lt;/p&gt;

&lt;p&gt;One of my apps had just gained a scheduler that sends payment reminders on a timer. Multiple workers, one queue of things to send — so exactly one worker must own a given run. The obvious implementation is: check whether the lock row is free, and if it is, claim it. Two lines of code, and a window between them where another worker can do the same check and reach the same conclusion. Same lost update, dressed as an invoice going out twice.&lt;/p&gt;

&lt;p&gt;SQLite was one of the supported backends and it has no advisory locks, so the design couldn't lean on a database primitive. That constraint turned out to be a gift, because it forced the version that's actually testable: a plain table where &lt;strong&gt;acquiring is a single conditional &lt;code&gt;UPDATE&lt;/code&gt;, and a first-time claim is a single &lt;code&gt;INSERT&lt;/code&gt; on the primary key&lt;/strong&gt;. No check-then-act window, because there's no gap between checking and acting — the database's own atomicity does the arbitration. Releasing verifies a holder token, so a worker can't release someone else's lock. A TTL reclaims locks from workers that died holding them. Six tests, verified against three database engines.&lt;/p&gt;

&lt;p&gt;The todo file and the scheduler are the same bug at different altitudes. The database one got a rigorous fix because it was going to touch customers. The file one is still open because it only bites me.&lt;/p&gt;

&lt;p&gt;I notice that's a real ordering, and also that it's how the file ended up unprotected in the first place.&lt;/p&gt;

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

&lt;p&gt;If more than one process writes a whole file, you have a lost-update bug, whether or not you've seen it yet. Agent sessions count as processes. They're just processes that write convincing commit messages.&lt;/p&gt;

&lt;p&gt;Make claiming a resource one statement, not two. Any "check, then act" pair has a gap in it, and the gap is where two workers agree they both won.&lt;/p&gt;

&lt;p&gt;When you're recovering a multi-writer file, don't ask what changed — ask what has no counterpart. Pick a distinctive token from each old line and search for it in the new one. Reworded is not deleted, and a diff can't tell them apart.&lt;/p&gt;

&lt;p&gt;The lock table, the single-statement claim, and its six tests are here: &lt;a href="https://github.com/hideyukiMORI/nene-clear/pull/406" rel="noopener noreferrer"&gt;nene-clear#406&lt;/a&gt;. The todo file still has no lock.&lt;/p&gt;

&lt;p&gt;What's the shared file in your setup that nobody has put a lock on?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>ai</category>
      <category>devops</category>
      <category>discuss</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>My Contribution Graph Broke and My Data Was Fine: Three Queries Told Me</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 05 Aug 2026 16:48:23 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-contribution-graph-broke-and-my-data-was-fine-three-queries-told-me-3b7j</link>
      <guid>https://dev.to/hideyukimori/my-contribution-graph-broke-and-my-data-was-fine-three-queries-told-me-3b7j</guid>
      <description>&lt;p&gt;My GitHub profile said &lt;strong&gt;Something went wrong&lt;/strong&gt; where the contribution graph should have been. The activity timeline underneath it was empty too.&lt;/p&gt;

&lt;p&gt;Two explanations arrive uninvited when that happens. Either something is wrong with my account, or GitHub is having an outage. Both are worth ruling out before you start refreshing and hoping, because they imply completely different responses — and as it turned out, neither was true.&lt;/p&gt;

&lt;p&gt;What follows is the whole diagnosis. It took about five minutes and three queries, and the method generalises well past this particular breakage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the actual request behind the broken thing
&lt;/h2&gt;

&lt;p&gt;A rendered page that fails tells you almost nothing. You want the specific call it makes.&lt;/p&gt;

&lt;p&gt;The contribution graph is drawn from &lt;code&gt;contributionsCollection&lt;/code&gt; in GitHub's GraphQL API, so I asked for it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{
  user(login: "your-account") {
    contributionsCollection { contributionCalendar { totalContributions } }
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That returned &lt;strong&gt;502 Bad Gateway&lt;/strong&gt; after about 11 seconds. Three attempts, three 502s.&lt;/p&gt;

&lt;p&gt;This is already worth something. A reproducible 502 with a long latency is a server-side timeout, not a browser problem — which means refreshing, clearing cache, and trying another browser are all guaranteed to waste your time. I stopped doing any of that immediately.&lt;/p&gt;

&lt;p&gt;But "one query is slow" doesn't yet tell me whether my account is broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 1: is the account itself healthy?
&lt;/h2&gt;

&lt;p&gt;Ask the same account for something trivial:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{ user(login: "your-account") { login createdAt } }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Instant response. The account resolves, the record exists, GraphQL is happy to talk about it.&lt;/p&gt;

&lt;p&gt;So the account isn't damaged or suspended. Whatever is failing is specific to the expensive query, not to me as a user. One suspect eliminated.&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 2: is GitHub itself down?
&lt;/h2&gt;

&lt;p&gt;Run the &lt;em&gt;same expensive query&lt;/em&gt; against a different account. I used Linus Torvalds, because it's a public account with a lot of activity and the query is read-only:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{
  user(login: "torvalds") {
    contributionsCollection { contributionCalendar { totalContributions } }
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Returned fine.&lt;/p&gt;

&lt;p&gt;This is the control that matters most, and it's the one people skip. Same endpoint, same query shape, different input — and it works. So the aggregation machinery is up. The failure needs &lt;em&gt;my&lt;/em&gt; data to happen.&lt;/p&gt;

&lt;p&gt;Second suspect eliminated. By now I know the problem lives in the intersection of this query and this account, which is a much smaller place to look than "GitHub is broken."&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 3: shrink the input until it works
&lt;/h2&gt;

&lt;p&gt;The default &lt;code&gt;contributionsCollection&lt;/code&gt; window is a trailing year. So I asked for a week:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{
  user(login: "your-account") {
    contributionsCollection(from: "2026-07-12T00:00:00Z", to: "2026-07-19T00:00:00Z") {
      totalCommitContributions
      totalPullRequestContributions
      restrictedContributionsCount
    }
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It returned immediately: &lt;strong&gt;2,024 contributions in seven days&lt;/strong&gt; — 560 commits, 541 pull requests, 457 in private repositories.&lt;/p&gt;

&lt;p&gt;That single response answers both remaining questions at once.&lt;/p&gt;

&lt;p&gt;The failure is a function of window size, so it's an aggregation timeout — the backend can't finish summing a year of this account's activity inside its own limit. And &lt;strong&gt;the underlying data is intact&lt;/strong&gt;, because a seven-day slice of it just came back with real numbers in it. Nothing is corrupt or missing. Only the summing-up is failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A view that won't render and data that's gone are different problems, and one query tells them apart.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distinction was the whole point of the exercise. If the data had been damaged I'd have had an urgent problem. Instead I had a cosmetic one, and I could go back to work.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it happened, and what I did about it
&lt;/h2&gt;

&lt;p&gt;The cause is unglamorous: the day before, I'd pushed an unusually heavy burst of work across a lot of repositories at once. A trailing-year aggregation over a high-activity account spanning dozens of repos — private ones included — is simply an expensive thing to compute, and that burst pushed it past whatever budget the backend allows.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because I can't see GitHub's internals. That's the explanation most consistent with the evidence I have — window size determines failure, other accounts are unaffected — not something I confirmed from the inside.&lt;/p&gt;

&lt;p&gt;What I did about it: nothing.&lt;/p&gt;

&lt;p&gt;That was the actual decision, and it was only available to me because of control 3. Once you know the data is fine and the failure is a load-shaped timeout on a view, the correct response is to leave it alone and let the aggregation get cheaper as the burst falls out of the trailing window. There was no fix to apply. There was a wrong action available — filing a support ticket about data loss, or worse, "repairing" something — and skipping it was the win.&lt;/p&gt;

&lt;p&gt;Eleven days later I ran the same year-long query again. It came back in &lt;strong&gt;2.3 seconds&lt;/strong&gt; with 21,253 contributions. It healed exactly the way the evidence said it would.&lt;/p&gt;
&lt;h2&gt;
  
  
  The method, minus my specific problem
&lt;/h2&gt;

&lt;p&gt;Three questions, in this order, each answerable with one call:&lt;/p&gt;

&lt;p&gt;Does a cheap request to the same subject work? If yes, the subject is fine and you're looking at a query problem, not an account problem.&lt;/p&gt;

&lt;p&gt;Does the same expensive request work for a different subject? If yes, the service is fine and the failure needs your particular data.&lt;/p&gt;

&lt;p&gt;Does the expensive request work on a smaller input? If yes, it's a scale or timeout issue — and crucially, you have just proved the data underneath is readable.&lt;/p&gt;

&lt;p&gt;None of that is clever. What makes it useful is that each answer eliminates an entire class of cause, so five minutes of it beats an afternoon of refreshing the page and reading status pages that say all systems operational.&lt;/p&gt;

&lt;p&gt;And the third question does double duty: it's the one that tells you whether you're facing an emergency or an inconvenience. That's usually the thing you actually need to know first.&lt;/p&gt;

&lt;p&gt;What's the last "everything is broken" you had that turned out to be one query being asked for too much at once?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>github</category>
      <category>api</category>
      <category>graphql</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I Made My First Sync Do Nothing on Purpose: Proving Idempotence Before It Can Hurt</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:32:58 +0000</pubDate>
      <link>https://dev.to/hideyukimori/i-made-my-first-sync-do-nothing-on-purpose-proving-idempotence-before-it-can-hurt-42n9</link>
      <guid>https://dev.to/hideyukimori/i-made-my-first-sync-do-nothing-on-purpose-proving-idempotence-before-it-can-hurt-42n9</guid>
      <description>&lt;p&gt;The most dangerous moment in a sync's life is the first time you run it.&lt;/p&gt;

&lt;p&gt;I write my posts in a git repository and push them to a publishing platform through its API. The sync is one-way and it sends whole articles: fetch everything that's live, compare against what's in the repo, and &lt;code&gt;PUT&lt;/code&gt; the ones that differ.&lt;/p&gt;

&lt;p&gt;Think about what that does on run number one, when the repo has never been reconciled against live. Every article where my local copy is stale gets overwritten with the stale copy. All at once. On published posts that people have already read.&lt;/p&gt;

&lt;p&gt;The usual mitigations are eyeballing a diff, or enabling it for one article first. Neither of them proves anything about the other articles. I wanted a first run whose &lt;em&gt;correctness was structural&lt;/em&gt; rather than checked by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seed from live, not from your repo
&lt;/h2&gt;

&lt;p&gt;The trick is almost too simple to write down: &lt;strong&gt;build the initial state of the mirror by downloading what's already published, not by using what you have locally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the mirror starts as an exact copy of live, then logically the first sync must find zero differences and change nothing. The first run becomes a no-op by construction.&lt;/p&gt;

&lt;p&gt;And that turns the first run into an experiment. If it really does nothing, then my fetching, my normalisation, and my comparison all agree with the platform's own idea of what those articles contain. If it tries to write something, my comparison is wrong — and I've learned that &lt;em&gt;before&lt;/em&gt; the write happens, on a run I already expected to be silent.&lt;/p&gt;

&lt;p&gt;I called it canary zero: the canary that proves the machinery works by not moving.&lt;/p&gt;

&lt;p&gt;Here's the real log from that first run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⏭ 03-dev-building-a-typed-cms-for-business-data (id=4052442) 差分なし → no-op
⏭ 04-dev-building-self-hosted-business-tools-for-japan (id=4024309) 差分なし → no-op
⏭ 09-dev-disposable-tenant-demo (id=4099005) 差分なし → no-op
⏭ 11-dev-introducing-nene2-ai-readable-business-apis (id=3957239) 差分なし → no-op
⏭ 12-dev-mcp-should-not-touch-your-database (id=3989175) 差分なし → no-op
⏭ 14-dev-prod-fix-not-hostage (id=4188474) 差分なし → no-op
⏭ 16-dev-shared-todo-txt-human-ai (id=4168967) 差分なし → no-op

合計 PUT=0 no-op=7 新規skip=0 ファイル欠=0 不一致=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Seven articles, zero writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I verified that something worked correctly by looking at a log where nothing happened.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before even that, the sync had a dry-run mode — do everything except the write, and print what it &lt;em&gt;would&lt;/em&gt; have sent. Idempotence is a property you want to demonstrate before the first &lt;code&gt;PUT&lt;/code&gt; exists in your history, not after.&lt;/p&gt;
&lt;h2&gt;
  
  
  Normalisation is not preprocessing. It's the definition of "different."
&lt;/h2&gt;

&lt;p&gt;The comparison only means something if you've decided what counts as a difference, and this is where the design earns its keep.&lt;/p&gt;

&lt;p&gt;Mine is deliberately thin — line endings, trailing whitespace, runs of blank lines, and the quotes the platform strips from the frontmatter title:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\n{3,}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(?m)^(title:\s*)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(.*)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s*$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\1\2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then the dry run found something I would never have predicted.&lt;/p&gt;

&lt;p&gt;The platform &lt;strong&gt;adds language labels to unlabelled code fences&lt;/strong&gt;. I write a bare &lt;code&gt;&lt;/code&gt;`&lt;code&gt;&lt;/code&gt;, it looks at the contents, decides that's shell or plaintext or conf, and stores it that way. So the version I sent and the version it returns are permanently different, through no edit by any human.&lt;/p&gt;

&lt;p&gt;The consequence isn't cosmetic. Every sync would see a difference, &lt;code&gt;PUT&lt;/code&gt; the article, get back the labelled version, and see a difference again on the next run. &lt;strong&gt;A permanent write loop against published articles, driven entirely by an artifact of the comparison.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is one line — ignore the fence label when comparing:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`python&lt;br&gt;
s = re.sub(r"(?m)^(`&lt;/code&gt;)[\w.#+-]*", r"\1", s)&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Note what it does &lt;em&gt;not&lt;/em&gt; do. It only affects comparison. What gets sent is still exactly what's in the repo, so the platform keeps applying its own labels and I never fight it. Two safeguards for one rule.&lt;/p&gt;

&lt;p&gt;After that, the run went back to &lt;code&gt;PUT=0 no-op=7&lt;/code&gt;. Zero churn, verified the same way as before: by nothing happening.&lt;/p&gt;

&lt;p&gt;This is the part I'd generalise. Normalisation feels like a preprocessing detail, something you sprinkle on before a diff. It isn't. &lt;strong&gt;Every rule you add or omit is you deciding what "changed" means&lt;/strong&gt;, and if you never decide, your comparison will happily measure something you didn't intend — in my case, the platform's syntax highlighter.&lt;/p&gt;
&lt;h2&gt;
  
  
  When you can't get the proof, remove the target instead
&lt;/h2&gt;

&lt;p&gt;I run a second mirror to a different platform, and that one has a native git integration — I push, it publishes. No API calls, so no comparison, so no no-op log. The canary-zero trick simply isn't available.&lt;/p&gt;

&lt;p&gt;Rather than pretend it was fine, I changed what the initialisation protected: &lt;strong&gt;connect the integration with zero articles in the repository&lt;/strong&gt;, then add them back one at a time.&lt;/p&gt;

&lt;p&gt;If there's nothing to overwrite, the first run can't overwrite anything. I couldn't prove the machinery was safe, so I made the blast radius empty and grew it deliberately — one article, then another, then a few.&lt;/p&gt;

&lt;p&gt;Same goal, different mechanism, because the mechanisms have different properties. I think that's the more useful takeaway than either trick on its own: decide what the initialisation has to guarantee, then pick the technique that can actually deliver it &lt;em&gt;here&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other edge of the same knife
&lt;/h2&gt;

&lt;p&gt;One honest postscript, because a sync this decisive cuts both ways.&lt;/p&gt;

&lt;p&gt;It sends whole articles, which means the publish flag in my repo is authoritative too. I once pushed a stale local copy of a &lt;strong&gt;published&lt;/strong&gt; post that still said &lt;code&gt;published: false&lt;/code&gt;, and the sync faithfully unpublished a live article.&lt;/p&gt;

&lt;p&gt;That's not a separate bug. It's the same property that makes the design work — the repo is the truth and the platform is made to match it — applied to an input I hadn't checked. The more self-healing your sync is, the more precisely it will reproduce a mistake in its input. Mine now has a documented pre-push check for exactly this.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Initialise from the live system, so the first run is a no-op by construction. Then treat that no-op as your proof that the comparison is correct — it's the only test you get to run before the writes start.&lt;/p&gt;

&lt;p&gt;Write the dry-run mode first. Idempotence demonstrated after your first production write is a postmortem, not a test.&lt;/p&gt;

&lt;p&gt;Treat normalisation as the definition of "different," not as cleanup. Ask what the platform silently changes about your content, because whatever that is will look like an edit forever.&lt;/p&gt;

&lt;p&gt;And if the proof isn't available for your mechanism, shrink what can be damaged until the first run is safe by arithmetic instead.&lt;/p&gt;

&lt;p&gt;What does your deploy target quietly rewrite after you hand it your content?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>devops</category>
      <category>api</category>
      <category>githubactions</category>
      <category>testing</category>
    </item>
    <item>
      <title>My Fact-Check Caught the Viral Claim and Missed My Own Notes</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Tue, 04 Aug 2026 09:44:25 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-fact-check-caught-the-viral-claim-and-missed-my-own-notes-5gea</link>
      <guid>https://dev.to/hideyukimori/my-fact-check-caught-the-viral-claim-and-missed-my-own-notes-5gea</guid>
      <description>&lt;p&gt;I thought I had verified it. Six days later I found out I hadn't — not all of it.&lt;/p&gt;

&lt;p&gt;A post crossed my feed claiming that a very high share of engineers at one AI company run self-improving agent loops. I don't repeat numbers I haven't sourced, so before reacting I went looking for where it came from. That wasn't caution for its own sake: if the claim were true, I'd have had to rebuild how I run my own agent fleet.&lt;/p&gt;

&lt;p&gt;I couldn't find that sentence in the public record.&lt;/p&gt;

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

&lt;p&gt;Three separate, real things — none of which was the claim.&lt;/p&gt;

&lt;p&gt;An engineer there is reported as saying he no longer writes code by hand, with a record day of about 150 pull requests. I only ever reached secondhand accounts of that, so I'm passing it along as a report, not as a measurement.&lt;/p&gt;

&lt;p&gt;Anthropic's leadership has publicly estimated that 90% or more of their code is written by Claude, including scripts and experimental code. That's an estimate, and the source says so.&lt;/p&gt;

&lt;p&gt;And there's a published measurement: as of May 2026, more than 80% of the code merged into their codebase was authored by Claude.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every source described how much code the model writes. None described how many humans run the loop.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The viral version had swapped the subject — code became engineers — and borrowed "90%" from a different sentence about a different thing. Nobody had to lie for that to happen. A number just drifted one noun to the left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Absence is the hard part
&lt;/h2&gt;

&lt;p&gt;I want to be careful about what I'm claiming, because this is where fact-checks usually overreach.&lt;/p&gt;

&lt;p&gt;I searched from several angles, pulled the top sources, and had each claim challenged by verifiers whose job was to &lt;em&gt;refute&lt;/em&gt; it rather than confirm it. The verdict was unanimous. But unanimous about what? Not that the sentence doesn't exist. Only that I couldn't reach it.&lt;/p&gt;

&lt;p&gt;I've been burned by this exact gap before. I once read a &lt;code&gt;grep&lt;/code&gt; returning zero hits as proof that something wasn't there, and it was — my pattern was wrong. I've also had a search API's "no results" turn out to be a display cutoff rather than an empty set. Zero hits means your search stopped, not that the world is empty.&lt;/p&gt;

&lt;p&gt;So the sentence I'll defend is "I couldn't find it in the public record," and not "it does not exist." Those are different claims, and only one of them is something I can actually support.&lt;/p&gt;

&lt;p&gt;The search did hand me something useful, though. A published analysis of about 400,000 Claude Code sessions from roughly 235,000 people, between October 2025 and April 2026, found that people make about 70% of the planning decisions but only about 20% of the execution ones. I keep a human gate on every go/no-go call in my setup, and I'd half-assumed that made me slow to automate. Turns out it's roughly where the measured division of labour already sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then my own note failed the same check
&lt;/h2&gt;

&lt;p&gt;Six days later I was about to post a short version of all this. My rule is to re-fetch primary sources before anything with a number in it goes out, even when the research was already verified — days passing counts as drift.&lt;/p&gt;

&lt;p&gt;Four claims in the draft. Three held. One didn't.&lt;/p&gt;

&lt;p&gt;My research notes had recorded something like "only 0–20% of tasks can be fully delegated." That phrasing appears nowhere in the study. There's no "delegable" anywhere in it, and no 0–20% range. The most likely explanation is that I read "humans make about 20% of execution decisions" and quietly rewrote it as "0–20% of work can be delegated" — which is a different statement about a different thing.&lt;/p&gt;

&lt;p&gt;Which is to say: the same swap I had just caught in someone else's number, in my own summary, six days later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check I built for other people's numbers never ran on my own.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What caught it wasn't the elaborate part. Not the multi-angle search, not the adversarial verifiers. It was one plain re-fetch of the source document, at the last possible moment, because a rule said to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap wasn't verification. It was the return path.
&lt;/h2&gt;

&lt;p&gt;Here's the part that actually changed how I work.&lt;/p&gt;

&lt;p&gt;When I fixed that line, I fixed it in the outgoing post. The research note that produced the post stayed wrong. It sat there for six days, untouched since the day it was written, with no correction task filed against it — and I only noticed because I went back to write this article and checked.&lt;/p&gt;

&lt;p&gt;My published work had a gate. The notes that fed it did not. Corrections travelled outward to the artifact and stopped, when the thing that needed fixing was upstream of everything the artifact would ever become.&lt;/p&gt;

&lt;p&gt;That's fixed now, and I'd rather show it than assert it. The note carries a strikethrough on the wrong figure, a dated correction marker with the right one, and a line saying how it was caught — because silently editing a note leaves no evidence that anything was ever wrong. A second person re-fetched the source independently and confirmed the original text contains neither the word nor the range. And the missing step got written down as a rule: when you correct a number, immediately grep the whole workspace for the same claim and kill every copy.&lt;/p&gt;

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

&lt;p&gt;For any number-shaped claim, confirm &lt;strong&gt;the subject and the denominator&lt;/strong&gt; in a primary source before you let it move your roadmap. Most viral statistics aren't fabricated — they're real numbers describing something adjacent.&lt;/p&gt;

&lt;p&gt;"I couldn't find it" and "it doesn't exist" are different sentences, and only one of them is verifiable. Say the one you can support.&lt;/p&gt;

&lt;p&gt;Run your verification on your own notes, not just on other people's claims — and make sure the correction travels back to where the note lives, not just to what you published.&lt;/p&gt;

&lt;p&gt;Where do your corrections go: into the post, or back into the note?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.anthropic.com/institute/recursive-self-improvement" rel="noopener noreferrer"&gt;When AI builds itself&lt;/a&gt; (the &amp;gt;80% measurement and the 90%+ leadership estimate) · &lt;a href="https://www.anthropic.com/research/claude-code-expertise" rel="noopener noreferrer"&gt;How Claude Code is used in practice&lt;/a&gt; (the ~400,000-session analysis and the 70%/20% split)&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>ai</category>
      <category>discuss</category>
      <category>productivity</category>
      <category>learning</category>
    </item>
    <item>
      <title>My Upload Check Trusted the Attacker's Word: 8 Bytes Fixed It</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:12:50 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-upload-check-trusted-the-attackers-word-8-bytes-fixed-it-540m</link>
      <guid>https://dev.to/hideyukimori/my-upload-check-trusted-the-attackers-word-8-bytes-fixed-it-540m</guid>
      <description>&lt;p&gt;A QA pass sent my document uploader a Windows executable that declared itself &lt;code&gt;application/pdf&lt;/code&gt;. It was accepted, stored, and listed like any other document.&lt;/p&gt;

&lt;p&gt;The MIME allowlist that was supposed to stop it had run, matched, and passed. It wasn't buggy. It was reading a value the uploader had written.&lt;/p&gt;

&lt;p&gt;This is the companion to &lt;a href="https://dev.to/hideyukimori/half-my-tests-failed-and-none-were-broken-my-shell-poisoned-phpunit-14ej"&gt;my Bug Smash post about environment variables poisoning a test run&lt;/a&gt; — that one was about my machine lying to me. This one is about my &lt;em&gt;input&lt;/em&gt; lying to me.&lt;/p&gt;

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

&lt;p&gt;The uploader had what looked like several layers of protection. Laid out by who controls each value, the picture changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it checked&lt;/th&gt;
&lt;th&gt;Who controls it&lt;/th&gt;
&lt;th&gt;Stops a spoof?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accept=".pdf,.jpg,.jpeg,.png"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;file picker filter&lt;/td&gt;
&lt;td&gt;the browser UI&lt;/td&gt;
&lt;td&gt;❌ cosmetic only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declared MIME allowlist&lt;/td&gt;
&lt;td&gt;a string in the multipart body&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the uploader&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File extension&lt;/td&gt;
&lt;td&gt;the filename&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the uploader&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First bytes of the payload&lt;/td&gt;
&lt;td&gt;the actual content&lt;/td&gt;
&lt;td&gt;the file itself&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;nosniff&lt;/code&gt; + &lt;code&gt;attachment&lt;/code&gt; on download&lt;/td&gt;
&lt;td&gt;how the browser treats the response&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;td&gt;⚠️ mitigation, not a fix&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those layers were reading values the uploading client had chosen. The allowlist contents were correct — PDF, JPEG, PNG, exactly what the compliance rule says. It just never saw the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I wasn't validating the file. I was validating a string the uploader chose.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That bottom row deserves a note, because it's the one I could have hidden behind. The download path already sent &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt; and &lt;code&gt;Content-Disposition: attachment&lt;/code&gt; before any of this happened. That mitigation is real, and it predates the bug — which is exactly why it isn't a fix. It changes what a browser does with a file that I already agreed to store. Rejecting at intake is a different question, and I hadn't answered it.&lt;/p&gt;

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

&lt;p&gt;Not by a scanner. By a person following a QA script, escalating one step at a time: send a &lt;code&gt;.exe&lt;/code&gt; (rejected, good), send an &lt;code&gt;.svg&lt;/code&gt; (rejected, good), then send the &lt;code&gt;.exe&lt;/code&gt; bytes with a spoofed &lt;code&gt;application/pdf&lt;/code&gt; content type.&lt;/p&gt;

&lt;p&gt;That third step is the one automated checks tend not to reach, because it requires deciding to lie about your own request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: read eight bytes
&lt;/h2&gt;

&lt;p&gt;Keep the declared-MIME allowlist as a cheap first gate, then put content sniffing behind it. No new dependencies — no &lt;code&gt;finfo&lt;/code&gt;, no library, &lt;code&gt;composer.json&lt;/code&gt; untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sniffMimeType&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;$tmpPath&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;$handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tmpPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rb'&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;$handle&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&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;$handle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$header&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;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&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;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%PDF-'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'application/pdf'&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\xFF\xD8\xFF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x89&lt;/span&gt;&lt;span class="s2"&gt;PNG&lt;/span&gt;&lt;span class="se"&gt;\r\n\x1A\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/png'&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;null&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;Eight bytes because the PNG signature is eight bytes long; the other two are shorter prefixes of the same read. The call site is four 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="nv"&gt;$sniffed&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;sniffMimeType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tmpPath&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;$sniffed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sniffed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ALLOWED_MIME_TYPES&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MimeTypeNotAllowedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sniffed&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&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 decisions in there matter more than the signatures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Undecidable means rejected.&lt;/strong&gt; An unreadable file, an empty file, or anything whose first bytes don't match a known signature all return &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt; fails. A sniffer that returns "unknown" and then shrugs is not a gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two rejections say different things.&lt;/strong&gt; A disallowed declaration gets &lt;code&gt;File type 'x' is not allowed. Only PDF, JPEG, and PNG are accepted.&lt;/code&gt; A spoof gets &lt;code&gt;Declared file type 'application/pdf' does not match the file content ('unknown'). Only genuine PDF, JPEG, and PNG files are accepted.&lt;/code&gt; Collapsing those into one message would have been less code and would have thrown away the only signal that distinguishes a confused user from someone probing you.&lt;/p&gt;

&lt;p&gt;One thing I did not do: SVG is rejected, not sanitized. An SVG renamed and re-declared as PNG fails the signature check and never reaches storage. If you need to &lt;em&gt;accept&lt;/em&gt; SVG, this post doesn't help you — that's a different problem with a much larger surface.&lt;/p&gt;
&lt;h2&gt;
  
  
  The test that couldn't be written
&lt;/h2&gt;

&lt;p&gt;Here's the part that changed how I read my own test suites.&lt;/p&gt;

&lt;p&gt;Before the fix, the test helper passed &lt;code&gt;tmpPath: '/tmp/fake-upload'&lt;/code&gt;. That's a string. There was no file at that path. There had never been a file at that path.&lt;/p&gt;

&lt;p&gt;So a content-based test wasn't merely missing from the suite. &lt;strong&gt;It was impossible to write.&lt;/strong&gt; The validation logic stopped at the declared value because the fixtures had no content for it to go on to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check was as deep as my fixtures were.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The line that unlocked everything was &lt;code&gt;tempnam()&lt;/code&gt; — create a real temporary file, write real bytes into it, delete it in &lt;code&gt;tearDown()&lt;/code&gt;. Once fixtures were files instead of strings, the new cases wrote themselves: a spoofed &lt;code&gt;.exe&lt;/code&gt; asserting the mismatch message, an SVG declared as PNG, and genuine JPEG and PNG regressions to prove the gate still lets real documents through.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;The vulnerability is closed at intake, and I'd rather end on the part I haven't finished than on the part I have.&lt;/p&gt;

&lt;p&gt;The end-to-end test that originally caught this still carries its discovery-time assertion:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;modalText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upload resolved (accepted or errored)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeTruthy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Read that carefully. It passes when the spoofed file is accepted and listed, and it passes when the upload errors out. It was written to &lt;em&gt;observe&lt;/em&gt; what happened, back when nobody knew. It would go green whether or not my fix works.&lt;/p&gt;

&lt;p&gt;I closed the vulnerability and left the test that found it unable to prove it. A test written to demonstrate a bug does not become a regression test until you flip its expectation.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If the value you're validating arrived in the request body, you're validating the attacker's input, not the file.&lt;/li&gt;
&lt;li&gt;Undecidable must mean rejected. "Unknown, so I'll allow it" is not a gate.&lt;/li&gt;
&lt;li&gt;Fixtures set the ceiling on what your tests can check. A fixture that isn't a file can never catch a content bug.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What's still in your upload path that the uploader gets to declare?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>security</category>
      <category>php</category>
    </item>
    <item>
      <title>Fact-Checking My Own Blog Posts Turned Into Product QA: 3 Real Bugs</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:08:02 +0000</pubDate>
      <link>https://dev.to/hideyukimori/fact-checking-my-own-blog-posts-turned-into-product-qa-3-real-bugs-f7p</link>
      <guid>https://dev.to/hideyukimori/fact-checking-my-own-blog-posts-turned-into-product-qa-3-real-bugs-f7p</guid>
      <description>&lt;p&gt;I was fact-checking a sentence in one of my own blog posts — "the API returns &lt;code&gt;no-store&lt;/code&gt;" — and found out the API did no such thing.&lt;/p&gt;

&lt;p&gt;So I didn't edit the sentence. I fixed the product.&lt;/p&gt;

&lt;p&gt;I have a rule for myself: before I publish anything technical, I go through it and check every factual claim against the actual code. Not to impress the reader — to keep myself honest, because it's embarrassingly easy to write something that &lt;em&gt;sounds&lt;/em&gt; true. This week that habit stopped being about the writing. Reviewing four posts turned up &lt;strong&gt;three real bugs, in the product, not the posts.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: the sentence was right, the product was late
&lt;/h2&gt;

&lt;p&gt;The post was about an auth-header quirk on shared hosting, and it claimed the API returned &lt;code&gt;Cache-Control: no-store&lt;/code&gt; on authenticated responses. Reasonable thing to claim. Also, when I actually checked, not true — the header wasn't being set.&lt;/p&gt;

&lt;p&gt;Because of how the app worked around a proxy that strips the standard &lt;code&gt;Authorization&lt;/code&gt; header, the usual "responses tied to Authorization aren't shared-cacheable" protection didn't apply. Which meant an authenticated response could, in theory, land in a shared cache.&lt;/p&gt;

&lt;p&gt;I had two options: soften the blog sentence, or make it true. I added a small middleware that sets &lt;code&gt;no-store&lt;/code&gt; at the outermost layer of the pipeline, left a couple of tests behind, and shipped it. The sentence is now correct because the product changed to match it — not the other way around.&lt;/p&gt;

&lt;p&gt;That's the case I like most. &lt;strong&gt;My writing was ahead of my code, and checking the writing is what surfaced it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: "nothing accumulates" — except the thing that did
&lt;/h2&gt;

&lt;p&gt;Another post described a disposable-demo setup: click a link, get a throwaway tenant, and an hourly sweep deletes everything, so nothing piles up. I went to verify the "nothing piles up" claim literally.&lt;/p&gt;

&lt;p&gt;Nearly nothing piled up. But each demo tenant left behind a small per-tenant stamp file — a throttle bookkeeping artifact — that the sweep wasn't cleaning. Every demo click added one and never removed it. Not a leak that would take the server down soon, but a direct contradiction of a sentence I was about to publish.&lt;/p&gt;

&lt;p&gt;The sweep now removes those stamps when it deletes a tenant, and self-heals any orphans from tenants that are already gone. Claim restored to true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the demo was showing the future
&lt;/h2&gt;

&lt;p&gt;While writing the English version of that demo post, I clicked through the seeded data the way a reader would. The demo plants realistic history — a few invoices, some payments — so it looks like a live business.&lt;/p&gt;

&lt;p&gt;Open it in the first half of a month and some of that "history" was dated in the &lt;em&gt;future&lt;/em&gt;: a payment recorded for a date that hadn't happened yet, a paid invoice due next week. The seed used fixed day-of-month values, so early in the month they landed ahead of today.&lt;/p&gt;

&lt;p&gt;The person most likely to notice that is an accountant — exactly the audience the demo is for. The fix clamps every seeded event date to "today or earlier," while leaving genuinely future dates (like due dates) alone. I added a test that seeds on the first of the month — the worst case — and confirmed it failed before the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being honest about the yield
&lt;/h2&gt;

&lt;p&gt;Four posts, three product fixes — but I don't want to oversell the hit rate. Two of the four posts had &lt;strong&gt;zero&lt;/strong&gt; product bugs; their claims were already true, and checking them just cost me an hour and bought me confidence. The three bugs clustered in two of the posts, not one per article. If I dressed this up as "every blog post hides a bug," that would be its own small fabrication.&lt;/p&gt;

&lt;p&gt;The point isn't a reliable yield. It's the lens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why prose catches what tests miss
&lt;/h2&gt;

&lt;p&gt;Tests check the assertions you &lt;em&gt;thought&lt;/em&gt; to write. Prose makes a different kind of claim, and a more dangerous one: &lt;strong&gt;"the system does X,"&lt;/strong&gt; stated plainly enough for a stranger to falsify.&lt;/p&gt;

&lt;p&gt;Your code doesn't say &lt;code&gt;no-store&lt;/code&gt;. Your blog post does. Your test suite never asserted "nothing accumulates" or "no dates are in the future" — but your paragraph did, out loud, to the whole internet. Writing forces you to compress behavior into flat declarative sentences, and flat declarative sentences are exactly the thing you can walk over to the code and check.&lt;/p&gt;

&lt;p&gt;So publishing under a rule of "every claim gets verified against the code" isn't just hygiene for the post. It's a QA pass driven by a question your tests never asked: &lt;em&gt;is what I'm telling people actually true right now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And when the answer is "no, but it should be" — like the &lt;code&gt;no-store&lt;/code&gt; case — the honest fix isn't to edit the sentence down. It's to make the sentence true.&lt;/p&gt;

&lt;p&gt;Do you check your posts against the code before you hit publish — and has it ever turned up a bug in the thing you were writing about?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>testing</category>
      <category>ai</category>
      <category>discuss</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My Generator Hid a Lint Error: CI Never Checks What Nobody Commits</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:49:46 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-generator-hid-a-lint-error-ci-never-checks-what-nobody-commits-3186</link>
      <guid>https://dev.to/hideyukimori/my-generator-hid-a-lint-error-ci-never-checks-what-nobody-commits-3186</guid>
      <description>&lt;p&gt;The lint error lived in a file my CI had never seen. It &lt;em&gt;couldn't&lt;/em&gt; have seen it — the file only exists after you run a generator with a particular flag, and nobody had ever committed that flag's output.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/NENE2" rel="noopener noreferrer"&gt;NENE2&lt;/a&gt;, my framework for typed business apps, ships frontend scaffolding generators: &lt;code&gt;gen:entity&lt;/code&gt; builds the typed API layer for a resource, &lt;code&gt;gen:feature&lt;/code&gt; builds a state-machine-shaped feature on top. They're template-based and deterministic on purpose — deterministic output is what makes generated code reviewable and lets AI agents use the generators safely.&lt;/p&gt;

&lt;p&gt;And like most generators, they have flags. &lt;code&gt;gen:entity &amp;lt;noun&amp;gt; --write&lt;/code&gt; adds mutation hooks and write handlers on top of the read-only default. Flags mean branches. Branches mean output shapes that may exist nowhere in your repo.&lt;/p&gt;

&lt;p&gt;That's where the bug was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The entity mutations template emitted this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;UseMutationResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Noun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AppError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The third type argument, &lt;code&gt;unknown&lt;/code&gt;, is exactly the parameter's default — so &lt;code&gt;@typescript-eslint/no-unnecessary-type-arguments&lt;/code&gt; flags it. Which means every single output of &lt;code&gt;gen:entity &amp;lt;noun&amp;gt; --write&lt;/code&gt; failed lint, in a codebase where CI runs ESLint with &lt;code&gt;--max-warnings 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And CI was green the whole time. The generator's determinism tests passed — they verify the templates produce stable, expected output. The committed exemplar for the &lt;em&gt;default&lt;/em&gt; archetype passed lint, because it's real committed code and CI lints it like everything else. But the &lt;code&gt;--write&lt;/code&gt; branch's output had never been committed anywhere, so no type-checker and no linter had ever run over a single line of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A determinism test proves your generator is consistent. It will happily prove it's consistently wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a cousin of a broader failure mode I keep running into: gates that glow green while checking nothing in the branch that matters.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it surfaced
&lt;/h2&gt;

&lt;p&gt;Not by auditing. By building the next feature.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/hideyukiMORI/NENE2/pull/1580" rel="noopener noreferrer"&gt;PR #1580&lt;/a&gt; I was adding a &lt;code&gt;--mutation&lt;/code&gt; archetype to &lt;code&gt;gen:feature&lt;/code&gt; — a four-state union (&lt;code&gt;idle | submitting | error | success&lt;/code&gt;) for form-style features. That archetype consumes what &lt;code&gt;gen:entity --write&lt;/code&gt; produces, so for once, verifying the new thing forced me to &lt;em&gt;actually generate&lt;/em&gt; the old thing.&lt;/p&gt;

&lt;p&gt;The verification was a full rehearsal on real output: run &lt;code&gt;gen:entity payment&lt;/code&gt; and &lt;code&gt;gen:feature submit-payment payment --mutation&lt;/code&gt;, then put the generated files through the same gauntlet as committed code — type-check, ESLint with &lt;code&gt;--max-warnings 0&lt;/code&gt;, Prettier, and the generated transition tests against a real mock server.&lt;/p&gt;

&lt;p&gt;Lint went red on code no human had written and no CI had judged. There it was.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line in the template. Drop the third type argument — &lt;code&gt;TVariables&lt;/code&gt; defaults to &lt;code&gt;unknown&lt;/code&gt; anyway, so the emitted types are identical and behavior doesn't change. The PR body records the before/after; the generated output now comes out type-check clean, lint clean at &lt;code&gt;--max-warnings 0&lt;/code&gt;, with its four transition tests passing (4/4, exercised against mocked 200 and 500 responses).&lt;/p&gt;

&lt;p&gt;The fix took a minute. The bug had unlimited shelf life. That asymmetry is the whole story.&lt;/p&gt;
&lt;h2&gt;
  
  
  The general lesson: enumerate your generator's branches
&lt;/h2&gt;

&lt;p&gt;If your codebase has a generator with flags or optional archetypes, each branch of its output is code you're shipping to your future self — and CI's default posture toward it is total blindness. Two ways to close the gap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep a committed exemplar per branch.&lt;/strong&gt; Generate one real instance of every archetype/flag combination and commit it as living code. CI then type-checks, lints, and tests it forever, for free. Drift between templates and exemplar shows up as a normal red build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a generate-and-check smoke to CI.&lt;/strong&gt; Generate every branch into a temp dir, run type-check + lint + tests over the output, throw it away. No repo noise; costs CI minutes instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either works. What doesn't work is what I had: determinism tests that hold the generator's output stable while nothing ever asks whether that output is &lt;em&gt;valid&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Full honesty about where I actually am: PR #1580 did option 2 &lt;em&gt;manually, once&lt;/em&gt; — a rehearsal, not a gate. The PR body itself flags a committed exemplar for the mutation branch as a follow-up. So the branch that bit me is verified today and unguarded tomorrow, and I'm writing this partly so I can't quietly forget that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every generator flag is a code branch CI has never met. Green CI says nothing about output nobody commits.&lt;/li&gt;
&lt;li&gt;Determinism/golden tests check stability, not validity — pair them with a committed exemplar per branch, or a generate → type-check → lint → test smoke.&lt;/li&gt;
&lt;li&gt;The cheapest audit is a rehearsal: generate each archetype for real and run your normal &lt;code&gt;check&lt;/code&gt; over the output. My first rehearsal of an old flag found a lint error of unknown age.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Primary sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NENE2 &lt;a href="https://github.com/hideyukiMORI/NENE2/pull/1580" rel="noopener noreferrer"&gt;PR #1580&lt;/a&gt; (merge &lt;code&gt;c2b5311&lt;/code&gt;) — the &lt;code&gt;--mutation&lt;/code&gt; archetype, the rehearsal, and the one-line template fix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: &lt;a href="https://dev.to/hideyukimori/i-built-a-tiny-php-framework-for-ai-readable-business-apis-48eo"&gt;I built a tiny PHP framework for AI-readable business APIs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have a generator in your codebase: when did the output of &lt;em&gt;every one&lt;/em&gt; of its flags last pass your linter? I'd honestly love to know if anyone gates this properly.&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>typescript</category>
      <category>testing</category>
    </item>
    <item>
      <title>My Committed Bundle Went Stale: CI Rebuilt It and Never Compared</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:38:47 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-committed-bundle-went-stale-ci-rebuilt-it-and-never-compared-3p7m</link>
      <guid>https://dev.to/hideyukimori/my-committed-bundle-went-stale-ci-rebuilt-it-and-never-compared-3p7m</guid>
      <description>&lt;p&gt;I found out that a bundle committed to one of my repos had gone stale because an unrelated PR left my git tree dirty.&lt;/p&gt;

&lt;p&gt;Not a test. Not a review. A dirty tree, noticed by accident.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/nene-concierge" rel="noopener noreferrer"&gt;nene-concierge&lt;/a&gt; is one of my self-hosted business tools. Its repo commits a built frontend artifact: &lt;code&gt;public_html/admin/app.js&lt;/code&gt;, the admin bundle, produced by esbuild via &lt;code&gt;npm run build&lt;/code&gt;. The &lt;code&gt;public_html/&lt;/code&gt; tree is the deployable root, generated files included.&lt;/p&gt;

&lt;p&gt;Committing build output is a choice with a known tradeoff. I knew the tradeoff. I just wasn't paying for it.&lt;/p&gt;

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

&lt;p&gt;An earlier refactor (&lt;a href="https://github.com/hideyukiMORI/nene-concierge/issues/178" rel="noopener noreferrer"&gt;#178&lt;/a&gt;/&lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/179" rel="noopener noreferrer"&gt;#179&lt;/a&gt;) added &lt;code&gt;"type": "module"&lt;/code&gt; to &lt;code&gt;frontend/package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That one line changed esbuild's CJS interop output. Every helper call in the bundle went from &lt;code&gt;__toESM(x)&lt;/code&gt; to &lt;code&gt;__toESM(x, 1)&lt;/code&gt;. From that day on, the &lt;code&gt;app.js&lt;/code&gt; sitting in &lt;code&gt;main&lt;/code&gt; no longer matched what a clean build produces — a 573-insertion diff of pure drift.&lt;/p&gt;

&lt;p&gt;Nothing failed. Type-check green. Lint green. Tests green. Nothing in the pipeline had any opinion about that file.&lt;/p&gt;

&lt;p&gt;Here's the detail that stings most. My CI runs &lt;code&gt;npm run check&lt;/code&gt;, and &lt;code&gt;check&lt;/code&gt; &lt;em&gt;includes&lt;/em&gt; &lt;code&gt;build&lt;/code&gt;. So CI was rebuilding that bundle on every single run — producing the correct, fresh output in its own workspace — and then throwing it away without ever comparing it to the file committed two directories over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A committed artifact is a claim about your source code, and nothing was checking the claim.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it surfaced
&lt;/h2&gt;

&lt;p&gt;By luck. While verifying an unrelated PR (&lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/183" rel="noopener noreferrer"&gt;#183&lt;/a&gt;, a lint-suppressions ratchet gate), I ran the full check locally and &lt;code&gt;git status&lt;/code&gt; came back dirty: &lt;code&gt;npm run build&lt;/code&gt; had rewritten &lt;code&gt;public_html/admin/app.js&lt;/code&gt; under me.&lt;/p&gt;

&lt;p&gt;That PR's body records it as an explicitly out-of-scope finding, the artifact was restored so the ratchet PR stayed clean, and the drift got its own issue: &lt;a href="https://github.com/hideyukiMORI/nene-concierge/issues/185" rel="noopener noreferrer"&gt;#185&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If that ratchet PR hadn't happened to run a build on my machine, I have no idea when I'd have noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — and what the fix doesn't fix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/186" rel="noopener noreferrer"&gt;PR #186&lt;/a&gt; (merged as &lt;code&gt;b7eb1ea&lt;/code&gt;) is deliberately boring: run &lt;code&gt;npm run build&lt;/code&gt;, commit the output as-is. Machine diff only, zero source changes, two generated files touched (&lt;code&gt;app.js&lt;/code&gt; and its sourcemap).&lt;/p&gt;

&lt;p&gt;Two things made me trust it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency&lt;/strong&gt;: two independent builds, byte-identical output. If your bundler embeds timestamps or random chunk names, a freshness check is off the table until that's fixed — so prove determinism first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean tree after&lt;/strong&gt;: rebuild, then &lt;code&gt;git status&lt;/code&gt; shows nothing. That was the acceptance criterion on the issue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But notice what this fix is: a resync. It repairs &lt;em&gt;this&lt;/em&gt; drift and does nothing about the &lt;em&gt;next&lt;/em&gt; one. Any future change that shifts compiler output — a bundler upgrade, a tsconfig flag, another package.json line — re-creates the exact same silent lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson: freshness must be a test
&lt;/h2&gt;

&lt;p&gt;If a build artifact lives in git, CI has to prove it's fresh. The check is about four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Committed artifacts must match a clean build&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;test -z "$(git status --porcelain)" || {&lt;/span&gt;
      &lt;span class="s"&gt;git status; git diff | head -50&lt;/span&gt;
      &lt;span class="s"&gt;echo "::error::Committed build output is stale. Run npm run build and commit."&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Build from a clean checkout, then assert the tree is still clean. That's it. It would have turned this whole story into a red X on the PR that added &lt;code&gt;"type": "module"&lt;/code&gt;, instead of a discovery-by-accident later.&lt;/p&gt;

&lt;p&gt;Full honesty: as I write this, that gate is &lt;em&gt;not&lt;/em&gt; yet in nene-concierge's workflow — the resync is merged, the guard is the follow-up. I checked the workflow file before writing this paragraph, because claiming a fix you haven't shipped is exactly the kind of lie this article is about.&lt;/p&gt;

&lt;p&gt;And the cleaner alternative deserves saying out loud: if you &lt;em&gt;can&lt;/em&gt; avoid committing artifacts at all — build in CI, build at deploy — do that. An artifact that's never committed can't go stale in the repo. Committing output is sometimes the pragmatic choice when the deploy target is a plain host that just serves files; that convenience is fine, but it isn't free. The freshness check is the price.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A committed artifact is an unverified claim. CI must rebuild and &lt;code&gt;git status --porcelain&lt;/code&gt;-assert it, or the artifact will eventually lie.&lt;/li&gt;
&lt;li&gt;Toolchain config changes (&lt;code&gt;"type": "module"&lt;/code&gt;, bundler bumps) can change output while every semantic check stays green — drift needs no bug to happen.&lt;/li&gt;
&lt;li&gt;Prove build determinism (two builds, byte-identical) before you rely on any freshness comparison.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Primary sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;nene-concierge &lt;a href="https://github.com/hideyukiMORI/nene-concierge/issues/185" rel="noopener noreferrer"&gt;issue #185&lt;/a&gt; and &lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/186" rel="noopener noreferrer"&gt;PR #186&lt;/a&gt; (merge &lt;code&gt;b7eb1ea&lt;/code&gt;) — the drift and the resync&lt;/li&gt;
&lt;li&gt;nene-concierge &lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/183" rel="noopener noreferrer"&gt;PR #183&lt;/a&gt; — the out-of-scope note where the drift was first recorded&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: &lt;a href="https://dev.to/hideyukimori/click-a-link-get-a-throwaway-tenant-a-zero-signup-demo-for-a-self-hosted-app-1naj"&gt;Click a link, get a throwaway tenant: a zero-signup demo for a self-hosted app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do you commit build output anywhere — and if so, what would actually fail today if it went stale? If the answer is "nothing," I'd check.&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>ci</category>
      <category>esbuild</category>
    </item>
    <item>
      <title>Half My Tests Failed and None Were Broken: My Shell Poisoned PHPUnit</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 29 Jul 2026 15:00:34 +0000</pubDate>
      <link>https://dev.to/hideyukimori/half-my-tests-failed-and-none-were-broken-my-shell-poisoned-phpunit-14ej</link>
      <guid>https://dev.to/hideyukimori/half-my-tests-failed-and-none-were-broken-my-shell-poisoned-phpunit-14ej</guid>
      <description>&lt;p&gt;For about a day I believed I had roughly 150 broken tests. I was wrong — not one of them was broken. Then I was wrong about the cause. Twice.&lt;/p&gt;

&lt;p&gt;Here's the walk from "our test suite is rotting" to "one stray line in my shell was winning a fight I didn't know it was in."&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;I ran the suite and about half of it was red. Every failure was the same shape: &lt;code&gt;403 org-access-denied&lt;/code&gt;. Tenant-scoped tests, denied across the board.&lt;/p&gt;

&lt;p&gt;It looked exactly like accumulated test debt — the kind of thing you file, sigh at, and schedule for "later." So that's what I did. I opened an issue: &lt;em&gt;~150 failing tests, org access denied.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction #1: the tests were fine, my machine wasn't
&lt;/h2&gt;

&lt;p&gt;Before scheduling the cleanup, I ran the suite in a clean environment — a fresh shell with none of my profile loaded.&lt;/p&gt;

&lt;p&gt;All green.&lt;/p&gt;

&lt;p&gt;Not "fewer failures." Zero. The tests were fine. The failures were a property of &lt;strong&gt;my&lt;/strong&gt; environment, not the code. Which is a worse feeling than a broken test, honestly, because it means the call is coming from inside the house.&lt;/p&gt;

&lt;p&gt;The mechanism turned out to be a PHPUnit detail I'd never had to think about: env vars you set in the config &lt;strong&gt;do not override an env var that's already set in your shell&lt;/strong&gt; — not unless you mark them &lt;code&gt;force="true"&lt;/code&gt;. My shell profile exported a variable that the test config also set, and by default, the shell won. So my tests were quietly running against the wrong context, and everything tenant-scoped got denied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction #2: I blamed the scary variable, not the guilty one
&lt;/h2&gt;

&lt;p&gt;Here's the part I'm least proud of and find most useful.&lt;/p&gt;

&lt;p&gt;My first theory for &lt;em&gt;which&lt;/em&gt; variable was a JWT signing secret. It's the dangerous-sounding one; of course a bad secret breaks auth. I wrote it up that way.&lt;/p&gt;

&lt;p&gt;It was wrong, and the code says why. A signing secret is &lt;strong&gt;symmetric&lt;/strong&gt;: if signing and verifying both read the same (wrong) value, the tokens are still internally valid. A polluted secret doesn't produce &lt;code&gt;403 org-access-denied&lt;/code&gt; — it produces valid tokens for a wrong-but-consistent world. It couldn't be the cause.&lt;/p&gt;

&lt;p&gt;The actual culprit was boring: a &lt;strong&gt;tenant-slug&lt;/strong&gt; variable. My shell had it set to one thing; the tests minted tokens for another. So the request would resolve one tenant from the env, carry a token for a different tenant, and the access check — correctly — denied it. Every tenant-scoped test, 403.&lt;/p&gt;

&lt;p&gt;I'd spent my first guess on the variable that &lt;em&gt;sounded&lt;/em&gt; like a security problem, when a plain identifier was doing all the damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was one attribute
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;force="true"&lt;/code&gt; on the env entries, so the test configuration wins over whatever the shell happens to export. That's it. The suite is now hermetic: it runs the same on my machine, on a colleague's, and in CI, regardless of anyone's shell profile.&lt;/p&gt;

&lt;p&gt;I proved it the honest way — by injecting the bad variable &lt;em&gt;on purpose&lt;/em&gt;. With the injection and no &lt;code&gt;force&lt;/code&gt;, the suite collapses (about 150 failures and a pile of errors). With &lt;code&gt;force&lt;/code&gt;, the same injection does nothing; everything stays green. The fix is verified against the attack, not against my memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can't tell you
&lt;/h2&gt;

&lt;p&gt;I can't tell you the exact line that was in my shell profile that day. I don't have it pinned down, and I'm not going to invent it to make the story cleaner. What I have is a reproduction — inject the variable, watch it break — and that's what the fix is proven against.&lt;/p&gt;

&lt;p&gt;The count is fuzzy too. It was around 150 failures out of somewhere near 290 tests, and both numbers drifted day to day as the suite changed. "156 out of 291" would look precise and be a small lie. &lt;strong&gt;Half the suite red, zero tests actually broken&lt;/strong&gt; is the true and useful shape of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Make your tests hermetic.&lt;/strong&gt; Test configuration should beat the machine it runs on, unconditionally — &lt;code&gt;force&lt;/code&gt;, or the equivalent in your stack. A suite whose result depends on the developer's shell isn't testing your code; it's testing your dotfiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you're debugging, suspect the boring variable.&lt;/strong&gt; I lost real time pointing at a secret because it sounded dangerous, while a plain tenant id sat there quietly denying everything. The scary-looking cause is a great way to feel productive and stay wrong.&lt;/p&gt;

&lt;p&gt;What's the worst "it's the tests" you've had that turned out to be your own environment?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>php</category>
      <category>testing</category>
    </item>
    <item>
      <title>Pilot Then Fan Out: Killing Unknown Blockers in 2 Repos First</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 29 Jul 2026 14:59:58 +0000</pubDate>
      <link>https://dev.to/hideyukimori/pilot-then-fan-out-killing-unknown-blockers-in-2-repos-first-681</link>
      <guid>https://dev.to/hideyukimori/pilot-then-fan-out-killing-unknown-blockers-in-2-repos-first-681</guid>
      <description>&lt;p&gt;I maintain about a dozen small products that share one homemade framework. Last week I built a conformance linter — a little tool that scans each repo for architectural drift — and I was one command away from wiring it into all of them at once.&lt;/p&gt;

&lt;p&gt;I didn't. I ran it in two repos first.&lt;/p&gt;

&lt;p&gt;That two-repo dry run is the cheapest safety I've added all year, and it caught two things that would otherwise have gone off in every repo at the same time. One of them was the linter being wrong about my own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fan-out is tempting, and why it bites
&lt;/h2&gt;

&lt;p&gt;When you have a change that clearly belongs everywhere — a lint rule, a CI step, a dependency bump — the obvious move is to apply it everywhere. One sweep, done.&lt;/p&gt;

&lt;p&gt;The problem is that a shared tool doesn't fail in the tool. It fails in the &lt;strong&gt;consumers&lt;/strong&gt;, and consumers differ in ways the tool's own tests never exercise. So "all green in the framework repo" tells you almost nothing about what happens when a dozen repos actually pull it in.&lt;/p&gt;

&lt;p&gt;If you fan out first and discover the blocker second, you don't get one failure. You get the same failure a dozen times, concurrently, and now you're triaging a wall of red instead of a single problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pilot: two repos, chosen to disagree
&lt;/h2&gt;

&lt;p&gt;The trick isn't "test it somewhere first." It's picking the &lt;em&gt;right&lt;/em&gt; somewhere.&lt;/p&gt;

&lt;p&gt;I have two ways a repo consumes the shared framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one pulls it from a &lt;strong&gt;package registry&lt;/strong&gt; at a pinned version,&lt;/li&gt;
&lt;li&gt;the other uses a &lt;strong&gt;local path repo&lt;/strong&gt; — a symlink to a checkout that moves with development.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the pilot was one of each. Not two similar repos — two that maximize the difference along the axis most likely to break. If a change survives both consumption modes, it'll probably survive the rest. If it breaks, it breaks here, cheaply, where I'm paying attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocker 1: the local green was a lie
&lt;/h2&gt;

&lt;p&gt;The linter ran fine on my machine in every repo. Then I pushed it to the two pilots' CI and both went red immediately — a hard fatal, exit 255, before the linter did any work.&lt;/p&gt;

&lt;p&gt;The cause: the linter loaded the &lt;strong&gt;framework's own dependencies&lt;/strong&gt; to run. On my machine that resolved, because my local setup symlinks to a full checkout of the framework, dependencies and all. But a consumer doesn't have the framework's private &lt;code&gt;vendor/&lt;/code&gt; tree, and CI does a shallow clone without installing it. So the exact same command that was green locally was a guaranteed fatal in every consumer.&lt;/p&gt;

&lt;p&gt;"Works on my machine" had a specific, mechanical reason to lie: the symlink was hiding the fact that consumers don't get the tool's dependencies. The fix was to resolve against the &lt;em&gt;consumer's&lt;/em&gt; dependency tree instead of the tool's. Small change. But if I'd fanned out first, it would have been a dozen red pipelines at once, all with the same confusing exit 255.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocker 2: the linter was wrong about my own fleet
&lt;/h2&gt;

&lt;p&gt;The second one was more humbling.&lt;/p&gt;

&lt;p&gt;One of the linter's rules flags hardcoded default secrets — a real thing you want to catch. But my correct, intended pattern for dev secrets routes them through a guarded resolver that refuses to use them in production. The rule saw the literal and screamed, not understanding that this literal was the safe, guarded case.&lt;/p&gt;

&lt;p&gt;So the linter's very first real run produced a &lt;strong&gt;false positive in roughly eleven of my products at once&lt;/strong&gt; — every repo that used the guarded pattern, which is to say every repo doing it &lt;em&gt;right&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the nightmare version of a fleet-wide rollout: a tool that's confidently, uniformly wrong. If those false alarms had landed in a dozen repos on the same afternoon, the rational response would have been to distrust the linter and turn it off — killing the whole effort. Instead, the pilot showed me the rule needed an exception for the guarded pattern before anyone else saw a single false alarm.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd have lost by skipping the pilot
&lt;/h2&gt;

&lt;p&gt;I want to be honest about this part: the "dozen simultaneous red pipelines" is a thing that &lt;strong&gt;didn't&lt;/strong&gt; happen, so there's no log of it. It's a projection, not an incident.&lt;/p&gt;

&lt;p&gt;But it's a well-supported projection. Both blockers came from structure shared by &lt;em&gt;every&lt;/em&gt; consumer — the dependency layout and the guarded pattern — so both would have fired everywhere. The pilot didn't get lucky with two repos; it exercised the two conditions that made the failures universal. That's the difference between "I tested it" and "I tested the thing that varies."&lt;/p&gt;

&lt;h2&gt;
  
  
  The method, generalized
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unknown blockers live in the consumer, not the tool.&lt;/strong&gt; A shared tool's own green suite doesn't cover the environments it will run in. Assume the interesting failures are downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius = consumers × shared structure.&lt;/strong&gt; If a failure comes from something all consumers share, fanning out multiplies it. Sequence it instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the pilot to span the variation axis.&lt;/strong&gt; Two repos that differ where breakage is likely beat ten repos that are all the same. Diversity, not count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave a stop valve.&lt;/strong&gt; Every rollout step — human or agent — should be allowed to halt and escalate when a premise looks wrong, instead of pushing through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There was a bonus, too. Once the linter was correct and did fan out, it immediately surfaced a genuine latent auth gap in one repo that everyone had walked past. The tool I built to prevent drift turned out to be a decent detector of the drift already there. But it only earned that trust because it didn't cry wolf a dozen times on day one.&lt;/p&gt;

&lt;p&gt;When you roll a change across many repos, what's your pilot — and how do you choose which repos go first?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>devops</category>
      <category>ai</category>
      <category>discuss</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
