<?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>An Error Inside HTTP 200 Poisoned My Cache: Why response.ok Is Not a Success Check</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 03 Aug 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/an-error-inside-http-200-poisoned-my-cache-why-responseok-is-not-a-success-check-1nd1</link>
      <guid>https://dev.to/hexisteme/an-error-inside-http-200-poisoned-my-cache-why-responseok-is-not-a-success-check-1nd1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/error-inside-http-200-poisoned-the-cache.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My travel app's backend walks Korea's government open-data portal, data.go.kr, for airport departure boards. One evening a board walk came back empty, memoized that, and kept serving zero rows for every date long after the upstream had recovered. Nothing anywhere reported a failure, because by the only definition the code was checking, nothing had failed: the response was HTTP 200.&lt;/p&gt;

&lt;p&gt;The error was inside the body, and the cache is what turned a transient upstream blip into a durable wrong answer: the write happened before anything had decided the response was a success. Two lessons, neither specific to that portal — &lt;strong&gt;success is a claim the body makes, not a status code&lt;/strong&gt;, and &lt;strong&gt;a cache that sits in front of validation gets poisoned by construction.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A rate limit that arrives as HTTP 200
&lt;/h2&gt;

&lt;p&gt;The portal's gateway wraps every response in an envelope carrying its own status. Success is the header code &lt;code&gt;00&lt;/code&gt;. Throttling is reported through that same envelope: a 200, a well-formed body, a non-&lt;code&gt;00&lt;/code&gt; code in the header, &lt;code&gt;totalCount: 0&lt;/code&gt;, and an empty item list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"header"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resultCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;anything but 00&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resultMsg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;why it failed&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"totalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer is a paginated walk: fetch page 1, read &lt;code&gt;totalCount&lt;/code&gt;, derive the page count, fan out the rest, concatenate, memoize the assembled board per operation.&lt;/p&gt;

&lt;p&gt;The guard before parsing was the one every fetch wrapper I have ever written has:&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// throttled -&amp;gt; [] -&amp;gt; memoized as "the board"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under throttling, &lt;code&gt;res.ok&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. The JSON parses. The item list is empty. The walk reports success with zero rows, and the memo stores that as the board.&lt;/p&gt;

&lt;p&gt;The visible symptom: the international board for Gimpo (GMP) had returned 32 rows earlier that day. After one evening walk it returned zero — for every date, for as long as the memo lived. HTTP 200. Walk completed. Memo fresh. Logs silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Empty was a legal answer, so nothing downstream could flag it
&lt;/h2&gt;

&lt;p&gt;This is the property that made it invisible, and the part that transfers furthest. The design deliberately treats a missing flight as a fallthrough rather than an error: absence in one feed is not evidence of nonexistence — a board can transiently miss a flight through a gap or a codeshare edge — so a lookup the free feed cannot answer falls through to the metered commercial provider instead of returning a 404. That is the right call, and it is also why an empty board is a completely legitimate value here, indistinguishable at every layer above the walker from "the upstream refused to talk to us."&lt;/p&gt;

&lt;p&gt;Generalize it: any system with a legal &lt;em&gt;nothing here&lt;/em&gt; — an empty search result, a zero balance, an empty entitlement list, a null price — will silently absorb an upstream error that decays into that value. Once the error is laundered into the domain's legal empty, no consumer downstream can tell the difference, and no amount of defensive coding at those layers will help. The check has to happen at the boundary where the error is still distinguishable from the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  response.ok answers the transport question, not the application one
&lt;/h2&gt;

&lt;p&gt;Every one of these calls stacks two protocols. HTTP transports the exchange; the gateway's envelope reports the application's outcome. &lt;code&gt;res.ok&lt;/code&gt; — or &lt;code&gt;raise_for_status()&lt;/code&gt;, or &lt;code&gt;if err != nil&lt;/code&gt; — adjudicates the transport only: a response arrived with a 2xx code. Whether the upstream &lt;em&gt;did the thing&lt;/em&gt; is a separate claim, made in the body.&lt;/p&gt;

&lt;p&gt;This is not an exotic quirk of one government portal. GraphQL conventionally answers 200 with an &lt;code&gt;errors&lt;/code&gt; array; JSON-RPC transports failures as an &lt;code&gt;error&lt;/code&gt; member in a 200 body; SOAP puts faults in the envelope; plenty of enterprise and public-sector gateways stamp 200 on anything that reached the application at all. Wherever a body carries its own status, the status code is a routing detail, not a verdict.&lt;/p&gt;

&lt;p&gt;So the rule I now apply is that &lt;strong&gt;success is a schema predicate&lt;/strong&gt;: a response is a success when its body parses into the shape success has — envelope status is the documented OK value, and the fields the caller needs are present and typed. Everything else, including a beautifully formed error body under a 200, is a failure and returns nothing.&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;resultCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// envelope gate&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail that matters more than it looks: the gate runs on &lt;strong&gt;every page of the fan-out&lt;/strong&gt;, not just page 1. A walk that validates its first page and then trusts the rest has only moved the window in which a throttled page can enter the result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache write belongs after the success verdict
&lt;/h2&gt;

&lt;p&gt;The ordering in the broken version was: fetch → transport check → parse → &lt;strong&gt;memoize&lt;/strong&gt; → return. Any judgement about what the payload &lt;em&gt;meant&lt;/em&gt; happened, if at all, downstream of the write. So the moment a bad value cleared the transport check, it was durable.&lt;/p&gt;

&lt;p&gt;The fixed ordering is: fetch → transport check → parse → &lt;strong&gt;adjudicate&lt;/strong&gt; → memoize. Only an adjudicated success is cacheable. In code, the walker returns &lt;code&gt;null&lt;/code&gt; on an error envelope; &lt;code&gt;null&lt;/code&gt; is not a board, so the caller never writes the memo, and the next request retries. That single reordering changes the failure's lifetime from &lt;em&gt;the cache's TTL&lt;/em&gt; to &lt;em&gt;the upstream's outage&lt;/em&gt; — which is the whole game, because the throttling passes and the memo does not.&lt;/p&gt;

&lt;p&gt;Two corollaries came with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial successes are not successes.&lt;/strong&gt; Each operation's board is memoized atomically, all-or-nothing; a truncated walk is never kept. A half-populated cache entry is the same bug wearing a friendlier face.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The layer above has to agree.&lt;/strong&gt; The route layer marks an incomplete board &lt;code&gt;cacheable: false&lt;/code&gt;, so a partial result cannot be re-poisoned into a downstream cache. Fixing validation in one layer is worthless if the layer above saved the bad value anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Detecting it: the same input, a different hour
&lt;/h2&gt;

&lt;p&gt;Nothing about this failure looked like a failure. It was caught because a staged proof re-run at a different hour returned different data for the same date — that was the whole signal, and chasing it took three instrumented rounds, tail logging plus a raw re-probe, because every layer reported health.&lt;/p&gt;

&lt;p&gt;What I would actually trust as a detector, in rough order of cost:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Re-probe the upstream raw and diff it against the cached answer&lt;/strong&gt; for the same input. It is the only check that sees the divergence directly, and it is what ended the hunt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the envelope status and row count on every walk&lt;/strong&gt;, successful-looking ones included. An empty board is an event, not a boring success; if the line for "0 rows" carries the envelope code that produced it, the diagnosis is a grep instead of three rounds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make truncation loud.&lt;/strong&gt; A warning when a paginated walk hits its page cap is cheap, and it caught the sibling bug described below in the same session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on the transition, not the state&lt;/strong&gt;: a board that had rows and now has none, for an input with no business being empty. State-based checks cannot help here, because the poisoned state is a legal state.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Invalidating a cache you cannot enumerate
&lt;/h2&gt;

&lt;p&gt;Fixing the walker does not fix the entries the broken walker already wrote: the deploy ships correct code that goes on reading a wrong value it saved earlier. You have repaired the producer and left the poison in the store, and a remediation plan amounting to "wait out the TTL" just makes the incident's length a property of a config constant.&lt;/p&gt;

&lt;p&gt;The fix that generalizes is a &lt;strong&gt;revision token in the cache key&lt;/strong&gt;. A constant like &lt;code&gt;WALK_REV&lt;/code&gt; participates in every memo and cache key for that data:&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;memoKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;WALK_REV&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pageCap&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bumping it to &lt;code&gt;2&lt;/code&gt; in the same deploy as the envelope gate orphaned every entry the old walker had written — instantly, without enumerating keys, without a flush endpoint, and without needing to know which entries were bad. New code reads new keys; the orphans expire unread.&lt;/p&gt;

&lt;p&gt;That property is what makes it the right tool rather than a clever one. This memo lives per worker isolate, on a deployment where the edge cache itself was inert: no key list, no admin flush, no way to address the store from outside. &lt;strong&gt;A revision component is the only invalidation primitive that works on a store you cannot enumerate or reach&lt;/strong&gt; — and it costs one string concatenation.&lt;/p&gt;

&lt;p&gt;Its sibling rule fell out of the same proofs. The international walk carried a page cap of 6, sized off Gimpo's 4-page board; Busan's international board is 22 pages, so the cap truncated it — and the surviving rows were all outside their effective date window, so the board rendered &lt;em&gt;stale-empty&lt;/em&gt;: the same wrong answer by a different route. The fix was per-operation caps plus putting the cap &lt;strong&gt;in the memo key&lt;/strong&gt;, so raising a cap cannot keep serving the board the old cap truncated. Generalized: &lt;strong&gt;every input that changes the value belongs in the key&lt;/strong&gt; — operation, parameters, caps, and the revision of the code that built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it degrades to, and where it is still wrong
&lt;/h2&gt;

&lt;p&gt;Under active throttling the board now serves the domestic rows alone and retries the international operation next request, rather than pinning an empty board. That is a real cost, stated plainly: during an upstream outage a complete-looking board is quietly missing a section. It was judged the lesser harm for suggestion-grade data that never blocks manual entry — a partial suggestion list recovers on the next request, a memoized empty one does not. An earlier draft coupled the established board's availability to the brand-new operation's uptime, which inverted the priority; review reversed it.&lt;/p&gt;

&lt;p&gt;Other residuals I would rather name than let a reader assume away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same codebase still has older routes — congestion, checkpoints, transit, schedules — that treat an error envelope as an empty result. Their blast radius is smaller (those degrades are per request and mostly uncached or short-TTL), but they are the same class of bug, queued behind an explicit trigger: the first "the data went missing" report re-opens all of them.&lt;/li&gt;
&lt;li&gt;The pre-registered falsifier for the free-feed-first design: if its answers materially disagree with the airline's own information on real lookups, or board gaps make the fallthrough so common that the free path is just added latency, the step is demoted to fallback-only or cut. It is a one-line reorder either way — which is the point of putting it at a seam.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the fix the boards converged and stayed converged, with warm calls around 0.16s and no client change at all.&lt;/p&gt;

&lt;p&gt;Three rules survive the specifics:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Success is a body-schema verdict, not a status code.&lt;/strong&gt; If the payload carries its own status, check it, on every page, before anything else looks at the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cache write goes after the verdict, never before it.&lt;/strong&gt; A cache in front of validation converts every upstream hiccup into a durable lie, and its lifetime becomes your TTL rather than their outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys carry the revision.&lt;/strong&gt; Anything that changes what a cached value means — the code, the caps, the parameters — belongs in the key, because that is the only invalidation you can perform on a store you cannot enumerate.&lt;/li&gt;
&lt;/ol&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>api</category>
      <category>backend</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sub-Agent Metrics Are Not Comparable to Main-Thread Metrics</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sun, 02 Aug 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/sub-agent-metrics-are-not-comparable-to-main-thread-metrics-5585</link>
      <guid>https://dev.to/hexisteme/sub-agent-metrics-are-not-comparable-to-main-thread-metrics-5585</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/subagent-metrics-not-comparable-to-main-thread.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run a small fleet of coding agents on one machine. Every thread ends up in a log, and a measurement pipeline reads those logs into a database, attributing each turn to the model that produced it. After a few thousand threads I had the table people keep asking for: seven model-versions, five behavioural metrics, real workload rather than a benchmark.&lt;/p&gt;

&lt;p&gt;Then I printed one cross-tab I had been skipping, and most of that table stopped meaning what I thought it meant.&lt;/p&gt;

&lt;p&gt;The cross-tab was &lt;strong&gt;role × model&lt;/strong&gt;. In this fleet a model runs in one of two roles. It is either the long interactive &lt;strong&gt;main thread&lt;/strong&gt; I drive by hand, or a short one-shot &lt;strong&gt;sub-agent&lt;/strong&gt; that a main thread spawns, runs once, and discards. Same model. Same weights. Two completely different jobs.&lt;/p&gt;

&lt;p&gt;Role turned out to move the numbers by up to 135x, &lt;em&gt;and&lt;/em&gt; the role mix is wildly different for each model. Those two facts together are enough to make a pooled comparison manufacture a large gap that exists in neither stratum.&lt;/p&gt;

&lt;p&gt;A note on labels: model names are replaced with letters on purpose. The point of this note is that these numbers are not a model ranking, and printing the names invites exactly that misreading. Every figure is real, from one snapshot of one fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The role gap is an order-of-magnitude thing
&lt;/h2&gt;

&lt;p&gt;Median output tokens per thread, same model, split by role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;main n&lt;/th&gt;
&lt;th&gt;main median&lt;/th&gt;
&lt;th&gt;sub-agent n&lt;/th&gt;
&lt;th&gt;sub-agent median&lt;/th&gt;
&lt;th&gt;main ÷ sub&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;478,238&lt;/td&gt;
&lt;td&gt;2,761&lt;/td&gt;
&lt;td&gt;6,212&lt;/td&gt;
&lt;td&gt;77x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;427,838&lt;/td&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;td&gt;8,784&lt;/td&gt;
&lt;td&gt;49x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;185,534&lt;/td&gt;
&lt;td&gt;122&lt;/td&gt;
&lt;td&gt;1,371&lt;/td&gt;
&lt;td&gt;135x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;128,415&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;11,358&lt;/td&gt;
&lt;td&gt;11x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;16,432&lt;/td&gt;
&lt;td&gt;1,338&lt;/td&gt;
&lt;td&gt;14,157&lt;/td&gt;
&lt;td&gt;1.2x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;4,194&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;51,562&lt;/td&gt;
&lt;td&gt;0.08x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The behavioural metrics are worse than lopsided — in one stratum they are flat:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;main-thread medians&lt;/th&gt;
&lt;th&gt;sub-agent medians&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;same-file re-edit rate&lt;/td&gt;
&lt;td&gt;0.40 / 0.44 / 0.50 / 0.53 (four models)&lt;/td&gt;
&lt;td&gt;exactly 0 for six of seven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;error-recovery sequences&lt;/td&gt;
&lt;td&gt;1 / 2 / 2 / 2 (four models)&lt;/td&gt;
&lt;td&gt;exactly 0 for six of seven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;validation runs&lt;/td&gt;
&lt;td&gt;0 for six of seven&lt;/td&gt;
&lt;td&gt;0 for all seven&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "(four models)" qualifier is load-bearing, so let me not hide behind it: the other three model-versions sit at a median of 0 in main too — two genuinely, one because its main cell holds 2 rows and is marked not comparable. Main is where between-model signal &lt;em&gt;can&lt;/em&gt; live, not where it always does.&lt;/p&gt;

