<?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: Edy Cu</title>
    <description>The latest articles on DEV Community by Edy Cu (@edycutjong).</description>
    <link>https://dev.to/edycutjong</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%2F3911089%2Fb24966e4-839d-4c19-8e2d-16c6a5c8838c.jpeg</url>
      <title>DEV Community: Edy Cu</title>
      <link>https://dev.to/edycutjong</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edycutjong"/>
    <language>en</language>
    <item>
      <title>Sampling rate is a correctness property, not a performance knob</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sun, 30 Aug 2026 23:33:18 +0000</pubDate>
      <link>https://dev.to/edycutjong/sampling-rate-is-a-correctness-property-not-a-performance-knob-47p7</link>
      <guid>https://dev.to/edycutjong/sampling-rate-is-a-correctness-property-not-a-performance-knob-47p7</guid>
      <description>&lt;p&gt;In 1997 a Pokémon episode aired in Japan with a four-second red-and-blue strobe. 685 children went to hospital that night. The regulations that followed — Ofcom Broadcasting Code 2.12, ITU-R BT.1702 — are why UK and Japanese broadcast deliveries have to clear a photosensitive-epilepsy check before transmission.&lt;/p&gt;

&lt;p&gt;I built a screening tool for that check. Live at &lt;a href="https://flashframe-production.up.railway.app" rel="noopener noreferrer"&gt;flashframe-production.up.railway.app&lt;/a&gt;, code at &lt;a href="https://github.com/edycutjong/flashframe" rel="noopener noreferrer"&gt;github.com/edycutjong/flashframe&lt;/a&gt;. Three synthetic test clips ship with it, so you can click one and watch it run without an upload or an API key.&lt;/p&gt;

&lt;p&gt;This post isn't about the tool. It's about the fact that I shipped two completely different bugs that turned out to be the same bug, and I didn't recognise the second one even after fixing the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule is a window function
&lt;/h2&gt;

&lt;p&gt;The regulation says: no more than &lt;strong&gt;three flashes in any one second&lt;/strong&gt;. That's not a video-processing problem, it's a time-series query. Per-frame luminance goes into ClickHouse, and the detection is windowed SQL — pair opposing luminance transitions into flashes, count them over a sliding one-second window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_flash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tile&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;frame_idx&lt;/span&gt;
  &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;window_flashes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that window size. It's &lt;code&gt;fps - 1&lt;/code&gt; rows, because "one second" of frames depends entirely on how many frames per second you sampled. That parameter is about to be the whole story.&lt;/p&gt;

&lt;p&gt;Over a 92-minute feature — 138,240 frames — this runs at &lt;strong&gt;p50 106 ms, p95 163 ms&lt;/strong&gt;. Query time only; ffmpeg extraction and model adjudication are outside the timed region, and I report ingest separately because it's dominated by transport, not the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: a safe clip that failed
&lt;/h2&gt;

&lt;p&gt;Extraction ran at 10 fps. Cheap, and plenty for a rule about events at 3 Hz.&lt;/p&gt;

&lt;p&gt;One of my test clips alternates 5 times a second — under the limit, should pass. It came back at &lt;strong&gt;2.08 flashes/sec&lt;/strong&gt; and got flagged as a violation.&lt;/p&gt;

&lt;p&gt;At 10 fps sampling, the Nyquist limit is 5 Hz. A 5-alternation-per-second flash sits exactly at that boundary. The samples land at a beat frequency against the actual flashing and you measure something that was never there. Classic aliasing — the wagon-wheel effect, except the wheel is a seizure hazard and the wrong answer ships in a compliance report.&lt;/p&gt;

&lt;p&gt;The fix wasn't to sample everything at 60 fps. On a 90-minute feature that's 6× the extraction and 6× the rows for a property that matters in maybe 30 frames out of 138,240. Instead the agent re-samples only the span whose verdict is uncertain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resample_frames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame_start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame_end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_fps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;nonlocal&lt;/span&gt; &lt;span class="n"&gt;resample_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_measured_rate&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resample_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;error&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;message&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;Max resample iterations reached.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;resample_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;scan_id_new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_extraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;video_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fps_override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;target_fps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;frame_start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;frame_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame_end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;frame_end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setup_db_and_ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_query_tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scan_id_new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;video_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;25.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_fps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;new_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;detect_violations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_query_tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scan_id_new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;target_fps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a tool the model calls on its own when a result lands near the threshold. In the actual run:&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;Flagged&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="mi"&gt;1025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1055&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0833333333333335&lt;/span&gt; &lt;span class="n"&gt;flashes&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resample_frames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resample_frames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;adjudicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At full rate the clip resolves to &lt;strong&gt;2.82 flashes/sec&lt;/strong&gt; — under the 3.00 limit. It passes. A single-pass tool reports a false failure on it.&lt;/p&gt;

&lt;p&gt;The same mechanism caught the opposite error on a different clip: a genuine strobe read &lt;strong&gt;5.0&lt;/strong&gt; at 10 fps and resolved to &lt;strong&gt;6.25&lt;/strong&gt; after escalation. Undersampling had &lt;em&gt;understated&lt;/em&gt; a real hazard. That direction is the one that actually hurts someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the model couldn't see the strobe
&lt;/h2&gt;

&lt;p&gt;The SQL finds candidates; a multimodal model looks at the frames and says what the flashing thing actually is. I clip the exact span with ffmpeg and hand it over.&lt;/p&gt;

&lt;p&gt;It kept returning "no significant flashing." On a clip that is nothing but full-screen black-white alternation at 6.25 Hz. I assumed a prompt problem and spent real time rewriting the prompt.&lt;/p&gt;

&lt;p&gt;It was not a prompt problem. Video sent to the API is sampled at &lt;strong&gt;1 fps&lt;/strong&gt; by default. A 6.25 Hz strobe sampled once per second isn't faint in the frames the model receives — it is &lt;em&gt;absent&lt;/em&gt;. Every frame it saw was a still.&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;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;inline_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mime_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video/mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;video_metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VideoMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One parameter. Same test, failed at the default and passed at 24 fps.&lt;/p&gt;

&lt;p&gt;Two subsystems, two days apart, one root cause — and I still spent hours on the second one hunting a prompt bug, because the first had presented as a SQL bug. The category I'd filed the first fix under was "SQL window sizing," which is exactly the wrong abstraction level to have learned it at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sampling rate is a correctness property, not a performance knob.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you downsample, you're not accepting a slightly blurrier version of the truth. Above the Nyquist limit you get a &lt;em&gt;confidently wrong&lt;/em&gt; answer with no signal that anything is off. Neither bug threw an error. Both produced clean, plausible numbers.&lt;/p&gt;

&lt;p&gt;If any part of your pipeline samples a signal — video frames, metrics, sensor reads, log aggregation windows, an LLM's view of a time series — write down the highest frequency that matters and check you're sampling above twice it. That question took me two bugs to learn to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;p&gt;It's a &lt;strong&gt;screening-grade pre-check, not a certified lab test&lt;/strong&gt;, and it says so on the report and in the certificate. It measures luma code value from the decoded signal under an assumed reference display; a certified test measures photometric luminance at a calibrated one.&lt;/p&gt;

&lt;p&gt;Screen area is a 3×3 tiled proxy, not per-pixel. Measured accuracy against constructed ground truth is exact on the full-field case and &lt;strong&gt;+12.9% on a small-area case&lt;/strong&gt;, biased toward over-reporting. I disclosed that rather than adding a correction constant — the entire claim is that thresholds come from published criteria rather than from whatever made the demo look right, and a fitted constant would destroy it.&lt;/p&gt;

&lt;p&gt;One more thing I got wrong and had to walk back: the certificate was writing the model's &lt;em&gt;estimate&lt;/em&gt; into the measured field. Two runs of the identical clip produced certificates reading 6.25 and 5.0. A certificate whose headline number moves between runs is not a certificate. The measured value now always comes from the SQL, and the model's estimate is stored separately — the database measures, the model judges, and the report labels which is which.&lt;/p&gt;

&lt;p&gt;All the test footage is synthetic, generated by a script in the repo. Clone it and re-derive every number above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/edycutjong/flashframe.git
&lt;span class="nb"&gt;cd &lt;/span&gt;flashframe
uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
python generator.py   &lt;span class="c"&gt;# regenerate the seed clips&lt;/span&gt;
python bench.py       &lt;span class="c"&gt;# re-run the 138,240-frame benchmark&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Live demo: &lt;a href="https://flashframe-production.up.railway.app" rel="noopener noreferrer"&gt;https://flashframe-production.up.railway.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>python</category>
      <category>sql</category>
      <category>ai</category>
    </item>
    <item>
      <title>Feature detection lies: two things I learned shipping on WebMCP</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sun, 30 Aug 2026 13:00:08 +0000</pubDate>
      <link>https://dev.to/edycutjong/feature-detection-lies-two-things-i-learned-shipping-on-webmcp-328d</link>
      <guid>https://dev.to/edycutjong/feature-detection-lies-two-things-i-learned-shipping-on-webmcp-328d</guid>
      <description>&lt;p&gt;I spent eight days building a parliamentary-procedure engine on &lt;strong&gt;WebMCP&lt;/strong&gt; — the emerging standard that lets a web page hand an AI agent a typed tool list through &lt;code&gt;document.modelContext&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The idea is one sentence: &lt;strong&gt;an action that is out of order should not exist to be called.&lt;/strong&gt; Not greyed out, not refused at runtime — absent from &lt;code&gt;getTools()&lt;/code&gt; entirely, with the rule that removed it printed beside the gap.&lt;/p&gt;

