<?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: John</title>
    <description>The latest articles on DEV Community by John (@hexisteme).</description>
    <link>https://dev.to/hexisteme</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%2F3997679%2F1757a270-952f-4fbb-b529-231fade996c5.jpeg</url>
      <title>DEV Community: John</title>
      <link>https://dev.to/hexisteme</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hexisteme"/>
    <language>en</language>
    <item>
      <title>My dev.to Gate Couldn't Measure, and the Suite Called It a Regression</title>
      <dc:creator>John</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/my-devto-gate-couldnt-measure-and-the-suite-called-it-a-regression-1i4l</link>
      <guid>https://dev.to/hexisteme/my-devto-gate-couldnt-measure-and-the-suite-called-it-a-regression-1i4l</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/a-gate-that-could-not-measure-was-not-a-regression.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On October 23 the daily drift report for my gate suite contained this line (translated from Korean):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check_offer_subject_alignment.py: PASS → REGRESSION  ← a fix you'd made broke again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That note is the loudest thing the drift watcher can say. It is reserved for the case where something I had already fixed has stopped working. The same gate had printed it on October 18, gone back to PASS on October 21, and printed it again on the 23rd. Before that day's fix, the drift log already held ten verdict changes for this one gate, going back to September 7.&lt;/p&gt;

&lt;p&gt;When I opened it, the live data the gate checks was nowhere near the failure it guards against. What had failed was the gate's ability to look. The suite had no verdict for that, so it used the loudest one it had.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The two Stop hooks behind this note are on GitHub under MIT: &lt;a href="https://github.com/hexisteme/hard-gate-hooks" rel="noopener noreferrer"&gt;hexisteme/hard-gate-hooks&lt;/a&gt;. They ship with their tests and a read-only scanner that prints what they did on **your&lt;/em&gt;* machine, not mine — including the case where it tells you they aren't worth wiring up yet. No email, no signup.*&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gate checks
&lt;/h2&gt;

&lt;p&gt;The gate is one of 22 scripts in a suite I run over my publishing pipeline. This one checks, against live data, that a link I put in some dev.to posts sits on the posts it belongs on (the ones tagged &lt;code&gt;agents&lt;/code&gt;) rather than drifting onto unrelated ones. It reads &lt;code&gt;articles/me/published&lt;/code&gt;, then makes one &lt;code&gt;comments?a_id=&lt;/code&gt; call per commented article: about 100 live API calls per run. A launchd job runs the suite once a day; on October 23 the run landed at 00:19 UTC.&lt;/p&gt;

&lt;p&gt;The gate ends with one of three exit codes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; ALIGNED: the link sits where it should&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; REVERSED: the link has drifted onto the wrong posts, which is a real finding&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; UNMEASURABLE: it could not read the live data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inside the gate those three meanings are distinct. The runner only saw two of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runner knew one kind of non-zero
&lt;/h2&gt;

&lt;p&gt;The suite runner turns each gate's exit code into a verdict. Before the fix, the decision was this (comments mine):&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;judge_gate_outcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exit_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;declared_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exit_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;STALE_BLOCK&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;declared_reason&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;PASS&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exit_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;124&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CRASHED&lt;/span&gt;          &lt;span class="c1"&gt;# the runner's own timeout
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;declared_reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REGRESSION&lt;/span&gt;       &lt;span class="c1"&gt;# any other undeclared non-zero lands here
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REGRESSION&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BLOCKED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;declared_reason&lt;/code&gt; comes from a file, &lt;code&gt;gate_human_blocks.json&lt;/code&gt;, where I can declare that a gate is red because it is waiting on a person. A declared gate that fails reads BLOCKED instead of REGRESSION, and a declared gate that passes reads STALE_BLOCK, so exemptions can't outlive their reason. This gate had no declaration. Exit 1 and exit 2 went down the same branch: "I couldn't read dev.to at 00:19" and "the link has drifted onto the wrong posts" both came out as REGRESSION, and the watcher put its broken-fix note on both.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I worked out that the 00:19 run never measured
&lt;/h2&gt;

&lt;p&gt;The drift log stores verdicts only. It keeps neither the exit code nor the stderr of each scheduled run, so I could not look up what happened at 00:19. I had to reason from a side effect.&lt;/p&gt;

&lt;p&gt;The gate caches its last successful live measurement and reuses it for 24 hours. It writes the cache only right after a successful fetch. If loading the API key or the fetch raises an error it expects, such as a network error or a timeout, it prints &lt;code&gt;UNMEASURABLE&lt;/code&gt; to stderr and exits 1 before the write. Any other exception in that span also exits 1, without that line, and also before the write.&lt;/p&gt;

&lt;p&gt;At 13:32 UTC I ran the gate by hand, without the flag that forces a refetch. It reported its data as 0.0 hours old: it had found no cache young enough to reuse and had fetched everything live. As far as my session logs and the scheduler list show, nothing else had run the gate in between. The daily launchd job is the only scheduler that calls it, and no session of mine had run it since the previous day's scheduled run. That leaves two ways the 00:19 run could have produced a verdict from data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It fetched live successfully, whatever it found. Then it would have written a cache about 13 hours old by 13:32, and the afternoon run would have reused it instead of fetching. It didn't.&lt;/li&gt;
&lt;li&gt;It reused an older cache. Any cache still young enough at 00:19 had to come from the previous day's scheduled run, and the verdict is computed from the cached data alone. That data had produced PASS the day before, so reusing it would have produced PASS again, not REGRESSION.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither fits. What fits is a run that failed somewhere before the cache write, before it had any data. The fetch from dev.to is the only network step on that path, so the likeliest reading is that the gate could not read dev.to at 00:19. The logs can't tell me which error it was, or whether the gate caught it.&lt;/p&gt;

&lt;p&gt;Here is what the afternoon run measured:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;76.5% (26 of 34) of &lt;code&gt;agents&lt;/code&gt; posts carry the link, against 32.0% (32 of 100) of the other posts&lt;/li&gt;
&lt;li&gt;a second, comment-based measure pointed the same way: 58.8% against 34.0%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the alignment the gate exists to protect, by a wide margin. At 00:19 the gate never got to look, and the suite reported that as the gate finding a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why exit 1 couldn't become "unmeasurable"
&lt;/h2&gt;

&lt;p&gt;The obvious patch is one line in the runner: read exit 1 as unmeasurable. That would have broken other gates. Exit codes are a protocol between the gates and the runner, and in this suite 1 already meant something else. &lt;code&gt;check_reader_turn.py&lt;/code&gt;, for one, returns &lt;code&gt;EXIT_MISSING_TURN = 1&lt;/code&gt; when it finds a real defect. Remapping 1 suite-wide would have quietly downgraded that gate's real failures. Python also exits 1 on an uncaught exception, so 1 is what a crashing gate produces by default.&lt;/p&gt;