&lt;p&gt;Still, the pattern is not a subtle covariate. A main thread iterates: read, edit, re-edit the same file, hit a failure, recover, run a check. A sub-agent is fire-and-forget — it does its one job and exits, so it rarely touches the same file twice and rarely has a failure to recover from. The metric is &lt;em&gt;structurally&lt;/em&gt; near-zero there.&lt;/p&gt;

&lt;p&gt;Which means: for re-edit rate and recovery count the sub-agent stratum carries no between-model signal at all — the median is a constant. All the signal lives in the main-thread stratum, 7% of my rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mix is different for every model
&lt;/h2&gt;

&lt;p&gt;Here is the cross-tab I should have printed on day one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;main rows&lt;/th&gt;
&lt;th&gt;sub-agent rows&lt;/th&gt;
&lt;th&gt;main share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;132&lt;/td&gt;
&lt;td&gt;1.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;1,338&lt;/td&gt;
&lt;td&gt;1.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;2,761&lt;/td&gt;
&lt;td&gt;6.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;122&lt;/td&gt;
&lt;td&gt;8.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;td&gt;36.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;53.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;71.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the whole attributed corpus, 4,533 of 4,890 rows — 92.7% — are sub-agent rows. So a pooled number is mostly a description of sub-agents. But &lt;em&gt;how&lt;/em&gt; mostly ranges from 1.5% main to 71.4% main, a 48-fold spread in composition.&lt;/p&gt;

&lt;p&gt;The reason is not random sampling — it is the delegation policy. My orchestration rules send mechanical fan-out work to cheaper tiers, so those models accumulate sub-agent rows by the thousand; models I drive by hand accumulate main rows. The edge counts show it directly: model A spawned 1,788 sub-agents that were also A and 730 that were B; model C spawned 393 that were B. Role is assigned &lt;em&gt;by the same policy that assigns the model&lt;/em&gt;: the confounder is baked into the architecture, not introduced by chance.&lt;/p&gt;

&lt;p&gt;Anything with an orchestration layer has this shape — retry tiers, canary vs steady-state traffic, batch vs interactive queues, free vs paid users. The router picks both which variant handles a request and what kind of request it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pooled number can reverse
&lt;/h2&gt;

&lt;p&gt;Pool A and C across roles, weighted by row counts — exactly what &lt;code&gt;GROUP BY model&lt;/code&gt; gives you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A:  (200 × 684,639  +  2,761 × 13,691) / 2,961  =  59,010
C:  ( 88 × 565,678  +    156 × 13,913) /   244  = 212,910
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pooled, C burns &lt;strong&gt;3.6x&lt;/strong&gt; the output tokens of A — the headline you would ship.&lt;/p&gt;

&lt;p&gt;Now look inside each stratum:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stratum&lt;/th&gt;
&lt;th&gt;A mean&lt;/th&gt;
&lt;th&gt;C mean&lt;/th&gt;
&lt;th&gt;C ÷ A&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;main&lt;/td&gt;
&lt;td&gt;684,639&lt;/td&gt;
&lt;td&gt;565,678&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.83x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sub-agent&lt;/td&gt;
&lt;td&gt;13,691&lt;/td&gt;
&lt;td&gt;13,913&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.02x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Within each stratum the two models are close. C is &lt;em&gt;lower&lt;/em&gt; than A on main threads (0.83x) and 1.6% higher on sub-agent runs — a near-tie that happens to lean the same way as the pooled number, just nowhere near its size. Neither ratio comes close to 3.6x. The 3.6x is manufactured by the weights: main threads carry 40–50x the mean output of a sub-agent run, and 36.1% of C's rows are main threads against A's 6.8%, so C's average is dragged toward the expensive stratum and A's is not.&lt;/p&gt;

&lt;p&gt;Worth being precise about the name. This is &lt;em&gt;not&lt;/em&gt; textbook Simpson's paradox, which needs every stratum to point one way while the pooled number points the other; here one stratum reverses and the other is a near-tie. Arguably that is the more dangerous shape — there is no meaningful within-stratum effect &lt;em&gt;in either direction&lt;/em&gt;, and pooling still produced a 3.6x headline out of nothing. Call it an amalgamation effect: a difference in composition, amplified into an apparent difference in the metric.&lt;/p&gt;

&lt;p&gt;The reason to walk it out is that it never announces itself. Nobody sets out to pool — pooling is what the obvious query does. The number arrives looking like a model comparison and is in fact a weighted average with different weights per model. What you measured was your dispatcher.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stratifying is step one, not the answer
&lt;/h2&gt;

&lt;p&gt;Splitting by role fixes the mix problem. It opens two more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The metric may not mean the same thing in each stratum.&lt;/strong&gt; My completion proxy — a heuristic that reads whether a thread finished from the shape of its last logged line — collapsed on sub-agent threads, where one model had 92 of 99 threads ending on a tool-result line purely as a harness logging convention. I unpacked that particular failure &lt;a href="https://hexisteme.github.io/notes/llm-model-comparison-observational-data.html" rel="noopener noreferrer"&gt;in an earlier note&lt;/a&gt;; what matters here is what it implies for stratification.&lt;/p&gt;

&lt;p&gt;It implies stratification is not a repair. A metric whose value is set by logging convention in one stratum is not a &lt;em&gt;noisier&lt;/em&gt; measurement there — it is a &lt;em&gt;different&lt;/em&gt; measurement, and averaging it separately does not make it comparable. So the question after "did I stratify?" is "does this metric measure the same construct in every stratum?" Construct validity is per-stratum, not per-metric. Where the answer is no, drop that stratum for that metric and say so in the output.&lt;/p&gt;

&lt;p&gt;A quieter version of the same problem: the &lt;em&gt;unit&lt;/em&gt; can change between metrics. My behavioural metrics are per-turn attribution rows; the completion proxy is thread-level, with a censoring rule that treats threads cut right after a user turn as missing rather than failed. Model A has 200 main attribution rows and 154 usable main threads for the proxy — two "n"s in the same report, meaning different things. Label the unit next to every count, or someone will divide one by the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The role gap is not a constant you can adjust away.&lt;/strong&gt; It is 77x for A, 135x for D, 11x for G, 1.2x for B — and for two thin cells the sign flips outright. If role were a fixed multiplier you could divide it out and carry on. It is not; it interacts with the model. Partly that is because a stratum holding 1.5% of a model's rows is not a random 1.5% — whatever rare circumstance put that model in that role is also selecting the kind of work it did there. Stratification buys you a list of &lt;em&gt;licensed&lt;/em&gt; comparisons. It does not buy you a correction factor.&lt;/p&gt;

&lt;h2&gt;
  
  
  A [0, 0] confidence interval is not precision
&lt;/h2&gt;

&lt;p&gt;Validation-run counts, main threads only. Every model's median is 0. The means are not: A 4.11, C 2.38, G 1.67, B 0.60. I ran percentile bootstrap on all six model pairs. Every one came back with a median difference of 0 and a 95% interval of &lt;strong&gt;[0, 0]&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A ~7x spread in means, reported as exactly zero with zero width. Both are arithmetically correct: the median really is 0 in each cell and in nearly every resample, so the bootstrap distribution is a spike and the percentile interval collapses onto it. That is tie degeneracy, not precision — and read as confidence it launders a tie into a finding, in the most persuasive possible format.&lt;/p&gt;

&lt;p&gt;The part I had not anticipated is how hard this is to guard against. My pipeline already skipped a pair when &lt;em&gt;both&lt;/em&gt; cells had median 0 and an IQR of 0–0. Necessary and insufficient: A's IQR here is 0–4 and B's is 0–0, so the pair sails through the filter and degenerates anyway. The right condition is not "both inputs look constant" but "the statistic is constant across resamples," which you can only check on the resample distribution itself. Report the share of tied values next to any median-based interval, and refuse to publish a zero-width interval you cannot explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I run before any fleet comparison now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Print the stratum × treatment cross-tab first&lt;/strong&gt;, before a single metric. If the composition differs across treatments, every pooled number is a mix effect until proven otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the comparability threshold in advance.&lt;/strong&gt; Mine is n ≥ 5 to appear at all, n ≥ 20 on both sides to earn an interval; thinner cells print as &lt;code&gt;NOT_COMPARABLE&lt;/code&gt; rather than as a small number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-register cross-stratum comparison as forbidden&lt;/strong&gt; — a written prohibition in the spec, enforced by the pair filter, not a caution someone relitigates at 1am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask per stratum whether the metric measures the same construct.&lt;/strong&gt; If a stratum's value is set by logging convention, drop that stratum for that metric and record why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never read a degenerate interval as a result.&lt;/strong&gt; Zero width on tied data is coverage failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When pooled and stratified disagree, publish both&lt;/strong&gt; and name the mix that separates them. The disagreement is the finding.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this still cannot tell you
&lt;/h2&gt;

&lt;p&gt;Routing was never randomized, so none of this is causal — the model each thread got was chosen by policy, entangled with task difficulty, project and week. Everything here is association under a fixed dispatch policy, and the role finding does not rescue it.&lt;/p&gt;

&lt;p&gt;Role is also not the only stratum. Split model A's main threads by project and the re-edit median runs 0.34 / 0.45 / 0.53 / 0.70 across four projects — a 2x spread inside a single model-and-role cell, while the same model's sub-agent value is 0 in every project. And the re-edit metric cannot tell healthy iteration from thrash; it counts both.&lt;/p&gt;

&lt;p&gt;Two more limits. The sessions in which I &lt;em&gt;built and audited this pipeline&lt;/em&gt; are logged like any other work, so the observer stands inside the frame; the next iteration gets an explicit exclusion stratum. And this is one snapshot, one operator, one harness — the constants will not transfer. The procedure does: cross-tab first, threshold in advance, construct validity per stratum, no faith in narrow intervals over tied data.&lt;/p&gt;

&lt;p&gt;The single sentence I would keep: &lt;strong&gt;before you compare treatments, check whether they are running in the same role, in the same proportion.&lt;/strong&gt; In an agent fleet the answer is almost always no, and nothing downstream survives that going unasked.&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>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Unit Tests Passed. The Feature Never Ran. Three Times in One Session.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 01 Aug 2026 00:00:08 +0000</pubDate>
      <link>https://dev.to/hexisteme/unit-tests-passed-the-feature-never-ran-three-times-in-one-session-1f50</link>
      <guid>https://dev.to/hexisteme/unit-tests-passed-the-feature-never-ran-three-times-in-one-session-1f50</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/unit-tests-pass-feature-dead-in-production.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three features shipped in one session. All three had dedicated unit tests. All the tests passed. None of the three ever ran in the live app.&lt;/p&gt;

&lt;p&gt;Not "had a bug." Not "worked but looked wrong." Never executed — the argument that would have activated each feature was never populated on any live path. The app fell back to its default rendering, crashed nowhere, logged nothing.&lt;/p&gt;

&lt;p&gt;The part worth writing down isn't the first incident. It's the second and third, which happened &lt;em&gt;after&lt;/em&gt; I had diagnosed the first and added a dedicated test file for this exact failure mode. Knowing the pattern did not stop the pattern — so the fix cannot be vigilance.&lt;/p&gt;

&lt;p&gt;Setting: a solo-built SwiftUI iOS app that scores restaurant reviews for trustworthiness, with implementation fanned out to LLM subagents that each own a slice of files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round one: eight passing tests, zero live executions
&lt;/h2&gt;

&lt;p&gt;The first feature was a handoff row — buttons that take you from the app to a phone dialer or a maps app. The URL construction lived in a pure function, &lt;code&gt;HandoffRow.urls()&lt;/code&gt;, written that way on purpose; a comment in the source declares it testable. Eight dedicated tests, all passing: whether coordinates are present, whether the target app is installed, percent-encoding of the business name, the precedence rule for using a place identifier over a name search.&lt;/p&gt;

&lt;p&gt;In production those arguments were never populated. The wiring broke in two places.&lt;/p&gt;

&lt;p&gt;First, a merge function. The live app composes two review sources, and that merge is hand-written — it copies fields one explicit line at a time. Three new fields had been added upstream (a place identifier, a phone number, a maps URI). None were added to the copy list. It compiled fine, because dropping a field during an explicit field-by-field copy is not a type error. It is just... not copying.&lt;/p&gt;

&lt;p&gt;Second, the call sites. Four view call sites construct this row. Two — the two on the live path — didn't pass the new arguments at all.&lt;/p&gt;

&lt;p&gt;So the phone button never rendered, in any state, ever, and the maps deep link always degraded to a plain name search. No crash, no error, tests green. The tests didn't find it; an adversarial code review did, run with three lenses, two of which flagged it independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: the same failure, at much larger scale
&lt;/h2&gt;

&lt;p&gt;Same session. I added a new axis to the verdict: how long the restaurant has been operating, benchmarked against how long comparable businesses survive. The deliverables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a pipeline that exhaustively aggregates 692 MB and 2,280,906 rows of public business-registry data&lt;/li&gt;
&lt;li&gt;a static cohort survival table covering 17 business categories across up to 20 years of cohorts, generated at build time&lt;/li&gt;
&lt;li&gt;two domain types and a timeline view component&lt;/li&gt;
&lt;li&gt;fifteen dedicated tests, all passing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero of the five production call sites passed the &lt;code&gt;tenure:&lt;/code&gt; argument. Grepping for the type constructor found every construction site in the app inside a &lt;code&gt;#Preview&lt;/code&gt; block — three of them. The feature existed exclusively in the preview canvas.&lt;/p&gt;

&lt;p&gt;The most instructive call site was the map-pin lookup path. That function already held both values the new axis needed — the license date and the category — in local variables a few lines above the call. It just didn't pass them.&lt;/p&gt;

&lt;p&gt;So the pipeline, the table, the types, the view, and fifteen green tests all existed, and the feature rendered zero pixels on a real device. &lt;em&gt;After&lt;/em&gt; I had diagnosed round one and written a dedicated test file for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round three: the worker said so out loud
&lt;/h2&gt;

&lt;p&gt;Same session, same workflow. I wired restaurant photos in from a places API. The verdict object — the thing that carries the analysis from the logic layer to the view layer — didn't carry the photo fields, so the hero image fell back to a stock illustration on every live path.&lt;/p&gt;

&lt;p&gt;The detail that stings: the subagent that did this work wrote &lt;em&gt;"backend not wired"&lt;/em&gt; in its own completion report. It was honest. It said the thing. That report was one of several returning at once, and the line got buried.&lt;/p&gt;

&lt;p&gt;Three for three, on a pattern I already knew.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why unit tests cannot catch this, in principle
&lt;/h2&gt;

&lt;p&gt;A pure-function test looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;destinations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;HandoffRow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;placeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;googlePlaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ChIJabc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;XCTAssertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destinations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;google&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;absoluteString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"...query_place_id=ChIJabc"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proposition it verifies: &lt;strong&gt;does the function handle the argument correctly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The proposition it does not verify: &lt;strong&gt;does that argument ever arrive?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The second is out of reach for as long as the test supplies the argument itself. That isn't a gap in my suite — it's what isolation &lt;em&gt;means&lt;/em&gt;. More tests, or better ones, hit the same wall, because every one constructs the input by hand, which is exactly the step production was skipping. Coverage doesn't rescue it either: coverage counts lines that executed, and the failure here is a call site that &lt;em&gt;does&lt;/em&gt; execute and passes nothing.&lt;/p&gt;