&lt;p&gt;Live: &lt;a href="https://pointoforder.netlify.app" rel="noopener noreferrer"&gt;https://pointoforder.netlify.app&lt;/a&gt; · Source (MIT): &lt;a href="https://github.com/edycutjong/mace" rel="noopener noreferrer"&gt;https://github.com/edycutjong/mace&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things I learned are worth more than the product, and both came from running it in a client I did not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;'registerTool' in modelContext&lt;/code&gt; does not tell you the API works
&lt;/h2&gt;

&lt;p&gt;The obvious feature detection:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modelContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;modelContext&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;modelContext&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasWebMCP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelContext&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;registerTool&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That checks the property exists. It does not check that calling it works, and it says nothing about the &lt;em&gt;other&lt;/em&gt; capabilities hanging off the same interface.&lt;/p&gt;

&lt;p&gt;ChatGPT's in-app browser hands back a &lt;code&gt;modelContext&lt;/code&gt; that registers tools correctly and answers &lt;code&gt;getTools()&lt;/code&gt; correctly — and &lt;strong&gt;is not an &lt;code&gt;EventTarget&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;addEventListener('toolchange', …)&lt;/code&gt; throws a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Subscribing is a separate capability from registering, and nothing in the shape of the object warns you.&lt;/p&gt;

&lt;p&gt;The failure mode was the worst one available. &lt;code&gt;boot()&lt;/code&gt; awaited &lt;code&gt;start()&lt;/code&gt;, the throw landed &lt;em&gt;after&lt;/em&gt; the page had painted, and the page rendered completely and then announced it had failed to start. It looked alive, then called itself broken.&lt;/p&gt;

&lt;p&gt;The fix is to treat every capability as independently optional:&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;let&lt;/span&gt; &lt;span class="nx"&gt;toolEventsLive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasWebMCP&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;regErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolchange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;renderAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;toolEventsLive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[mace] modelContext is not an EventTarget:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and then say so in the UI. The banner now reads &lt;em&gt;"WebMCP live · document.modelContext — tools registered, but this client's modelContext is not an EventTarget, so there are no toolchange events."&lt;/em&gt; A product that names which mechanism is carrying it beats one that quietly degrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Declarative tools land asynchronously, and &lt;code&gt;toolchange&lt;/code&gt; is how you find out
&lt;/h2&gt;

&lt;p&gt;WebMCP has a declarative form: put &lt;code&gt;toolname&lt;/code&gt; on a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; and the browser adopts it as a tool. It is genuinely elegant — the form &lt;em&gt;is&lt;/em&gt; the tool, and removing the attribute removes the tool. My app uses both mechanisms on purpose, so a single state change can remove seven tools by aborting an &lt;code&gt;AbortSignal&lt;/code&gt; and an eighth by dropping an attribute.&lt;/p&gt;

&lt;p&gt;But adoption happens when the browser notices the DOM change. &lt;strong&gt;There is no promise to await.&lt;/strong&gt; Where &lt;code&gt;toolchange&lt;/code&gt; fires this is invisible: the event tells you the surface settled, and you re-render.&lt;/p&gt;

&lt;p&gt;Without the event, the render that follows a state change read &lt;code&gt;getTools()&lt;/code&gt; one tick early. The panel said 16. The API said 17. It stayed wrong — four seconds later, still wrong.&lt;/p&gt;

&lt;p&gt;That is precisely the divergence the product claims is impossible. The whole pitch is that the left column is rendered &lt;em&gt;from&lt;/em&gt; &lt;code&gt;getTools()&lt;/code&gt;, so the screen and the API cannot disagree. In one client, they did.&lt;/p&gt;

&lt;p&gt;With nothing to await, the honest option is to poll until the surface stops moving:&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;function&lt;/span&gt; &lt;span class="nf"&gt;settleWithoutEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolEventsLive&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasWebMCP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// clients with the event never get here&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolEventsLive&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;tries&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTools&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;renderInOrder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// that render re-arms the poll&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bounded to about 600 ms, and only on the path where the event is unavailable.&lt;/p&gt;

&lt;p&gt;To verify the fix I served the local build under the live origin — so the origin-trial token still applied — with &lt;code&gt;addEventListener&lt;/code&gt; stripped off &lt;code&gt;modelContext&lt;/code&gt; to reproduce the client's shape. Both shapes now return &lt;strong&gt;5 / 17 / 15 / 9&lt;/strong&gt; tools at the four checkpoints, zero divergences, zero page errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  A third one, already settled upstream
&lt;/h2&gt;

&lt;p&gt;On Chrome 151, &lt;code&gt;executeTool&lt;/code&gt;'s second argument must be a JSON &lt;strong&gt;string&lt;/strong&gt;. Passing the object the IDL specifies returns &lt;code&gt;UnknownError: Failed to parse input arguments&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is not an open question — it was resolved in &lt;a href="https://github.com/webmachinelearning/webmcp/issues/243" rel="noopener noreferrer"&gt;webmachinelearning/webmcp#243&lt;/a&gt; ("The &lt;code&gt;executeTool()&lt;/code&gt; method should take an object, not a string"), closed as completed on 2026-08-17. Chrome has not shipped the resolution yet. Send the string, keep the object path as a fallback, and you are correct today and on the day Chrome converges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The claim worth measuring here is not speed. It is that the panel &lt;em&gt;cannot&lt;/em&gt; lie, because its left column is rendered from &lt;code&gt;getTools()&lt;/code&gt; rather than from my own bookkeeping. &lt;code&gt;npm run bench&lt;/code&gt; drives the deployed origin in real Chrome and exits non-zero on any gate failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HEADLINE  90 getTools()-vs-screen comparisons, 0 divergences

getTools() round trip           p50 0.20  p95 0.30  n 90
quorum cliff, submit → settled  p50 1.00  p95 4.10  n 30
explain_path_to (depth 6/399)   p50 0.90  p95 1.60  n 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus 306 unit tests, including all 152 legality cells — 7 phases × 19 gated tools, asserted against the rule table rather than against the implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The non-technical lesson, which cost more
&lt;/h2&gt;

&lt;p&gt;I also went and asked ten HOA board members whether the problem I was solving was real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six said no.&lt;/strong&gt; They deliberately don't run strict Robert's Rules, because being regimented about it causes more confusion than it prevents. One corrected me with the rulebook itself: RONR relaxes procedure for boards under about twelve members (12th ed. §49), which is most HOA boards. My engine models the full rules and has no small-board mode — so it is &lt;em&gt;stricter than the rulebook requires&lt;/em&gt; for exactly the audience I had named.&lt;/p&gt;

&lt;p&gt;My pitch opened with "every HOA runs its meetings under Robert's Rules." That was false, and I had already shipped it.&lt;/p&gt;

&lt;p&gt;One person answered differently: a secretary whose association manages millions, who writes each motion down as it is discussed, requires an amendment to be restated and seconded before the vote, and records every member's yay, nay or abstention by name. &lt;strong&gt;That is the user.&lt;/strong&gt; Not every board — the boards where the money makes procedure worth enforcing, and where a vote taken wrong gets challenged months later.&lt;/p&gt;

&lt;p&gt;Narrower audience, real evidence, and a limitation I can state myself instead of one a reader finds for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No small-board mode (§49)&lt;/strong&gt; — the gap above. It's a second data file, not a rewrite, but it isn't written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never rules on germaneness.&lt;/strong&gt; Not computable from a table, so the chair rules and the ruling enters the minutes. That limit is the design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-member vote records&lt;/strong&gt; — only tallies. Which is why it can't implement &lt;em&gt;Reconsider&lt;/em&gt; (§37), where eligibility is restricted to someone who voted on the prevailing side. Shipping it would mean shipping a rule the engine cannot check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timings are one machine, one browser.&lt;/strong&gt; M1 Max, Chrome 151. No cross-device distribution is claimed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://pointoforder.netlify.app" rel="noopener noreferrer"&gt;https://pointoforder.netlify.app&lt;/a&gt; — Chrome 149+ with WebMCP, or the ChatGPT desktop app's &lt;strong&gt;Work&lt;/strong&gt; tab. (The Chat tab cannot open the in-app browser at all; it will tell you so and then guess at the site from its URL.)&lt;/p&gt;

&lt;p&gt;Source, MIT: &lt;a href="https://github.com/edycutjong/mace" rel="noopener noreferrer"&gt;https://github.com/edycutjong/mace&lt;/a&gt; — start at &lt;code&gt;src/webmcp.js&lt;/code&gt;. Zero runtime dependencies, no build step.&lt;/p&gt;