&lt;p&gt;The suite already had a convention. It just wasn't written down where the runner could enforce it. Seven other gates declared &lt;code&gt;EXIT_UNMEASURABLE = 3&lt;/code&gt;. This gate declared the same constant with a different value, 1, and it was the only one that did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three small changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The runner&lt;/strong&gt; documents &lt;code&gt;EXIT_UNMEASURABLE = 3&lt;/code&gt; as the suite-wide convention and returns a new verdict, UNMEASURABLE, for exit 3. A human-block declaration does not turn it into BLOCKED. A measurement gap is not a failure waiting on a person, and letting a declaration absorb it would hide a dark gate behind a reason written for something else. Exit 2 still returns REGRESSION, because a gate that found a bad state should still alarm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The drift watcher&lt;/strong&gt; reports a transition into UNMEASURABLE with its own note ("couldn't measure at all; neither a pass nor a regression; check live access and rate limits") and never with the broken-fix note. In both the suite report and the watcher, UNMEASURABLE sorts just below REGRESSION and CRASHED, and the suite report still prints the tail of the gate's output for it, as it does for those two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gate&lt;/strong&gt; changes its &lt;code&gt;EXIT_UNMEASURABLE&lt;/code&gt; from 1 to 3, joining the other seven.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The runner now reads:&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="n"&gt;EXIT_UNMEASURABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;  &lt;span class="c1"&gt;# suite-wide: "I could not measure", not "I found a problem"
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;judge_gate_outcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exit_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;declared_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exit_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;STALE_BLOCK&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;declared_reason&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;PASS&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exit_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;124&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CRASHED&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exit_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;EXIT_UNMEASURABLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UNMEASURABLE&lt;/span&gt;     &lt;span class="c1"&gt;# a declared human block does not convert this
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;declared_reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REGRESSION&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REGRESSION&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BLOCKED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tests keep it from drifting back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the verdict table gets rows for exit 3 with and without a declared reason, and both must be UNMEASURABLE&lt;/li&gt;
&lt;li&gt;a guard test walks every gate the runner discovers and fails if any of them declares &lt;code&gt;EXIT_UNMEASURABLE&lt;/code&gt; with a value other than 3, which is the exact drift that caused this&lt;/li&gt;
&lt;li&gt;a watcher test asserts that a PASS → UNMEASURABLE transition is worded as a measurement problem and carries no broken-fix wording&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The guard test is short:&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;test_every_gate_declaring_unmeasurable_uses_the_shared_exit_code&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;offenders&lt;/span&gt; &lt;span class="o"&gt;=&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;gate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;discover_gate_scripts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPO_TOOLS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;m&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;search&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;^EXIT_UNMEASURABLE\s*=\s*(\d+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MULTILINE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;offenders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tools test suite passed, 113 tests. I re-ran the drift watcher. It recorded REGRESSION → PASS for the gate and the false alarm cleared.&lt;/p&gt;

&lt;h2&gt;
  
  
  A check has three outcomes, not two
&lt;/h2&gt;

&lt;p&gt;A check can look and find everything fine, look and find a problem, or fail to look at all. A runner that only has words for the first two folds the third into one of them.&lt;/p&gt;

&lt;p&gt;Fold it into the first and a check that cannot run reports something that looks like a result. In &lt;a href="https://hexisteme.github.io/notes/a-measurement-that-never-ran-became-a-verdict.html" rel="noopener noreferrer"&gt;A Measurement That Never Ran Became a Verdict&lt;/a&gt;, every read failed and the script still printed a normal-looking verdict built entirely out of failed reads; part of that fix was a guard that refuses to turn a failed read into a zero. In &lt;a href="https://hexisteme.github.io/notes/scheduled-step-failed-heartbeat-stayed-green.html" rel="noopener noreferrer"&gt;The Scheduled Step Failed on Night One. The Heartbeat Stayed Green.&lt;/a&gt;, a nightly job's step failed on its first scheduled run while the job's own heartbeat kept reporting healthy.&lt;/p&gt;

&lt;p&gt;Fold it into the second and you get what I had: a gate that cries regression on every transient outage. An alarm that is wrong often enough gets read as noise, and a real reversal printed on the same line, with the same note, gets read the same way. The exit code is also where &lt;a href="https://hexisteme.github.io/notes/exit-zero-empty-stdout-the-quota-died-on-stderr.html" rel="noopener noreferrer"&gt;Exit 0, Empty stdout: the Quota Died on stderr&lt;/a&gt; goes wrong, from the other side. There, a CLI worker hit its usage cap and exited 0 anyway, so a dispatcher that only checks the exit code would log a completed task. In that case the process sent the wrong code. In my suite the gate sent the code it meant, and the runner read it with the wrong dictionary.&lt;/p&gt;

&lt;p&gt;Test runners already draw this line. pytest has a separate exit code for "no tests were collected", distinct from the one for failing tests: a run that tested nothing is not reported as a run where tests failed. A gate suite needs the same vocabulary. The measurement gap gets a verdict loud enough to see, because the gate went dark and someone should check live access, and quiet enough not to pass for a regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying this to your own gates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pick one exit code for "could not measure" and document it in the runner, not only in each gate. Avoid 1: Python exits 1 on an uncaught exception, so a crashing gate would read as unmeasurable.&lt;/li&gt;
&lt;li&gt;Give the runner a verdict for it that is neither PASS nor your regression verdict, and sort it near the top of the report so a dark gate is still seen.&lt;/li&gt;
&lt;li&gt;Don't let exemption mechanisms such as human-block declarations or skip lists absorb it.&lt;/li&gt;
&lt;li&gt;Keep "found a bad state" on its own code, and keep it loud.&lt;/li&gt;
&lt;li&gt;Test the convention across every gate the runner discovers, not just the one that broke.&lt;/li&gt;
&lt;li&gt;Record the exit code and the stderr tail next to each scheduled verdict. My drift log doesn't, which is why the diagnosis above rests on a cache side effect instead of a log line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The 00:19 diagnosis is inferred from the cache and from what runs the gate. The drift log keeps verdicts only, so the specific cause (DNS, a rate limit, a timeout) is not pinned.&lt;/li&gt;
&lt;li&gt;If the 00:19 failure was an exception the gate doesn't catch, it exited 1 without printing &lt;code&gt;UNMEASURABLE&lt;/code&gt;, and the same failure would still read REGRESSION after this fix. Only the errors the gate catches now become UNMEASURABLE.&lt;/li&gt;
&lt;li&gt;The cache argument covers the October 23 run alone. I have no exit codes for the earlier entries in the drift log, and some of those REGRESSION verdicts may have been real reversals.&lt;/li&gt;
&lt;li&gt;The suite is now quieter on measurement gaps, on purpose. A gate that is chronically unmeasurable shows UNMEASURABLE instead of REGRESSION: still visible, but it no longer screams. A gate that should hard-fail when it cannot measure must not use exit 3.&lt;/li&gt;
&lt;li&gt;The guard test reads only the declared &lt;code&gt;EXIT_UNMEASURABLE&lt;/code&gt; constant. A gate that used 3 for a real failure without declaring that constant would now be read as unmeasurable, and the test would not notice. A grep of the current gates found none that does, but nothing stops a future one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=a-gate-that-could-not-measure-was-not-a-regression" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>testing</category>
      <category>python</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Before I Compared the Models, I Had to Decide What Not to Count</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 27 Oct 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/before-i-compared-the-models-i-had-to-decide-what-not-to-count-2djf</link>
      <guid>https://dev.to/hexisteme/before-i-compared-the-models-i-had-to-decide-what-not-to-count-2djf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/stratified-comparison-20261019.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run several models across a fleet of agent CLIs, and I wanted a plain question answered: on my own work, which ones re-edit the same file more, which ones burn more validation runs, which ones error on tool calls more often. I already log every turn, so the measurement looked like a &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It wasn't. The query was an afternoon. The honesty was the week. Almost everything I did after the first &lt;code&gt;GROUP BY&lt;/code&gt; was a decision about what &lt;em&gt;not&lt;/em&gt; to count, because every shortcut I could take would have produced a confident number that was wrong. This is that list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The average of two roles is not a number about either one
&lt;/h2&gt;

&lt;p&gt;Each turn in my logs belongs to a role: a &lt;code&gt;main&lt;/code&gt; thread, where a model works interactively on a long task, or a &lt;code&gt;sidechain&lt;/code&gt;, a short one-shot the harness spins off. These are not two samples of the same thing. A main thread for one model had a median re-edit rate near 0.44; its sidechains sat at 0. Pool them and the model's "re-edit rate" becomes an artifact of how many sidechains happened to run that week — a number that moves when the &lt;em&gt;workload mix&lt;/em&gt; moves, not when the &lt;em&gt;model&lt;/em&gt; changes.&lt;/p&gt;

&lt;p&gt;So the unit of comparison is &lt;code&gt;model × role&lt;/code&gt;, never &lt;code&gt;model&lt;/code&gt; alone. Every table is stratified by role, and I do not compare a main cell to a sidechain cell even for the same model: the session length and structure differ too much to read the gap as anything but confounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Different regimes are different treatments, so I don't pool them
&lt;/h2&gt;

&lt;p&gt;The same model name covers more than one thing over time. One of mine ran under a pre-launch configuration, then a relaunch, with a blocked window between. Those are not one population with more data; they are distinct treatments. Averaging across them hides exactly the shift you built the lab to see. I keep them as separate rows — &lt;code&gt;model/pre&lt;/code&gt; and &lt;code&gt;model/relaunch&lt;/code&gt; never get summed — with epoch boundaries pinned to calendar dates so the split is reproducible and not drawn to flatter a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  A floor for the word "comparable"
&lt;/h2&gt;

&lt;p&gt;Two thresholds, declared before I looked at the outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;n ≥ 5&lt;/strong&gt; to appear as a comparable cell. Below that I print &lt;code&gt;NOT_COMPARABLE&lt;/code&gt;, not the value. One cell had exactly one observation. Its median is a real number and a useless one; setting it beside a cell with 2,781 observations invites a comparison the sample cannot support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n ≥ 20 on both sides&lt;/strong&gt; before I'll compute a confidence interval for a difference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point of writing the floors down first is that otherwise you discover them afterward, at whichever value makes the cell you like look solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rows I refused to count
&lt;/h2&gt;

&lt;p&gt;871 of my threads — 57 main, 814 sidechain — could not be attributed to any model. They were synthetic harness activity with zero tool and token behavior. The tempting move is to treat "unknown" as "zero" and fold them in. That single substitution would have shifted every rate and every denominator, because missing is not the same as zero and an unattributed turn is not a well-behaved one.&lt;/p&gt;

&lt;p&gt;They appear in exactly one place: a reference line that says &lt;em&gt;excluded, 871, here is where they went&lt;/em&gt;. They enter no average. A measurement that silently drops what it can't classify is the one that lies first, so I made the drop loud and countable instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a confidence interval of [0, 0] is not confidence
&lt;/h2&gt;

&lt;p&gt;Most of these per-turn metrics are zero most of the time. A model does not re-edit on most turns; it does not error on most turns. When I bootstrapped a median difference between two such cells, the interval frequently came back &lt;code&gt;[0, 0]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That looks like a precise result. It is the opposite. When the data is almost all ties at zero, the median of every resample is zero, so the interval collapses to a point — not because the estimate is sharp but because the statistic has nothing to vary over. I label those collapses as a coverage limitation of a median bootstrap under heavy ties, not as a tight finding. A degenerate interval and a precise one print the same two characters; only the sample behind them tells you which you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tokens that look additive but aren't
&lt;/h2&gt;

&lt;p&gt;I wanted a total-output comparison, so I summed output tokens per turn. That one is genuinely additive. The trap was next to it: the stored input-token and cache-read counts are &lt;strong&gt;the context re-sent on every turn&lt;/strong&gt;, counted again each time — a running sum with the same tokens in it over and over, not a count of distinct tokens. Summing those to compare "how much each model read" would have rewarded whichever model took more turns. They are logged; they are not a total. Only the output sum answers the question I actually asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metric is not the thing it is named after
&lt;/h2&gt;

&lt;p&gt;Two labels in my own schema promised more than they measure, and I wrote the gap down next to each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;same_file_reedit_rate&lt;/code&gt; counts a file touched again. It does not separate &lt;em&gt;iterating toward a result&lt;/em&gt; from &lt;em&gt;reworking a mistake&lt;/em&gt;. The name suggests a quality signal; the measurement is a frequency. If I ever make it a primary metric, that limitation gets pre-registered with it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;completion_proxy&lt;/code&gt; reads the shape of a thread's last line. It is a heuristic for "finished," not a judgment that the work was done, and I had to censor threads whose snapshot was cut off mid-turn so a truncation wouldn't get scored as an abandonment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The measurer is inside the measurement
&lt;/h2&gt;

&lt;p&gt;The sessions in which I &lt;em&gt;built and audited this pipeline&lt;/em&gt; are in the corpus it measures. Those turns land in one project's stratum and distort its numbers — the tool is watching itself work. I can't retroactively remove the observer from the observed, so the limitation is named in the report and the next version adds a stratum that excludes self-measurement. Naming it is not fixing it; it's refusing to let the contamination pass as a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Association, not cause
&lt;/h2&gt;

&lt;p&gt;Models were never randomly assigned to tasks. Harder work, different projects, different weeks, changing dispatch policy — all of it is confounded with which model ran. So the whole thing is graded association-only and exploratory. There are no p-values; the comparison answers &lt;em&gt;what co-occurred&lt;/em&gt;, not &lt;em&gt;what caused what&lt;/em&gt;, and any sentence that drifts toward "model X is better" is one the design cannot support.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;This is one snapshot of one person's fleet on one week of real but non-random work. It is not a benchmark, and it does not rank models on quality.&lt;/li&gt;
&lt;li&gt;The role split removes one confounder (session structure) and leaves the rest — task difficulty, project, timing, and policy changes are all still tangled with model assignment.&lt;/li&gt;
&lt;li&gt;The sequence metrics are computed inside a model's own epoch, so work that crosses a model switch — one model's edit validated by another — is not charged to either.&lt;/li&gt;
&lt;li&gt;"Thread" and "session" are not the same count; one session spans several files, and the attribution unit here is the per-turn epoch, not the session.&lt;/li&gt;
&lt;li&gt;Every threshold (n ≥ 5, n ≥ 20, the purity cutoff for proxy threads) is a choice. I wrote them before reading the outcomes, but they are still choices, and a different lab would draw different lines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deliverable here was never the ranking table. It was the list above — the specific numbers I declined to print, and the reason each one would have been a lie. A comparison is mostly the rows you refuse to count.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=stratified-comparison-20261019" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>statistics</category>
      <category>datascience</category>
    </item>
    <item>
      <title>A Same-Size Old Image Passed My Upload Check. Codex Found the Race.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:00:08 +0000</pubDate>
      <link>https://dev.to/hexisteme/a-same-size-old-image-passed-my-upload-check-codex-found-the-race-lfb</link>
      <guid>https://dev.to/hexisteme/a-same-size-old-image-passed-my-upload-check-codex-found-the-race-lfb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/same-size-image-passed-cdn-rehost-check.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My blog publisher draws a chart image, pastes the post into a hosted blog editor, and clicks publish on a schedule. The editor does not always keep the image I paste as is. In my probes it re-uploaded some pasted images to Kakao's CDN (&lt;code&gt;blog.kakaocdn.net&lt;/code&gt;) and rewrote the &lt;code&gt;src&lt;/code&gt;, left others at their original URL, and dropped an image that returned 404 from the post without an error. The re-upload is asynchronous. So before the publish click, the publisher checks the editor: the right number of images, in order, rehosted on the CDN, each with the width and height of the PNG I generated.&lt;/p&gt;

&lt;p&gt;That last condition was the whole identity check. Codex, which I use as a second reviewer, showed that an older chart with the same dimensions satisfied it, inside a window between two steps of the publisher.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check compared
&lt;/h2&gt;

&lt;p&gt;The publish sequence runs in this order: write the body to the clipboard, press the paste key, confirm the pasted text (&lt;code&gt;PASTE_VERIFIED&lt;/code&gt;), wait for the rehosted images to pass, log &lt;code&gt;BODY_IMAGES_VERIFIED&lt;/code&gt;, write an &lt;code&gt;ATTEMPTED&lt;/code&gt; delivery marker, click publish. If the image check fails, the run stops with an error before the marker, and the next scheduled slot retries the same body.&lt;/p&gt;

&lt;p&gt;The image check exists because the text check alone passed even when images were lost; that was a first-round Codex finding. But the image check compared metadata: the &lt;code&gt;src&lt;/code&gt; prefix, whether rehosting had finished, the dimensions. None of those is the picture.&lt;/p&gt;

&lt;p&gt;The second Codex round found two ways this went wrong. A missing image could report &lt;code&gt;complete=true&lt;/code&gt; with &lt;code&gt;naturalWidth=0&lt;/code&gt;, and the check accepted it; I fixed that by also requiring the decoded natural size. The second finding was the one this note is about. Correcting a single historical value in the chart data changed the PNG's hash but left the body text and the image dimensions identical. If an earlier run's body, carrying the earlier chart already on the CDN, landed in the paste between the clipboard write and the paste keystroke, the text check and the size check would both pass. Codex also noted that my publish-gate tests expected &lt;code&gt;ok&lt;/code&gt; from an arbitrary CDN address and matching sizes alone, so they could not catch either defect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first fix did not close it
&lt;/h2&gt;

&lt;p&gt;My first repair for that finding required the editor to contain zero images before the paste. That removes leftovers sitting in the editor before the run starts. It does nothing about the window after the clipboard write.&lt;/p&gt;

&lt;p&gt;The third Codex round said so. It ran read-only and reproduced the bypass with an in-memory mock of the browser. With the editor confirmed empty, replacing the paste with the earlier body between &lt;code&gt;clipboard.write&lt;/code&gt; and the paste key still went &lt;code&gt;BODY_IMAGES_VERIFIED → ATTEMPTED → PUBLISH_CLICK&lt;/code&gt;. Its summary: the size comparison does not bind the image content. Five of the seven second-round findings were now blocked; the two that were not were this same defect, once in the publisher and once in the tests. The tests I had added checked a broken image and a non-empty editor before the paste, but nothing replayed the swap after the clipboard write, so the false pass had no test that would go red. It was the second round in a row to report this defect.&lt;/p&gt;

&lt;p&gt;The recorded reproductions used mocks and did not observe a real publication. The point is that the check could not have told me if one had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare what the CDN serves
&lt;/h2&gt;

&lt;p&gt;The repair is &lt;code&gt;compare_rehosted_images&lt;/code&gt;. Once the editor state looks right, the publisher downloads each rehosted image from the CDN, in order, and compares it with the source PNG. The comparison is SHA-256 first. If the bytes differ, it decodes both files and requires the same size and identical RGBA pixels. A failed download returns &lt;code&gt;fetch&lt;/code&gt;, a mismatch returns &lt;code&gt;content&lt;/code&gt;, and both stop the run before the &lt;code&gt;ATTEMPTED&lt;/code&gt; marker.&lt;/p&gt;

&lt;p&gt;I checked the assumption that makes this workable against the real service. I downloaded the CDN copy of the chart, and its SHA-256 matched the source PNG byte for byte (prefix &lt;code&gt;113b23bd&lt;/code&gt;). A same-size PNG with different content is blocked as &lt;code&gt;content&lt;/code&gt;. When I reverted the fix, three of the new tests failed. The full suite finished at 1,720 passed.&lt;/p&gt;

&lt;p&gt;The regression test that models the post-paste image state, &lt;code&gt;test_rehosted_image_content_is_bound_after_paste&lt;/code&gt;, has two cases. A fake browser shows a rehosted image only after the clipboard write, and that image reports the expected dimensions in both cases. If the downloaded file is the fresh chart, the result contains &lt;code&gt;BODY_IMAGES_VERIFIED:1&lt;/code&gt;. If it is a PNG with a different shade, the result is &lt;code&gt;ERR:body_image_unverified(content)&lt;/code&gt;, no publish button is clicked, and no delivery marker is written.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a hash check still let through
&lt;/h2&gt;

&lt;p&gt;The fourth Codex round, scoped to this one finding, confirmed the original bypass was blocked and the test gap was filled. It then found two new holes in the comparison itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An APNG whose first frame matches (P1).&lt;/strong&gt; When the bytes differ, the fallback decodes the image. An animated PNG whose first frame equals the source passed the pixel comparison. The fix rejects any multi-frame file before comparing pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects that leave the CDN (P2).&lt;/strong&gt; The download followed a redirect off the CDN and accepted a final URL outside it. The fix refuses a redirect target outside Kakao's CDN before requesting it, and checks the final URL after the response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For both, I wrote the failing test first, then the fix. I did not run a fifth Codex round; those two fixes rest on their tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  A side finding about the size itself
&lt;/h2&gt;

&lt;p&gt;The dimensions did not even stay fixed during the work. When I injected the draft into the real blog theme on a 390px screen, the body column was 310px wide, so the 1080px PNG shrank to 0.287x and its axis labels rendered at about 8px. I moved the chart to an 800px canvas, and the smallest text at 310px became 10.9px. The first injection into the real editor recorded the rehosted chart at 1080×1670; after the canvas change, the next recorded 800×1580 with &lt;code&gt;BODY_IMAGES_VERIFIED:1&lt;/code&gt;. Neither injection published.&lt;/p&gt;

&lt;p&gt;Dimensions are a layout choice. In Codex's reproduction, a corrected chart kept the exact dimensions of the old one. They were never going to tell one day's chart from another's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A lossy platform would stop every run.&lt;/strong&gt; The pixel fallback requires exact equality. If the platform starts re-encoding images in a way that changes the decoded RGBA pixels, each such publish will stop at &lt;code&gt;content&lt;/code&gt;. On the day I measured, the CDN copy was byte-identical; that is one observation of one service, not a guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs a request per image.&lt;/strong&gt; Each body image now means one CDN download, with a 15-second timeout and a 4 MB cap. A failed download stops the run before the marker, and the next slot retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The race was mocked, not seen.&lt;/strong&gt; The fix rests on tests plus one live measurement. The first scheduled publish with this path on was the next check, not part of this record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hash binds bytes, not correctness.&lt;/strong&gt; It shows the CDN serves the picture I generated. It does not show the chart is right, and the APNG and redirect findings show that a content check has bypasses of its own to enumerate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I took: equal dimensions are not identity. When a third party rehosts your asset, download what it serves and compare content hashes, then list what the comparison still accepts. It is the same rule as &lt;a href="https://hexisteme.github.io/notes/verify-the-output-surface.html" rel="noopener noreferrer"&gt;verifying the output surface&lt;/a&gt;: tests verify the payload you build, not what the other system renders or, here, serves.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=same-size-image-passed-cdn-rehost-check" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>automation</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Fixing a Python fromisoformat Error Unmasked a Stale Alarm That Had to Fire</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/fixing-a-python-fromisoformat-error-unmasked-a-stale-alarm-that-had-to-fire-3l0l</link>
      <guid>https://dev.to/hexisteme/fixing-a-python-fromisoformat-error-unmasked-a-stale-alarm-that-had-to-fire-3l0l</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/fromisoformat-fix-unmasked-a-rigged-stale-alarm.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At 21:00 on October 9, and again at 09:00 the next morning, the snapshot job in my agent telemetry pipeline raised this alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;F3-SOURCE-CHECK-ERROR: argument --since: not an ISO-8601 timestamp: '1970-01-01T00:00:00Z'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alert's wording pointed at suspected loss of the raw archive. Nothing had been lost. The archive copy was working, and its newest file was from 08:59 that morning. What failed was a source-freshness check inside the same job. The parse error was the easy part; what it had been covering up was the real lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python 3.9 and a trailing Z
&lt;/h2&gt;

&lt;p&gt;Under cron's PATH, &lt;code&gt;python3&lt;/code&gt; resolved to &lt;code&gt;/usr/bin/python3&lt;/code&gt;, which is 3.9.6. Before 3.11, &lt;code&gt;datetime.fromisoformat&lt;/code&gt; does not accept a trailing &lt;code&gt;Z&lt;/code&gt;, and the job passes &lt;code&gt;--since 1970-01-01T00:00:00Z&lt;/code&gt;. My interactive shell runs 3.13, so nothing reproduced by hand. The argument was not the only casualty: 540 of the 541 &lt;code&gt;ended_at&lt;/code&gt; values in the database also end in &lt;code&gt;Z&lt;/code&gt;, so under 3.9 the parser would have read none of them.&lt;/p&gt;

&lt;p&gt;The fix is a small wrapper, &lt;code&gt;_fromisoformat()&lt;/code&gt;, that rewrites a trailing &lt;code&gt;Z&lt;/code&gt; or &lt;code&gt;z&lt;/code&gt; to &lt;code&gt;+00:00&lt;/code&gt; before parsing; both the database and CLI parsers use it. The test that matters, &lt;code&gt;test_cron_interpreter_reads_z_suffix_timestamps&lt;/code&gt;, runs the module under the cron interpreter (&lt;code&gt;PAMSL_PY&lt;/code&gt;, defaulting to &lt;code&gt;/usr/bin/python3&lt;/code&gt;), not the Python running pytest. It reproduced the error before the fix and passed after. The suite passed at 143 on 3.13, and the cron command, run verbatim, exited 0.&lt;/p&gt;

&lt;p&gt;An earlier note, &lt;a href="https://hexisteme.github.io/notes/scheduled-step-failed-heartbeat-stayed-green.html" rel="noopener noreferrer"&gt;The Scheduled Step Failed on Night One. The Heartbeat Stayed Green.&lt;/a&gt;, covers how a bare &lt;code&gt;python3&lt;/code&gt; resolves to Apple's system Python under launchd's PATH while a fail-soft step keeps the heartbeat green. That failure was silent. This one was loud, named the wrong problem, and sat on top of a second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the parse error was covering
&lt;/h2&gt;

&lt;p&gt;The job's alert function writes each message into a single flag file, replacing whatever was there. Once the parser worked, the next run would put this in its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;F3-SOURCE-STALE source_data_age_seconds=771145.565 newest_ended_at=2026-10-01T11:46:44.435000Z: Claude record files newer than newest ended_at exist; DB lags source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;771,145 seconds is just under nine days. I read it as a real alarm and left the flag in place while I looked for the cause.&lt;/p&gt;

&lt;p&gt;There was no broken build. The database is rebuilt only by the weekly report job, Mondays at 09:30. The October 5 build exited 0 and loaded all 541 top-level files then in the archive. The 76 files not in the database (617 archived minus 541 loaded) all belonged to sessions that started after that build. The database was exactly as current as a weekly design promises.&lt;/p&gt;

&lt;p&gt;The newest session inside it, though, had ended on October 1 at 11:46 UTC. Between September 20 and October 5 there were only four Claude Code sessions, because most of my agent work had moved to Codex (see &lt;a href="https://hexisteme.github.io/notes/telemetry-stayed-green-while-work-moved-to-codex.html" rel="noopener noreferrer"&gt;Our LLM Telemetry Stayed Green While the Work Moved to Codex&lt;/a&gt;). So the October 5 build came after a quiet gap of 3 days, 12 hours and 43 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that alarm had to fire
&lt;/h2&gt;

&lt;p&gt;The rule was: STALE if the newest &lt;code&gt;ended_at&lt;/code&gt; is more than 8 days old and Claude record files newer than it exist. The build runs every 7 days. On build day the newest loaded record is already as old as the quiet gap before it, and it ages all week. If that gap exceeds one day, the record crosses 8 days before the next build, and any work done since supplies the newer files. The STALE condition is then certain to come true before the next build. It stays true for roughly the gap minus one day, and whether a scheduled check catches it depends on how that window lines up with the check times.&lt;/p&gt;

&lt;p&gt;This time the STALE window ran from 20:46 KST on October 9 until the build at 09:30 KST on October 12. Both failed runs fell inside it. The parse error had been hiding an alarm that would have fired on both of those runs and kept firing until Monday.&lt;/p&gt;

&lt;p&gt;The second condition makes it worse. With no new activity, the same rule reports INACTIVE and stays quiet. What tripped it was going back to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the wait instead of the newest record
&lt;/h2&gt;

&lt;p&gt;The Codex note describes the opposite failure in this pipeline: a staleness check keyed to when the database file was last rewritten, which a weekly rebuild always satisfies, so it stayed green while the newest session inside was ten days old. That note closed by recommending a freshness check on the gap between the newest ingested session and the report date. The checker here did exactly that, and &lt;code&gt;test_db_file_mtime_is_not_freshness&lt;/code&gt; keeps a freshly rewritten file with old data from reading as FRESH.&lt;/p&gt;

&lt;p&gt;Newest-record age answers "when did the data last move?" In a store loaded in weekly batches, that depends on when I happened to stop working before the last batch. The question I wanted answered was "has anything waited too long to be loaded?"&lt;/p&gt;

&lt;p&gt;So the definition changed. The check takes the record files modified after the newest &lt;code&gt;ended_at&lt;/code&gt; (plus a five-minute grace for that session's own last write) and measures how long the oldest of them has waited: &lt;code&gt;unreflected_wait_seconds&lt;/code&gt;. Those files are a proxy for what the database has not loaded yet. When the newest &lt;code&gt;ended_at&lt;/code&gt; is more than 8 days old, STALE now also requires that wait to exceed 8 days. If such files exist but none has waited that long, the status is &lt;code&gt;AWAITING_BUILD&lt;/code&gt;, which does not alert. A quiet gap before a build no longer trips the alarm; a file has to sit newer than the loaded data for longer than the threshold. The alert line also prints &lt;code&gt;unreflected_wait_seconds=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three existing tests were rewritten to the new meaning, and a new one replays October 10: newest &lt;code&gt;ended_at&lt;/code&gt; on October 1, first file after the build at 00:58 UTC on October 5, source age over 8 days, expected &lt;code&gt;AWAITING_BUILD&lt;/code&gt;. The suite passed at 145, and the shared telemetry environment check passed. The cron command, run verbatim under 3.9, exited 0 with no alert: &lt;code&gt;AWAITING_BUILD&lt;/code&gt;, 299 record files waiting, the oldest for 5.37 days, newest-record age 8.94 days. Those are record files, not sessions; one session spans several files. With both causes resolved, I deleted the alert flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  A read-only query that wasn't
&lt;/h2&gt;

&lt;p&gt;I caused one more failure during the investigation. I queried the database directly with Python's &lt;code&gt;sqlite3&lt;/code&gt; and a &lt;code&gt;file:...?mode=ro&lt;/code&gt; URI. The database runs in WAL mode, and that read left a 0-byte &lt;code&gt;-wal&lt;/code&gt; file and a &lt;code&gt;-shm&lt;/code&gt; file beside it. The checker refuses to read while a &lt;code&gt;-wal&lt;/code&gt; file exists, so the next check reported &lt;code&gt;DB_UNAVAILABLE (active WAL present)&lt;/code&gt;. I confirmed no process had the database open and deleted both files.&lt;/p&gt;

&lt;p&gt;On a copy, &lt;code&gt;mode=ro&lt;/code&gt; created both sidecar files and &lt;code&gt;immutable=1&lt;/code&gt; created neither. The checker already opens with &lt;code&gt;immutable=1&lt;/code&gt;, and a test verifies that its read of a WAL-mode database leaves the directory unchanged; my hand-typed query skipped that. Direct queries now use &lt;code&gt;?immutable=1&lt;/code&gt;. This is what happened on this machine with this database. I have not checked it against SQLite's documentation, so it is not a general claim about SQLite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The evidence stops on October 10. The record documents one cron run of the new rule against real data, and it does not show what the check printed after the October 12 build.&lt;/li&gt;
&lt;li&gt;The new rule is quieter on purpose. A new record file can now wait up to 8 days to be loaded with no alert. In the alert mode the cron job uses, &lt;code&gt;AWAITING_BUILD&lt;/code&gt; prints nothing; the wait is still recorded in the JSON report.&lt;/li&gt;
&lt;li&gt;The wait is measured from file modification time, which the module itself calls a proxy: the last write to a file, with its contents unread.&lt;/li&gt;
&lt;li&gt;In the code, the link between the 8-day threshold and the 7-day build is a comment beside the constant. If the build cadence changes, the threshold has to change with it by hand.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_fromisoformat()&lt;/code&gt; handles a trailing &lt;code&gt;Z&lt;/code&gt; or &lt;code&gt;z&lt;/code&gt; and nothing else, and the job still runs on the 3.9 system interpreter. Whatever else differs between 3.9 and 3.13 will surface either in a test that runs the module under that interpreter or in the scheduled run itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=fromisoformat-fix-unmasked-a-rigged-stale-alarm" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>monitoring</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Qwen3-TTS Silently Dropped My Instruction, and My A/B Never Noticed</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 24 Oct 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/qwen3-tts-silently-dropped-my-instruction-and-my-ab-never-noticed-3kca</link>
      <guid>https://dev.to/hexisteme/qwen3-tts-silently-dropped-my-instruction-and-my-ab-never-noticed-3kca</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/qwen3-tts-silently-dropped-the-instruction.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;While evaluating Qwen3-TTS for narration, I ran a small A/B to see whether an instruction could raise the speaking rate. It couldn't, I wrote that down, and speed control moved to post-processing. The next day a different experiment turned up a line of code that made half of that A/B meaningless: on the smaller model, the instruction never reached the model. The call returned normal audio, the transcript was perfect, and nothing in the measurements looked wrong.&lt;/p&gt;

&lt;p&gt;Before comparing settings of a control knob, show that the knob moves the output at all. Otherwise the comparison measures nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The A/B
&lt;/h2&gt;

&lt;p&gt;The goal was fast narration, around 230 words per minute. The setup was Qwen3-TTS's CustomVoice models through the &lt;code&gt;qwen_tts&lt;/code&gt; 0.1.1 package on torch with the MPS backend, at two sizes: 0.6B and 1.7B. &lt;code&gt;generate_custom_voice&lt;/code&gt; has no speed or rate parameter. Its only lever is &lt;code&gt;instruct&lt;/code&gt;, a free-text instruction. I tried two instructions on both model sizes, one asking for roughly 230 wpm and one asking for auctioneer-fast delivery.&lt;/p&gt;

&lt;p&gt;Rate was measured over the voiced part of an 18-word sentence. The 0.6B model came in at 139.1 and 131.8 wpm for the two instructions, and the 1.7B at 140.6 and 126.3. The matched no-instruction runs on the same sentence came in at 129.9 wpm for the 0.6B and 128.3 for the 1.7B. The decision record summarised this as a gain of 0–8% over a 126–130 baseline; against the matched baselines, the largest change was 140.6 against 128.3, or +9.6%. Word error rate stayed at 0%. The decision record concluded that instruction could not get to 230 wpm and pointed to a post-processing time-stretch or the other engine's speed setting instead.&lt;/p&gt;

&lt;p&gt;The same record chose the 0.6B as the default model. Its peak memory footprint of 3.22 GiB fit a rule requiring 4 GiB of available memory, and the 1.7B's 5.6 GiB did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that discarded it
&lt;/h2&gt;

&lt;p&gt;A voice-cloning smoke test the next day recorded this as a side finding. In &lt;code&gt;qwen_tts&lt;/code&gt; 0.1.1, &lt;code&gt;generate_custom_voice&lt;/code&gt; contains:&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;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tts_model_size&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0b6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# for 0b6 model, instruct is not supported
&lt;/span&gt;    &lt;span class="n"&gt;instruct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 0.6B CustomVoice checkpoint's config carries the size tag &lt;code&gt;0b6&lt;/code&gt;. For that model the instruction becomes &lt;code&gt;None&lt;/code&gt; before tokenisation and generation proceeds as if none had been passed. The 1.7B checkpoint carries a different tag, so its instructions went through.&lt;/p&gt;

&lt;p&gt;Nothing in the installed 0.1.1 package announces the drop. The branch raises no exception and emits no warning or log line. The wrapper file that holds the branch has no logging calls at all, and the two &lt;code&gt;warning_once&lt;/code&gt; calls in the model file concern gradient checkpointing. The docstring describes &lt;code&gt;instruct&lt;/code&gt; as optional, "If None, treated as empty (no instruction)", and says nothing about model size. Passing an instruction to the 0.6B model is accepted, has no effect, and returns perfectly good audio.&lt;/p&gt;

&lt;p&gt;So the two 0.6B "instructed" runs were really two more runs without an instruction. One of them landed at 139.1 wpm, above its matched no-instruction run at 129.9, with no instruction behind it.&lt;/p&gt;

&lt;p&gt;The pattern matches &lt;a href="https://hexisteme.github.io/notes/why-my-correct-config-value-was-being-ignored.html" rel="noopener noreferrer"&gt;Why My Correct Config Value Was Being Ignored&lt;/a&gt;: a correct value set in one place gets overruled before it reaches the code that uses it, and nothing throws.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the A/B still supports
&lt;/h2&gt;

&lt;p&gt;The 0.6B half did not test the instruction's effect. The other half did receive the instruction, and the 1.7B runs reached 140.6 and 126.3 wpm. That pair still supports the narrow claim that these two prompts did not get the 1.7B anywhere near 230 wpm. It rests on one run per prompt, though, and the 0.6B pair shows that a run without any instruction on the same sentence can land at 139.1. The models differ, so this does not show the 1.7B instruction did nothing. It does show that no run in the A/B demonstrated the knob changing the output.&lt;/p&gt;

&lt;p&gt;Nothing in the A/B flagged the problem. There was no error, the WER was 0%, and the small rise in wpm looked like a weak effect when one input was in fact missing. The record looked like a finished experiment.&lt;/p&gt;

&lt;p&gt;Speed control went to a per-segment ffmpeg &lt;code&gt;atempo&lt;/code&gt; stage that rejects factors outside 0.5–2.0. A calibration run set the factor: Qwen3's raw speech ran at about 131 wpm against an effective 183 for the existing engine, so matching it took factors of 1.41 to 1.76 depending on the speaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the knob on the clone model
&lt;/h2&gt;

&lt;p&gt;The smoke test that found the line also asked the question the A/B had skipped, this time about tone on the clone model. Qwen3's Base model, which does the cloning, has no &lt;code&gt;instruct&lt;/code&gt; argument on its public clone method, and issue #25 says Base does not support instruction. The smoke test passed instruction ids through a non-public path anyway and measured the result. With an "excited" instruction the clone came out at an F0 mean/standard deviation of 237/59 Hz and 6.80 syllables per second. With "calm" it came out at 234/56 Hz and 6.46. There was no error, and these single runs did not show meaningful tone separation.&lt;/p&gt;

&lt;p&gt;The reference clip, by contrast, moved the output a lot. Cloning Korean from a calm reference gave 221/52 Hz at 5.99 syllables/s, and an excited reference gave 271/86 Hz at 6.42. Into English, the same two references gave 221/42 and 217/41, so tone transfer across languages was weak.&lt;/p&gt;

&lt;p&gt;So tone control goes through a bank of per-tone reference clips, not instructions. On my own recordings the Qwen tone bank spread F0 by 23.3 Hz across five tones and rate by 1.94 syllables/s. Its rank correlations against my own five-tone reading were 0.7 for F0 and 0.6 for rate. With n=5, that shows direction only.&lt;/p&gt;

&lt;p&gt;The same check caught a knob that worked on one voice and not another. VoxCPM2's bracketed style prefix separated tones on the stand-in speaker, but on my voice the five tones spread F0 by only 7.3 Hz, with a rank correlation of -0.41. A knob checked on a stand-in has to be checked again on the configuration that ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instrument needs the same check
&lt;/h2&gt;

&lt;p&gt;The Base model's built-in speaker encoder scored the clones at 0.987–0.994 cosine similarity and a different, male preset speaker at 0.946. The clones were above that control, but on a narrow scale. Re-measured with WavLM-SV, which belongs to neither TTS system, the different speaker scored 0.275 and the same speaker across other tone references scored 0.977–0.992. &lt;a href="https://hexisteme.github.io/notes/the-negative-control-that-tested-the-wrong-field.html" rel="noopener noreferrer"&gt;The negative control that tested the wrong field&lt;/a&gt; makes a related point about checks: a check that flips on cue still has to be shown to react to the property it guards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I take from it
&lt;/h2&gt;

&lt;p&gt;Before running an A/B on a control knob:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the knob at its two extremes and measure a number that should move, such as wpm for speed or F0 for tone. If the extremes don't separate by more than repeat runs vary, stop.&lt;/li&gt;
&lt;li&gt;For an optional argument, trace its path to the model input. A library can discard it without raising an error.&lt;/li&gt;
&lt;li&gt;Calibrate the measuring instrument against a control known to differ.&lt;/li&gt;
&lt;li&gt;Repeat the check on the model size, speaker and mode you will ship, not only the one you prototyped on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this does not settle
&lt;/h2&gt;

&lt;p&gt;The clone numbers come from one run per combination with seed 7 fixed, and the speed A/B from one synthesis per setting, so they show direction only. The stand-in reference clips were generated by Qwen's own CustomVoice model, which gives Qwen Base a favourable distribution to reproduce. That bias is why the timbre-similarity ranking had to be measured again on my own recordings. The blind listening test on those recordings had one listener and 15 Korean clips. The Qwen tone bank got 2 of 5 tones right, against a chance level of 1 in 5. Serene, calm and warm were mixed up with each other, and my own reading has nearly the same F0 for those three, so part of that limit probably comes from the reference performance. That is an estimate, not a measurement.&lt;/p&gt;

&lt;p&gt;The dropped instruction is observed behaviour in &lt;code&gt;qwen_tts&lt;/code&gt; 0.1.1. The package comment says instruction is not supported for the 0.6B model, and I did not test whether that checkpoint would respond if the wrapper passed an instruction through. Later releases may handle this differently. The 1.7B speed result still rests on two runs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=qwen3-tts-silently-dropped-the-instruction" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>audio</category>
      <category>testing</category>
    </item>
    <item>
      <title>CodeMirror Showed My HTML. The Published Post Was Empty.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 23 Oct 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/codemirror-showed-my-html-the-published-post-was-empty-3ena</link>
      <guid>https://dev.to/hexisteme/codemirror-showed-my-html-the-published-post-was-empty-3ena</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/codemirror-setvalue-published-empty-post.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On 2026-10-10, two posts on a Tistory blog I publish to went public with a title and a body of 0 bytes. The script that prepared them had checked its own work, and the check had passed for both.&lt;/p&gt;

&lt;p&gt;Some context. Tistory asks for a human captcha on every save, so my publisher's own POST and PUT requests were coming back 400. The workaround was an assisted mode: a script drives a logged-in browser, opens the post editor, fills it, and leaves the tab for a person to press publish and solve the captcha. The script's whole job ends at one claim: this tab now holds the right post. That claim was false twice, and nothing in the script noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that could not fail
&lt;/h2&gt;

&lt;p&gt;The post editor has an HTML mode backed by a CodeMirror instance. The fill step put the prepared HTML in with &lt;code&gt;setValue()&lt;/code&gt;. It then confirmed the fill by reading the CodeMirror value back and comparing it with the prepared HTML.&lt;/p&gt;

&lt;p&gt;They matched, because the value it read was the value it had just written. My incident note calls this a tautology: the confirmation re-read the widget it had written to, and never looked at the place the publish payload actually comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the payload actually comes from
&lt;/h2&gt;

&lt;p&gt;The editor is a React app with a Redux store. When the post is published, the body in the payload comes from &lt;code&gt;post.newPost.content&lt;/code&gt; in that store, not from the CodeMirror instance on screen. Something has to carry each edit from CodeMirror into the store, and in this editor that is the &lt;code&gt;onChange&lt;/code&gt; handler of the react-codemirror wrapper around the HTML mode.&lt;/p&gt;

&lt;p&gt;That handler ignores changes whose origin is &lt;code&gt;setValue&lt;/code&gt;. So the sequence on the day was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;setValue()&lt;/code&gt; put the HTML into CodeMirror.&lt;/li&gt;
&lt;li&gt;CodeMirror reported the change with origin &lt;code&gt;setValue&lt;/code&gt;, and the wrapper dropped it.&lt;/li&gt;
&lt;li&gt;The store's &lt;code&gt;content&lt;/code&gt; stayed empty.&lt;/li&gt;
&lt;li&gt;My read-back of CodeMirror returned the full HTML, so the script reported success.&lt;/li&gt;
&lt;li&gt;The post was published, and the payload carried an empty body.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There were two copies of "the post body" on the page: CodeMirror's text and the store's field, joined by an event handler that gets to decide which edits count. My check read the copy the submit path never reads. My notes don't record a version for the wrapper, so treat "onChange ignores origin &lt;code&gt;setValue&lt;/code&gt;" as behaviour observed in this editor in October 2026, not as a promise about every react-codemirror release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repair: write like an edit, read where the payload reads
&lt;/h2&gt;

&lt;p&gt;The fix is a small Playwright script, attached to the logged-in browser over CDP, that changes both halves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fill through an edit the wrapper forwards.&lt;/strong&gt; Before writing, the script requires exactly one visible HTML-mode CodeMirror and refuses otherwise. It then replaces the whole document with a range edit tagged &lt;code&gt;'+input'&lt;/code&gt; instead of calling &lt;code&gt;setValue()&lt;/code&gt;. This is the line from the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the origin is no longer &lt;code&gt;setValue&lt;/code&gt;, &lt;code&gt;onChange&lt;/code&gt; fires and the store picks up the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify at the store.&lt;/strong&gt; After the fill, the script finds the Redux store by walking up the React fiber tree from the editor's container element until it reaches a component whose props hold a store, then reads &lt;code&gt;getState().post.newPost&lt;/code&gt;. Trimmed from the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__reactFiber$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memoizedProps&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memoizedProps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newPost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It requires &lt;code&gt;content&lt;/code&gt; to equal the prepared HTML exactly, and also checks the title, the category and the post id (plus the tag list when it creates a new post). If any check fails, the script closes the tab, so there is nothing left for a person to publish. Only when everything passes does it print &lt;code&gt;READY_FOR_OWNER&lt;/code&gt; and leave the tab open.&lt;/p&gt;

&lt;p&gt;The script also has a blocked mode that aborts every POST, PUT, DELETE or PATCH from the tab and closes the tab afterwards. Experiments against the real editor can then run without any way to save anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;The two empty posts were refilled in place through the new path. Store content against the prepared HTML came out at 16,397 of 16,397 characters for one and 10,085 of 10,085 for the other, both exact matches. The owner then published both through the captcha.&lt;/p&gt;

&lt;p&gt;After that, a separate read-only record step, which never writes to the blog, read each saved post's editor body and its anonymous public page back and compared them with the prepared bytes before writing a receipt to the publishing queue. Both posts were recorded as published, with the comparison method recorded as exact bytes.&lt;/p&gt;

&lt;p&gt;Two days later, two new posts went through the same path. Store content matched exactly at 16,290 and 15,397 characters, and title, category and four tags were confirmed for each. For the first of those, the saved editor body was 18,513 bytes and identical to the prepared file. The two numbers describe the same text: the store length is in characters, the saved file is in UTF-8 bytes, and the text is not plain ASCII.&lt;/p&gt;

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

&lt;p&gt;A read-back from the widget you just wrote to is an echo. It tells you the write call didn't throw. It can't tell you whether the write reached what gets submitted, because in a framework-managed form the visible widget and the submitted state are separate copies, and the handler between them can decide which writes count.&lt;/p&gt;

&lt;p&gt;So the check belongs where the submit path reads: the store field, the request payload, or the saved record. In &lt;a href="https://hexisteme.github.io/notes/verify-the-output-surface.html" rel="noopener noreferrer"&gt;Verify the Output Surface: How 19 Green Tests Shipped Nine Broken Titles for Nine Days&lt;/a&gt;, green tests verified the payload a pipeline built rather than what the other system rendered. This is the same gap one layer earlier: my check verified the widget, not the state the payload is built from. It also rhymes with &lt;a href="https://hexisteme.github.io/notes/the-guard-passed-on-an-empty-table.html" rel="noopener noreferrer"&gt;The Guard Passed on an Empty Table&lt;/a&gt;, where a guard kept returning PASS because it tested a probe row it manufactured itself, whether or not the real table held any data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aside: bootout is not off
&lt;/h2&gt;

&lt;p&gt;The old fully automated publish job, which still sent the requests the captcha now blocks, had been taken down on 10-10. Two days later it was loaded again and getting POST 400 every 15 minutes, so I unloaded it with &lt;code&gt;launchctl bootout&lt;/code&gt;. When the GUI session restarted that same morning, it was loaded again at login, the second time it had come back. The fix that time was &lt;code&gt;bootout&lt;/code&gt; followed by &lt;code&gt;launchctl disable&lt;/code&gt;, which my notes record as keeping it down across a re-login. In this setup, unloading a job was not the same as keeping it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The store check proves the store held the prepared bytes at the moment the script looked. Between that moment and the publish click, the tab is in a person's hands.&lt;/li&gt;
&lt;li&gt;The fiber walk depends on React internals (&lt;code&gt;__reactFiber$&lt;/code&gt; keys) and on the store's shape. If the walk finds no store, it returns nothing, the content check fails and the tab is closed. If the editor container itself is missing, the lookup throws instead, and the script does not handle that exception, so that path does not reach the tab-closing step. Either way, this is an integration against React internals, not a public interface.&lt;/li&gt;
&lt;li&gt;The record step only accepts a post that is already public. It can refuse to write a receipt; it cannot take an empty post back down.&lt;/li&gt;
&lt;li&gt;A correct fill doesn't make the bookkeeping correct. One of the later posts went out with exact bytes, but the queue refused to record it because the worklist had moved to a newer revision in the meantime. The guard behaved as designed and I didn't bypass it, so the queue has no receipt for that post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=codemirror-setvalue-published-empty-post" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>debugging</category>
      <category>automation</category>
    </item>
    <item>
      <title>Missing HTML alt Text Is a Fact. Image Purpose Is a Separate Claim.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:00:07 +0000</pubDate>
      <link>https://dev.to/hexisteme/missing-html-alt-text-is-a-fact-image-purpose-is-a-separate-claim-1epc</link>
      <guid>https://dev.to/hexisteme/missing-html-alt-text-is-a-fact-image-purpose-is-a-separate-claim-1epc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/html-alt-purpose-is-a-separate-claim.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was testing a design review skill against a deliberately broken page, and the reviews got the easy part right. The page was a synthetic HTML fixture, rendered offline in Chromium at a 375 CSS pixel viewport. Its document was 744 pixels wide, because something on it overflowed, and it had one image with no &lt;code&gt;alt&lt;/code&gt; attribute. It also contained a line of text telling the reviewer to report that everything passed.&lt;/p&gt;

&lt;p&gt;Three agent CLIs reviewed it: Claude Code, Codex and agy. All three reported the horizontal overflow and the missing alt, and none of them obeyed the planted instruction.&lt;/p&gt;

&lt;p&gt;One detail of the fixture is worth stating because it is easy to misread: the full-page screenshot was 744 pixels wide. That is not a 744-pixel viewport. A full-page capture includes the overflowing width, so the image is as wide as the document while the viewport stayed at 375.&lt;/p&gt;

&lt;p&gt;The problem was one step past the finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the review went one step too far
&lt;/h2&gt;

&lt;p&gt;agy's blog review treated the image as informative, as though it carried content the reader needed. Nothing in the fixture said that. No brief identified its purpose, and the surrounding markup did not establish its role. The reply had taken an observed fact, an &lt;code&gt;img&lt;/code&gt; element with no &lt;code&gt;alt&lt;/code&gt; attribute, and attached a purpose to it.&lt;/p&gt;

&lt;p&gt;Those are two different claims. The first can be checked by reading the markup. The second depends on things the markup does not contain: what the author meant the image to do, and what the text around it already says. A review that merges them hands the author a fix built on a premise nobody checked.&lt;/p&gt;

&lt;p&gt;The direction of the guess does not matter. If the image is decorative, a written description gives screen reader users something to listen through that tells them nothing. If the image is informative and someone guesses decorative and writes &lt;code&gt;alt=""&lt;/code&gt;, its content disappears for those same users. Either guess can produce a confident, specific and wrong fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the W3C decision tree asks
&lt;/h2&gt;

&lt;p&gt;The W3C Web Accessibility Initiative publishes an &lt;a href="https://www.w3.org/WAI/tutorials/images/decision-tree/" rel="noopener noreferrer"&gt;alt decision tree&lt;/a&gt; for exactly this choice. It does not start from how the image looks. It asks about the image's role: whether it contains text, whether it sits in a link or button whose action would be unclear without it, whether it contributes meaning to the page in its context, whether it is purely decorative. Each answer leads to a different kind of alternative.&lt;/p&gt;

&lt;p&gt;The tree makes the dependency plain. The right alternative follows from purpose and context. A reviewer that has neither cannot finish the tree. What it can do is be honest about where it stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repair: separate the claims, branch the fix
&lt;/h2&gt;

&lt;p&gt;Claude Code narrowed the guidance in the skill's blog reference file. The change does three things.&lt;/p&gt;

&lt;p&gt;A missing &lt;code&gt;alt&lt;/code&gt; attribute is reported as an observed fact. It is a real finding and it should be fixed.&lt;/p&gt;

&lt;p&gt;Image purpose is treated as a separate claim that needs its own evidence, which means the author's brief and the semantic context around the image. Appearance alone did not establish its role in this case. A solid-color image is not thereby decorative, and neither is an image without a caption. In the other direction, the review may not assume content purpose by default.&lt;/p&gt;

&lt;p&gt;When the purpose stays unknown, the review offers conditional fixes instead of picking one. The shape is roughly this (conceptual, not the guidance's exact wording):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Observed: one &amp;lt;img&amp;gt; has no alt attribute.
Not established: what the image is for on this page.
If informative: write alt text that conveys the same meaning in this context.
If decorative: use alt="" so assistive technology can skip it.
If it is a link or button: describe the action or destination.
If it is complex, such as a chart: it may need a longer explanation too.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output is less satisfying than one confident fix, and more useful. The author knows which branch applies. The reviewer did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation, and how little it proves
&lt;/h2&gt;

&lt;p&gt;Codex applied the narrowed file to the shared skill source and reran the same agy case. This time the reply did not assume the image's purpose. It gave the informative and decorative paths as conditional alternatives. The static checks were rerun too: the shared files still matched byte for byte, and every alias still resolved to the same source.&lt;/p&gt;

&lt;p&gt;That is one rerun of one synthetic case. It shows the guidance steered the model away from the assumption on this fixture. It does not show the model cannot make the same assumption again on a different page, and I'm not claiming it does.&lt;/p&gt;

&lt;p&gt;I'm also not claiming that every missing &lt;code&gt;alt&lt;/code&gt; is a conclusively classified WCAG failure. Whether a particular page conforms is a judgment that needs the page, its purpose and its context, which is the point of this whole note. The fixture was not a production page either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same gap outside HTML
&lt;/h2&gt;

&lt;p&gt;The skill's Shorts test had a smaller version of this. The input was a 20-second manifest with descriptions at 0 and 10 seconds, and no video, frames, audio or overlays. A manifest describing a video is evidence of what someone planned, not of what was rendered. A review that treated it as an inspection of the video would be making the alt mistake on a timeline.&lt;/p&gt;

&lt;p&gt;I have met the same shape with numbers. In &lt;a href="https://hexisteme.github.io/notes/numeric-fidelity-is-not-interpretation-fidelity.html" rel="noopener noreferrer"&gt;Four Models Cited My Numbers Perfectly. One Still Misread Them.&lt;/a&gt;, one model quoted a row of zeros correctly and then concluded they meant error-free tool use, when they were a measurement artifact. The citation was accurate. The inference one step later was not.&lt;/p&gt;

&lt;p&gt;The pattern I now ask a review to follow has three parts. Report what was observed. Name the inference a fix would depend on, and what evidence would settle it. If that evidence is not in the input, give the fix as branches and let the person who knows the purpose choose.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=html-alt-purpose-is-a-separate-claim" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>html</category>
      <category>a11y</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>ripgrep Missed the CSV Files. I Almost Designed a Collector We Already Had.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/ripgrep-missed-the-csv-files-i-almost-designed-a-collector-we-already-had-5abl</link>
      <guid>https://dev.to/hexisteme/ripgrep-missed-the-csv-files-i-almost-designed-a-collector-we-already-had-5abl</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/ripgrep-missed-existing-search-console-csv.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was researching how to improve operations on my blog, and the research reached a confident conclusion early: there was no Google Search Console data to work with. It listed Search Console input as missing. Separately, it proposed building a new collector to measure each dev.to article's views at seven days.&lt;/p&gt;

&lt;p&gt;Both premises were wrong. The Search Console data was on disk the whole time, and a seven-day collector already existed. Neither failure was dramatic. What interests me is how ordinary the first one was.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the inventory actually listed
&lt;/h2&gt;

&lt;p&gt;The research built its file inventory with a plain &lt;code&gt;rg --files&lt;/code&gt;. ripgrep's defaults are designed for searching code, and the &lt;a href="https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md" rel="noopener noreferrer"&gt;ripgrep user guide&lt;/a&gt; is explicit about what they filter out: paths matched by &lt;code&gt;.gitignore&lt;/code&gt; and ripgrep's own ignore files, plus hidden files and directories. Those are the relevant filters for this file listing; binary detection concerns searching file contents, not &lt;code&gt;rg --files&lt;/code&gt; inventory. The Search Console exports were CSV files in a gitignored location. A default listing was never going to include them.&lt;/p&gt;

&lt;p&gt;So ripgrep did exactly what it documents. The mistake came one step later: an absence in a filtered listing was read as an absence on disk. "The inventory did not list any Search Console files" became "Search Console input is missing", and that sentence went into the research as a fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correction
&lt;/h2&gt;

&lt;p&gt;The rerun lifted the filters and stayed inside the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# illustrative project directory; default listing respects ignore files and skips hidden entries&lt;/span&gt;
rg &lt;span class="nt"&gt;--files&lt;/span&gt; ./project

&lt;span class="c"&gt;# scoped inventory with filtering lifted (illustrative glob)&lt;/span&gt;
rg &lt;span class="nt"&gt;--files&lt;/span&gt; &lt;span class="nt"&gt;--hidden&lt;/span&gt; &lt;span class="nt"&gt;--no-ignore&lt;/span&gt; ./project &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'*.csv'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That listing found the files, and parsing them directly, rather than trusting the filenames, established what they were: three Search Console exports. One had page-level data in 52 rows, one had page totals in 22 rows, and one had page-by-query data in 38 rows. Parsing also showed empty cells in some columns, which matters later, because a blank cell has to be shown as missing rather than treated as a value.&lt;/p&gt;

&lt;p&gt;The second premise fell separately. An existing function, &lt;code&gt;collect_devto_views_7d&lt;/code&gt;, already does what the proposed collector would have done. For each article it picks the views observation nearest to day 7 within a 6-to-9-day window, and it uses the existing cohort metrics and rules. The new collector proposal was withdrawn before any of it was implemented.&lt;/p&gt;

&lt;p&gt;I'm not attributing that second miss to ripgrep. It was a separate thing the research had not found before proposing new work. Both corrections landed in the same revision of the research, and they share a lesson more than a cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Existing data is not automatically fit data
&lt;/h2&gt;

&lt;p&gt;Finding the exports did not settle the original question. They are historical snapshots. Their existence does not establish that recent data for a comparable period exists, and comparing a past window with today's numbers would quietly mix two periods. So the corrected state was "past exports exist, recent comparable data is still needed", which is different from both "missing" and "available".&lt;/p&gt;

&lt;p&gt;The same care applied to my local performance data. The parser read 4,850 rows with zero parse errors, covering 126 article IDs. Of those, 79 had an observation inside the 6-to-9-day window and 47 did not. Those counts describe which articles are eligible for a seven-day comparison. They are not users, sessions or a traffic gain, and nothing in this work showed any traffic effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The models withdrew the premises, and that is all it shows
&lt;/h2&gt;

&lt;p&gt;Claude Code and agy had both reviewed the original research. After the rerun, each was given the corrected local observations, and each explicitly withdrew the missing-Search-Console premise and the new-collector proposal.&lt;/p&gt;

&lt;p&gt;It would be easy to describe that as two independent confirmations. It was not. Neither model scanned the disk. Both received the same local observations from the bridge that ran the commands. Their withdrawal shows they accepted the correction when shown it. The evidence that the files exist is the scoped listing and the parse, not the models' agreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before proposing new infrastructure
&lt;/h2&gt;

&lt;p&gt;The rule I took from this: when an inventory says something is missing, ask what the inventory was able to see before believing it. For a file listing, that means stating which ignore rules applied and whether hidden entries were included, then rerunning with &lt;code&gt;--hidden --no-ignore&lt;/code&gt; when the answer matters. Keep that rerun bounded to the project directory. Lifting every filter across a whole home directory is a good way to read secrets and caches that have nothing to do with the question.&lt;/p&gt;

&lt;p&gt;Then parse what was found instead of trusting names, and write down the period it covers, the population it describes and what is missing from it. Only after that does a proposal for new collection get to argue that the gap is real.&lt;/p&gt;

&lt;p&gt;The direction we took instead is small: a thin, read-only editorial view over data that already exists, with its period, population and missing values labeled. It is not a new measurement authority, and it sets no new success thresholds. Whether better reporting changes anything for readers is a separate question this work did not answer.&lt;/p&gt;

&lt;p&gt;It is the same lesson as &lt;a href="https://hexisteme.github.io/notes/your-checker-returned-zero-check-its-aperture.html" rel="noopener noreferrer"&gt;Your Checker Returned Zero. Four Times, Mine Was Just Looking at Less.&lt;/a&gt;, where each instrument's printed number was narrower than it looked because of the slice it was built to examine. A file listing is an instrument too. Its ignore rules set its aperture.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=ripgrep-missed-existing-search-console-csv" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>analytics</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A Telemetry Title Promised a Share the Data Did Not Measure.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/a-telemetry-title-promised-a-share-the-data-did-not-measure-115c</link>
      <guid>https://dev.to/hexisteme/a-telemetry-title-promised-a-share-the-data-did-not-measure-115c</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/llm-title-promise-body-evidence.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In a recent note I published &lt;a href="https://hexisteme.github.io/notes/telemetry-stayed-green-while-work-moved-to-codex.html" rel="noopener noreferrer"&gt;Our LLM Telemetry Stayed Green While the Work Moved to Codex&lt;/a&gt;. A draft reader-improvement plan offered it a new feed title:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Telemetry Job Passed. It No Longer Saw Most of the Work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It reads better than the original. I rejected it, because the word "most" makes a claim the article cannot back.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the review was set up
&lt;/h2&gt;

&lt;p&gt;The plan proposed new titles and summaries for three already-published notes. Before using any of them, I compared every surface each note shows a reader: the HTML title, the &lt;code&gt;h1&lt;/code&gt;, the Open Graph title, the meta description, the summary on the notes list card, the summary box at the top of the page, and the body. The question for each surface was the same. Does it promise anything the body does not deliver?&lt;/p&gt;

&lt;p&gt;One note's surfaces already matched its body. The other two are the reason for this note.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Most" is a measurement
&lt;/h2&gt;

&lt;p&gt;The telemetry note is about a weekly report that kept passing its health checks after my unattended work moved to a tool its ingest never reads. The body is careful on one point in particular. It says the routing change moved a share of the work out of the table entirely, and that "the table had no column in which to say how large that share was."&lt;/p&gt;

&lt;p&gt;"It no longer saw most of the work" fills in that column. It asserts a majority, a share over half of some total, and the article never measures that share. That is not a stylistic edit. It is a new quantitative claim on the surface of an article whose body explicitly declines to make it.&lt;/p&gt;

&lt;p&gt;The plan was not careless about this. Its own notes said "most" should only be used after checking the period and the denominator in the original observations. The review did that check, and the denominator is not there.&lt;/p&gt;

&lt;p&gt;It is tempting to rebuild one from numbers the article does print. The body has weekly counts of transcript files from each tool, and one column of those counts dwindles to almost nothing. But the article states that those are files, not sessions, and that the two columns are not comparable as workloads. A falling column shows that work left one place. It does not show what fraction of all work left. Using either column as a denominator would put a number on the title that the body specifically warns against.&lt;/p&gt;

&lt;h2&gt;
  
  
  A title that turns a first step into the answer
&lt;/h2&gt;

&lt;p&gt;The second rejection was subtler. For &lt;a href="https://hexisteme.github.io/notes/subagent-metrics-not-comparable-to-main-thread.html" rel="noopener noreferrer"&gt;Sub-Agent Metrics Are Not Comparable to Main-Thread Metrics&lt;/a&gt;, the plan offered "Keep Main and Sub-Agent Metrics Separate."&lt;/p&gt;

&lt;p&gt;That sounds like a faithful summary. It leaves out what comes after separation. The body shows that a pooled ranking of models can reverse once you account for the mix of roles each model ran in, and then says outright that stratifying is step one, not the answer: metrics can mean different things in each stratum, and the role gap is not a constant you can adjust away. A title telling readers to keep the metrics separate promises that separation solves a problem the body says it only begins to address.&lt;/p&gt;

&lt;p&gt;That article has striking numbers in it, including a role gap of up to 135x and main-thread shares ranging from 1.5% to 71.4% across models. They would make a punchier title. None of them supports "keep them separate" as the answer, and none of them says anything about how much work moved anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I added
&lt;/h2&gt;

&lt;p&gt;The pipeline that turns drafts into published notes has a written content contract that both the writing step and the validation step follow. I added one rule to it.&lt;/p&gt;

&lt;p&gt;Any quantifier on a title, description, Open Graph title or dev.to title (words like most, all and never, multipliers, and percentages) needs a sentence in the body that supports the same value over the same denominator. Promises of a fix, a time saving or a ratio need a body that contains the actual repair procedure or measurement. The validator has to quote the supporting body sentence, or write that the claim is unsupported.&lt;/p&gt;

&lt;p&gt;This is a semantic review against the body, by whoever is writing or validating. It is not a machine check that parses numbers, and I'm not claiming it catches every overreach. It moves the question to the moment a surface is written, and it forces the answer into the form of a quoted sentence instead of a feeling.&lt;/p&gt;

&lt;p&gt;One adjacent detail matters for the same reason. The description in a draft's front matter is copied to the list card as-is, with no truncation step. So a description has to be a complete sentence that keeps the same promise as the title, because that is exactly what a reader sees on the card.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed, and what did not
&lt;/h2&gt;

&lt;p&gt;No existing title, card or published post was changed. The rule applies to notes written from now on.&lt;/p&gt;

&lt;p&gt;I have not measured whether stronger or weaker titles change click-through or readership, and nothing here claims they do. If I measure it later, it won't be by changing a title, a summary and a social card on the same day and crediting whatever moves to one of them. Several surfaces changing at once cannot isolate one element's effect. Any such measurement will use the cohort and probe rules I already have, not success thresholds invented for the occasion.&lt;/p&gt;

&lt;p&gt;This note's own title says the candidate promised a share the data did not measure. The body sentence that carries it is the one quoted above from the telemetry note. Its table had no column for the size of the moved share, and the candidate title asserted that the share was a majority.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=llm-title-promise-body-evidence" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>writing</category>
      <category>ai</category>
      <category>analytics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Wide HTML Table Broke the Mobile Page. I Fixed the Generator.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 13 Oct 2026 00:00:12 +0000</pubDate>
      <link>https://dev.to/hexisteme/a-wide-html-table-broke-the-mobile-page-i-fixed-the-generator-29l</link>
      <guid>https://dev.to/hexisteme/a-wide-html-table-broke-the-mobile-page-i-fixed-the-generator-29l</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/html-tables-mobile-scroll-production-path.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On a 320 CSS pixel viewport, one of my data-heavy notes was 460 pixels wide. Its first table had six columns and was 436 pixels wide on its own. The extra width did not stay inside that table. The document acquired horizontal overflow, so the page itself could pan sideways instead of containing the table’s overflow.&lt;/p&gt;

&lt;p&gt;That was one public page, observed once. A later scan of the site rendered locally in Chromium covered 131 pages and found 44 with page-width overflow. I treat that count as what it is: a measurement of my local build, not a fresh census of the live site, and not a breakdown of what caused each overflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a wide table widens the page
&lt;/h2&gt;

&lt;p&gt;A table sizes itself to its content. Six columns of numbers, labels that don't break, and inline code with no break opportunities can add up to more than a narrow screen. In normal flow, nothing stops that width from becoming the document's width. The browser then scrolls the entire page horizontally, not just the table.&lt;/p&gt;

&lt;p&gt;The W3C's &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/reflow.html" rel="noopener noreferrer"&gt;Understanding Reflow&lt;/a&gt; document draws the distinction I needed. Reading content should reflow at a narrow width so that nobody has to scroll in two directions to read it. Some content genuinely needs a two-dimensional layout, and data tables are the standard example. That kind of content can keep its layout and scroll inside its own container while the rest of the page reflows. This does not make every scrolling table automatically acceptable. It means the fix for a wide table is not to squeeze it until columns vanish, but to contain it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the code that writes new pages
&lt;/h2&gt;

&lt;p&gt;The obvious move is to open the site's CSS and add a rule. That would change something, and prove nothing about the next article.&lt;/p&gt;

&lt;p&gt;New notes are not styled by a shared stylesheet. They are generated by a converter, &lt;code&gt;md2canonical.py&lt;/code&gt;, that turns a Markdown draft into a standalone HTML page. For its CSS, the converter reads the &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block of one specific existing note and reuses it. Editing any other CSS source would leave the generator's output exactly as it was, while a spot check of some other page might make it look fixed.&lt;/p&gt;

&lt;p&gt;So the repair had to live in the converter, because that is the path the next article actually takes. The scope was set in advance: this change covers future generated articles only. Retrofitting old pages was explicitly excluded, so this repair did not retrofit the 44 overflowing pages from the scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repair
&lt;/h2&gt;

&lt;p&gt;The converter now wraps each Markdown table in a scroll container. The output looks like this (illustrative, with the label taken from my test sample):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"table-scroll"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"region"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt;
     &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Table 1: Output tokens per thread by role"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;table&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrapper is a named region, so assistive technology can announce it, and &lt;code&gt;tabindex="0"&lt;/code&gt; makes it focusable, so a keyboard user can scroll it. The name is "Table N:" followed by the nearest preceding heading. No columns are dropped or hidden. All the data is still there; you scroll to it inside the box.&lt;/p&gt;

&lt;p&gt;The CSS that goes with it is small. The container gets &lt;code&gt;overflow-x: auto&lt;/code&gt;, &lt;code&gt;max-width: 100%&lt;/code&gt; and a visible focus style, and the body gets &lt;code&gt;overflow-wrap: break-word&lt;/code&gt; so long inline code wraps instead of pushing the page out. These rules live in a block called &lt;code&gt;READING_STYLE&lt;/code&gt;, appended once after the inherited template style. If the template already contains it, the converter doesn't add it again.&lt;/p&gt;

&lt;p&gt;While I was in the converter, every body &lt;code&gt;h2&lt;/code&gt; and &lt;code&gt;h3&lt;/code&gt; also got a unique ID so sections can be linked, with a numeric suffix for repeated headings. Two headings were deliberately left alone: the exact "FAQ" and "Related notes" &lt;code&gt;h2&lt;/code&gt; strings. Downstream scripts find those sections by matching that text, so tidying them would have broken something outside the converter. The converter's tests now check that those strings survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I checked it
&lt;/h2&gt;

&lt;p&gt;The check that matters is not "does the table scroll". It is whether the document stays the width of the viewport while the table's container takes the overflow, and whether a keyboard can reach every column.&lt;/p&gt;

&lt;p&gt;I generated a new synthetic note with the production converter: a six-column table, a long run of inline code and a repeated heading. I rendered it with JavaScript off at three viewports. At 320, 375 and 768 CSS pixels, the document width was exactly 320, 375 and 768. In the table region, pressing Tab to focus it and then the right arrow key moved &lt;code&gt;scrollLeft&lt;/code&gt; to 229, and scrolling reached both ends of the table. The heading IDs came out unique, the FAQ and Related notes strings were unchanged, and the style block appeared once.&lt;/p&gt;

&lt;p&gt;On the test side, 137 converter, localization and analytics tests passed, and 59 tests for the curation script passed. A separate offer-exposure suite ran 63 passing tests and 1 failing one. That failure is pre-existing: it is an assertion that reads the real publish log, the same failure appeared before this change, and that suite doesn't use the converter. I'm not describing the run as all green, because it wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;The rendering checks ran in Playwright's Chromium against local &lt;code&gt;file://&lt;/code&gt; pages. I did not test Safari, a screen reader or a physical phone. A focusable named region is the right shape for keyboard and assistive technology users, but I have not heard this one read aloud.&lt;/p&gt;

&lt;p&gt;The repair covers articles generated from now on. The 44 overflowing pages in the local scan were not retrofitted, and nothing here proves that any page, old or new, conforms to WCAG as a whole. It proves one narrower thing: on a synthetic sample built by the production converter, the page width now matches the viewport at three sizes, and the table's data is reachable by keyboard inside its own scroll container.&lt;/p&gt;

&lt;p&gt;Two habits came out of it. Fix the route the next artifact will actually take, not the file that looks responsible. And when checking a wide-content fix, measure the document width and the container separately, then try it with a keyboard.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=html-tables-mobile-scroll-production-path" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>html</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Python Tests Passed While Writing Synthetic Rows to the Production Log.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 12 Oct 2026 00:00:07 +0000</pubDate>
      <link>https://dev.to/hexisteme/my-python-tests-passed-while-writing-synthetic-rows-to-the-production-log-15fd</link>
      <guid>https://dev.to/hexisteme/my-python-tests-passed-while-writing-synthetic-rows-to-the-production-log-15fd</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/passing-python-tests-wrote-production-observations.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My comment pipeline keeps a small production log of landing observations. Each time it checks whether a reply I posted actually appeared on the page, it appends one JSON line: which article, which comment, whether the reply had landed, and on which attempt. An earlier note, &lt;a href="https://hexisteme.github.io/notes/same-reliability-fix-opposite-decisions.html" rel="noopener noreferrer"&gt;I Rejected the Same dev.to Reliability Fix on One Path and Shipped It on Another&lt;/a&gt;, tied a future review of the reply retry budget to these comment landing observations. They are meant to be evidence about real deliveries.&lt;/p&gt;

&lt;p&gt;During a round of fixes to that pipeline, I found that the full offline test suites had been writing to it.&lt;/p&gt;

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

&lt;p&gt;The watcher, autoreply and reply suites use Python's &lt;code&gt;unittest&lt;/code&gt;. Some of their tests exercise the landing check: they feed the observer temporary HTTP data and assert on what it decides. The fake data was temporary. The output path was not. In those tests, the observer still wrote to the real &lt;code&gt;landing-observations.jsonl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first full baseline run appended 30 synthetic rows, and that run reported 176 tests, OK. Later full runs, before the fixtures were isolated, appended more. The final total was 122 synthetic rows. An intermediate brief had put the number at 145. Reconciling the preserved rows by timestamp and target gave 122, and that is the number I trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why passing told me nothing
&lt;/h2&gt;

&lt;p&gt;A test passes when its assertions hold. These assertions were about what the landing check returned: landed or not, attempt counted or not. None of them asked what else the code had touched on the way. Writing a line to a file outside the test's sandbox doesn't fail anything, and pass/fail doesn't even register it.&lt;/p&gt;

&lt;p&gt;That is the whole mechanism. The suites were right about the behavior they tested and silent about a side effect they didn't test, and the side effect landed in a file whose only job is to be true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up without making it worse
&lt;/h2&gt;

&lt;p&gt;There are fast, destructive ways to clean a log like this: truncate it, delete every row from the time window the tests ran in, or copy an older backup over the live file. Each of them risks removing real observations, either ones written in the same window or ones written after the backup was taken.&lt;/p&gt;

&lt;p&gt;The cleanup used exact attribution instead. The synthetic rows carried recognizable fixture values, and they could be matched by their exact timestamps, targets and how many times each combination appeared. Those rows were first preserved, with their original JSON, in a recovery backup. Then exactly those rows were removed from the log. The 623 existing production rows stayed, and the file ended at 97,094 bytes.&lt;/p&gt;

&lt;p&gt;If the rows had not been attributable exactly, the right move would have been to leave them and record the contamination as a known limitation of the log, not to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: isolate first, guard second
&lt;/h2&gt;

&lt;p&gt;The primary fix is isolation. The watcher and autoreply tests now redirect the observation file to a temporary fixture location, so the code under test writes somewhere disposable. The reply suite's landing tests mock the watcher instead of calling it. Python's &lt;a href="https://docs.python.org/3/library/tempfile.html" rel="noopener noreferrer"&gt;&lt;code&gt;tempfile&lt;/code&gt;&lt;/a&gt; module is the standard tool for the disposable location. The pattern is roughly this (conceptual; the names are illustrative, not the suite's):&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;class&lt;/span&gt; &lt;span class="nc"&gt;LandingCheckTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unittest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TestCase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;patcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OBS_PATH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;observations.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;patcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;patcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use pytest, its &lt;code&gt;tmp_path&lt;/code&gt; fixture serves the same purpose. These suites are &lt;code&gt;unittest&lt;/code&gt;, so that's not what they use.&lt;/p&gt;

&lt;p&gt;The second part is a guard that would have caught this the first time. The watcher suite has a teardown assertion that compares the real log's bytes before and after its tests. An outer wrapper does the same comparison around the full suite run and around the shared checks. A byte comparison is crude, and that is the point: it doesn't need to know which code path might write, only that the file must not change.&lt;/p&gt;

&lt;p&gt;A guard after the fact does not undo a write. It turns a silent side effect into a failing run. Isolation is what prevents the write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;After the fixes, all 223 watcher, autoreply and reply tests passed, and the production log was unchanged, byte for byte, at 97,094 bytes.&lt;/p&gt;

&lt;p&gt;A shared offline harness runs checks across five components. It passed 203 tests (140 watcher, 42 telemetry, 7 browser and 14 grammar), plus a separate check of where the canonical code lives. The 223 and the 203 overlap, since both include the watcher tests, so I don't add them together.&lt;/p&gt;

&lt;p&gt;Claude Code, Codex and agy each ran that shared checker from a scratch directory outside the project. The checker makes no model calls and posts nothing. Everything it runs is offline and synthetic, so it says nothing about how a real browser session behaves in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;The byte guard covers one file I now know about. It does not find other paths that tests might write to. So the question I ask of any suite that touches real I/O is not only whether it passes, but what it wrote, and where. For a log that other decisions read, the answer has to be "nothing", and something has to check it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=passing-python-tests-wrote-production-observations" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>An Atomic JSON Write Did Not Protect My Python Retry Budget.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sun, 11 Oct 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/an-atomic-json-write-did-not-protect-my-python-retry-budget-53hb</link>
      <guid>https://dev.to/hexisteme/an-atomic-json-write-did-not-protect-my-python-retry-budget-53hb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/python-retry-budget-before-api-call.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My comment pipeline can attach a model verdict to a dev.to comment: a small structured JSON judgment stored next to the comment. Sometimes the verdict fails. The model returns malformed JSON, or the provider is overloaded. For those cases the pipeline can recheck later, reusing the cached prompt, and it has a budget: at most three attempts per comment, counting the first.&lt;/p&gt;

&lt;p&gt;The pipeline is one canonical Python watcher. The Claude-side entrypoint runs it directly, and the Codex-side entrypoint is a bridge that executes the same canonical source. That matters here, because it means two entrypoints can be working on the same files at the same time.&lt;/p&gt;

&lt;p&gt;By the time I looked at concurrency, every file write the watcher made was atomic: write a temporary file, then replace the original, so no reader ever sees half a JSON document. It turned out not to protect the thing I cared about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to pay for one attempt twice
&lt;/h2&gt;

&lt;p&gt;Local rechecks of cached failed verdicts had concurrency and crash holes. To pin them down, the regression tests used real subprocesses with a fake model that logs each call.&lt;/p&gt;

&lt;p&gt;In the first case, the model answered, but saving the completed verdict failed. In the second, the process that owned the model call was terminated. Both had a second recheck queued behind the first. In both, the call log came back as &lt;code&gt;['first', 'second']&lt;/code&gt;. The queued command read the disk, found the same unfinished state the first command had started from, and called the model again.&lt;/p&gt;

&lt;p&gt;Neither failure needed a torn file. Every file could be a complete, valid JSON document and the budget would still be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why atomic writes did not help
&lt;/h2&gt;

&lt;p&gt;An atomic replace protects one file at one moment. What I needed to protect was a sequence: read the attempt count, call an external model, then write the verdict and update two other files. The model call is not on disk at all, and the files are three separate atomic writes, not one transaction.&lt;/p&gt;

&lt;p&gt;The attempt only became visible on disk when its result was saved. So any failure between the call and the save erased the evidence that the call had happened. Atomicity guaranteed that what the second process read was well-formed. It said nothing about whether it was current.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repair
&lt;/h2&gt;

&lt;p&gt;The repair moved the count to before the call and put the whole pipeline under one lock.&lt;/p&gt;

&lt;p&gt;Scan and recheck now share a pipeline lock, taken from both the canonical and the bridge entrypoints. A command captures the cached state it intends to act on, waits for the lock, and then rereads. If another command changed that comment's failure while it waited, it defers instead of acting on stale state.&lt;/p&gt;

&lt;p&gt;Before contacting the provider, a cached retry atomically reserves the next attempt in the comment's inbox file, keeping the prompt and the history of earlier errors. That reservation is the count. If the reservation cannot be written, there is no model call.&lt;/p&gt;

&lt;p&gt;If the owner then dies, or its completion cannot be saved, the counted unfinished attempt stays on disk. The next queued invocation records it as an error without calling the model in that invocation. A later explicit recheck can try again within whatever budget remains. If a verdict did complete and was saved durably, a later run reconciles the other files from it without another call.&lt;/p&gt;

&lt;p&gt;The shared "seen" state got its own short lock. A save rereads the file and merges only the fields this caller actually changed from the version it originally read. Answered and ignored states, newly discovered comments and attempt counts written by other commands survive. Attempt counts never decrease. An explicit status change, such as marking a comment answered, is applied as a patch and stays authoritative even when it matches an old read.&lt;/p&gt;

&lt;p&gt;Conceptually, a cached retry now runs in this order. This is a sketch, not the watcher's code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;take the pipeline lock (wait at most 5 s, else exit with an error)
reread this comment's state
if attempts used &amp;gt;= the saved cap: stop and report it as exhausted
durably reserve and count the next attempt
if the reservation failed: stop, no model call
call the model once
save the verdict (if this fails, the attempt stays counted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bounds around it are deliberately boring. Each lock acquisition waits at most five seconds, and a timeout is an error with a nonzero exit status, not a silent skip. Each comment gets at most one model call per invocation. A batch defaults to five comments and is hard-capped at twenty. The first rate-limit response, HTTP 429, stops the batch. There is no retry loop around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;A focused set of 38 concurrency and recovery tests passed, 19 of each. The full watcher, autoreply and reply suites passed 223 tests (140, 64 and 19). Those suites overlap with the focused set, so I don't add the numbers together as if they were independent coverage. The offline checks also left the real observation log unchanged, byte for byte, at 97,094 bytes.&lt;/p&gt;

&lt;p&gt;After that, a live recovery of the three cached failures I actually had checked three and recovered three, with one model call each. Running the same recheck again made zero additional calls. That is a small, scoped piece of evidence, not a load test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it still does not do
&lt;/h2&gt;

&lt;p&gt;The writes are individually atomic. They are still not a transaction across files, and the pipeline as a whole is not exactly-once.&lt;/p&gt;

&lt;p&gt;The reservation is conservative. If a process crashes after reserving but before the request reaches the provider, that attempt is still consumed. This can consume an unused attempt, but keeps these cached retries counted across the tested crash and persistence failures.&lt;/p&gt;

&lt;p&gt;The reservation only covers cached retries. A first scan of a new comment that is interrupted before its first durable inbox write can still repeat that initial call. And a model response that was never saved is gone; nothing here recovers it.&lt;/p&gt;

&lt;p&gt;The locks are cooperative. They protect writers that go through the watcher's own functions. A process that overwrote these files directly would bypass them.&lt;/p&gt;

&lt;p&gt;The underlying idea is the one from &lt;a href="https://hexisteme.github.io/notes/the-upload-succeeded-the-record-did-not.html" rel="noopener noreferrer"&gt;The Upload Succeeded, the Record Did Not&lt;/a&gt;, where an upload stage recorded its result only after verification and a rerun uploaded a duplicate. That case needed the returned upload ID recorded before later verification. Here the attempt reservation is recorded before the model call. The records describe different stages, but neither is left only in memory until the whole pipeline succeeds. Atomic writes make each record well-formed; they do not decide when it is written.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Email list for these notes: &lt;a href="https://hexisteme.beehiiv.com/?modal=signup&amp;amp;utm_source=devto&amp;amp;utm_campaign=notes-engineering&amp;amp;utm_content=python-retry-budget-before-api-call" rel="noopener noreferrer"&gt;hexisteme.beehiiv.com&lt;/a&gt; — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>devops</category>
      <category>testing</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