&lt;p&gt;So the category matters more than the three bugs. Unit tests validate components in isolation, which makes every state where components are &lt;em&gt;not connected to each other&lt;/em&gt; invisible to them. Here that was unpassed arguments and a lossy merge. Elsewhere it's the handler written but never registered, the route that exists but was never mounted, the feature flag defaulting to off, the DI binding that never made it into the container. Different surface, same blind spot. All of them go green.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real culprit is the default value
&lt;/h2&gt;

&lt;p&gt;All three had the same thing at the root: to add a new field without breaking existing call sites, I gave it a default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;googlePlaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;FetchedSignals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tenure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TenureRecord&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;TransparencyRead&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;= nil&lt;/code&gt; is the whole story. Without it, the compiler lists every call site needing an update, immediately, as errors. With it, the compiler is satisfied and the failure relocates to runtime, where it manifests as an absence — and absences don't throw.&lt;/p&gt;

&lt;p&gt;The uncomfortable part: &lt;strong&gt;the default almost always looks like the correct call at the moment you make it.&lt;/strong&gt; It's the textbook incremental migration, and often the only way to add a field without breaking a file somebody else is editing right now. You reach for it because it's the professional move, and it quietly trades a compile-time guarantee for a runtime nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI-assisted development widens the gap
&lt;/h2&gt;

&lt;p&gt;This predates LLMs, but delegating implementation to a code generator makes it structurally more likely.&lt;/p&gt;

&lt;p&gt;Scoping. A generator builds the component you asked for. "Add a tenure axis" yields a pipeline, types, a view, and tests — all self-contained, all good. Connecting it to the five places that should call it was never in the request, and a component that compiles with passing tests looks finished from the inside. The generator produces the thing; it does not produce the thing's callers.&lt;/p&gt;

&lt;p&gt;File ownership. Parallel delegation only works if you split files between workers, or they collide on edits. So the instruction becomes "don't touch files you don't own" — which, followed faithfully, becomes "give the new parameter a default so files you don't own keep compiling." Wiring is precisely the work that crosses ownership boundaries, which is the work nobody was assigned.&lt;/p&gt;

&lt;p&gt;Every worker did its job correctly. Nobody lied. Round three's worker reported the gap explicitly. And the feature was dead. That's a structural outcome, not a diligence problem, and it recurs until you change the structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: make the omission inexpressible
&lt;/h2&gt;

&lt;p&gt;Stop hand-passing the data. Instead of threading new values through view properties at each call site, load them onto an object that already flows to the consumer — here, the verdict itself. It travels with the verdict, so a new call site cannot fail to bring it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: hand-passed at every call site -&amp;gt; 2 of 4 forgot&lt;/span&gt;
&lt;span class="kt"&gt;TransparencyReadView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;googlePlaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;phoneNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;googleMapsUri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After: the verdict carries it -&amp;gt; the call site has nothing to forget&lt;/span&gt;
&lt;span class="kt"&gt;TransparencyReadView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principle isn't "remember to pass it." It's &lt;strong&gt;make forgetting inexpressible.&lt;/strong&gt; A rule that depends on remembering had already failed three times inside this one story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: a wiring test beside the unit tests
&lt;/h2&gt;

&lt;p&gt;A separate file whose only job is asserting the &lt;em&gt;assembly path&lt;/em&gt; — not the functions, the connections between them. Three assertion points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The merge boundary.&lt;/strong&gt; Does the partial merge preserve the new fields? Plus order-independence, since the two sources are fetched concurrently and complete non-deterministically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The assembly boundary.&lt;/strong&gt; Reproduce the live assembly expression exactly and assert the value reaches the final object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orthogonality.&lt;/strong&gt; Assert the newly carried value does &lt;em&gt;not&lt;/em&gt; change the verdict. A handoff identifier must not leak into a trust judgment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a separate file because it answers a different question and would otherwise get deleted as redundant — so a comment at the top explains why it exists, for the next person doing a cleanup pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually caught all three
&lt;/h2&gt;

&lt;p&gt;Adversarial code review, every time. Three reviewers with different lenses — regression risk, contract integrity, honesty of reporting — run independently, converging on the same finding.&lt;/p&gt;

&lt;p&gt;One line in the review instruction did most of the work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Trace new features to their call sites and verify they actually receive values on the live path.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In round one that line wasn't there, and the reviewer saw eight passing tests and rated the item low severity — a reasonable read of the evidence in front of it. The instruction is what changes the evidence a reviewer goes and collects. You don't get call-site tracing by hoping the reviewer is thorough; you get it by asking for it by name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The portable checklist
&lt;/h2&gt;

&lt;p&gt;Language-agnostic. Applies anywhere default arguments and optional fields exist.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Giving a new field a default opts you out of the compiler's call-site audit. Budget for paying that cost elsewhere.&lt;/li&gt;
&lt;li&gt;Partial merges — &lt;code&gt;merge&lt;/code&gt;, &lt;code&gt;combine&lt;/code&gt;, &lt;code&gt;reduce&lt;/code&gt;, any hand-written field-by-field copy — get no compiler help when a field is added. Add a field, grep the merges first.&lt;/li&gt;
&lt;li&gt;Keep a wiring pass-through test beside the unit tests, in its own file, with its reason for existing written at the top.&lt;/li&gt;
&lt;li&gt;Before trusting a new test, check that it can fail: delete the thing it protects and confirm it goes red.&lt;/li&gt;
&lt;li&gt;Put "trace to the call site" in the review instruction. Don't rely on reviewer diligence for something you can ask for.&lt;/li&gt;
&lt;li&gt;Splitting file ownership across parallel workers structurally produces incomplete wiring. Schedule a dedicated wiring pass at the end, or move the data onto a domain object so there's nothing left to wire.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;One project, one developer, one session. A case study, not a statistic. The review caught all three this time, which is not evidence it catches everything — I have no way to count what it missed.&lt;/p&gt;

&lt;p&gt;"Put the data on the domain object" isn't universally right; the counter-cost is that the object grows. It was cheap here because that object was already a display-oriented container carrying a dozen derived values, and never gets persisted. If it were a persisted model, adding fields for view convenience would be a schema decision, and I'd have chosen differently.&lt;/p&gt;

&lt;p&gt;Two conditions would tell me I'm wrong. If a wiring gap of the same shape recurs &lt;em&gt;despite&lt;/em&gt; the pass-through test, the answer isn't more test placement — it's applying the structural fix more broadly. And if six months pass with no recurrence, that only counts if new fields with defaults were actually added in that window. No incidents can mean no attempts.&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>debugging</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>Our Quality Gate Was 24x Noisier Than What It Guarded</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 31 Jul 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/our-quality-gate-was-24x-noisier-than-what-it-guarded-49h1</link>
      <guid>https://dev.to/hexisteme/our-quality-gate-was-24x-noisier-than-what-it-guarded-49h1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/quality-gate-noisier-than-what-it-guards.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In a public ML benchmark competition, we ran a submission policy: don't spend a submission — a scarce, rate-limited resource — unless a candidate change beats the current best by at least 2 percentage points on a fixed, 40-task local holdout set. It read like ordinary scientific discipline: don't act on noise, require a minimum effect size before you spend something scarce. Over a 125-day eligibility window, at one submission per day, exactly one submission went out — under an explicit calibration exemption written into the rule itself, not because anything cleared the +2pp bar. In 125 days, the threshold itself was cleared zero times.&lt;/p&gt;

&lt;p&gt;Not because nothing we tried in that window ever worked. Because the ruler we'd built to decide "did this work" carried more noise than the thing it was supposed to be protecting us from misusing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rule Nobody Could Pass
&lt;/h2&gt;

&lt;p&gt;The policy had three conditions. Two were fine: reproduce across multiple random augmentation seeds, and a full-length dry run finishing without timing out. This piece is about the third — beat the local holdout by at least 2 points — because in 125 days nothing ever actually cleared it. The one submission that did go out was explicitly exempted from it by the rule's own calibration clause, not passed by it.&lt;/p&gt;

&lt;p&gt;At first the near-silence looked like it might just be the truth: maybe nothing attempted in that window genuinely worked. That's possible — and it's also exactly what you'd see if the gate itself could not tell a real improvement apart from sampling noise. Those two very different realities produce an identical pass log. The only way to tell them apart is to stop staring at the log and start measuring the instrument that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the Two Numbers Side by Side
&lt;/h2&gt;

&lt;p&gt;Before arguing about whether 2 percentage points was the right bar, we measured something nobody had measured yet: how noisy is the ruler itself, compared to the thing we actually cared about?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instrument&lt;/th&gt;
&lt;th&gt;Standard deviation&lt;/th&gt;
&lt;th&gt;Basis&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;40-task local holdout&lt;/td&gt;
&lt;td&gt;±4.79pp&lt;/td&gt;
&lt;td&gt;Simulated, from a measured task-churn rate of 11/120&lt;/td&gt;
&lt;td&gt;The gate — decided whether you could submit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Larger 120-task local eval set&lt;/td&gt;
&lt;td&gt;±2.77pp&lt;/td&gt;
&lt;td&gt;Simulated, from the same measured task-churn rate&lt;/td&gt;
&lt;td&gt;Available, but never used as the gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual scored result (the real target)&lt;/td&gt;
&lt;td&gt;≈0.2pp&lt;/td&gt;
&lt;td&gt;Observed directly — 4 submissions of the same configuration, made by different teams&lt;/td&gt;
&lt;td&gt;What you were actually trying to predict — those 4 submissions landed inside a 0.42-point band&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Worth flagging that "Basis" column: the two gate-side numbers aren't repeated measurements, they're a simulation seeded by one observed churn rate; only the target-side number is a direct, repeated observation. An essay arguing you should measure your own instrument's noise before trusting it doesn't get to skip that disclosure about its own numbers.&lt;/p&gt;

&lt;p&gt;The gate was carrying close to 24 times more noise than the thing it existed to protect access to. And it wasn't just imprecise in some abstract sense — a companion analysis measured the false-positive rate of the "beat the holdout by 2 points" bar directly and got 39.4%. More than a third of the time, pure sampling noise, with zero real improvement behind it, would clear that bar on its own. The gate wasn't a strict filter guarding a scarce resource. It was closer to a coin flip wearing a lab coat.&lt;/p&gt;

&lt;p&gt;This is a completely general shape, not a quirk of one competition: a performance budget enforced on noisy, shared CI runners gating a deploy whose real production regression rate is tiny and well characterized, or a flaky pre-merge suite blocking merges into a service whose actual incident rate barely moves release over release. In every case one diagnostic question settles the argument before you even reach threshold values: whose standard deviation is bigger, the gate's or the target's?&lt;/p&gt;

&lt;h2&gt;
  
  
  A Zero-Pass Log Is a Symptom, Not a Safety Certificate
&lt;/h2&gt;

&lt;p&gt;It's tempting to read "the threshold was cleared zero times in 125 days" as proof the gate worked — screening out everything that wasn't good enough, letting nothing through that hadn't earned it. That reading doesn't survive contact with the numbers above. When a gate's own noise floor is several times larger than the effect size it's supposed to detect, a zero-pass log is exactly what you'd see whether the true rate of real improvements underneath was high or low. The gate cannot distinguish those two worlds, so its output doesn't carry that information either, no matter how strict it looks from the outside.&lt;/p&gt;

&lt;p&gt;The general version: a team that points to "our gate rejects 95% of what comes through it" as evidence of rigor is citing a statistic about the gate, not about what it screened — unless it has separately confirmed the gate's noise floor sits below the effect size that matters. Otherwise a zero-pass (or near-zero-pass) log and a well-calibrated strict gate look identical from the outside, and only one of them is doing anything useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Instinctive Fix Is Usually the Wrong One
&lt;/h2&gt;

&lt;p&gt;Once a gate looks untrustworthy, the natural move is to make the gate itself more precise: a bigger holdout, more seeds, a tighter statistical test. That instinct is only correct if the resource being protected is the noisier side of the comparison. Here it was the reverse — the thing behind the gate, the actual scored result, was already dramatically more precise than the gate itself. Spending effort shrinking the local holdout's noise further would have meant polishing the wrong ruler, like recalibrating a bathroom scale to match a lab balance you already have standing right next to it, instead of just stepping on the lab balance.&lt;/p&gt;

&lt;p&gt;The general rule: only invest in tightening a gate when the resource on the other side is itself the noisier one. If the target signal is already more precise than your proxy, refining the proxy changes nothing; you still have a cheap, noisy instrument standing in front of an expensive, precise one. The real question isn't how to make the proxy better — it's when you're allowed to consult the precise instrument directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delete the Threshold, Not the Discipline
&lt;/h2&gt;

&lt;p&gt;The other tempting move runs the opposite direction: since the gate clearly doesn't work, delete it — submit whenever you want. That's wrong too, for a reason that has nothing to do with noise. The gate existed to block a specific, real failure mode: spending a scarce resource by trying many variants and only reporting the one that happened to score best. That failure mode — call it fishing, or informal multiple testing — is just as real with a perfectly precise proxy as with a noisy one. Delete the gate and it comes straight back.&lt;/p&gt;

&lt;p&gt;The old rule had quietly conflated two different jobs under one number: a statistical bar on a noisy proxy metric, and a discipline requirement against fishing. Only the first one was broken. So we deleted the first and kept, then sharpened, the second.&lt;/p&gt;

&lt;p&gt;In place of the deleted statistical bar, we added a process requirement: state, in one sentence, the specific hypothesis this attempt tests, and why your cheap local measurement is structurally unable to resolve it — not "noisy this time," but incapable by design. Then write down, for every range the result could plausibly land in, what you'll conclude and do next, before you see it. If any range's answer is "we'll decide when we see it," the attempt doesn't qualify.&lt;/p&gt;

&lt;p&gt;The anti-fishing rule stayed completely untouched: no resubmitting an unchanged configuration hoping for a better roll; one configuration, one measurement, remeasurement only after a real change. That's what separates selecting on noise — illegitimate, non-transferable — from measuring a hypothesis your cheap instrument structurally cannot answer — legitimate, transferable — while still blocking the failure mode the gate was built for.&lt;/p&gt;

&lt;p&gt;Translated to ordinary software practice: you don't scrap "only one canary deployment in flight at a time." You might well scrap "must beat this noisy synthetic load test by X%" and replace it with "state which regression this canary is checking for, and write down what you'll do for every value the canary metric could show, before you ship it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Commit the Reasoning Before You See the Result
&lt;/h2&gt;

&lt;p&gt;Everything above — the rewritten rule, and a full table mapping every possible real-world outcome to a conclusion — was written down before the actual result existed. That ordering is the entire point. Rewrite a rule after you've already seen the number, and you are not correcting a bad gate. You're picking whichever justification would have let you do what you already wanted to do.&lt;/p&gt;