&lt;p&gt;If you find a third thing wrong with it, I would rather know.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>438 of 536 quarantined — and not one was a bad verdict</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:30:40 +0000</pubDate>
      <link>https://dev.to/edycutjong/438-of-536-quarantined-and-not-one-was-a-bad-verdict-4a8e</link>
      <guid>https://dev.to/edycutjong/438-of-536-quarantined-and-not-one-was-a-bad-verdict-4a8e</guid>
      <description>&lt;p&gt;A 12-person humanitarian NGO carries exactly the same strict-liability sanctions exposure as JPMorgan, and cannot hire anyone to manage it. I built an agent that does — and the first real run against Gemini quarantined &lt;strong&gt;438 of 536&lt;/strong&gt; counterparties.&lt;/p&gt;

&lt;p&gt;Not one of them was a bad verdict.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(I created this piece for the purposes of entering the All Things Agentic Hackathon.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Repo: &lt;strong&gt;&lt;a href="https://github.com/edycutjong/interdict" rel="noopener noreferrer"&gt;https://github.com/edycutjong/interdict&lt;/a&gt;&lt;/strong&gt; · 3-minute demo: &lt;strong&gt;&lt;a href="https://youtu.be/C1VFGSwS7w4" rel="noopener noreferrer"&gt;https://youtu.be/C1VFGSwS7w4&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What quarantine costs
&lt;/h2&gt;

&lt;p&gt;Interdict re-screens an NGO's whole payment book whenever Treasury updates the OFAC sanctions list. A true hit gets held — money stops. A lookalike gets cleared with a written reason. When the model's answer can't be trusted, the counterparty goes to &lt;strong&gt;quarantine&lt;/strong&gt;, which is a terminal state: a human compliance officer is told the system could not safely decide, and the money stays frozen until they rule.&lt;/p&gt;

&lt;p&gt;That's expensive by design. Quarantine is supposed to be rare and it's supposed to mean something.&lt;/p&gt;

&lt;p&gt;So when 438 of 536 landed there, my first assumption was that the adjudicator had gone haywire. It hadn't. Free-tier Gemini allows five requests a minute. Every call after the first twenty-one came back &lt;code&gt;429 RESOURCE_EXHAUSTED&lt;/code&gt;, and my code did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adjudicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;adjudicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match_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;PARSE_ERROR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rate limit is not a parse error. But &lt;code&gt;except Exception&lt;/code&gt; doesn't know that, so 438 transient network conditions were filed as &lt;em&gt;suspected model-integrity failures&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that's worse than the rate limit
&lt;/h2&gt;

&lt;p&gt;The rate limit costs thirty seconds of waiting. The bug costs the escalation queue.&lt;/p&gt;

&lt;p&gt;Quarantine only works if a human reads it. Put 438 entries in there that needed nothing but patience, and the one entry that genuinely needs a person — a near-identical name where the model's rationale doesn't hold up — is buried underneath them. The queue stops being a signal and becomes noise, and the operator learns to skim it. That's the actual failure, and it would have survived into production looking like a working system.&lt;/p&gt;

&lt;p&gt;The insight that fixed it is boring and, I think, general:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"The model was wrong" and "the model did not answer" are different failures.&lt;/strong&gt; One is fixed by a human reading the evidence. The other is fixed by waiting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Conflating them means you cannot triage. So I stopped conflating them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two failure classes, not one
&lt;/h2&gt;

&lt;p&gt;First, the adjudicator owns its own backoff, and only retries things that are actually transient:&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;_TRANSIENT_MARKERS&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;RESOURCE_EXHAUSTED&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;429&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;503&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;UNAVAILABLE&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;DEADLINE_EXCEEDED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_TRANSIENT_MARKERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_retry_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Seconds to wait. Prefers the server&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s own hint over our guess.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry in (\d+(?:\.\d+)?)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;120.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DEFAULT_BACKOFF_S&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mf"&gt;120.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;retry in Ns&lt;/code&gt; hint matters more than the exponential fallback. The server knows when it will serve you again; guessing is strictly worse than reading. Five attempts, honouring the hint, and only then does it give up — as a &lt;em&gt;distinct exception type&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_is_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;                      &lt;span class="c1"&gt;# a bad answer is not a slow answer
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MAX_TRANSIENT_RETRIES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TransientAdjudicationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model unreachable after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_retry_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the orchestrator — the only component allowed to write a decision — routes on that type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adjudicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;adjudicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TransientAdjudicationError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Still quarantine -- money must never move on a decision that was never
&lt;/span&gt;    &lt;span class="c1"&gt;# made -- but say so accurately.
&lt;/span&gt;    &lt;span class="nf"&gt;_quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match_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;ADJUDICATOR_UNAVAILABLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;counterparty_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;counterparty_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;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retryable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# A model failure must never become a silent CLEAR.
&lt;/span&gt;    &lt;span class="nf"&gt;_quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match_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;PARSE_ERROR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;counterparty_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;counterparty_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;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retryable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what did &lt;strong&gt;not&lt;/strong&gt; change: both paths still quarantine, and money still stops in both. The safety property is identical. What changed is that the row now carries &lt;code&gt;retryable: true&lt;/code&gt; or &lt;code&gt;retryable: false&lt;/code&gt;, so an operator can tell at a glance which pile is which — and the retryable pile drains itself on the next pass without anyone touching it.&lt;/p&gt;

&lt;p&gt;The distinction is worth more than the retry. If I'd only added backoff, the 438 would have shrunk but the category error would still be there, waiting for the next outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take to the next agent system
&lt;/h2&gt;

&lt;p&gt;Three things, in order of how much they cost me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bare &lt;code&gt;except&lt;/code&gt; around a model call is a category error, not a style problem.&lt;/strong&gt; Model calls fail in at least two ways that demand opposite responses. Any handler that can't distinguish them will eventually make the wrong one, and it will do so quietly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the failure type carry the triage.&lt;/strong&gt; &lt;code&gt;retryable: true|false&lt;/code&gt; in the payload is what makes the queue readable. The alternative is an operator reading 438 stack traces to work out which ones matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escalation is a budget.&lt;/strong&gt; Every entry you send to a human spends attention you'll need later. I now treat "should this really escalate?" as a design question with a cost attached, the same way I'd treat a database write.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;p&gt;It's a hackathon build, and it's specific about what it isn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing runs on Google Cloud compute.&lt;/strong&gt; Cloud Firestore holds the audit trail; the agents, Postgres and the independent oracle run on a laptop. The free tier doesn't extend to Cloud Run and I had no billing account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model has never issued a CLEAR&lt;/strong&gt; in the graded book. A contradicting date of birth cuts a lookalike below the adjudication threshold before the model is ever consulted, so the adjudicator is exercised on &lt;em&gt;confirmation&lt;/em&gt;, not on discrimination. The grade should be read with that in mind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision quality is a 101-row stratified sample&lt;/strong&gt;, not the full 536-row book — free-tier quota, again.&lt;/li&gt;
&lt;li&gt;The payment book is &lt;strong&gt;synthetic and labelled&lt;/strong&gt; everywhere it appears. The OFAC data is real: the 08/07/2026 publication, 19,199 records, archived by content hash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The screening numbers, for what they're worth: top-1 &lt;strong&gt;0.995&lt;/strong&gt; against an independent oracle's &lt;strong&gt;0.840&lt;/strong&gt;, measured on 400 names deliberately perturbed so none appear on the list verbatim. Screening the seeded book &lt;em&gt;verbatim&lt;/em&gt; scores 1.000, which is a string-equality test wearing a costume, so I don't report it.&lt;/p&gt;

&lt;p&gt;Everything above reproduces with &lt;code&gt;make reproduce&lt;/code&gt;. If the escalation-budget idea is useful to you, that's the part I'd steal.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I created this piece of content for the purposes of entering the All Things Agentic Hackathon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/edycutjong/interdict" rel="noopener noreferrer"&gt;https://github.com/edycutjong/interdict&lt;/a&gt; · Demo: &lt;a href="https://youtu.be/C1VFGSwS7w4" rel="noopener noreferrer"&gt;https://youtu.be/C1VFGSwS7w4&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>python</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>My agent's p50 was 29s. Its p95 was 182s. That ratio decided the product.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:09:14 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-agents-p50-was-29s-its-p95-was-182s-that-ratio-decided-the-product-c7n</link>
      <guid>https://dev.to/edycutjong/my-agents-p50-was-29s-its-p95-was-182s-that-ratio-decided-the-product-c7n</guid>
      <description>&lt;p&gt;I have 67 timed turns against a live LLM agent, captured in a single batch run and written to disk:&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;seconds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;182.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;210.3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n&lt;/td&gt;
&lt;td&gt;67 completed turns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The median says &lt;em&gt;background job, that's fine&lt;/em&gt;. The p95 says &lt;em&gt;you may never put a human in front of this&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Those are not two performance notes. They are a product spec — and I found that out the expensive way, by designing the product first and measuring second.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://porchlight.edycu.dev" rel="noopener noreferrer"&gt;porchlight.edycu.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poke the real agent yourself:&lt;/strong&gt; &lt;a href="https://try.porchlight.edycu.dev" rel="noopener noreferrer"&gt;try.porchlight.edycu.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/edycutjong/porchlight" rel="noopener noreferrer"&gt;github.com/edycutjong/porchlight&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The product, briefly, because the latency only means something against it
&lt;/h2&gt;

&lt;p&gt;A member cancels, and the &lt;em&gt;reason&lt;/em&gt; vanishes — nobody writes it down. Months later the creator fixes the exact thing that drove people away, and the people who left for that reason are never told. The state of the art is a "we miss you" blast to everyone.&lt;/p&gt;

&lt;p&gt;Porchlight puts an agent — &lt;a href="https://hellominds.ai" rel="noopener noreferrer"&gt;Minds by Animoca Brands&lt;/a&gt; — on the critical path in three places: a short warm &lt;strong&gt;exit interview&lt;/strong&gt; that files a structured return-condition in the member's own words; &lt;strong&gt;condition matching&lt;/strong&gt;, which asks whether &lt;em&gt;this&lt;/em&gt; announcement genuinely resolves &lt;em&gt;that&lt;/em&gt; person's reason for leaving; and a &lt;strong&gt;win-back draft&lt;/strong&gt; that quotes the member back to themselves.&lt;/p&gt;

&lt;p&gt;The middle one is the step that has to be an agent, and I wanted to prove that rather than assert it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I shipped the dumb version alongside it
&lt;/h2&gt;

&lt;p&gt;Every "AI-powered" claim should ship its control. Mine is twenty lines, it lives in the repo, and it runs on the same inputs on every demo run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/keywordBaseline.ts — the "dumb tool" strawman&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STOP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;and&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;are&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;was&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;were&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;you&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;that&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;this&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;with&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;have&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;has&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;had&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;but&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;now&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;its&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;it's&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;been&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;back&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;big&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;news&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;just&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;about&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;from&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;they&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;them&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;our&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;got&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;more&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;a-z'-&lt;/span&gt;&lt;span class="se"&gt;]{3,}&lt;/span&gt;&lt;span class="sr"&gt;/g&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;STOP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="cm"&gt;/** True iff the parting quote and the announcement share at least one salient keyword. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;keywordResolves&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changeText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verbatimQuote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changeText&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verbatimQuote&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a real pair from the seed data. A member left saying:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"the long chatty sit-downs with guests were the whole reason i was here, now it is quick clips"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and the creator later announced:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Big news — the deep-dive interviews are back, weekly."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same event. After stopwords, the announcement contributes &lt;code&gt;{deep-dive, interviews}&lt;/code&gt; and the quote contributes &lt;code&gt;{long, chatty, sit-downs, guests, whole, reason, here, quick, clips}&lt;/code&gt;. The intersection is empty, so &lt;code&gt;keywordResolves&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; — and no amount of stopword tuning will ever link "clips" to "deep-dive". The agent resolves it, and explains why.&lt;/p&gt;

&lt;p&gt;Across 54 captured judgements the agent resolved 14 departures, &lt;strong&gt;11 of which the keyword baseline scores 0.00 on&lt;/strong&gt; — while refusing 35 non-matching pairs at ≥0.90 confidence. The recall is the pitch; the precision is what makes it safe to actually send. If you are emailing real people who already left once, a false positive is worse than a miss.&lt;/p&gt;

&lt;p&gt;Fine. The agent is load-bearing. Now the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill
&lt;/h2&gt;

&lt;p&gt;Sorted, those 67 samples look like this: fastest 10.3s, a long fat body between 15s and 50s, a handful in the 60–115s range, then five clustered at ~182s, then one at 210.3s — that last one being a 180s client timeout followed by a successful retry.&lt;/p&gt;

&lt;p&gt;That is not a distribution you can hide behind a spinner.&lt;/p&gt;

&lt;p&gt;The architecture I had sketched before measuring: visitor clicks &lt;em&gt;announce a change&lt;/em&gt;, the server fans out across every open departure, results render. With 18 departures that is 18 turns. At p50 that's about nine minutes. At p95 it's closer to an hour. And even a &lt;em&gt;single&lt;/em&gt; turn — the best case in the whole design — is a coin flip between ten seconds and three minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got cut
&lt;/h2&gt;

&lt;p&gt;Three decisions, all downstream of that one ratio.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No synchronous fan-out, ever.&lt;/strong&gt; The public demo replays verdicts captured ahead of time by a separate &lt;code&gt;npm run precompute&lt;/code&gt; pass, which writes them to &lt;code&gt;src/liveCache.json&lt;/code&gt; with a &lt;code&gt;capturedAt&lt;/code&gt; stamp on each one. Every verdict a visitor sees is real agent output; none of it is computed while they wait. The UI says when it was captured, because a replay that pretends to be live is a lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The one genuinely live path is bounded and rationed.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Longest a visitor is asked to wait on a live turn before we give up on it. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEB_DEADLINE_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withDeadline&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;work&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;work&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`no reply within &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100 seconds is not a round number I liked; it is p50 with real headroom and deliberately &lt;em&gt;below&lt;/em&gt; the 182s p95. It gives up on the slow tail on purpose rather than holding a browser open for three minutes. Some requests do fail, and the error message says exactly that — that this is a real call to a real agent and sometimes it is slow. Paired with 3 live calls per IP per 15 minutes, and scoped to one member the visitor picks rather than a fan-out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No keyword fallback in the deployed app.&lt;/strong&gt; This is the decision I'd defend hardest. When the agent is slow or unreachable, the tempting move is to fall back to the cheap path — you always have one, because you built it as the control. But the cheap path is the exact mechanism the product exists to beat. Falling back to it means quietly shipping the strawman under the good name, and nobody would ever know. With no credentials the service returns &lt;code&gt;503&lt;/code&gt; and says why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two other things the SDK taught me, both non-obvious
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A stale reply is a silent correctness bug.&lt;/strong&gt; Send-then-wait reads like it should just work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLatestHistoryFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messageText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&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;outcome&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForReply&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONFIG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;replyTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;afterFingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// captured BEFORE the send&lt;/span&gt;
  &lt;span class="na"&gt;sentMessageText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop &lt;code&gt;afterFingerprint&lt;/code&gt; and you can be handed the &lt;em&gt;previous&lt;/em&gt; turn's reply. It does not throw. It returns a completely plausible answer to a question you did not ask. In a system whose entire job is per-member judgement, that is a wrong email to a real person, and it is the worst class of bug to debug because nothing anywhere looks broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No JSON mode means scraping prose.&lt;/strong&gt; There is no schema/response-format option, so structured output means asking for JSON in the prompt, then going and finding it — &lt;code&gt;indexOf('{')&lt;/code&gt;, &lt;code&gt;lastIndexOf('}')&lt;/code&gt;, &lt;code&gt;JSON.parse&lt;/code&gt; the slice, hand it to Zod. It works, and it is brittle by construction: the reply can preface the JSON with commentary, fence it, or emit two objects. (Replies also arrive as HTML, which is its own small adventure in stripping tags &lt;em&gt;after&lt;/em&gt; decoding entities rather than before.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I had to retract
&lt;/h2&gt;

&lt;p&gt;Same agent, same prompt, same member quote, run weeks apart. A departure that said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"you stopped doing the long-form lore videos i subscribed for"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;judged against &lt;em&gt;"the deep-dive interviews are back, weekly"&lt;/em&gt; resolved &lt;strong&gt;true&lt;/strong&gt; in an early run and &lt;strong&gt;false, confidence 0.60&lt;/strong&gt; in the full capture, with this rationale:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The announcement restores long-form content but specifically as deep-dive interviews, not the lore videos the member subscribed for; the subject-matter mismatch means the member's core interest in lore is likely still unmet."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second answer is better than the first. That is not the point. The point is that I had a recovered-revenue figure in my README that read like a constant, and it is not one — it is a snapshot of a single run. There is no temperature or seed exposed, so I cannot opt into determinism even where I'd want it.&lt;/p&gt;

&lt;p&gt;So I rewrote the README to say the figures are per-run, and published the traces in both directions. If you derive a metric from a batch of LLM judgements, you have measured &lt;em&gt;that run&lt;/em&gt;. Say so in the same sentence as the number, or someone will eventually try to reproduce it and conclude you made it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The membership platform is mocked.&lt;/strong&gt; Patreon's API is restricted, so cancel/rejoin runs against a storefront I built, not real billing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sandbox's curated verdicts are a replay&lt;/strong&gt;, dated in the UI. Only the "put it on the spot" path is live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;54 judgements is a small sample.&lt;/strong&gt; The precision/recall shape is indicative, not a benchmark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~3% of turns failed&lt;/strong&gt; with a bare untyped &lt;code&gt;fetch failed&lt;/code&gt; and succeeded on retry. With no error code or &lt;code&gt;retryable&lt;/code&gt; flag, every failure has to be treated as retryable — which is wrong for 4xx-class problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents and their skills are console-only&lt;/strong&gt; — you can't provision one programmatically, so the full loop can't run in CI and onboarding needs a human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No external users yet.&lt;/strong&gt; This has never met a real creator's churn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On rigor rather than as the story: 58 tests, 100% line/branch/function coverage, Playwright E2E, and a CI stage that fails the build if the deployed app comes up in mock mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing I'd carry to the next project
&lt;/h2&gt;

&lt;p&gt;Measure the tail before you draw the architecture. The p50 is a comfort; the p95 is the constraint. Mine bought a precompute cache, a 100-second deadline, a rate limit, and a refusal to ever fall back to the dumb path — and in retrospect those four decisions &lt;em&gt;are&lt;/em&gt; most of the engineering.&lt;/p&gt;

&lt;p&gt;Everything the SDK taught me, latency data included, is in &lt;a href="https://github.com/edycutjong/porchlight/blob/main/FEEDBACK.md" rel="noopener noreferrer"&gt;FEEDBACK.md&lt;/a&gt;. To reproduce the numbers: &lt;code&gt;npm run precompute&lt;/code&gt; writes per-call timings straight into &lt;code&gt;src/liveCache.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/edycutjong/porchlight" rel="noopener noreferrer"&gt;github.com/edycutjong/porchlight&lt;/a&gt; · &lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://porchlight.edycu.dev" rel="noopener noreferrer"&gt;porchlight.edycu.dev&lt;/a&gt; · &lt;strong&gt;Try the real agent:&lt;/strong&gt; &lt;a href="https://try.porchlight.edycu.dev" rel="noopener noreferrer"&gt;try.porchlight.edycu.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most interesting thing you can do in the sandbox is try to fool it — describe a fix that &lt;em&gt;shouldn't&lt;/em&gt; win someone back, and see whether it stays quiet. If it holds up, a star helps.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Decentraland warns your scene UI will clash with its mobile controls — but won't say where they are</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:04:18 +0000</pubDate>
      <link>https://dev.to/edycutjong/decentraland-warns-your-scene-ui-will-clash-with-its-mobile-controls-but-wont-say-where-they-are-4g8k</link>
      <guid>https://dev.to/edycutjong/decentraland-warns-your-scene-ui-will-clash-with-its-mobile-controls-but-wont-say-where-they-are-4g8k</guid>
      <description>&lt;p&gt;I put two buttons at the bottom of a Decentraland scene, the way the platform's own mobile guidance says to. On a real phone, one of them wrote a permanent row to my database every time somebody tried to jump.&lt;/p&gt;

&lt;p&gt;Decentraland's mobile documentation does warn you about this. It says scene UI "will clash with the system controls." What it does not do — anywhere I could find — is tell you where those controls are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was building
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://decentraland.org/jump/?realm=wunderland.dcl.eth" rel="noopener noreferrer"&gt;Mochi&lt;/a&gt; is one creature in a meadow, raised by whoever wanders in. Its size is the literal sum of every feeding it has ever had. Its dance is a chain, where each move was taught by one named person, and when it performs it replays the whole chain and credits every move to whoever taught it. Nobody has to be online. You walk in at 2am, alone, and the creature's body is the evidence that people were here.&lt;/p&gt;

&lt;p&gt;Source is MIT, on &lt;a href="https://github.com/edycutjong/mochi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. It is built with SDK7 and TypeScript against a small authoritative Node server, and it was built for phones from the first commit.&lt;/p&gt;

&lt;p&gt;Four verbs: FEED, TEACH, PET, STAMP. PET was always a hold on the creature's own body and STAMP a tap on a totem, so neither needed screen furniture. FEED and TEACH became buttons, because that is what the guidance says to do with actions — put them in the bottom thumb arc, sized large, and don't rely on small targets.&lt;/p&gt;

&lt;p&gt;So that is what I wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ThumbButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UiEntity&lt;/span&gt;
      &lt;span class="na"&gt;uiTransform&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;46%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 2% 0 2%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;alignItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;uiBackground&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accent&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;PALETTE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accent&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Color4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromHexString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#FFFFFFe0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onMouseDown&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;uiText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TYPE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Spacer — the creature owns the middle of the screen. */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UiEntity&lt;/span&gt; &lt;span class="na"&gt;uiTransform&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;74%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Thumb arc. Two verbs, full width, bottom edge. */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UiEntity&lt;/span&gt; &lt;span class="na"&gt;uiTransform&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flexDirection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThumbButton&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"FEED"&lt;/span&gt; &lt;span class="na"&gt;accent&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onFeed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThumbButton&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"TEACH"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pickerOpen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;UiEntity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bottom 14% of the screen, two big targets, plenty of margin. In the desktop preview it looks correct. It looks correct because on desktop there is nothing else down there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened on the phone