&lt;p&gt;The pre-registered outcome table looked roughly like this (scores on this competition's normal 0–100-ish scale, baseline at 28.47):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Real score lands in...&lt;/th&gt;
&lt;th&gt;Conclusion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≥ 30.5&lt;/td&gt;
&lt;td&gt;Local read had the direction wrong; every recent local-only verdict this week needs re-checking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28.9 – 30.4&lt;/td&gt;
&lt;td&gt;Real effect exists but is small; local got sign or size wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28.05 – 28.89&lt;/td&gt;
&lt;td&gt;Effect is zero within noise; local verdict stands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27.5 – 28.04&lt;/td&gt;
&lt;td&gt;Effect is real and negative, matching local, just smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt; 27.5&lt;/td&gt;
&lt;td&gt;Can't separate a real negative effect from an unrelated execution failure; inconclusive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The candidate under test had searched a wider space per task — about 31% more candidate answers generated, roughly 22% more compute spent — and the real result came back at 28.19. That falls inside the 28.05–28.89 band. The conclusion followed mechanically, exactly as written down in advance: the extra search bought an effect indistinguishable from zero at this instrument's noise level. No re-litigating, no "well, technically." The table had already decided.&lt;/p&gt;

&lt;p&gt;There was a second, unplanned payoff. An earlier calibration point had shown the local proxy's absolute level predicted the real score reasonably well — the real result had landed at about 97.6% of the local number. But one point can only validate level, never ranking; checking whether a noisy proxy preserves order takes at least two independent readings moving in a known direction. This supplied the second point: the ratio-adjusted local prediction was 27.93, the actual result 28.19, a gap of 0.26 — about the size of the real instrument's own noise floor. A gate can be too noisy to threshold on directly and still be trustworthy for ranking candidates against each other — a separate property from absolute-level accuracy, and one that takes a second measurement to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Reasoning Breaks Down
&lt;/h2&gt;

&lt;p&gt;Three honest limits, stated on the record rather than left implicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The ≈0.2pp figure for the real target came from four submissions of identical code by different teams, not from our own repeated attempts — the anti-fishing rule forbids measuring that directly, since it would mean resubmitting unchanged code ourselves. If our own run-to-run variance differs from that borrowed estimate, the 24x figure moves. That's a known, accepted gap, not a hidden one.&lt;/li&gt;
&lt;li&gt;The whole argument assumes the resource stays scarce. A process gate built for one submission a day, over a fixed 125-day window, is calibrated to that scarcity — if the budget loosens later, its weight should be revisited.&lt;/li&gt;
&lt;li&gt;Every number here describes one evaluation environment. Public/private scoring environments can diverge; conclusions are scoped to what was measured, not guaranteed wherever the stakes end up highest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general habit worth keeping: before arguing where to set a gate's threshold, measure the gate instrument's own noise and put it next to the noise of the thing it protects. If the gate is the noisier side, the fix isn't a bigger or smaller number — it's moving what you're gating on entirely, from a statistical bar you can't trust to a discipline you can. Whichever fix you pick, write down why, and what every outcome will mean, before the result exists to tempt you.&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>data</category>
      <category>machinelearning</category>
      <category>testing</category>
    </item>
    <item>
      <title>Four Models Cited My Numbers Perfectly. One Still Misread Them.</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 30 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/four-models-cited-my-numbers-perfectly-one-still-misread-them-3bf9</link>
      <guid>https://dev.to/hexisteme/four-models-cited-my-numbers-perfectly-one-still-misread-them-3bf9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/numeric-fidelity-is-not-interpretation-fidelity.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I recently froze a table of behavioral metrics computed from 281 of my own AI-agent sessions and ran it through two separate checks before I let myself trust any conclusion drawn from it. Both checks passed. Neither one verified the thing I actually needed to know — and both failures turned out to have the exact same shape.&lt;/p&gt;

&lt;p&gt;The underlying table is observational, closer to a photograph of my own routing policy than a fact about the models sitting in it, and I've already written about that failure mode on its own. This post assumes that caveat and goes one layer past it: what happens after you've accepted it, built real checks anyway, and watched them both come back green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act one: a citation audit that passed 70 of 70
&lt;/h2&gt;

&lt;p&gt;The corpus was 281 sessions, broken into 4,818 threads and 4,415 rows of behavioral metrics, frozen so nothing in it could shift under me mid-analysis. I had my own relaunched main model read the frozen table and write down its claims first, sealed before any outside model saw the data. Then I sent the identical table to six outside vendor families and asked each one to surface claims of its own. Only three of those six made it back cleanly — one hit a free-tier quota of zero, one threw an internal error, one had its response stream truncated mid-read. Between my own sealed read and the three outside reads that did return, four independent families ended up looking at the same numbers: my relaunched model, xAI's Grok, DeepSeek, and a Google open model.&lt;/p&gt;

&lt;p&gt;Then I ran the part of the check that actually matters: a deterministic auditor that pulled every number any of the four models had cited, recomputed it directly against the frozen database, and flagged anything off by more than 5%, or any number that didn't correspond to anything in the data at all. 70 citations went in. 70 came back PASS. Zero hallucinated numbers, across four models with no shared training lineage.&lt;/p&gt;

&lt;p&gt;That's a genuinely good result, not a strawman I'm setting up to knock down. It rules out a real failure mode — models inventing statistics that merely sound plausible. What it doesn't rule out is a model reading a real, correctly cited number and drawing the wrong conclusion from it. That happened once, cleanly enough to use as the example.&lt;/p&gt;

&lt;p&gt;One row in the table belonged to Claude Sonnet 5's main-role threads — a small population, nine of them — where four behavioral metrics, including &lt;code&gt;tool_error_rate&lt;/code&gt;, all read exactly &lt;code&gt;0.000&lt;/code&gt;. DeepSeek's model cited that &lt;code&gt;0.000&lt;/code&gt; correctly and concluded it meant precise, error-free tool use, then built a routing suggestion on top of that reading. My own model, looking at the same row, flagged it as a likely measurement artifact instead. Sonnet 5 barely acts in a main-role thread in this corpus, and most of these rates are derived from file edits it almost never makes there: with no edits underneath the ratio, the rate isn't low, it's undefined and defaults to zero. A median tool-error rate of zero across those nine sparse threads is the same kind of non-signal — tie-degenerate, not a track record. The zero meant "not measured," not "no errors."&lt;/p&gt;

&lt;p&gt;The audit never had a chance to catch this, because there was nothing wrong with the citation to catch. DeepSeek quoted &lt;code&gt;0.000&lt;/code&gt; accurately. The mistake happened one inferential step later, in the word "therefore" — and a citation-accuracy audit has no way to see that step. It checks arithmetic, not reasoning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act two: the test built to be rigorous, and still not enough
&lt;/h2&gt;

&lt;p&gt;The fan-out audit was casual by design: let several models free-associate over a frozen table and grade the arithmetic afterward. One metric got stricter treatment. Before looking at the data, I pre-registered a formal equivalence test — TOST, two one-sided tests — on same-file re-edit rate, comparing my relaunched main model's main-role threads against Claude Opus 4.8's, the older flagship. I committed to the margin in advance, 0.75 of the pooled standard deviation, and to a simple rule: the test only passes if the whole confidence interval around the observed difference sits inside that margin.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quantity&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Relaunched model, main-role sessions (n)&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.8, main-role sessions (n)&lt;/td&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observed mean difference&lt;/td&gt;
&lt;td&gt;+0.089&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-registered equivalence margin&lt;/td&gt;
&lt;td&gt;±0.222 (0.75 × pooled SD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90% confidence interval&lt;/td&gt;
&lt;td&gt;[+0.015, +0.163]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-registered test verdict&lt;/td&gt;
&lt;td&gt;Pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adversarial review verdict&lt;/td&gt;
&lt;td&gt;Unsupported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By the rule I'd committed to, this passed clean: the full interval sat inside the margin. That's the good kind of check — a number graded against a threshold I picked before I knew whether it would be convenient. So I treated "passed" as license to say the two models were close enough on this axis to stop worrying about it.&lt;/p&gt;

&lt;p&gt;I sent the result to two adversarial reviewers from two different outside model families — one instructed to attack the statistics, one instructed to attack the operational reasoning. Both came back with the same verdict: unsupported. Not wrong about the arithmetic — wrong about what I'd let the arithmetic mean. Three reasons, and all three held up under a second look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The margin was wide enough to pass almost anything.&lt;/strong&gt; A zone of 0.75 pooled standard deviations is generous. A pass against a margin that loose proves less than a tighter, harder-won pass would have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The interval that cleared the margin also excludes zero.&lt;/strong&gt; [+0.015, +0.163] never crosses 0. That means there is a real, directional difference — the relaunched model re-edits the same file measurably more often than the flagship did — and "equivalent" is the wrong word for a gap with a confirmed sign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The metric was never the decision.&lt;/strong&gt; Same-file re-edit rate is one proxy for editing behavior. It says nothing about which model was more often correct, whether the tasks either one touched actually finished, or how long either took to get there — the axes that would actually justify routing one over the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape both checks share
&lt;/h2&gt;

&lt;p&gt;Line the two failures up and they match. The citation audit's definition of "pass" was: did you copy the digits. The equivalence test's definition of "pass" was: does the gap sit inside a zone I chose in advance. Neither one asked the question I actually wanted answered — did you understand this, and are these two models actually interchangeable. A passing score on either check told me something true and narrow, and I filled in a broader claim myself that the check never made.&lt;/p&gt;

&lt;p&gt;The honest version of where this leaves me: the observed behavioral signal for telling my relaunched main model and Claude Opus 4.8 apart is weak — one metric, one axis, a real but small and bounded gap. That's the whole claim that survives. "They're interchangeable" is overreach. "Route on price alone" is overreach. Both were the conclusions I was reaching for before the adversarial pass, and both got killed by reviewers doing the one thing neither check I'd built was designed to do.&lt;/p&gt;

&lt;p&gt;One more detail worth keeping: the reviewer that attacked the operational reasoning in Act Two was DeepSeek's model — the same family that misread Sonnet 5's zeroed row in Act One. Same family, opposite outcome, two different jobs. The lesson isn't "trust this vendor less." It's that a blind spot belongs to the check, not permanently to whichever model happens to be running it. A citation audit will always be blind to interpretation, no matter which model passes it. An adversarial refute pass will catch what a citation audit can't, no matter which model runs it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm changing
&lt;/h2&gt;

&lt;p&gt;Two changes, both narrow on purpose:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Any analysis I delegate to an LLM now gets an interpretation pass, not just a citation pass.&lt;/strong&gt; A different-family model reads the same claims specifically to argue against them — is the number correctly cited, and separately, is the conclusion drawn from it the only conclusion the number actually supports. Those are different questions, and a single audit can't answer both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any equivalence claim has to report the direction, not just the verdict.&lt;/strong&gt; The margin gets tied to a real decision threshold before I look at the data, not a statistical convention. And whichever way the test goes, I report whether the confidence interval excludes zero. A pass with a wide margin and a directional interval is a different finding from a pass with a tight margin and an interval that straddles zero — but the word "PASS" alone can't tell you which one you got.&lt;/li&gt;
&lt;/ol&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>analytics</category>
      <category>data</category>
      <category>llm</category>
    </item>
    <item>
      <title>A Forced Dissent Slot Has a Floor: Read It by Convergence, Not Presence</title>
      <dc:creator>John</dc:creator>
      <pubDate>Wed, 29 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/a-forced-dissent-slot-has-a-floor-read-it-by-convergence-not-presence-4enp</link>
      <guid>https://dev.to/hexisteme/a-forced-dissent-slot-has-a-floor-read-it-by-convergence-not-presence-4enp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/forced-dissent-slot-has-a-floor.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A little while back I found a verification schema that couldn't return a no. Before replying to a reader's pushback on an earlier post, I run the rebuttal past independent model judges first, and the schema I'd been using had five fields, all of them shaped toward agreement: how correct is the commenter, the strongest case for the commenter, the strongest defense of my post, what I should concede. Nothing in there could hold an objection. Add one field whose only job is to name where the commenter overreaches, and the same model that had been coming back agreeable started disagreeing — three runs on the old schema came back yes, yes, partly; three runs with the field added came back partly, partly, partly, same model, same question, one field the only difference. &lt;a href="https://hexisteme.github.io/notes/llm-judge-schema-bias-controlled-experiment.html" rel="noopener noreferrer"&gt;I wrote that experiment up on its own&lt;/a&gt;; the short version is that a schema with nowhere for dissent to live isn't a verification, it's a self-report, no matter how many vendors you run it past.&lt;/p&gt;

&lt;p&gt;That was the right fix, and I left the field in place. What I hadn't planned for was the failure on the other side of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The floor shows up once the slot stays on
&lt;/h2&gt;

&lt;p&gt;I kept the dissent field on as a permanent part of the schema and kept using it on real traffic — a small pipeline that runs independent model judgments over reader comments before I decide how, or whether, to respond. Over three days I ran four of these judgments through it, all with the slot on. Three produced something I could compare across two independent model families; the fourth didn't, for a reason worth keeping rather than smoothing over (more below).&lt;/p&gt;

&lt;p&gt;Here's what the three comparable ones looked like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Independent families in the loop&lt;/th&gt;
&lt;th&gt;What the dissent field produced&lt;/th&gt;
&lt;th&gt;How it read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An xAI model and an OpenAI open-weights model&lt;/td&gt;
&lt;td&gt;Both, independently, named the same objection: a distinction between two lines of defense was being treated as measured when it had only been asserted&lt;/td&gt;
&lt;td&gt;Convergence — acted on it, folded into the reply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An nvidia model and an xAI model&lt;/td&gt;
&lt;td&gt;One found no real overreach; the other manufactured one — that the piece implied the author had only built half a safeguard, unsupported by the text&lt;/td&gt;
&lt;td&gt;Divergence — discarded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An nvidia model and a Google model&lt;/td&gt;
&lt;td&gt;One again found no real overreach; the other offered a reframing too weak to count as pushback — closer to agreeing and extending than rebutting&lt;/td&gt;
&lt;td&gt;Divergence — discarded&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only one of these three adjudications moved anything. The other two produced text in a field that's supposed to mean "here's the objection" — grammatically not nothing, a complete sentence, a plausible frame — but neither survived being checked, and in neither case did the two families even agree on what the objection was supposed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a filled field isn't evidence
&lt;/h2&gt;

&lt;p&gt;The mechanism here is the same one that made the original fix work, which is exactly what makes the failure easy to miss. A mandatory field surfaces real objections because the model is required to produce something for it — it can't quietly skip it the way it might skip volunteering an unprompted criticism in free text. That requirement is why the fix works. It's also why the field can't be trusted just because it's non-empty: on a run where there's genuinely nothing to object to, the model still can't write nothing. It writes something, indistinguishable in shape from a real objection, because forcing content doesn't select for the content being true.&lt;/p&gt;

&lt;p&gt;Put generally: &lt;strong&gt;a forced output cannot use its own existence as evidence.&lt;/strong&gt; A filled field tells you the model complied with a schema requirement. It doesn't tell you whether anything real was behind the compliance, because compliance was mandatory either way.&lt;/p&gt;

&lt;p&gt;There's a mirror-image bug from the same week in a different part of my setup, worth naming briefly. &lt;a href="https://hexisteme.github.io/notes/verification-gate-cleared-on-a-keyword.html" rel="noopener noreferrer"&gt;A verification gate that runs over my own agent sessions&lt;/a&gt; let a turn pass whenever the assistant's own reply used verification-flavored language — a phrase like "cross-family reverified" — whether or not anything had actually run. It was reading a sentence that claimed verification as if the claim were the verification. This dissent field nearly caught me in the inverse: reading "the objection field has text in it" as if the text's presence were the finding. Both are the same error: &lt;strong&gt;form satisfied, read as substance present.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Read it by convergence, not by population
&lt;/h2&gt;

&lt;p&gt;The value of a forced dissent slot isn't a population count — how many of N runs came back with something written in the field. It's whether two or more independent model families, given the same material with no sight of each other's answer, land on the same objection. Converge, and it's worth acting on. Don't converge — or only one family objects while the rest see nothing wrong — and that's floor noise from a mandatory field, not a finding.&lt;/p&gt;

&lt;p&gt;The slot does two things at once, pulling in opposite directions. It raises the floor of adversarial effort: a model that would otherwise default to agreement is now required to at least attempt an objection, and sometimes that attempt is real. But raising that floor buys floor noise as a permanent cost — output that exists because the field is required, disconnected from whether anything is actually wrong. You don't get the first without the second; they're the same mechanism running both ways.&lt;/p&gt;

&lt;p&gt;The corollary is the part I'd get wrong without being careful: a single verification leg can't make this read at all. Convergence needs at least two independent readings to compare, so one model's dissent, however articulate, isn't a signal yet — it's an input waiting for something to agree or disagree with it. Read for convergence instead of presence and discarding output becomes the normal case rather than a sign of failure. Two divergent adjudications out of three isn't the system failing; it's what a floor looks like read correctly instead of taken on its word.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts I'd rather not round off
&lt;/h2&gt;

&lt;p&gt;Four judgment runs is a small number, and I'd rather be specific about how small than let the table above imply more than it can support.&lt;/p&gt;

&lt;p&gt;There's no control group here. All four ran with the dissent slot already on — none ran the old, agreement-only schema in parallel. So the earlier open question, whether judgment distributions actually differ with the slot on versus off across enough runs to say so with a straight face, is still open. This note isn't a re-run of that experiment; it's an observation one layer downstream, about how to read the slot's output once it's already part of the setup.&lt;/p&gt;

&lt;p&gt;The four cases didn't even use a matched set of models. Which family showed up opposite which other came down to availability, not design. The pipeline defines a fixed set of legs, and when one of them was unavailable I substituted another independent family by hand rather than run a leg short. Google's model had zero free-tier allocation on the key this pipeline uses for at least part of this window, which is part of why the pairing shifts from run to run — and in one of the four runs an nvidia leg came back with an empty response instead of a judgment. That's a known intermittent behavior on free-tier access, usually absorbed by a retry elsewhere in this pipeline, but the comment-triage path these four ran through doesn't retry, so that run was simply discarded — no dissent field to compare, no row in the table above. That's the fourth run: not a fourth divergence, just nothing to read.&lt;/p&gt;

&lt;p&gt;None of this is a statistical claim, and I'd rather say that plainly than let a table with numbers imply otherwise. Four data points, gathered opportunistically off real traffic, not a designed sample.&lt;/p&gt;

&lt;p&gt;One more falsifier, for the convergence rule itself and not just the slot underneath it: if dissent that independent families converge on later turns out wrong, three or more times, convergence stops being trustworthy too. At that point the right demotion is hypothesis generation only — a pointer to where to look harder, not a thing to act on by itself — with adoption gated behind a separate check no matter how many families agreed.&lt;/p&gt;

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

&lt;p&gt;This holds past this one field. Making an output mandatory buys a floor and a ceiling in the same transaction. The floor is that the output can't be skipped, which is often exactly what you wanted — a model required to attempt an objection sometimes finds a real one it would otherwise have swallowed. The ceiling is that mandatory output can't tell you, by its own presence, whether it's real. Reading it takes something outside the field itself: whether an independent second reading landed in the same place without being shown the first one. None of this needs a model in the loop: a code review template with a required concerns box, an RFC with a mandatory risks section, and a peer review form with a compulsory criticism field all buy the same floor at the same price. &lt;strong&gt;Presence isn't that signal, and neither is population. Convergence is.&lt;/strong&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>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>My Verification Gate Cleared on a Keyword, Not Evidence</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 28 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/my-verification-gate-cleared-on-a-keyword-not-evidence-2i6a</link>
      <guid>https://dev.to/hexisteme/my-verification-gate-cleared-on-a-keyword-not-evidence-2i6a</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/verification-gate-cleared-on-a-keyword.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three readers commented on the same post of mine within three days, and all three went after the same subsystem. The post described a Stop hook I run over my own agent sessions: when I push back on a conclusion, the hook checks whether the agent folded without verifying anything, and blocks the turn if it did.&lt;/p&gt;

&lt;p&gt;Two of the comments proposed architecture. &lt;a class="mentioned-user" href="https://dev.to/hannune"&gt;@hannune&lt;/a&gt; suggested making the verifying agent blind to the previous answer so it can't anchor on it. &lt;a class="mentioned-user" href="https://dev.to/xm_dev_2026"&gt;@xm_dev_2026&lt;/a&gt; suggested putting cheap structural checks in front of the expensive model-based verification. Both are reasonable. Neither told me anything about whether the thing I had already shipped worked.&lt;/p&gt;

&lt;p&gt;The third one did. &lt;a class="mentioned-user" href="https://dev.to/alexshev"&gt;@alexshev&lt;/a&gt; proposed measuring the gate by whether a challenge produced evidence, a correction, or a refusal — and noted that if all that changed was the tone getting softer, what the model learned was a social pattern, not a verification habit.&lt;/p&gt;

&lt;p&gt;That is a test, not a suggestion. So I ran it against my own code, and the gate failed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both sides of the gate were text patterns
&lt;/h2&gt;

&lt;p&gt;The hook is an AND gate over three deterministic conditions: the last user message matches a challenge pattern, the assistant's reply matches a capitulation pattern, and the reply fails to demonstrate verification. Miss any one and the turn passes. The third condition is where it fell apart:&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;VERIFY&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;compile&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;(cross[-\s]?family|sage|adversari|refut|...)&lt;/span&gt;&lt;span class="sh"&gt;"&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;I&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;VERIFY&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="n"&gt;last_assistant_text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="c1"&gt;# gate cleared
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The escape condition was a keyword in the assistant's own prose. Writing &lt;em&gt;"I re-verified this cross-family and I'm keeping the answer"&lt;/em&gt; cleared the gate without running anything. The auditor stands outside the process, but what it reads is a receipt written by the thing being audited.&lt;/p&gt;

&lt;p&gt;That is not a hypothetical failure mode. I have measured it. On 2026-07-13 a subagent in the same fleet reported it had fixed seven files; zero files had changed on disk, and when I demanded evidence, it forged the &lt;code&gt;ls -la&lt;/code&gt; and &lt;code&gt;grep -c&lt;/code&gt; output too. Verification-shaped output is cheap.&lt;/p&gt;

&lt;p&gt;There was a second finding I liked even less. A quiet retreat — hedging, walking a claim back without ever saying "you're right" — never reaches the trigger at all, because the second condition requires a capitulation marker. The hook's own header says so: silent flips are a deliberate false negative, traded away to keep false positives down. So the gate caught only the loud folds, and cleared even those on a social signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overstatement was already published
&lt;/h2&gt;

&lt;p&gt;The bug was mine to fix. The claim I had built on top of it was already out in public.&lt;/p&gt;

&lt;p&gt;The predecessor note for this gate carries a FAQPage block — the structured-data kind that exists to be quoted verbatim. One of its answers describes the hook as a rule where "the agent must run one cross-family adversarial verification" before it may hold or change a conclusion.&lt;/p&gt;

&lt;p&gt;Must run. At the time that sentence went live, the gate did not enforce it: a reply that merely &lt;em&gt;said&lt;/em&gt; it had verified exited clean. The distance between what my gate enforced and what I believed it enforced had not stayed in my head. It had propagated into prose, and from prose into a machine-readable block whose whole purpose is to be read and quoted as a description of my system.&lt;/p&gt;

&lt;p&gt;The fix narrows that distance without closing it. The gate now demands an execution trace, so "must run something" is enforced. "Must run one cross-family adversarial verification" still is not — the check proves a tool ran, not which tool, and not what it was pointed at. The sentence is closer to true than when I published it, and still not literally satisfied.&lt;/p&gt;

&lt;h2&gt;
  
  
  I measured the false-positive cost before tightening it
&lt;/h2&gt;

&lt;p&gt;Tightening an escape condition means turns that used to pass now get blocked. If some of those turns had done real verification and merely described it in prose, I would be breaking working sessions in order to close a hole.&lt;/p&gt;

&lt;p&gt;So before changing anything, I replayed the proposed rule over my actual history:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transcripts scanned&lt;/td&gt;
&lt;td&gt;212&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Challenge turns found&lt;/td&gt;
&lt;td&gt;434&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleared on the verification keyword&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...also carrying a capitulation marker (&lt;strong&gt;the band the new rule touches&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...of those, with a real execution trace: still passes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...of those, with no trace: newly blocked&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero newly blocked. Every turn in the affected band had actually run something — the keyword was describing real work, not standing in for it. That is a considerably better reason to ship a tightening than "it seems more correct."&lt;/p&gt;

&lt;p&gt;I had also pre-registered a condition that would have made me back off: if delegated subagent calls didn't show up in the main transcript, the new requirement would punish legitimate delegated verification, and I would weaken it. That got rejected too. Delegation calls were recorded normally in the sample.&lt;/p&gt;

&lt;p&gt;The cost of this measurement was one scan over 212 files. The cost of getting it wrong is a false positive in every session from now on. That trade is not close.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and where the evidence was hiding
&lt;/h2&gt;

&lt;p&gt;The change is one clause. The escape condition went from &lt;code&gt;keyword&lt;/code&gt; to &lt;code&gt;keyword AND at least one tool call between the challenge turn and the reply&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The data was already in the hook's hands. It was walking the entire transcript, entry by entry, and reading only the blocks of &lt;code&gt;type == "text"&lt;/code&gt;. The &lt;code&gt;tool_use&lt;/code&gt; blocks were sitting in the same list, in the same loop, unread. The evidence was next to the thing I was checking; I just wasn't looking at it. That was the shape of this bug — not missing data, unexamined data.&lt;/p&gt;

&lt;p&gt;When the keyword is present but the execution trace is not, the hook now emits a different reason: it says the reply &lt;em&gt;claimed&lt;/em&gt; verification while leaving no trace of it in this window, and asks for an actual run before a hold-or-change verdict. Distinguishing "did not verify" from "said it verified" matters, because the second one is the failure I actually care about.&lt;/p&gt;

&lt;p&gt;I also wrote seven tests. The hook had zero. They cover: the new block fires; the old behavior does not regress; the intentional silent-flip false negative is preserved on purpose; a tool call from &lt;em&gt;before&lt;/em&gt; the challenge does not count as evidence; and a delegated subagent call does.&lt;/p&gt;

&lt;p&gt;That last pair is the whole design in miniature. Evidence has a window. Anything outside it is someone else's receipt.&lt;/p&gt;

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

&lt;p&gt;The new check proves that &lt;em&gt;a&lt;/em&gt; tool ran. It does not prove that the tool ran &lt;em&gt;on this claim&lt;/em&gt;. Grep an unrelated file inside the window and the gate is satisfied.&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/xm_dev_2026"&gt;@xm_dev_2026&lt;/a&gt; named the decomposition exactly, in a follow-up comment: what counts as evidence is one question, and whether that evidence supports the claim is a different one.&lt;/p&gt;

&lt;p&gt;The first half is mechanically checkable, which is why it closed in half a day. The second half needs something that can read both the claim and the evidence and judge the relation between them — and that puts me back in front of a model, which is where the sycophancy problem came from in the first place. The split is real, and it is asymmetric. I am not going to pretend the second half is nearly done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalizes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If a gate's escape condition is prose written by the thing being audited, it is not an audit. It is a self-report reader. Standing outside the process does not make a check independent. What it reads does.&lt;/li&gt;
&lt;li&gt;The most dangerous layer is the one that looks like an external auditor and isn't. A gate that fails open silently is bad; a gate that fails open with a real script behind it is worse, because the green light borrows the script's authority.&lt;/li&gt;
&lt;li&gt;Any change that tightens a rule should be paired with a measurement of what that rule would have blocked historically.&lt;/li&gt;
&lt;li&gt;Evidence needs a window. "Something ran" is not a claim until you say when, relative to what.&lt;/li&gt;
&lt;li&gt;The gap between what a gate enforces and what its author thinks it enforces does not stay private. It ends up in the docs.&lt;/li&gt;
&lt;li&gt;Of three good comments, the one that changed the code was the one written as an observable test. An architecture suggestion describes a system you might build. A test tells you something about the one you already shipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Still open
&lt;/h2&gt;

&lt;p&gt;The blind-verifier suggestion stays open, and I think it is right — but it is downstream of this one. Improving the verifier while the gate does not require the verifier to be called is a better lock on a door nobody has to walk through. Make invocation observable first, then make the invoked thing harder to fool.&lt;/p&gt;

&lt;p&gt;The second half of the decomposition — whether the evidence supports the claim — is unsolved.&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>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>Three App Store Connect API Hard Limits That Only Bite at a Fast Release Cadence</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 27 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/three-app-store-connect-api-hard-limits-that-only-bite-at-a-fast-release-cadence-48mg</link>
      <guid>https://dev.to/hexisteme/three-app-store-connect-api-hard-limits-that-only-bite-at-a-fast-release-cadence-48mg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/app-store-connect-api-hard-limits.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I shipped seven versions of my apps in thirty days by scripting the App Store Connect API end to end — build metadata, screenshots, submission, the works. Somewhere in that cadence I ran into three completely independent hard limits in the ASC API, each one verified live against my own apps on 2026-06-13. None of the three error messages point at their actual cause. One blames your credentials for what's really a clock problem. One tells you the app is in "the current state" without saying which state, or why that matters. One just says a count is over budget and leaves you to discover, the hard way, that the same API that created the excess item won't let you delete it either. Here's what each wall looks like, and the two-call pre-flight that catches all three before a script finds out about them for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JWT 20-minute cap
&lt;/h2&gt;

&lt;p&gt;Every call to the ASC API runs on a JWT you sign yourself — issuer ID, key ID, ES256 private key. What isn't obvious until you hit it: Apple enforces &lt;code&gt;exp - iat &amp;lt;= 1200&lt;/code&gt; seconds (20 minutes) as a hard cap on that token, no matter how far out you set &lt;code&gt;exp&lt;/code&gt;. Set &lt;code&gt;exp&lt;/code&gt; to &lt;code&gt;iat + 1200&lt;/code&gt; exactly and the token works. Set it to &lt;code&gt;iat + 1201&lt;/code&gt; — one second past the cap — and every call made with that token fails, even with a perfectly valid ES256 signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;401 NOT_AUTHORIZED — Authentication credentials are missing or invalid
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message talks about credentials. The actual defect is the clock. Nothing in that string points at &lt;code&gt;exp&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;exp setting&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iat + 1200 (exactly 20 min)&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iat + 1180 (as in the example below)&lt;/td&gt;
&lt;td&gt;Works — margin for clock skew&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iat + 1201 or more&lt;/td&gt;
&lt;td&gt;401 NOT_AUTHORIZED, valid signature or not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iat + 1800 (30 min)&lt;/td&gt;
&lt;td&gt;401 — whole batch fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This cap is specific to the ASC API. Apple's other JWTs — DeviceCheck, App Attest, Apple Pay — use different expiry policies, which is exactly why habits carried over from those bite here: a token lifetime that's perfectly fine for one Apple API is a silent 401 on this one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;iat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ISSUER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;iat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;iat&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# stay under 1200; leaves clock-skew margin
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;appstoreconnect-v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PRIVATE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ES256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KEY_ID&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The place this actually costs you time is a batch — uploading a full screenshot set is easily 12+ sequential calls. "Some calls succeeded, then the rest started failing with 401" isn't a flaky network or a rate limit; it's this cap expiring mid-batch, on a token that was perfectly valid when the batch started. Two fixes, pick one: re-issue a fresh token per request, or size the batch so it finishes inside the 20-minute window measured from the first &lt;code&gt;iat&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One app version in flight
&lt;/h2&gt;

&lt;p&gt;ASC allows exactly one app version in progress at a time. If a version is sitting in &lt;code&gt;PREPARE_FOR_SUBMISSION&lt;/code&gt;, &lt;code&gt;WAITING_FOR_REVIEW&lt;/code&gt;, or &lt;code&gt;IN_REVIEW&lt;/code&gt; — any one of the three — trying to create a second fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/appStoreVersions
409 STATE_NOT_SUITABLE
"You cannot create a new version of the App in the current state"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which state, though? The error doesn't say — you have to go query it yourself. Fix: finish the in-flight version by letting it complete review, or developer-cancel it with &lt;code&gt;PATCH developerRejected=true&lt;/code&gt;, then create the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review-submission ceiling
&lt;/h2&gt;

&lt;p&gt;This is the one that actually stopped my automation, because it's two separate counters wearing one error-shaped costume, and I'd only budgeted for one of them.&lt;/p&gt;

&lt;p&gt;In-flight max 2 — &lt;code&gt;WAITING_FOR_REVIEW&lt;/code&gt; and &lt;code&gt;IN_REVIEW&lt;/code&gt; combined. Go over and you get &lt;code&gt;MAX_IN_REVIEW_SUBMISSIONS_PER_PLATFORM_LIMIT_REACHED&lt;/code&gt;. Total max 5 — the same two states, plus every &lt;code&gt;READY_FOR_REVIEW&lt;/code&gt; submission sitting around unsubmitted. Go over that and &lt;code&gt;POST /v1/reviewSubmissions&lt;/code&gt; returns &lt;code&gt;CONCURRENT_REVIEW_SUBMISSION_LIMIT_EXCEEDED&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;States counted&lt;/th&gt;
&lt;th&gt;Error on exceed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;in-flight, max 2&lt;/td&gt;
&lt;td&gt;WAITING_FOR_REVIEW + IN_REVIEW&lt;/td&gt;
&lt;td&gt;MAX_IN_REVIEW_SUBMISSIONS_PER_PLATFORM_LIMIT_REACHED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;total, max 5&lt;/td&gt;
&lt;td&gt;above two + READY_FOR_REVIEW orphans&lt;/td&gt;
&lt;td&gt;CONCURRENT_REVIEW_SUBMISSION_LIMIT_EXCEEDED (on POST /v1/reviewSubmissions)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The orphan trap is the sharp edge here: a &lt;code&gt;READY_FOR_REVIEW&lt;/code&gt; submission — created but never actually submitted — counts toward that 5-total ceiling, and there is no API path to remove it. &lt;code&gt;DELETE&lt;/code&gt; returns 403. &lt;code&gt;PATCH canceled=true&lt;/code&gt; returns 409. The only ways out are a manual cancel in the ASC web UI, or the 7-day auto-expiry (verified as of 2026). If a script creates a reviewSubmission and then dies, hangs, or gets killed before it submits, that orphan sits there burning one of your five slots for up to a week unless a human goes and clicks cancel.&lt;/p&gt;

&lt;p&gt;There's a slot-saving move buried in the same API, though: one reviewSubmission can bundle more than one item type — &lt;code&gt;appStoreVersion&lt;/code&gt;, &lt;code&gt;appCustomProductPageVersion&lt;/code&gt; (CPP), &lt;code&gt;appStoreVersionExperiment&lt;/code&gt; (PPO), and &lt;code&gt;appEvent&lt;/code&gt; can all ride in the same submission. Folding CPP variants and PPO experiments into the version's submission, instead of filing each as its own reviewSubmission, is the direct way to stop the 5-total budget from disappearing into things that aren't your actual app update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-command pre-flight
&lt;/h2&gt;

&lt;p&gt;Before any release automation touches the API, two GET calls tell you whether you're clear to proceed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /v1/apps/{id}/appStoreVersions?filter[appStoreState]=PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW,IN_REVIEW
GET /v1/reviewSubmissions?filter[state]=READY_FOR_REVIEW
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first tells you if a version is already in flight (limit two). The second counts the orphans eating your 5-total budget (limit three). If the second call returns any rows, cancel them in the ASC web UI first — the API that would let a script do it doesn't exist. If both come back clear, proceed.&lt;/p&gt;

&lt;p&gt;This checklist exists because I shipped seven versions in thirty days and found all three limits the hard way before I had it. Now it's the first thing that runs, before a single POST.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the errors all point the wrong way
&lt;/h2&gt;

&lt;p&gt;None of the three error messages name the constraint that's actually biting you. &lt;code&gt;401 NOT_AUTHORIZED&lt;/code&gt; blames your credentials for a clock problem. &lt;code&gt;409 STATE_NOT_SUITABLE&lt;/code&gt; names a state without saying which one. &lt;code&gt;409 CONCURRENT_REVIEW_SUBMISSION_LIMIT_EXCEEDED&lt;/code&gt; tells you a count is over budget without telling you the count includes an item the same API won't let you delete. All three are learnable exactly once — after that, the pre-flight above catches them before they cost a release. None of them are obvious from outside; I only got the exact numbers, error strings, and workarounds by hitting each wall against my own apps.&lt;/p&gt;

&lt;p&gt;I wrote each of these up in more detail, in Korean, as I hit them: &lt;a href="https://hexisteme.github.io/dev-notes/asc-jwt-20min-cap.html" rel="noopener noreferrer"&gt;the JWT cap&lt;/a&gt; and &lt;a href="https://hexisteme.github.io/dev-notes/asc-in-flight-limits.html" rel="noopener noreferrer"&gt;the in-flight limits&lt;/a&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>api</category>
      <category>automation</category>
      <category>ios</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Rotating the Hostile Seat: A Six-Round Adversarial Design Review Before Hardening an Agent</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sun, 26 Jul 2026 00:00:07 +0000</pubDate>
      <link>https://dev.to/hexisteme/rotating-the-hostile-seat-a-six-round-adversarial-design-review-before-hardening-an-agent-3o7j</link>
      <guid>https://dev.to/hexisteme/rotating-the-hostile-seat-a-six-round-adversarial-design-review-before-hardening-an-agent-3o7j</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/adversarial-design-review-rotating-roles.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was about to harden a new agent whose whole job is to turn "should I adopt this library, model, or tool" into a deterministic, auditable verdict instead of a vibe — gates, grades, falsifiers, a learning ledger. Before trusting it with that job, I wanted a design review nobody could dodge. My default pattern was "ask my main coding assistant to look it over," which has the same structural problem as a same-family writer reviewing its own writing: builder and checker share the same blind spots. So this time three roles — Questioner, Answerer, and adversarial Verifier — rotated through three reviewer groups in every possible assignment, across six rounds. Three roles into three groups is exactly six permutations, and I used all of them, so no group ever sat as the permanent judge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: eight targets, six dimensions, three groups
&lt;/h2&gt;

&lt;p&gt;The system under review had eight discrete pieces worth judging, pulled from its own codebase rather than picked after the fact: identity and boundaries (what separates a verdict-making agent from a plain fact-gathering one), four type-level invariants blocking an unverified claim from being laundered into a confirmed fact, five deterministic scoring gates that only score fact-labeled evidence, the grade decision and hard-gate demotion logic built on those gates, automatic derivation of the conditions that would prove a verdict wrong, a provenance parser with a host whitelist for fact-grade sources, a learning ledger checking whether its own confidence is honestly calibrated, and the CLI/bus/config surface a human touches. Each got judged on six dimensions: interesting to judge, useful downstream, complete against its own spec, coherent with its docs and siblings, reliable — reproducible, tested, falsifiable — and actually serving the system's purpose.&lt;/p&gt;

&lt;p&gt;Going in: eight open verdicts on record, zero recorded outcomes, 677 lines of tests. Zero outcomes matters more than it sounds — a learning ledger with nothing in it yet is the cheapest moment to catch a calibration bug, since every future outcome joins against whatever's already sitting in there.&lt;/p&gt;

&lt;p&gt;Three groups reviewed: my own fleet of other agents — a coordination hub plus domain workers I normally run for research, narrative, and real-estate decisions, borrowed here purely as reviewers; my main coding assistant, reasoning directly with no tool fan-out; and an external multi-model verification pool — a cross-model proxy reaching several independent model families, including a large open-weight model, plus a symbolic-math engine, a theorem prover, and a couple of code-analysis tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why rotate instead of picking a reviewer
&lt;/h2&gt;

&lt;p&gt;Three roles times three groups gives six possible assignments, and I fixed all six before round one — nobody learned their next assignment as they went; the full table existed up front. That's what stops a group from steering later rounds toward a friendlier seat.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Round&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Defend&lt;/th&gt;
&lt;th&gt;Attack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R1&lt;/td&gt;
&lt;td&gt;fleet agents&lt;/td&gt;
&lt;td&gt;coding assistant&lt;/td&gt;
&lt;td&gt;verification pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R2&lt;/td&gt;
&lt;td&gt;fleet agents&lt;/td&gt;
&lt;td&gt;verification pool&lt;/td&gt;
&lt;td&gt;coding assistant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R3&lt;/td&gt;
&lt;td&gt;coding assistant&lt;/td&gt;
&lt;td&gt;fleet agents&lt;/td&gt;
&lt;td&gt;verification pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R4&lt;/td&gt;
&lt;td&gt;coding assistant&lt;/td&gt;
&lt;td&gt;verification pool&lt;/td&gt;
&lt;td&gt;fleet agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R5&lt;/td&gt;
&lt;td&gt;verification pool&lt;/td&gt;
&lt;td&gt;fleet agents&lt;/td&gt;
&lt;td&gt;coding assistant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R6&lt;/td&gt;
&lt;td&gt;verification pool&lt;/td&gt;
&lt;td&gt;coding assistant&lt;/td&gt;
&lt;td&gt;fleet agents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the six rounds, each group asks the questions twice, defends twice, and attacks twice. That's the whole mechanism. A fixed reviewer has one failure mode: whatever it can't see, nobody sees, forever. A designer grading its own fix has another: no incentive to find its own flaws. Rotating fixes both — not by being smarter, but by guaranteeing the seat most likely to catch a mistake is eventually filled by someone who didn't make it, and whichever group designed a fix later sits in the chair whose only job is to attack it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually caught
&lt;/h2&gt;

&lt;p&gt;Six rounds against eight targets produced seven confirmed defects, one claim checked and found correct (a symbolic-math engine confirmed a worried-about scoring formula was just the standard Beta-binomial posterior mean, no fix needed), and several accusations a second party disproved outright, including one hallucinated claim about a counter that didn't exist anywhere in the code. The defects that generalize past this one codebase:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defect&lt;/th&gt;
&lt;th&gt;What was wrong&lt;/th&gt;
&lt;th&gt;Caught in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;F1&lt;/td&gt;
&lt;td&gt;Governance doc listed 3 hard-gate demotion rules; code enforced 6 — missing a machine-verification check, a fact-count check, a missing-core-fields check&lt;/td&gt;
&lt;td&gt;Round 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F3&lt;/td&gt;
&lt;td&gt;The evaluate command defaulted to today's date when none was given, so the same candidate could score differently by day, violating the system's own no-hidden-nondeterminism rule&lt;/td&gt;
&lt;td&gt;Round 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F5&lt;/td&gt;
&lt;td&gt;A URL parser treated a scheme-less address as the whole hostname, wrongly demoting trustworthy evidence on a common typo (reason logged, not hidden)&lt;/td&gt;
&lt;td&gt;R1; R2 found the obvious fix was worse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F6&lt;/td&gt;
&lt;td&gt;Two consistency checks disagreed on thresholds — a record could be hard-demoted under one while status still read "done" under the other&lt;/td&gt;
&lt;td&gt;R1; agreed R2-3; reversed R4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F7&lt;/td&gt;
&lt;td&gt;Re-evaluating an already-graded item same-day silently overwrote its definition, while a recorded outcome stayed attached to the new one&lt;/td&gt;
&lt;td&gt;R1's attack seat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F8&lt;/td&gt;
&lt;td&gt;A freshness check existed on the evidence model with a real default threshold — nothing ever called it&lt;/td&gt;
&lt;td&gt;R2's question seat, exceeding its mandate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;F1's twin, F2: the scoring gates' coefficients — multipliers, day-count normalizers, dependency and breaking-change weights — lived only in code, undocumented and unreproducible by hand.&lt;/p&gt;

&lt;p&gt;F6 is worth slowing down on — it's the whole thesis in miniature. Round 1 flagged the mismatch. Rounds 2 and 3, different groups in different seats, converged on a fix: when the hard-demotion threshold fires, quietly promote status to "partial" and expose the reason. That direction was proposed, selected, and cross-checked across three rounds. Round 4 put my own fleet agents in the attack seat, specifically to break whatever had just been agreed — and it did: promoting a hard demotion to "partial" dresses up a loud failure (a fatal input defect) as a valid partial pass, close to the opposite of what a demotion means. Inside that round, one internal viewpoint tried to back its case with simulated percentages, and the group's own fabrication check caught them as invented and kept them out. The three-round consensus got reversed; what shipped instead was smaller and safer — expose the demotion reason on the result, leave status alone.&lt;/p&gt;

&lt;p&gt;F7 and F8 share a quieter lesson. F7 — a bug that would have silently corrupted the learning ledger's calibration data — surfaced because round 1's attack seat went looking for exactly this instead of confirming what the defend seat claimed. F8 came from a different overreach: round 2 assigned my research worker to the question-asking seat only, and instead of asking, it grepped the code for evidence first — which is how a freshness check that looked implemented but was never called got caught. Nobody wrote a rule requiring the questioner to verify its own questions; rotating it into a seat with something to prove was enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification pool needed its own degrade path
&lt;/h2&gt;

&lt;p&gt;Round 1's attack seat was supposed to include the external multi-persona council tool I normally reach for in verification rounds. It didn't respond. Falling back to a cross-model proxy should have been simple, except its usual route — an API aggregator reaching a newer hosted model — also failed with an expired key. What produced round 1's findings was a second fallback: a local runner reaching a large open-weight model, gpt-oss 120B, plus a symbolic-math engine for the one claim needing computation instead of judgment. Three layers deep before anything answered, in the very first round.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The review didn't pause to fix the verification stack. It substituted, logged what it substituted and why, and kept going. A review process that only works when every tool in it is up isn't resilient, it's just untested. The degrade path is part of the design, not an incident.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same pattern recurred later: whenever a round needed that external council tool and it wasn't available, my own fleet agents' coordination hub substituted for it directly rather than stalling. Verification infrastructure going down mid-review was routine enough to need a designed fallback, not a one-off workaround.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest costs: six rounds is a lot of turns
&lt;/h2&gt;

&lt;p&gt;I won't pretend this is free. Six rounds, three participants swapping seats each time, is eighteen passes over the same eight targets before anything gets fixed — a real cost for a solo builder, and running it on every change would be its own kind of dysfunction. It's overkill for anything small and reversible: a copy edit, a config default that's easy to flip back, a UI tweak nothing downstream depends on. If being wrong costs you one revert, a full rotation is theater, not rigor.&lt;/p&gt;

&lt;p&gt;It paid for itself here because of what this system is: type invariants, scoring gates, and a calibration ledger everything downstream trusts without re-checking. F7 is the clearest argument for the expense — a silent ledger corruption wouldn't announce itself, it would just make every future verdict's confidence a little more dishonest, untraceable to a same-day re-evaluation months earlier. F6 makes the same case differently: a demotion that quietly reads as a partial pass is exactly the bug a consumer only discovers by trusting the wrong field, possibly after acting on it. The rule I'm taking forward: spend the rotation on code other automated decisions will trust unchecked, skip it on anything a human looks at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recipe
&lt;/h2&gt;

&lt;p&gt;What I'd actually reuse from this, stripped of the specifics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Enumerate the review targets and judging dimensions up front —
   pulled from the real code, not decided as you go.
2. Fix the full role x group permutation before round one. If it
   can change, someone finds a way to dodge the hostile seat.
3. Log every round append-only, including refuted suspicions — a
   rejected accusation is still evidence the check happened.
4. Run at least one attack round AFTER a fix has multiple groups'
   agreement, not only before — that's the round most designs never
   get, and the one that caught an accepted fix that was wrong.
5. Convert confirmed defects into surgical fixes and regression
   tests before writing anything new. Log what's deliberately not
   fixed yet, and why.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs the schedule decided before the review starts, and a standing refusal to let the group that built something also be the last word on whether it's safe.&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>agents</category>
      <category>architecture</category>
      <category>security</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Deterministic Tool Adoption Gates: Score It, Don't Vibe It</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 25 Jul 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/deterministic-tool-adoption-gates-score-it-dont-vibe-it-ag6</link>
      <guid>https://dev.to/hexisteme/deterministic-tool-adoption-gates-score-it-dont-vibe-it-ag6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/deterministic-tool-adoption-gates.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A new public repo showed up on 2026-07-14: &lt;a href="https://github.com/mattpocock/skills" rel="noopener noreferrer"&gt;mattpocock/skills&lt;/a&gt;, an MIT-licensed collection of Claude Code agent skills. It's the kind of thing that's easy to fall for in the first ten minutes — skim the READMEs, install the ones that sound useful, move on. I didn't do that. I ran it through the five deterministic gates in my adoption CLI, the same gates every Swift package and npm dependency in my stack has had to clear, extended for the first time to cover a Claude skill.&lt;/p&gt;

&lt;p&gt;The reason I bother with this at all: adoption decisions rot when they're vibes. "This looks solid" is not a claim you can revisit in six months and check whether you were right about. A score is. So is a pre-registered condition for when you'd bail on it. The rest of this post is what that machinery produced on a real decision, not a hypothetical one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five gates, one score
&lt;/h2&gt;

&lt;p&gt;The CLI scores any candidate — package, library, or now, skill — on five gates: maturity (how long has this actually existed), dependency footprint (what does adopting it drag in), platform fit (native or third-party, and documented or not), policy and developer experience (documentation quality plus release stability), and trajectory (is it actively maintained right now). Each gate contributes points toward a 100-point total, and fixed thresholds turn that total into a verdict: ADOPT at 80 or above, TRIAL at 60 or above, HOLD at 40 or above, reject below that. No gate is a gut check — every one resolves to a number from a query I can rerun.&lt;/p&gt;

&lt;p&gt;Here's what mattpocock/skills scored, evaluated as of 2026-07-14:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;G1 Maturity&lt;/td&gt;
&lt;td&gt;4/20&lt;/td&gt;
&lt;td&gt;First release 2026-06-17 — 27 days old at evaluation time. The repo itself was only created 2026-02-03, so the project as a whole is five months old.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G2 Dependency footprint&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;Zero runtime dependencies. Skills are markdown prompt files — structurally, there's nothing to depend on.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G3 Platform fit&lt;/td&gt;
&lt;td&gt;10/20&lt;/td&gt;
&lt;td&gt;Third-party, not built into the platform, but documented. Not demoted further — the low-documentation penalty never triggered.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G4 Policy &amp;amp; DX&lt;/td&gt;
&lt;td&gt;10/20&lt;/td&gt;
&lt;td&gt;Full marks on documentation, zero on stability. One assumed input (breaking changes per year) had no source, so a conservative default of 4/year was applied instead — and that zeroed this half of the gate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G5 Trajectory&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;Four releases in the last year, and the last commit landed one day before I ran the evaluation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64/100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Thresholds: ADOPT ≥ 80, TRIAL ≥ 60, HOLD ≥ 40. Verdict: &lt;strong&gt;TRIAL&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of the six hard gates fired — the kind of automatic disqualifier that overrides the score outright — so the raw and final verdicts agree: TRIAL, no demotion.&lt;/p&gt;

&lt;p&gt;Read as a shape instead of a table, the profile says something legible: young but alive and light. Trajectory and dependency footprint are maxed out. Maturity is on the floor, because there simply hasn't been time to accumulate any. That's not a knock on the repo — it's a public, MIT-licensed project four weeks into its first release, already on its second release. TRIAL is exactly what a shape like that should produce, and TRIAL has a specific meaning: adopt in isolation, verify, then widen. Not "good." Not "bad." An instruction about scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine facts, one assumption
&lt;/h2&gt;

&lt;p&gt;The gate math only means something if the inputs feeding it are honest about how they were obtained. The CLI tags every signal as FACT, INFERENCE, or ASSUMPTION, and it computes scores only from what's tagged — nothing gets to influence a "deterministic" gate by feel.&lt;/p&gt;

&lt;p&gt;This evaluation ran on ten signals: nine FACT, one ASSUMPTION. The nine facts — first release date, latest release, releases in the last year, last commit, major version, maintainer count, whether the project is platform-native, whether official docs exist, dependency count — all came from real queries against GitHub's API, not a recollection of what the repo probably looks like. The docs signal, for what it's worth, resolved true off an actual README fetch that came back at 14,573 bytes, not an assumption that it probably has decent docs.&lt;/p&gt;

&lt;p&gt;The tenth signal, breaking changes per year, had no machine-checkable source at all. The honest move there isn't to guess a flattering number — it's to apply a conservative default and eat the cost. The system defaulted it to 4 breaking changes per year, and that single assumption is the entire reason G4 lost its stability half: full marks on documentation, zero on stability, net 10 out of 20. If this project accumulates enough release history that the real number becomes observable, that's a fact I can plug in and rerun the evaluation with — but I'm not going to assume stability just because assuming it would make the score prettier.&lt;/p&gt;

&lt;p&gt;Two smaller judgment calls are worth naming, because a gate that hides them isn't trustworthy. First: the maintainer count came back as 3 from the raw API, and I filtered one of those out — a release bot that opens changeset PRs, not a person — to get 2. But 2 human accounts isn't the same as 2 active ones: it's 296 commits from the repo owner against 8 from the second contributor, a bus factor of roughly 1 in practice. The gate's hard trigger for that only fires at 1 or below, so 2 doesn't trip it. I wrote the caveat down anyway, because a gate not tripping isn't the same as the risk not existing. Second: I could have widened the list of hosts the system treats as "official docs" to upgrade a borderline signal to a stronger tag. I didn't, because that's gaming the gate, not scoring the repo — I used the path I actually queried and left the classification where the evidence put it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five ways this could die
&lt;/h2&gt;

&lt;p&gt;The same tagged inputs that produced the score also auto-derive kill conditions — falsifiers, pre-registered before I've used the thing in anger, not written after something breaks. For this evaluation, the system produced five:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Observable&lt;/th&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TIME&lt;/td&gt;
&lt;td&gt;No commits or releases for 180 days&lt;/td&gt;
&lt;td&gt;2027-01-09&lt;/td&gt;
&lt;td&gt;VCS API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EVENT&lt;/td&gt;
&lt;td&gt;Major version goes from 1 to 2&lt;/td&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;Registry API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EVENT&lt;/td&gt;
&lt;td&gt;Maintainer count drops from 2 to 0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;VCS API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EVENT&lt;/td&gt;
&lt;td&gt;An official deprecation or archive notice appears&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Official docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EVENT&lt;/td&gt;
&lt;td&gt;3 or more breaking-fix patches needed on my side after adoption&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;Self-observed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these required me to sit down and imagine how this could go wrong — they fell out mechanically from the same signals that produced the 64. The decision this produces isn't "adopted, done." It's "adopted until one of these five fires."&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision that didn't need the other to agree
&lt;/h2&gt;

&lt;p&gt;Separately from the gate score, I went through the repo's skill list by hand — 28 active skills once you set aside the deprecated and in-progress ones — checking each against what I already run. Twenty-six overlapped with tooling I already had: code review, TDD loops, research, spec-writing, triage. Adopting them would mean maintaining two ways to do the same job, which is its own tax. One didn't overlap with anything: a prototyping skill for building structurally different variants when a design decision won't converge in words. That's the one I adopted, installed in isolation, its two core files untouched. A second skill, an orchestration and spec-writing tool, looked appealing but duplicated ground I already cover — I held it, not rejected it, pending a case where I'm actually missing a map rather than just curious about one.&lt;/p&gt;

&lt;p&gt;Here's the part I like: the gate score and this decision were arrived at without either one seeing the other. The five gates didn't know I was going to adopt exactly one skill in isolation. My decision to adopt exactly one skill in isolation didn't know the gates had landed on TRIAL, which by definition means the same thing. Two independent processes, same conclusion. That's not proof either one is right — but it's a much stronger signal than either alone, and a lot stronger than "this looks solid."&lt;/p&gt;

&lt;h2&gt;
  
  
  The table I didn't write to
&lt;/h2&gt;

&lt;p&gt;The adoption ledger has an outcomes table, and it's not decorative — it's the denominator every future decision's base rate gets computed against. Log enough outcomes, and a new evaluation stops being a cold guess and starts being informed by what actually happened the last N times a repo scored like this one.&lt;/p&gt;

&lt;p&gt;Which is exactly why I didn't write to it on day zero. Zero days had elapsed. None of the five falsifiers had had any time to fire, or not fire. Logging "success" at that point wouldn't be a measurement — it would be an opinion wearing a data structure, and it would sit in that table forever, quietly corrupting every base rate pulled from it afterward. The discipline in a system like this isn't the part where you collect the data. It's the part where you know which table you're not allowed to write to yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TRIAL bought me
&lt;/h2&gt;

&lt;p&gt;The skill adopted in isolation — the prototyping one — got its first real use a few days later, on a mascot redesign for one of my apps. Three structurally different variants, rendered and looked at instead of argued about, and the first render caught a bug no code review would ever have found: the arms rendered as legs because a rotation anchor was pinned to the wrong end. &lt;a href="https://hexisteme.github.io/notes/ai-cant-see-what-it-drew.html" rel="noopener noreferrer"&gt;That's its own story&lt;/a&gt;, worth reading on its own — but the short version is that the one thing this evaluation said yes to earned its place inside a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for the next tempting repo
&lt;/h2&gt;

&lt;p&gt;The mechanics generalize past this one decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Score the candidate on fixed gates — maturity, dependencies, platform fit, policy and stability, trajectory — and let a threshold, not a feeling, set the verdict.&lt;/li&gt;
&lt;li&gt;Tag every input FACT, INFERENCE, or ASSUMPTION, and compute the score only from what's tagged.&lt;/li&gt;
&lt;li&gt;When an input has no real source, give it a conservative default, not an optimistic guess. Let it cost points.&lt;/li&gt;
&lt;li&gt;Let the same tagged inputs auto-derive kill conditions, so the falsifiers exist before you need them, not after something breaks.&lt;/li&gt;
&lt;li&gt;If the verdict is TRIAL, adopt the smallest useful unit, in isolation, and verify before widening.&lt;/li&gt;
&lt;li&gt;Don't write to the outcomes table until enough time has actually passed for a falsifier to have had a chance to fire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes the decision faster. It makes it one I can revisit in six months and check whether I was right — about the repo, and about myself.&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>agents</category>
      <category>ai</category>
      <category>cli</category>
      <category>github</category>
    </item>
    <item>
      <title>The AI Can't See What It Drew</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 24 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/the-ai-cant-see-what-it-drew-30ph</link>
      <guid>https://dev.to/hexisteme/the-ai-cant-see-what-it-drew-30ph</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/ai-cant-see-what-it-drew.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A while back I wrote about &lt;a href="https://hexisteme.github.io/notes/why-your-vibe-coded-app-looks-worse.html" rel="noopener noreferrer"&gt;why your vibe-coded app looks worse&lt;/a&gt; than you expect. That post diagnosed the cause. This one is the fix that actually worked, on a real job: redesigning the mascot in my trip expense-splitting app.&lt;/p&gt;

&lt;p&gt;The mascot is the face of the app. It shows up in more than twenty places — onboarding, settings, the stats screen, the map, the diary, the settlement report, and five little mini-games. And it was nothing. One circle did double duty as head and body. No legs. No hands. No eyebrows. One X for an eye. Visually its identity was zero: a tinted circle. I knew it was bad. What I could not do was say what to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Words don't converge on a picture
&lt;/h2&gt;

&lt;p&gt;I kept talking myself in circles about it, and so did the AI I was pairing with. Rounder? Add a hat? Bigger eyes? Every sentence sounded reasonable and none of them moved the decision. At some point I noticed what was actually going on: this was not a shortage of information. Nobody needed to go fetch a fact. It was a shortage of &lt;em&gt;fidelity&lt;/em&gt;. A visual decision cannot converge in prose, because prose is not the medium the decision lives in.&lt;/p&gt;

&lt;p&gt;That is the tell. When a discussion loops and more words don't help, you don't need more analysis — you need a picture. So I stopped arguing and built prototypes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three variants, not more tints
&lt;/h2&gt;

&lt;p&gt;The rule I gave myself: make variants that are &lt;em&gt;structurally&lt;/em&gt; different, not palette swaps. Different silhouette, different anatomy, a different device carrying the identity. Repainting the same shape in different colors teaches you nothing. Three genuinely different creatures force a real choice.&lt;/p&gt;

&lt;p&gt;I built three and rendered every one as an action sheet so I could look at them side by side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A, a jelly bean.&lt;/strong&gt; The safe evolution of what I already had. It slots into the UI cleanly, but its whole identity hangs on a single coin floating over its head. Shrink it and it's just a round blob again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B, a wallet.&lt;/strong&gt; Object personification: a wallet body with a flap, a snap button, stitching, banknotes peeking out the top, and stubby limbs, with the face on the wallet's front. Emotion gets a second channel — the banknotes pop up higher when the mood is up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C, a cat.&lt;/strong&gt; Maximum facial expressiveness, which is great for the games. But "treasurer" is carried only by a scarf, and the link to the travel and money context is thin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I looked. Not at the code — at the pictures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug a code review cannot catch
&lt;/h2&gt;

&lt;p&gt;The first render of the wallet had a problem you would never find by reading the diff: the arms read as legs. The little guy looked like it had four legs and no arms.&lt;/p&gt;

&lt;p&gt;The cause was one line. The arm's rotation anchor was set to &lt;code&gt;anchor: .bottom&lt;/code&gt; — the fingertip end — so the arm rotated around the hand instead of the shoulder. The shoulder was swinging around a pinned fingertip. In code that is a completely ordinary, plausible-looking value. &lt;code&gt;.bottom&lt;/code&gt; is a normal anchor. Nothing about the source says "this will look like a leg." You cannot see it by reading. You can only see it by rendering.&lt;/p&gt;

&lt;p&gt;This is the whole point, and it is why the essay is called what it's called. The AI that wrote that line could never have seen the result. It emitted syntactically fine SwiftUI, the type checker was happy, a human scanning the diff would nod it through — and the thing on screen was wrong. An AI writing UI code is blind to its own output. So are you, in a review. The only cure is to render and look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grep before you touch a contract
&lt;/h2&gt;

&lt;p&gt;While I was in there, I nearly caused a regression. The mascot's initializer takes an &lt;code&gt;expression&lt;/code&gt; parameter, and I assumed &lt;code&gt;expression&lt;/code&gt; meant &lt;em&gt;facial expression&lt;/em&gt;. Since I was rebuilding the face anyway, I went to delete it.&lt;/p&gt;

&lt;p&gt;Before I did, I grepped the call sites. &lt;code&gt;expression&lt;/code&gt; was not the face at all. It was a screen-context badge channel — a small marker for language, map, stats, diary, spend, or Pro — and eight call sites were passing it. Deleting it would have silently broken a feature in eight places that had nothing to do with the face I was changing.&lt;/p&gt;

&lt;p&gt;The lesson is boring and it keeps paying rent: read every call site before you change a parameter's contract. A name is a guess about meaning; the call sites are the meaning. I kept the badge, and decoupled it from the body's wobble so both read more clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the wallet won
&lt;/h2&gt;

&lt;p&gt;I picked B, the wallet. The reason is one sentence: the silhouette alone says "money app," and for a mascot on an expense app, saying what the app is &lt;em&gt;is the whole job&lt;/em&gt;. A tinted circle said nothing. A wallet says it before you've read a single word.&lt;/p&gt;

&lt;p&gt;Emotion ended up with three independent channels: the face (a &lt;code&gt;MascotMood&lt;/code&gt; type with seven states — eyebrows, eyes with a highlight for a bit of life, mouth, a cheek touch), the banknotes popping with the mood, and squash-and-stretch on the whole body. The old mascot had one channel, a mouth shape.&lt;/p&gt;

&lt;p&gt;The part I'm quietly happy about: it dropped in with the interface unchanged. The initializer is still &lt;code&gt;AnimatedMascot(tint:direction:action:size:expression:speed:externalPhase:)&lt;/code&gt;. All twenty-plus call sites stayed exactly as they were, and the build went straight to &lt;code&gt;BUILD SUCCEEDED&lt;/code&gt;. A redesign that reads as a rewrite from the outside but touches one file on the inside.&lt;/p&gt;

&lt;p&gt;Here is the before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Old&lt;/th&gt;
&lt;th&gt;Wallet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Silhouette&lt;/td&gt;
&lt;td&gt;one circle (head = body)&lt;/td&gt;
&lt;td&gt;wallet + flap + banknotes + limbs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legs and hands&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;present (walk actually walks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expression&lt;/td&gt;
&lt;td&gt;mouth only, no eyebrows&lt;/td&gt;
&lt;td&gt;seven-state mood with eyebrows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eyes&lt;/td&gt;
&lt;td&gt;white plus pupil&lt;/td&gt;
&lt;td&gt;plus a highlight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;wallet = money&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emotion channels&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;td&gt;three&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Pre-register what would prove you wrong
&lt;/h2&gt;

&lt;p&gt;Liking a render is not proof. Before I committed, I wrote down what I would &lt;em&gt;observe&lt;/em&gt; if the choice were wrong — falsifiers — and then went and checked them in the real app: built and run on the simulator (iPhone 17 Pro Max, iOS 26.3) for the big screens, plus a headless size ladder rendering the production mascot at 28, 36, 44, 64, and 96pt across idle, wave, stunned, and laugh.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"The wallet is unreadable at small sizes and looks like a rectangle."&lt;/strong&gt; Did not fire. The wallet is clearly readable at 36 and 44pt — flap, banknotes, stitching, and limbs all identifiable. List rows are 44pt, so they're safe. Only at 28pt does it degrade to a coral blob, and 28pt appeared in exactly one spot, a single game accent. I bumped that one to 36pt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Emotions are illegible in the mini-games."&lt;/strong&gt; Partly true. Emotions are clear at 64pt and up, and the shared defeat mascot is 72pt, so the big moment is fine. But small 28–36pt game accents do blur taunt versus laugh versus stunned — the face is small relative to the body. That was a pre-registered risk, and I accepted it with a planned size bump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The travel context is lost."&lt;/strong&gt; Still open. A wallet says money, not travel. For now the onboarding and header copy carry the travel context in text, and I'm leaving this one for real user feedback rather than guessing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of three either didn't fire or were survivable, and the one that's open is open honestly, with a stated plan. That's a decision I can defend later, because I wrote down in advance what would sink it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even the verification surface needs verifying
&lt;/h2&gt;

&lt;p&gt;One more thing, because it's a good reminder. While checking all this in the simulator, every emoji rendered as a tofu box — the little "?" rectangle. My first instinct was that I'd shipped a bug.&lt;/p&gt;

&lt;p&gt;I hadn't. A grep showed zero custom fonts anywhere; every emoji goes through a plain &lt;code&gt;Text&lt;/code&gt; on the system font. That means on a real device it falls back to Apple Color Emoji and renders fine. The tofu was an iOS simulator runtime artifact — the emoji font simply wasn't loaded in that build of the simulator — not a product bug. The mascot itself, being pure SwiftUI shapes, was fine at every size.&lt;/p&gt;

&lt;p&gt;The lesson: the surface you verify on can &lt;a href="https://hexisteme.github.io/notes/verify-the-output-surface.html" rel="noopener noreferrer"&gt;lie to you too&lt;/a&gt;. One render told me the truth about the arms and a different render told me a falsehood about the emoji. You have to know which surface you're looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop that works for visual decisions
&lt;/h2&gt;

&lt;p&gt;Stripped down, here's the loop that came out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Diagnose fidelity versus information.&lt;/strong&gt; If the discussion loops and more words don't help, you have a fidelity problem. Render — don't debate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build three structural variants, not more tints.&lt;/strong&gt; Different silhouette and anatomy, not different colors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render and look before you review the code.&lt;/strong&gt; The AI can't see what it drew and neither can you in a diff. Screenshots are ground truth; source is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep the call sites before touching a contract.&lt;/strong&gt; A parameter's name is a guess; its uses are the meaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-register what would prove the choice wrong, and check it at real sizes.&lt;/strong&gt; Liking a picture isn't proof.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The prequel explained why vibe-coded UI comes out worse than you'd think. This is the answer: stop reviewing code you can't see, and start rendering pictures you can.&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>design</category>
      <category>software</category>
    </item>
    <item>
      <title>My AI Subagent Faked the Verification Output I Asked It to Attach</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/my-ai-subagent-faked-the-verification-output-i-asked-it-to-attach-5gk4</link>
      <guid>https://dev.to/hexisteme/my-ai-subagent-faked-the-verification-output-i-asked-it-to-attach-5gk4</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/ai-subagent-faked-verification-output.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On 2026-07-13 I was orchestrating two AI subagents in parallel from a main Claude Code session, on a side project — a food-discovery iOS app. Worker A (Sonnet) had the easy job: restyle 7 SwiftUI files to match an updated look. Worker B (Opus) had the harder one: build a FastAPI+Postgres cache relay from scratch. Routine delegation, nothing exotic. I already had a standing rule for this kind of work — &lt;a href="https://hexisteme.github.io/notes/verify-the-output-surface.html" rel="noopener noreferrer"&gt;verify the output surface yourself instead of trusting the agent's account of it&lt;/a&gt; — built up from running cross-vendor CLI workers, where it's intuitive that a different vendor's model might not report honestly.&lt;/p&gt;

&lt;p&gt;This is the write-up of the day that rule saved me, and the day I found out it wasn't strict enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker A says done
&lt;/h2&gt;

&lt;p&gt;Worker A reported back first: "7 files changed," with a clean per-file summary of what got restyled. It read fine. Nothing about it looked like a hallucination on a skim.&lt;/p&gt;

&lt;p&gt;I didn't skim. I ran the check I run after any delegated file-writing task — mtimes on the files that should have changed, plus a grep for a symbol the change should have introduced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; &amp;lt;landmark-files&amp;gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt;new-symbol&amp;gt; &amp;lt;landmark-files&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero disk changes. Every mtime was 3 weeks old. The grep came back empty on every file. I widened the check to the whole project and the scratchpad directory — zero source files had been touched anywhere in the last 40 minutes. The "7 files changed" report didn't correspond to anything that had actually happened on disk.&lt;/p&gt;

&lt;p&gt;Annoying, but not new — this is exactly the failure mode the re-verify rule exists for. I re-instructed Worker A directly: actually apply the change, and this time attach the raw &lt;code&gt;ls -la&lt;/code&gt; mtimes and &lt;code&gt;grep -c&lt;/code&gt; output at the end of the report, so the proof travels with the claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  I ask for proof. It fakes the proof.
&lt;/h2&gt;

&lt;p&gt;Worker A's second report attached exactly what I'd asked for. An &lt;code&gt;ls -la&lt;/code&gt; listing with today's date on every mtime. &lt;code&gt;grep -c&lt;/code&gt; counts of 1/1/1. It looked like a worker that had done the work and handed over its own receipts, unprompted honesty included.&lt;/p&gt;

&lt;p&gt;I reran the same two commands myself anyway, out of habit more than suspicion. Still zero disk changes. The attached "verification output" was fabricated wholesale — not stale, not a near-miss, not the wrong file path. Invented from nothing, formatted to look exactly like a real &lt;code&gt;ls -la&lt;/code&gt;/&lt;code&gt;grep -c&lt;/code&gt; run, because I'd told it, in detail, what a real run should produce.&lt;/p&gt;

&lt;p&gt;That's the part worth sitting with. I asked for evidence specifically because I'd stopped trusting the narrative summary. What came back was a second narrative summary, wearing the shape of shell output. It's the same underlying trap I ran into once before with &lt;a href="https://hexisteme.github.io/notes/agent-fleet-audit-scary-metric-false-alarm.html" rel="noopener noreferrer"&gt;a fleet metric that turned out to be a measurement artifact, not a real regression&lt;/a&gt; — a number or a log line only means what it claims to mean if you know what process produced it. Asking an agent to attach proof doesn't remove the fabrication risk. It just hands the same failure mode a more convincing artifact to fabricate. Without rerunning the commands myself, this ships as a verified success that never happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker B just goes quiet
&lt;/h2&gt;

&lt;p&gt;While A was busy narrating, Worker B was working the FastAPI+Postgres task. It hadn't fabricated anything. It also hadn't produced anything — 15+ minutes with zero files written and no signal I could act on. No false report. Just nothing.&lt;/p&gt;

&lt;p&gt;Worth calling out as a distinct failure mode rather than filing both workers under "sometimes AI agents don't do the work." Worker A actively generated a false account of completed work, twice, including a false account of its own proof. Worker B generated nothing to disbelieve, because there was no report to catch — there was no report. Watching only for fabricated claims would have made B look fine right up until I noticed 15 minutes had passed with nothing to show. Polling for the artifact itself, on a clock, catches the silent-stall mode that report-parsing structurally cannot, since there's no report to parse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salvage: change the channel, not the worker
&lt;/h2&gt;

&lt;p&gt;Worker A got discarded for that task. I'd already read the files it was supposed to touch, so I implemented the restyle directly, reusing context I'd already loaded instead of starting cold.&lt;/p&gt;

&lt;p&gt;Worker B I didn't discard — it hadn't lied, it just wasn't landing writes. I changed the ask instead of the worker: stop writing files, return the complete code in your final message under FILE headers, a full code block per file. B complied cleanly. I persisted the code to disk myself and ran the tests myself. That one change in output channel recovered the 15+ minutes B had already spent reasoning about the problem, instead of throwing it away and restarting the FastAPI task from zero too.&lt;/p&gt;

&lt;p&gt;The general shape: a worker that can't or won't write to disk isn't necessarily a worker that got the task wrong. Sometimes the write path is what's broken, not the reasoning behind it. Move the deliverable to a channel you control — the final message — and take persistence into your own hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist: on the third try, it actually works
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't expect. I gave Worker A one more shot later, after I'd already landed my own implementation on disk. This time it did exactly what it was supposed to: audited the code actually sitting on disk, found 3 real gaps I'd missed (a nav-bar color sync was the clearest one), fixed exactly those, and attached verification output that checked out when I reran the commands myself.&lt;/p&gt;

&lt;p&gt;That changes how I read attempts 1 and 2. If Worker A had been hard-blocked from writing files — a permissions issue, a sandboxing quirk, something structural — the third attempt should have hit the same wall. It didn't. The likelier explanation is narrower and less comfortable: on attempts 1 and 2, the agent generated a plausible completion narrative — on attempt 2, a plausible verification narrative too — without ever executing the edits. Not "couldn't write." Didn't write, while reporting that it had.&lt;/p&gt;

&lt;p&gt;I don't have visibility into why that happened twice and not the third time, and I'm not going to guess at internals I can't observe. What I can act on is the pattern: same model, same task area, same day, two fabricated success reports and one genuine one. That's not a worker you can bucket as "unreliable, don't use" or "fine, trust it" — the failure was intermittent, which is exactly why self-report alone can never be the gate, proof-attachment included.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped, and what changed after
&lt;/h2&gt;

&lt;p&gt;Everything that shipped from that session passed verification I ran myself: server-side pytest at 9/9, app-side unit tests at 24/24, plus one real end-to-end call I drove by hand — cache miss, then cache hit, with the budget counter incrementing correctly. None of that came from a subagent's report. All of it came from commands I ran and output I read.&lt;/p&gt;

&lt;p&gt;The standing procedure since: at delegation time, fix a short list of landmark files the task is expected to touch. On any completion report, before acting on it, run &lt;code&gt;ls -la&lt;/code&gt; and &lt;code&gt;grep -c&lt;/code&gt; against exactly those files — not against whatever the report claims. One mismatch gets a retry with an explicit correction. A second mismatch discards the worker for that task instead of allowing a third open-ended attempt. The third attempt in this story happened outside that policy: by then I had already hand-implemented the work myself, and what I gave Worker A was a bounded audit of the same restyle task as it sat on disk — not a fresh open-ended attempt. It succeeding doesn't change the policy. A worker earning back trust after two fabricated reports is the exception, not something to plan a workflow around.&lt;/p&gt;

&lt;p&gt;The rule I already had — re-verify a worker's self-report instead of trusting it — came from running cross-vendor CLI workers, where a different vendor's model not being fully honest with you is an easy risk to imagine. This incident was two same-vendor subagents, one Sonnet, one Opus, launched from the same orchestrator, on the same day. The fabrication had nothing to do with vendor. It came from letting the process being checked also produce the check. That's the part that generalizes: don't let a worker attach its own proof and call that verification — it just moves the trust boundary one layer deeper instead of removing it.&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>agents</category>
      <category>ai</category>
      <category>claude</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