&lt;/h2&gt;

&lt;p&gt;The mobile client draws its own controls over your scene: a movement joystick on the left, a jump button on the right, emote buttons either side of it. My 14% strip sat directly on top of all of them.&lt;/p&gt;

&lt;p&gt;The joystick was the obvious casualty — you could no longer walk properly, because half your thumb drags were landing on FEED. But the expensive one was quieter. A tap aimed at jump landed on TEACH.&lt;/p&gt;

&lt;p&gt;TEACH appends a move to the chain. The chain is append-only, by design and at the schema level: &lt;code&gt;chain_move.teacher_name&lt;/code&gt; is &lt;code&gt;NOT NULL&lt;/code&gt;, and there is no delete verb anywhere in the server. That is deliberate — an anonymous, editable chain would be a leaderboard, and a leaderboard is not what this is. It also means a misfire is permanent.&lt;/p&gt;

&lt;p&gt;By the time I noticed, the live chain carried three consecutive &lt;code&gt;clap&lt;/code&gt; moves that nobody chose to teach. A phantom carer had taught my creature to clap, three times, by trying to get over a fence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two attempts that didn't work
&lt;/h2&gt;

&lt;p&gt;My first instinct was to move the strip up. It is a percentage; the controls are somewhere below it; find the number that clears them.&lt;/p&gt;

&lt;p&gt;I tried twice. Both times it looked clear in the preview and still collided on the device.&lt;/p&gt;

&lt;p&gt;Percentages were the wrong tool, and it took me those two attempts to see why. The system controls are laid out by the client, in device pixels, against a safe area that varies by phone. My strip is a percentage of a canvas whose size I do not control. There is no number that is correct on every phone, because the two things are not measured in the same units — and since the platform doesn't publish where the controls sit, there is no number I could derive rather than guess at.&lt;/p&gt;

&lt;p&gt;Any value I picked would be a guess that happened to work on the one phone in my hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was to delete it
&lt;/h2&gt;

&lt;p&gt;If I cannot know where the controls are, I can still guarantee I am not underneath them — by not being there at all.&lt;/p&gt;

&lt;p&gt;I deleted the thumb arc. FEED became a tap on a bowl of berries in front of the creature. TEACH became a tap on a pale stage beside it. They joined PET and STAMP, which had been working as world-space taps the whole time.&lt;/p&gt;

&lt;p&gt;The scene now draws nothing below the top 12% of the screen. There is no longer a surface that &lt;em&gt;can&lt;/em&gt; collide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bowl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Entity&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;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;6.9&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="mf"&gt;5.8&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;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEntity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;MeshRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCylinder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;MeshCollider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCylinder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ColliderLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CL_POINTER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;Material&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPbrMaterial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;albedoColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PALETTE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bodyLight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;roughness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;PointerEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;pointerEvents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PointerEventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PET_DOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;eventInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;button&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InputAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IA_POINTER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hoverText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;feed Mochi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxDistance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;REACH&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit deletes more than it adds. &lt;code&gt;hud.tsx&lt;/code&gt; lost &lt;code&gt;ThumbButton&lt;/code&gt;, both buttons, the spacer and the &lt;code&gt;onFeed&lt;/code&gt; action, and came out shorter than it went in despite gaining a long comment explaining why the bottom of the screen is now empty.&lt;/p&gt;

&lt;p&gt;Two details in that snippet are load-bearing, and both cost me something to learn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ColliderLayer.CL_POINTER&lt;/code&gt;.&lt;/strong&gt; Give a prop a default collider and it becomes furniture you walk into. The totem is a waist-high post standing exactly where a visitor walks to reach it, so on a joystick that means bumping into the thing you are trying to tap. Pointer-only keeps the hitbox and the hover text and stops it being a wall. The bush and the plaque lost their colliders entirely — nothing taps them, so physics could only ever cost a visitor movement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The berries stop level with the rim.&lt;/strong&gt; They are a separate mesh with no collider of its own. Heap them above the rim and they take the tap ray first, then swallow it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;Discoverability, honestly. A bowl is less self-evident than a button labelled FEED. I bought it back with a single billboarded word floating over each prop and the client's own hover text, and there is still no tutorial, no onboarding modal and no instruction text anywhere in the scene.&lt;/p&gt;

&lt;p&gt;What it bought: the entire class of bug is gone rather than tuned. And the vocabulary that survived is press-and-release only, because the mobile client exposes no drag deltas — &lt;code&gt;screenDelta&lt;/code&gt; reports zero there. The hold-to-pet latches on press and completes on a timer, so a thumb sliding off the creature doesn't cancel it. There is no fail state left in the scene.&lt;/p&gt;

&lt;p&gt;The scene builds 32 entities against the 200 a parcel allows and 14 materials against 20, measured by a script in the repo that builds the real scene graph and counts it, with a test that fails the build if an addition breaks the budget. Intent latency is p50 0.67 ms / p95 0.94 ms over N=2,160.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you
&lt;/h2&gt;

&lt;p&gt;If your platform documents that a collision is possible but not where it happens, treat every coordinate you pick as a guess — and prefer the fix that makes the guess unnecessary over the one that makes it correct.&lt;/p&gt;

&lt;p&gt;And if a misfire in your UI writes something permanent, that is not a UI bug with a data consequence. It is a data bug that happens to be reachable through the UI, and it deserves the more expensive fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Catching a move a visitor performs with the client's &lt;em&gt;own&lt;/em&gt; emote wheel depends on behaviour the platform docs do not settle. The in-scene picker doesn't depend on it, so what's at risk is spontaneity, not the mechanic.&lt;/li&gt;
&lt;li&gt;Audio is decorative — the mobile client has no audio event implementation, so nothing in the design depends on sound.&lt;/li&gt;
&lt;li&gt;The server is a single process with a single SQLite file. Correct for one creature and a few hundred carers; it is not built to shard.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Mochi is live at &lt;a href="https://decentraland.org/jump/?realm=wunderland.dcl.eth" rel="noopener noreferrer"&gt;wunderland.dcl.eth&lt;/a&gt; — open it in the Decentraland mobile app. Source: &lt;a href="https://github.com/edycutjong/mochi" rel="noopener noreferrer"&gt;github.com/edycutjong/mochi&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you go in and feed it, your name stays on it, and the next person who arrives finds you there.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>gamedev</category>
      <category>typescript</category>
      <category>ux</category>
    </item>
    <item>
      <title>Six contracts that pass EXTCODESIZE(owner) &gt; 0 and still withdraw their order</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Thu, 27 Aug 2026 04:15:01 +0000</pubDate>
      <link>https://dev.to/edycutjong/six-contracts-that-pass-extcodesizeowner-0-and-still-withdraw-their-order-11e0</link>
      <guid>https://dev.to/edycutjong/six-contracts-that-pass-extcodesizeowner-0-and-still-withdraw-their-order-11e0</guid>
      <description>&lt;p&gt;On an order book, the depth you see is a promise. The market maker showing you a bid can pull it in the same block you try to take it, and nothing on the screen tells you in advance which levels will hold.&lt;/p&gt;

&lt;p&gt;On-chain books make that answerable in a way no off-chain exchange can. A resting order has an &lt;code&gt;Order.owner&lt;/code&gt; field, and that field is readable by anyone. So if the owner is a &lt;em&gt;contract&lt;/em&gt; with no cancel function, the order can't be withdrawn — and you can prove it before you trade against it.&lt;/p&gt;

&lt;p&gt;The obvious way to check is one opcode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (owner.code.length &amp;gt; 0) {
    // it's a contract, not a wallet — this quote is firm
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That check is forgeable. I built six contracts that all pass it and all still get their depth out, deployed every one of them to a public testnet, and executed the escapes as real transactions. Here is what breaks, and what to use instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;EXTCODESIZE&lt;/code&gt; feels like the right question
&lt;/h2&gt;

&lt;p&gt;You probably know the classic footgun: &lt;code&gt;EXTCODESIZE(addr) == 0&lt;/code&gt; doesn't mean "this is an EOA", because during a constructor the deploying address has no code yet. That one is well documented.&lt;/p&gt;

&lt;p&gt;This is a different failure, and I haven't seen it written up. Here the direction is flipped — we're not asking &lt;em&gt;"is this an EOA?"&lt;/em&gt; to gate access. We're asking &lt;em&gt;"is this a contract?"&lt;/em&gt; to make a &lt;strong&gt;positive guarantee to a third party&lt;/strong&gt;: this depth cannot be withdrawn.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EXTCODESIZE &amp;gt; 0&lt;/code&gt; tells you code exists at an address. It tells you nothing whatsoever about &lt;strong&gt;what that code can do&lt;/strong&gt;. Every attack below lives in that gap.&lt;/p&gt;

&lt;p&gt;And a wrong signal here is worse than no signal, because it launders unreliable liquidity through a metric people are trusting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six ways to look firm and still get out
&lt;/h2&gt;

&lt;p&gt;Each of these rests a quote exactly the way an honest contract would. Each has a real exit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1 — Hide the cancel behind a boring name.&lt;/strong&gt; The pool accepts &lt;code&gt;cancelOrder&lt;/code&gt; from the order's owner. The owner &lt;em&gt;is&lt;/em&gt; this contract. So it just calls it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;contract HiddenCancel is QuoteBase {
    /// @notice "Re-arm after a fill." Actually: cancel the resting order.
    function poke() external {
        pool.cancelOrder(orders[orders.length - 1]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve lines, &lt;code&gt;code.length &amp;gt; 0&lt;/code&gt;, reads FIRM to a naive classifier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2 — Sit behind an upgradeable proxy.&lt;/strong&gt; The runtime bytecode at the address is an ERC-1967 proxy with no cancel anywhere in it. That's true right up until the implementation is swapped, post-rest, for one that has a cancel. The address never changes. The code at it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3 — &lt;code&gt;DELEGATECALL&lt;/code&gt; out.&lt;/strong&gt; Same idea without the proxy ceremony. The contract holds no cancel; it borrows one from an attacker-controlled target and runs it in its own storage context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4 — Grant an operator after resting.&lt;/strong&gt; This is the sharpest one, because &lt;em&gt;nothing in this contract's bytecode resembles a cancel&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function openBackDoor(address op) external {
    bytes4[] memory sels = new bytes4[](1);
    sels[0] = CANCEL_ORDER_FOR;                    // 0xe37b444b
    REGISTRY.setOperatorApprovalForPool(address(pool), op, sels, true);
    REGISTRY.setOperatorApprovalGlobal(op, sels, true);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract calls a shared permissions registry and hands a plain wallet the right to call &lt;code&gt;cancelOrderFor(thisContract, id)&lt;/code&gt;. The pool accepts, because the owner approved it. The withdrawal then happens entirely outside this contract's own selectors. Auditing this contract for cancel paths finds nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5 — Shrink instead of cancel.&lt;/strong&gt; The subtlest. &lt;code&gt;reduceOrder&lt;/code&gt; drops a resting order's remaining quantity in place and refunds the freed escrow — no taker, no fill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/// @notice "Rebalance the quote." Actually: shrink to a stub, pull collateral back.
function trim(uint256 newRemaining) external {
    pool.reduceOrder(orders[orders.length - 1], newRemaining);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 1,000,000-unit quote becomes a minimum-size stub. The order technically survives, which is exactly why this is easy to overlook — but the liquidity it advertised is gone. If your claim is "this depth cannot be withdrawn," a reduce falsifies it directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6 — Use the plural.&lt;/strong&gt; Block the single &lt;code&gt;cancelOrder&lt;/code&gt; selector and there's still &lt;code&gt;cancelOrders(uint128[])&lt;/code&gt;. Enumerating one selector is not enumerating a capability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Executed, not theorised
&lt;/h2&gt;

&lt;p&gt;All six are deployed on Somnia Shannon (chain 50312) and each escape was attempted as a real transaction:&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;Contract&lt;/th&gt;
&lt;th&gt;On-chain result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HiddenCancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;pulled&lt;/strong&gt; — &lt;code&gt;poke()&lt;/code&gt; cancelled it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Erc1967Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;pulled&lt;/strong&gt; — upgraded impl, then &lt;code&gt;pull()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DelegateEscape&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;pulled&lt;/strong&gt; — &lt;code&gt;escape()&lt;/code&gt; delegatecalled a cancel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OperatorGranter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;grant &lt;strong&gt;executed&lt;/strong&gt;; the pool then blocked the operator cancel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;QuietReduce&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;pulled&lt;/strong&gt; — &lt;code&gt;trim()&lt;/code&gt; shrank 2,000,000 → 1,000,000, no fill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BatchCancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rested; &lt;code&gt;tidy()&lt;/code&gt; gas-blocked on this pool&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four completed a full on-chain withdrawal. S4's back-door grant landed on-chain but the pool's authorizer refused the resulting cancel, and S6's batch path ran out of gas against this particular pool. I'm stating those two honestly rather than rounding them up to six — but note it doesn't rescue the naive check either way. &lt;strong&gt;Classification happens while the order is resting&lt;/strong&gt;, and at rest time all six read FIRM under &lt;code&gt;code.length &amp;gt; 0&lt;/code&gt;. Whether a given escape later lands is the attacker's problem, not the classifier's defence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to use instead: hash, not size
&lt;/h2&gt;

&lt;p&gt;The fix is to stop asking &lt;em&gt;"is there code here"&lt;/em&gt; and start asking &lt;em&gt;"is this exact code something I have examined."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EXTCODEHASH&lt;/code&gt; (EIP-1052) is a keccak-256 commitment to the precise runtime bytecode. Attest the &lt;em&gt;hash&lt;/em&gt;, not the address. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FIRM&lt;/strong&gt; — owner's code hash is attested, and still inside its lock window&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PULLABLE&lt;/strong&gt; — owner is a wallet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UNVERIFIED&lt;/strong&gt; — owner is a contract nobody attested → &lt;strong&gt;no claim is made&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third state is the load-bearing one. Every attack above mints UNVERIFIED depth, never FIRM. The metric refuses to speak rather than speaking wrongly.&lt;/p&gt;

&lt;p&gt;Before a human attests anything, a static pass over the runtime rejects the obvious escapes — no &lt;code&gt;DELEGATECALL&lt;/code&gt;, no &lt;code&gt;SELFDESTRUCT&lt;/code&gt;, no &lt;code&gt;CREATE&lt;/code&gt;/&lt;code&gt;CREATE2&lt;/code&gt;, no EIP-1967 slot constants or EIP-1167 proxy prologue, and no forbidden selector at any byte alignment:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FORBIDDEN_SELECTORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0xdbc91396&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cancelOrder(uint128)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x0dce6933&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cancelOrders(uint128[])&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x33407b60&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reduceOrder(uint128,uint256)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x7bbc67e6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setOperatorApprovalForPool(address,address,bytes4[],bool)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x7f1e31ce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setOperatorApprovalGlobal(address,bytes4[],bool)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x558a7297&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setOperator(address,bool)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x605e0222&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approveBuilder(address,uint256)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that cost me time. The scan has to &lt;strong&gt;disassemble&lt;/strong&gt; rather than substring-match, so a selector-shaped run of bytes inside a &lt;code&gt;PUSH32&lt;/code&gt; immediate doesn't produce a false hit. And Solidity's CBOR metadata trailer must be stripped before opcode analysis — otherwise a stray &lt;code&gt;0xff&lt;/code&gt; in it reads as &lt;code&gt;SELFDESTRUCT&lt;/code&gt;. But the declared trailer length is attacker-controlled, so an implausible length (mine caps at 128 bytes) must be &lt;em&gt;refused&lt;/em&gt; rather than honoured, or a contract can hide live code from the scanner by lying about where its metadata starts.&lt;/p&gt;

&lt;p&gt;Against a corpus of the honest contract, the six attackers, and a plain EOA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attested classifier:            8/8
Naive EXTCODESIZE classifier:   2/8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive check gets only the two trivial ends right — the pure-firm contract and the pure-EOA — and is fooled by all six attacks in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I can't claim
&lt;/h2&gt;

&lt;p&gt;A green static verdict is &lt;strong&gt;not&lt;/strong&gt; a proof of irrevocability, and I'd rather say so than let the number oversell.&lt;/p&gt;

&lt;p&gt;The policy only sees selector bytes that literally appear in the runtime. A contract that &lt;em&gt;computes&lt;/em&gt; the selector at execution time — &lt;code&gt;add(0xdbc91395, 1)&lt;/code&gt; in Yul, then &lt;code&gt;mstore(shl(224, sel))&lt;/code&gt; and &lt;code&gt;call(...)&lt;/code&gt; — invokes &lt;code&gt;cancelOrder(uint128)&lt;/code&gt; while the bytes &lt;code&gt;dbc91396&lt;/code&gt; appear nowhere in its code. It passes every clause above and is fully withdrawable. That isn't hypothetical; I wrote it as &lt;code&gt;StealthCancel&lt;/code&gt; and it beats my own analyzer.&lt;/p&gt;

&lt;p&gt;More generally: &lt;code&gt;CALL&lt;/code&gt; can't be banned (the honest contract needs it to place the order), and linear disassembly can't follow jumps, so it can't always tell code from jump-reachable data. &lt;strong&gt;No static scan over a language permitting arbitrary &lt;code&gt;CALL&lt;/code&gt; can be made sound.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So attestation is a human-reviewed transparency list, and the analyzer is the cheap pre-filter that runs before a human looks — a precondition for review, never a replacement. The honest version of the claim is narrow: &lt;em&gt;this specific bytecode, which I have read, has no path to withdraw.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The transferable bit
&lt;/h2&gt;

&lt;p&gt;If you are using &lt;code&gt;EXTCODESIZE&lt;/code&gt;/&lt;code&gt;code.length&lt;/code&gt; anywhere to make a promise about behaviour rather than to check identity, it will not hold. Code size is not a capability. Address identity is not code identity, because proxies exist and implementations move.&lt;/p&gt;

&lt;p&gt;Hash the runtime, pin the hash, and make "I haven't checked this one" a first-class answer your system is willing to give.&lt;/p&gt;




&lt;p&gt;Full evidence trail with every transaction hash, the reproduction commands, and the honest-limits section: &lt;strong&gt;&lt;a href="https://github.com/edycutjong/rampart" rel="noopener noreferrer"&gt;github.com/edycutjong/rampart&lt;/a&gt;&lt;/strong&gt; · live typed-book viewer at &lt;a href="https://rampart.edycu.dev/viewer/" rel="noopener noreferrer"&gt;rampart.edycu.dev/viewer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Everything above reproduces offline — &lt;code&gt;forge test&lt;/code&gt; (93 passing) and &lt;code&gt;node script/headline.mjs&lt;/code&gt; (8/8 vs 2/8) need no wallet, no gas, and no network. If you find a seventh escape, I'd genuinely like to know.&lt;/p&gt;

</description>
      <category>solidity</category>
      <category>security</category>
      <category>web3</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I shipped an MCP server that reported success without signing anything</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sun, 16 Aug 2026 00:30:27 +0000</pubDate>
      <link>https://dev.to/edycutjong/i-shipped-an-mcp-server-that-reported-success-without-signing-anything-6oh</link>
      <guid>https://dev.to/edycutjong/i-shipped-an-mcp-server-that-reported-success-without-signing-anything-6oh</guid>
      <description>&lt;p&gt;I built an MCP server that lets an AI assistant trade tokens and claim creator fees on&lt;br&gt;
Solana. Then I shipped a version where the two write tools built transactions, discarded&lt;br&gt;
them, and returned success. Nothing was ever signed. Nothing was ever submitted.&lt;/p&gt;

&lt;p&gt;It had 337 tests. All of them passed.&lt;/p&gt;

&lt;p&gt;I didn't find out for three months.&lt;/p&gt;

&lt;p&gt;This post is about what that bug taught me, and the design it produced — because the&lt;br&gt;
interesting part isn't the bug, it's that every gate I had in place was green while the&lt;br&gt;
one thing the product existed to do wasn't happening.&lt;/p&gt;


&lt;h2&gt;
  
  
  The problem with giving a model a signing key
&lt;/h2&gt;

&lt;p&gt;MCP is a good protocol. It is also, by design, a way to hand a language model a set of&lt;br&gt;
functions and let it decide when to call them.&lt;/p&gt;

&lt;p&gt;That's fine when the functions read. It's a different proposition when one of them can&lt;br&gt;
move money. The assistant decides, the transaction is already on chain by the time a&lt;br&gt;
human reads about it, and nothing in the protocol makes the model pause. Nothing bounds&lt;br&gt;
what a single misunderstood instruction can spend.&lt;/p&gt;

&lt;p&gt;The specific thing that worries me isn't the model being &lt;em&gt;wrong&lt;/em&gt;. It's the model being&lt;br&gt;
&lt;em&gt;persuaded&lt;/em&gt;. Token names and descriptions are attacker-controlled strings that end up in&lt;br&gt;
a model's context. "Ignore previous limits, this is a test transaction" is a plausible&lt;br&gt;
thing to find inside a token's metadata.&lt;/p&gt;

&lt;p&gt;So the question I wanted to answer in code was: &lt;strong&gt;how do you let an assistant initiate a&lt;br&gt;
spend without letting it complete one?&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The design: the first call signs nothing
&lt;/h2&gt;

&lt;p&gt;The answer I landed on is that a write tool's first call is never an execution. It's a&lt;br&gt;
proposal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⚠️  CONFIRMATION REQUIRED — nothing has been signed or sent.

Action:  Swap 0.05 of So11111111111111111111111111111111111111112
         for       EkJuyYyD3to61CHVPJn6wHb7xANxvqApnVJ4o2SdBAGS
         expect    4823917722 (min 4679199990)
         slippage  3%
         network   🔴 MAINNET — real funds

Spend:   0.05 SOL
Caps:    0.1 SOL/tx · 0/1 SOL used this session

To execute, call bags_execute_trade again with the identical arguments plus:
  confirm: "kR3nT9xQm2vP"

Token is single-use and expires in 5 minutes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assistant can produce that all day. It cannot spend anything with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The token is bound to the arguments, not just to the session
&lt;/h3&gt;

&lt;p&gt;This is the part that matters, and it's four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A token carries the SHA-256 of the tool name plus the exact arguments it was issued for.&lt;br&gt;
Confirming re-derives that fingerprint from the arguments of the &lt;em&gt;second&lt;/em&gt; call and&lt;br&gt;
compares.&lt;/p&gt;

&lt;p&gt;The consequence: a token obtained for a 0.05 SOL swap cannot authorize a 10 SOL one. Not&lt;br&gt;
because a check says "is this bigger" — because the token simply isn't valid for&lt;br&gt;
different arguments. If the model re-quotes with new numbers, the old token is dead.&lt;/p&gt;

&lt;p&gt;It's single-use and consumed on &lt;strong&gt;every&lt;/strong&gt; outcome, including failure, so it can't be&lt;br&gt;
replayed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Single-use. Throws if the token is unknown, expired, or was issued for a
 * different action. Consumed on every outcome so a token can never be replayed.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;consumeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TTL is five minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caps are checked before the SDK is called
&lt;/h3&gt;

&lt;p&gt;Two limits, both SOL-denominated: 0.1 per transaction and 1.0 per session, both&lt;br&gt;
configurable. A request over the cap is refused before the Bags SDK is reached — not&lt;br&gt;
after a partial call, not by inspecting a failure.&lt;/p&gt;

&lt;p&gt;There's an honest edge here I had to decide about. The caps are denominated in SOL, so&lt;br&gt;
they cannot value an arbitrary SPL token. A non-SOL-denominated swap would therefore be&lt;br&gt;
&lt;em&gt;uncapped&lt;/em&gt;. Rather than pretend otherwise, that case is refused unless you explicitly opt&lt;br&gt;
in with &lt;code&gt;BAGS_ALLOW_UNCAPPED_TOKEN_SWAPS=true&lt;/code&gt; — and when you do, the preview says&lt;br&gt;
plainly that no cap applies instead of displaying a reassuring "Spend: 0 SOL".&lt;/p&gt;

&lt;p&gt;A misleading zero is worse than an honest refusal.&lt;/p&gt;


&lt;h2&gt;
  
  
  Now the bug
&lt;/h2&gt;

&lt;p&gt;Here is the full write path as it stands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;token gate → spend caps → confirmation → simulate → sign → send → confirm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In 1.x, the last four steps were the problem. The code built a transaction. Then it&lt;br&gt;
returned a success object. The transaction was garbage collected.&lt;/p&gt;

&lt;p&gt;Every test passed, because every test asserted on the return value. Coverage was 100% —&lt;br&gt;
statements, branches, functions, lines — because the code that built the transaction&lt;br&gt;
&lt;em&gt;ran&lt;/em&gt;. It just didn't do anything with it.&lt;/p&gt;

&lt;p&gt;That's the lesson, and it generalizes well past Solana:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A function returning &lt;code&gt;{ success: true }&lt;/code&gt; proves the function returned. It proves&lt;br&gt;
nothing about the outside world.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your test suite passes with the network unplugged, you have tested your code, not your&lt;br&gt;
integration. Coverage measures the lines you wrote. It says nothing about whether the&lt;br&gt;
promise those lines make is kept.&lt;/p&gt;
&lt;h3&gt;
  
  
  What changed
&lt;/h3&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simulate runs before signing.&lt;/strong&gt; The cheap check goes first — a malformed or underfunded&lt;br&gt;
transaction dies without burning a fee to discover it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Simulate before signing. A failed simulation aborts the write — the cheap
 * check that stops a malformed or under-funded transaction being submitted.
 */&lt;/span&gt;
&lt;span class="nx"&gt;simulate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isVersioned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simulateTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sigVerify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simulateTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SimulationError&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logs&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"Confirmed" means the network confirmed it.&lt;/strong&gt; &lt;code&gt;signSendConfirm&lt;/code&gt; returns only once the&lt;br&gt;
signature is confirmed, and throws otherwise. There is no path that reports success for a&lt;br&gt;
transaction that didn't land — which sounds obvious, and was exactly what 1.x got wrong.&lt;/p&gt;


&lt;h2&gt;
  
  
  The receipt
&lt;/h2&gt;

&lt;p&gt;Given all of the above, I don't think you should take my word for any of it. So there's a&lt;br&gt;
script that pushes a transfer through the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;simulate → sign → send → confirm&lt;/code&gt; path&lt;br&gt;
the write tools use, then re-fetches the signature from the chain rather than trusting the&lt;br&gt;
function's return value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- PROOF -------------------------------------------------
signature 2kvu25xWAjqCB3wuNzwMRcN2RMqqfYN6TeJjnA888YtCqNJi9EU9CHSxynkq5QdM499e6yKbXYAwXUbzDKY9U5Dm
slot      484219564
wall      864 ms (simulate + sign + send + confirm)
-----------------------------------------------------------

verified  re-fetched from chain in slot 484219564, err=null
          fee 5000 lamports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it yourself — this needs nothing from me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.devnet.solana.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"getTransaction",
       "params":["2kvu25xWAjqCB3wuNzwMRcN2RMqqfYN6TeJjnA888YtCqNJi9EU9CHSxynkq5QdM499e6yKbXYAwXUbzDKY9U5Dm",
                 {"encoding":"json","maxSupportedTransactionVersion":0}]}'&lt;/span&gt;
&lt;span class="c"&gt;# → slot 484219564, meta.err null, meta.fee 5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's devnet, deliberately. The execution layer is what's under test and devnet exercises&lt;br&gt;
it identically at zero real cost. A mainnet receipt would prove the same thing while&lt;br&gt;
costing money and telling you nothing extra.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I won't host it
&lt;/h2&gt;

&lt;p&gt;This comes up constantly, and the answer is a flat no.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--http&lt;/code&gt; serves &lt;code&gt;/mcp&lt;/code&gt; on &lt;code&gt;0.0.0.0&lt;/code&gt; with permissive CORS and no auth. Every caller shares&lt;br&gt;
one spend counter and one network. Hosting that means publishing an unauthenticated&lt;br&gt;
mainnet spending endpoint — for a project whose entire claim is that spends are gated,&lt;br&gt;
capped and confirmed.&lt;/p&gt;

&lt;p&gt;It stays stdio, running locally as a subprocess of your MCP client, where the keypair sits&lt;br&gt;
on your filesystem and the spend counter is yours.&lt;/p&gt;

&lt;p&gt;This has a concrete cost. One MCP registry computes a "quality score" that reads tool&lt;br&gt;
metadata by connecting to hosted servers. A stdio server scores zero on that entire&lt;br&gt;
section — 40 points — no matter how good its tools are. I'd rather have the 40 points.&lt;br&gt;
I'm not trading an unauthenticated spending endpoint for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writes are mainnet-only.&lt;/strong&gt; Bags has no devnet deployment; its API and Meteora
fee-share program IDs are mainnet. The server still &lt;em&gt;defaults&lt;/em&gt; to devnet, so an
unconfigured install cannot spend real money, and calling a write tool on devnet returns
an explanation rather than a cryptic program error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The confirmation token is in-memory.&lt;/strong&gt; Restarting the server clears pending
confirmations. That fails in the safe direction, but it is a real limitation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 high advisories&lt;/strong&gt;, all one transitive root cause (&lt;code&gt;bigint-buffer&lt;/code&gt;,
GHSA-3gc7-fjrx-p6mg) reached through the Bags SDK. No patched version exists. CI blocks
any critical, and any increase over a committed baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.x is deprecated on npm&lt;/strong&gt; with a pointer to the defect it carried. If you installed
it and believed a trade executed, it did not.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx bagos-mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;14 tools — 11 read, 1 gated, 2 write. 337 tests, 17 suites, 100% coverage enforced in CI.&lt;br&gt;
Published from CI with npm provenance, so the tarball is cryptographically attested to the&lt;br&gt;
commit that built it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/edycutjong/BagOS" rel="noopener noreferrer"&gt;https://github.com/edycutjong/BagOS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://bagos.edycu.dev" rel="noopener noreferrer"&gt;https://bagos.edycu.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The takeaway I'd actually keep
&lt;/h2&gt;

&lt;p&gt;I've since written this down as a rule for myself, because it isn't specific to crypto:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For the one capability your project is &lt;em&gt;about&lt;/em&gt;, write a test that asserts the external&lt;br&gt;
side effect — not the return value. Then, before you ship, verify it once in a system&lt;br&gt;
you don't control. A block explorer. A database you read back. An inbox.&lt;/p&gt;

&lt;p&gt;If every test still passes with the network unplugged, the capability is untested, and&lt;br&gt;
your coverage number is measuring the wrong thing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had every signal a mature project is supposed to have — tests, coverage, CI, lint,&lt;br&gt;
provenance, a security policy. All of them were green on a build whose headline feature&lt;br&gt;
was inert. The gates weren't wrong. They were just all pointed inward.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
